newt_core/plan.rs
1//! The canonical agent **plan**: one `serde` [`Plan`] / [`Subtask`] definition
2//! shared by the human-driven collaborative `/plan` surface (#334) and the
3//! swarm scheduler (Workstream C / the future `newt-scheduler`).
4//!
5//! The plain-text plan file is the single source of truth across tiers
6//! (newt/drake *author* → headless wyvern *executes*), so this struct is the
7//! **only** definition both sides deserialize. Landing it once prevents the
8//! "two plan shapes wearing one filename" drift flagged in #334: the `/plan`
9//! S3a author and the C1 scheduler consume the *same* `struct`, or they diverge.
10//!
11//! Three properties are load-bearing and tested (§ `tests`):
12//!
13//! 1. **Fragment-validity.** A bare `[[subtask]]` list (no top-level `goal`)
14//! deserializes on its own, so a *slice* of a plan can be handed to one
15//! flight via `wyvern --plan`/stdin (#334 S3f). The handoff is a parse
16//! invariant, not a hope.
17//! 2. **Default-deny authority.** An omitted [`Subtask::caveat_policy`]
18//! deserializes to the *narrowest* policy — every capability axis denied —
19//! never `Caveats::top()` minus a few axes. A model-*proposed* plan must not
20//! acquire authority by omission; the model names what a subtask needs and
21//! the harness grants no more. This is the #319/#332 lesson ("the harness
22//! stamps, the model never asserts") applied at the orchestration layer, and
23//! it pre-wires Workstream C §7: *the plan requests, the parent grants, and
24//! `delegate` enforces `⊑`* (attenuation can only narrow, never widen).
25//! 3. **Resumability.** Per-subtask [`Subtask::status`] / [`Subtask::result`]
26//! make the plan file both the proposal *and* the run-log, so `/plan resume`
27//! and `/plan status` (#334 S3e) have a real thing to read and aggregation
28//! has a destination.
29
30use serde::{Deserialize, Serialize};
31
32use crate::role_profile::{ScopeKeyword, ScopeSpec};
33use crate::{Caveats, CountBound, Scope};
34
35/// A complete plan, or a fragment of one.
36///
37/// Serializes to TOML as an optional `goal` + `aggregation` scalar plus a
38/// `[[subtask]]` array-of-tables. `goal` is optional precisely so a bare
39/// `[[subtask]]` fragment parses (fragment-validity, §module docs).
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
41#[serde(deny_unknown_fields)]
42pub struct Plan {
43 /// The overall goal this plan pursues. `None` in a fragment.
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub goal: Option<String>,
46 /// How child results are combined back into the plan (default `Concat`).
47 #[serde(default)]
48 pub aggregation: Aggregation,
49 /// The subtasks, as TOML `[[subtask]]` tables. (Rust field `subtasks`,
50 /// TOML key `subtask`.)
51 #[serde(default, rename = "subtask")]
52 pub subtasks: Vec<Subtask>,
53}
54
55impl Plan {
56 /// Parse a plan (or fragment) from its TOML form.
57 ///
58 /// # Errors
59 /// Returns the `toml` deserialization error on malformed input or an
60 /// unknown field (the schema is `deny_unknown_fields` — one canonical shape).
61 pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
62 toml::from_str(s)
63 }
64
65 /// Serialize this plan to its canonical TOML form.
66 ///
67 /// # Errors
68 /// Returns the `toml` serialization error (should not occur for a
69 /// well-formed [`Plan`]).
70 pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
71 toml::to_string_pretty(self)
72 }
73
74 /// The subtask with id `id`, if present.
75 #[must_use]
76 pub fn subtask(&self, id: &str) -> Option<&Subtask> {
77 self.subtasks.iter().find(|s| s.id == id)
78 }
79
80 /// Root subtasks — those with no [`Subtask::parent`] (the top of the
81 /// decomposition tree).
82 #[must_use]
83 pub fn roots(&self) -> Vec<&Subtask> {
84 self.subtasks
85 .iter()
86 .filter(|s| s.parent.is_none())
87 .collect()
88 }
89
90 /// Direct children of `id` — subtasks whose `parent` is `id`.
91 #[must_use]
92 pub fn children(&self, id: &str) -> Vec<&Subtask> {
93 self.subtasks
94 .iter()
95 .filter(|s| s.parent.as_deref() == Some(id))
96 .collect()
97 }
98
99 /// Leaves — subtasks that no other subtask names as `parent`. A leaf is the
100 /// dispatch/execute unit (a leaf *is* a `CrewTask`); a non-leaf is a branch
101 /// (grouping / aggregation). In a flat, single-level plan *every* subtask is a
102 /// leaf, so this degrades to "all subtasks" — the pre-tree behaviour, so
103 /// existing flat plans are unaffected.
104 #[must_use]
105 pub fn leaves(&self) -> Vec<&Subtask> {
106 let parented: std::collections::HashSet<&str> = self
107 .subtasks
108 .iter()
109 .filter_map(|s| s.parent.as_deref())
110 .collect();
111 self.subtasks
112 .iter()
113 .filter(|s| !parented.contains(s.id.as_str()))
114 .collect()
115 }
116
117 /// The next **ready leaf** to dispatch — the execution cursor. A [`leaf`] that
118 /// is [`SubtaskStatus::Pending`] and whose every [`dep`] is
119 /// [`SubtaskStatus::Done`]. `None` when nothing is ready (all done, every
120 /// pending leaf is dep-blocked, or work is in flight). A `dep` counts as
121 /// satisfied iff the named subtask exists and is `Done`; an absent (e.g.
122 /// cross-fragment) dep is treated as unsatisfied, so a plan never runs a leaf
123 /// ahead of a prerequisite it cannot see.
124 ///
125 /// [`leaf`]: Plan::leaves
126 /// [`dep`]: Subtask::deps
127 #[must_use]
128 pub fn next_ready_leaf(&self) -> Option<&Subtask> {
129 self.leaves().into_iter().find(|s| {
130 s.status == SubtaskStatus::Pending
131 && s.deps
132 .iter()
133 .all(|d| matches!(self.subtask(d).map(|t| t.status), Some(SubtaskStatus::Done)))
134 })
135 }
136
137 /// The next leaf to **dispatch**, as `(id, CrewTask)` — [`next_ready_leaf`]
138 /// projected through [`Subtask::to_crew_task`]. This is the drive loop's read
139 /// step: dispatch the `CrewTask`, then [`mark`](Plan::mark) the `id`
140 /// `Done`/`Failed` and call again. `None` when the plan is complete or stalled
141 /// (every remaining leaf blocked by a non-`Done` dep). The `id` is returned
142 /// because the projected `CrewTask` deliberately drops it (it is the plan's
143 /// bookkeeping, not the child's).
144 ///
145 /// [`next_ready_leaf`]: Plan::next_ready_leaf
146 #[must_use]
147 pub fn next_dispatch(&self, parent: &Caveats) -> Option<(String, CrewTask)> {
148 self.next_ready_leaf()
149 .map(|s| (s.id.clone(), s.to_crew_task(parent)))
150 }
151
152 /// Record a leaf's outcome — set its [`status`](Subtask::status) and, when
153 /// `result` is `Some`, its [`result`](Subtask::result). No-op if `id` is
154 /// absent. The drive loop calls this after each dispatch; marking a leaf
155 /// `Done` may unblock its dependents on the next [`next_dispatch`], and
156 /// marking it `Failed` leaves them blocked (deps require `Done`), so the run
157 /// stops honestly at the first failure without a separate "stop" flag.
158 ///
159 /// [`next_dispatch`]: Plan::next_dispatch
160 pub fn mark(&mut self, id: &str, status: SubtaskStatus, result: Option<String>) {
161 if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
162 s.status = status;
163 if result.is_some() {
164 s.result = result;
165 }
166 }
167 }
168
169 /// Overwrite a subtask's instruction by id — used by failure-driven
170 /// re-grounding (#692) to steer a retried leaf at the symbol's real file.
171 pub fn set_instruction(&mut self, id: &str, instruction: &str) {
172 if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
173 s.instruction = instruction.to_string();
174 }
175 }
176
177 /// Drop a subtask's file scope by id (#812). Used by re-grounding: a
178 /// reground means the leaf's grounding was demonstrably wrong, so a
179 /// derived-from-the-same-grounding fence is stale — the retry runs
180 /// unfenced rather than deterministically re-refusing the corrected edit.
181 pub fn clear_context(&mut self, id: &str) {
182 if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
183 s.context.clear();
184 }
185 }
186
187 /// Every leaf is `Done` — the plan finished successfully (branches are
188 /// grouping nodes, so only leaf completion is load-bearing). An empty plan is
189 /// trivially complete.
190 #[must_use]
191 pub fn is_complete(&self) -> bool {
192 self.leaves()
193 .iter()
194 .all(|s| s.status == SubtaskStatus::Done)
195 }
196}
197
198/// One unit of work in a [`Plan`] — the serialized form of a single scheduler
199/// dispatch. A fragment-valid `[[subtask]]` table is exactly this.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct Subtask {
203 /// Stable identifier, referenced by other subtasks' [`Subtask::deps`].
204 pub id: String,
205 /// What the child agent is asked to do.
206 pub instruction: String,
207 /// Ids of subtasks that must complete before this one may start.
208 #[serde(default)]
209 pub deps: Vec<String>,
210 /// May this subtask run concurrently with its ready siblings?
211 #[serde(default)]
212 pub parallel_ok: bool,
213 /// The leaf's FILE SCOPE — its write lane (#812), not reading material.
214 /// At dispatch this is forwarded as `args["scope"]` and intersected into
215 /// the effective writable set (worktree ∩ fs_write ∩ scope): a meet-only
216 /// convenience fence that can only narrow, never widen. Empty = unfenced
217 /// (pre-#812 behavior). Matching is exact-file or directory-prefix, with
218 /// `./` and trailing-`/` normalized; degenerate entries (`""`, `"."`)
219 /// fail open. Populated by the harness's own def-site grounding, with
220 /// model-declared `files` appended as untrusted augmentation.
221 #[serde(default)]
222 pub context: Vec<String>,
223 /// Optional verify command whose **enforced** result gates the subtask
224 /// (#332 S1). Absent = no per-subtask gate.
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub verify: Option<String>,
227 /// Execution status — makes the plan file a resumable run-log.
228 #[serde(default)]
229 pub status: SubtaskStatus,
230 /// Where the child's output lands on completion (aggregation destination).
231 /// `None` until the subtask has run.
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub result: Option<String>,
234 /// The id of the subtask this one decomposes — `None` for a root. This is how
235 /// a flat `[[subtask]]` list expresses a task→sub-task **tree** (exactly as
236 /// [`Subtask::deps`] expresses a DAG via id-pointers, not nesting). A subtask
237 /// that no other subtask names as its `parent` is a **leaf** — the unit that
238 /// dispatches/executes (a leaf *is* a `CrewTask`); a subtask that *is* named
239 /// is a **branch** (a grouping / aggregation node). Kept flat on purpose: a
240 /// nested `Vec<Subtask>` would break the fragment handoff (one leaf slice =
241 /// one dispatch). `None` is also fine in a fragment whose parent lives outside
242 /// the slice (the pointer is soft, like a cross-fragment `dep`).
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub parent: Option<String>,
245 /// The authority this subtask declares it needs. **Default-deny**: an
246 /// omitted policy denies every capability axis (see [`CaveatPolicy`]).
247 ///
248 /// Serialized **last** so every scalar field precedes this sub-table — TOML
249 /// requires values before tables within a `[[subtask]]` entry.
250 #[serde(default)]
251 pub caveat_policy: CaveatPolicy,
252}
253
254/// A leaf [`Subtask`] projected into the unit a `CrewRunner` dispatches — the
255/// concrete realization of *"a leaf is a CrewTask"*. Produced by
256/// [`Subtask::to_crew_task`]; the runner adds placement (a `workspace_ref`) when
257/// it actually spawns the work, so it isn't carried here. No `id`/`deps`/`status`
258/// either — those are the plan's bookkeeping, not the child's concern.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct CrewTask {
261 /// What the child agent is asked to do (the subtask's instruction).
262 pub goal: String,
263 /// The authority the child runs under — the parent's grant **met** with the
264 /// subtask's declared policy. `meet` is the greatest lower bound, so this can
265 /// only *narrow* the parent (attenuation, never amplify): a model-proposed
266 /// plan can never widen the grant it was handed.
267 pub caveats: Caveats,
268 /// The leaf's file scope — its write lane (#812; see
269 /// [`Subtask::context`]). Forwarded as `args["scope"]` and intersected
270 /// into the effective writable set at apply; empty = unfenced.
271 pub context: Vec<String>,
272 /// The optional verify command that gates this task — forwarded to the crew
273 /// op so the child's work is checked before it is accepted (the #332
274 /// per-subtask gate). `None` = no per-task check.
275 pub verify: Option<String>,
276}
277
278impl Subtask {
279 /// Project this subtask into the [`CrewTask`] the active topology's
280 /// `CrewRunner` dispatches — the *same* projection for `/mode
281 /// single|crew|mesh|remote`, so a plan authored once lifts across runners
282 /// unchanged. `caveats = parent.meet(self.caveat_policy.to_caveats())`: the
283 /// plan *requests*, the parent *grants*, `meet` *enforces ⊑* (attenuation
284 /// only). Intended for a **leaf** (see [`Plan::leaves`]); a branch is a
285 /// grouping node, not a dispatch unit.
286 #[must_use]
287 pub fn to_crew_task(&self, parent: &Caveats) -> CrewTask {
288 CrewTask {
289 goal: self.instruction.clone(),
290 caveats: parent.meet(&self.caveat_policy.to_caveats()),
291 context: self.context.clone(),
292 verify: self.verify.clone(),
293 }
294 }
295}
296
297/// How child results combine back into the plan.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
299#[serde(rename_all = "lowercase")]
300pub enum Aggregation {
301 /// Concatenate child outputs in subtask order (the default).
302 #[default]
303 Concat,
304 /// Keep only the last completed subtask's output.
305 LastWins,
306 /// Fold children through a reducer (semantics resolved by the scheduler).
307 Reduce,
308 /// A caller-defined strategy, resolved by the scheduler.
309 Custom,
310}
311
312/// Per-subtask execution status; the plan file doubles as a run-log.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
314#[serde(rename_all = "lowercase")]
315pub enum SubtaskStatus {
316 /// Not yet started (the default).
317 #[default]
318 Pending,
319 /// Currently executing.
320 Running,
321 /// Completed successfully.
322 Done,
323 /// Failed (verify gate or execution error).
324 Failed,
325}
326
327/// The authority a [`Subtask`] declares it needs — **default-deny**.
328///
329/// Reuses the same human-friendly axis vocabulary as
330/// [`crate::role_profile::CaveatProfile`] ([`ScopeSpec`] per axis), but with the
331/// **opposite default**: where a role profile omits an axis to mean
332/// *unrestricted* (top of the axis, matching `Caveats::top()`), a plan omits an
333/// axis to mean *denied* (`none`). A model-proposed plan must not gain authority
334/// by leaving a field out.
335///
336/// The `Default` is fully denied; [`to_caveats`](CaveatPolicy::to_caveats)
337/// lowers the declared policy into the canonical [`Caveats`] lattice element the
338/// scheduler attenuates the parent key against. Because attenuation takes the
339/// *meet* with the parent, even an unspecified count bound (`None` → `Unlimited`
340/// request) is clamped down to the parent's finite bound — the request can never
341/// widen the grant.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct CaveatPolicy {
345 /// Filesystem read scope (default: `none`).
346 #[serde(default = "denied_axis")]
347 pub fs_read: ScopeSpec,
348 /// Filesystem write scope (default: `none`).
349 #[serde(default = "denied_axis")]
350 pub fs_write: ScopeSpec,
351 /// Command-execution scope (default: `none`).
352 #[serde(default = "denied_axis")]
353 pub exec: ScopeSpec,
354 /// Network scope (default: `none`).
355 #[serde(default = "denied_axis")]
356 pub net: ScopeSpec,
357 /// Tool-call ceiling. `None` = unspecified; the scheduler clamps it to the
358 /// parent's bound (it can never widen it).
359 #[serde(default)]
360 pub max_calls: Option<u64>,
361}
362
363/// A single denied axis (`Scope::none`) — the opposite of
364/// [`ScopeSpec::default`] (which is `all`). The deny default is what makes a
365/// model-proposed plan safe by omission.
366fn denied_axis() -> ScopeSpec {
367 ScopeSpec::Keyword(ScopeKeyword::None)
368}
369
370impl Default for CaveatPolicy {
371 fn default() -> Self {
372 Self {
373 fs_read: denied_axis(),
374 fs_write: denied_axis(),
375 exec: denied_axis(),
376 net: denied_axis(),
377 max_calls: None,
378 }
379 }
380}
381
382impl CaveatPolicy {
383 /// Lower this declared policy into the canonical [`Caveats`] lattice element
384 /// the scheduler attenuates the parent key against. Mirrors
385 /// `CaveatProfile::to_caveats`, but inherits this type's deny defaults.
386 #[must_use]
387 pub fn to_caveats(&self) -> Caveats {
388 Caveats {
389 fs_read: self.fs_read.to_scope(),
390 fs_write: self.fs_write.to_scope(),
391 exec: self.exec.to_scope(),
392 net: self.net.to_scope(),
393 max_calls: match self.max_calls {
394 Some(n) => CountBound::AtMost(n),
395 None => CountBound::Unlimited,
396 },
397 valid_for_generation: Scope::All,
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 const DENIED: ScopeSpec = ScopeSpec::Keyword(ScopeKeyword::None);
407
408 #[test]
409 fn bare_subtask_fragment_parses_without_a_goal() {
410 // Fragment-validity: a slice handed to `wyvern --plan` must parse alone.
411 let toml = r#"
412[[subtask]]
413id = "s1"
414instruction = "do the first thing"
415
416[[subtask]]
417id = "s2"
418instruction = "do the second thing"
419deps = ["s1"]
420"#;
421 let plan = Plan::from_toml_str(toml).unwrap();
422 assert!(plan.goal.is_none());
423 assert_eq!(plan.subtasks.len(), 2);
424 assert_eq!(plan.subtasks[1].deps, vec!["s1".to_string()]);
425 }
426
427 #[test]
428 fn omitted_caveat_policy_denies_every_axis() {
429 // The load-bearing safety property: no policy => no authority.
430 let toml = r#"
431[[subtask]]
432id = "s1"
433instruction = "untrusted, model-proposed"
434"#;
435 let plan = Plan::from_toml_str(toml).unwrap();
436 let pol = &plan.subtasks[0].caveat_policy;
437 assert_eq!(pol.fs_read, DENIED);
438 assert_eq!(pol.fs_write, DENIED);
439 assert_eq!(pol.exec, DENIED);
440 assert_eq!(pol.net, DENIED);
441 // And the struct default agrees with deserialized-absence.
442 assert_eq!(*pol, CaveatPolicy::default());
443 }
444
445 #[test]
446 fn default_policy_lowers_to_a_fully_denied_caveats() {
447 // Default-deny must hold at the lattice level too: every capability
448 // scope is `none`, never `Scope::All` (top). Compared via to_scope so
449 // the assertion does not depend on Caveats' own equality impl.
450 let cav = CaveatPolicy::default().to_caveats();
451 assert_eq!(cav.fs_read, Scope::none());
452 assert_eq!(cav.fs_write, Scope::none());
453 assert_eq!(cav.exec, Scope::none());
454 assert_eq!(cav.net, Scope::none());
455 }
456
457 #[test]
458 fn explicit_policy_is_honored_and_lowers_correctly() {
459 let toml = r#"
460[[subtask]]
461id = "s1"
462instruction = "scoped"
463
464[subtask.caveat_policy]
465fs_read = "all"
466fs_write = ["src/", "Cargo.toml"]
467exec = ["cargo"]
468net = "none"
469max_calls = 40
470"#;
471 let plan = Plan::from_toml_str(toml).unwrap();
472 let cav = plan.subtasks[0].caveat_policy.to_caveats();
473 assert_eq!(cav.fs_read, Scope::All);
474 assert_eq!(
475 cav.fs_write,
476 Scope::only(["src/".to_string(), "Cargo.toml".to_string()])
477 );
478 assert_eq!(cav.exec, Scope::only(["cargo".to_string()]));
479 assert_eq!(cav.net, Scope::none());
480 assert_eq!(cav.max_calls, CountBound::AtMost(40));
481 }
482
483 #[test]
484 fn status_defaults_to_pending() {
485 let toml = r#"
486[[subtask]]
487id = "s1"
488instruction = "x"
489"#;
490 let plan = Plan::from_toml_str(toml).unwrap();
491 assert_eq!(plan.subtasks[0].status, SubtaskStatus::Pending);
492 assert!(plan.subtasks[0].result.is_none());
493 }
494
495 #[test]
496 fn plan_round_trips_through_toml() {
497 let plan = Plan {
498 goal: Some("ship the thing".to_string()),
499 aggregation: Aggregation::Concat,
500 subtasks: vec![Subtask {
501 id: "s1".to_string(),
502 instruction: "write the module".to_string(),
503 deps: vec![],
504 parallel_ok: true,
505 context: vec!["src/lib.rs".to_string()],
506 verify: Some("cargo test -p x".to_string()),
507 caveat_policy: CaveatPolicy {
508 fs_write: ScopeSpec::Items(vec!["src/".to_string()]),
509 ..CaveatPolicy::default()
510 },
511 status: SubtaskStatus::Done,
512 result: Some("done".to_string()),
513 parent: Some("epic".to_string()),
514 }],
515 };
516 let text = plan.to_toml_string().unwrap();
517 let back = Plan::from_toml_str(&text).unwrap();
518 assert_eq!(back, plan);
519 }
520
521 #[test]
522 fn full_plan_with_aggregation_parses() {
523 let toml = r#"
524goal = "refactor the parser"
525aggregation = "lastwins"
526
527[[subtask]]
528id = "s1"
529instruction = "x"
530status = "running"
531"#;
532 let plan = Plan::from_toml_str(toml).unwrap();
533 assert_eq!(plan.goal.as_deref(), Some("refactor the parser"));
534 assert_eq!(plan.aggregation, Aggregation::LastWins);
535 assert_eq!(plan.subtasks[0].status, SubtaskStatus::Running);
536 }
537
538 #[test]
539 fn unknown_field_is_rejected() {
540 // deny_unknown_fields: one canonical shape, no silent drift.
541 let toml = r#"
542[[subtask]]
543id = "s1"
544instruction = "x"
545bogus_field = "should fail"
546"#;
547 assert!(Plan::from_toml_str(toml).is_err());
548 }
549
550 #[test]
551 fn empty_input_is_an_empty_plan() {
552 let plan = Plan::from_toml_str("").unwrap();
553 assert!(plan.goal.is_none());
554 assert!(plan.subtasks.is_empty());
555 assert_eq!(plan.aggregation, Aggregation::Concat);
556 }
557
558 #[test]
559 fn parent_pointers_build_a_tree() {
560 // A root branch "epic" decomposes into two leaves; plus a top-level leaf.
561 let toml = r#"
562[[subtask]]
563id = "epic"
564instruction = "the big task"
565
566[[subtask]]
567id = "a"
568instruction = "sub-task a"
569parent = "epic"
570
571[[subtask]]
572id = "b"
573instruction = "sub-task b"
574parent = "epic"
575
576[[subtask]]
577id = "solo"
578instruction = "a top-level leaf"
579"#;
580 let plan = Plan::from_toml_str(toml).unwrap();
581 let ids = |v: Vec<&Subtask>| v.iter().map(|s| s.id.clone()).collect::<Vec<_>>();
582 assert_eq!(ids(plan.roots()), vec!["epic", "solo"]);
583 assert_eq!(ids(plan.children("epic")), vec!["a", "b"]);
584 // epic is a branch (named as a parent) → not a leaf; a, b, solo are leaves.
585 assert_eq!(ids(plan.leaves()), vec!["a", "b", "solo"]);
586 assert_eq!(plan.subtask("a").unwrap().parent.as_deref(), Some("epic"));
587 }
588
589 #[test]
590 fn flat_plan_has_every_subtask_as_a_leaf() {
591 // Pre-tree behaviour preserved: no parents → every subtask is a leaf.
592 let plan = Plan::from_toml_str(
593 "[[subtask]]\nid=\"s1\"\ninstruction=\"x\"\n[[subtask]]\nid=\"s2\"\ninstruction=\"y\"\n",
594 )
595 .unwrap();
596 assert_eq!(plan.leaves().len(), 2);
597 assert_eq!(plan.roots().len(), 2);
598 }
599
600 #[test]
601 fn next_ready_leaf_is_the_execution_cursor() {
602 // a Done; b Pending with dep a (Done) → b is the ready leaf. c is
603 // dep-blocked (b not Done); epic is a branch, never a dispatch unit.
604 let toml = r#"
605[[subtask]]
606id = "epic"
607instruction = "branch"
608
609[[subtask]]
610id = "a"
611instruction = "first leaf"
612parent = "epic"
613status = "done"
614
615[[subtask]]
616id = "b"
617instruction = "second leaf"
618parent = "epic"
619deps = ["a"]
620
621[[subtask]]
622id = "c"
623instruction = "third leaf"
624parent = "epic"
625deps = ["b"]
626"#;
627 let plan = Plan::from_toml_str(toml).unwrap();
628 assert_eq!(plan.next_ready_leaf().expect("b ready").id, "b");
629 }
630
631 #[test]
632 fn next_ready_leaf_none_when_all_done_or_blocked() {
633 // a Done, b blocked on an absent dep → no ready leaf.
634 let toml = r#"
635[[subtask]]
636id = "a"
637instruction = "done"
638status = "done"
639
640[[subtask]]
641id = "b"
642instruction = "blocked"
643deps = ["never_exists"]
644"#;
645 let plan = Plan::from_toml_str(toml).unwrap();
646 assert!(plan.next_ready_leaf().is_none());
647 }
648
649 #[test]
650 fn parent_defaults_none_and_fragment_stays_valid() {
651 // Fragment-validity: a bare subtask whose parent lives outside the slice
652 // still parses (the pointer is soft); an omitted parent defaults to None.
653 let frag = Plan::from_toml_str(
654 "[[subtask]]\nid=\"leaf\"\ninstruction=\"x\"\nparent=\"outside_the_slice\"\n",
655 )
656 .unwrap();
657 assert_eq!(
658 frag.subtasks[0].parent.as_deref(),
659 Some("outside_the_slice")
660 );
661 let root = Plan::from_toml_str("[[subtask]]\nid=\"r\"\ninstruction=\"y\"\n").unwrap();
662 assert!(root.subtasks[0].parent.is_none());
663 assert_eq!(root.roots().len(), 1);
664 }
665
666 #[test]
667 fn to_crew_task_projects_goal_context_and_attenuated_caveats() {
668 // A leaf declaring fs_write=["src/"] only; the parent grants everything.
669 let toml = r#"
670[[subtask]]
671id = "leaf"
672instruction = "write the module"
673context = ["src/lib.rs"]
674
675[subtask.caveat_policy]
676fs_write = ["src/"]
677"#;
678 let plan = Plan::from_toml_str(toml).unwrap();
679 let task = plan.subtasks[0].to_crew_task(&Caveats::top());
680 assert_eq!(task.goal, "write the module");
681 assert_eq!(task.context, vec!["src/lib.rs".to_string()]);
682 // The child gets exactly what it declared (parent=top allows all):
683 assert_eq!(task.caveats.fs_write, Scope::only(["src/".to_string()]));
684 // Everything else stays DENIED (default-deny leaf) — never widened to top.
685 assert_eq!(task.caveats.fs_read, Scope::none());
686 assert_eq!(task.caveats.exec, Scope::none());
687 assert_eq!(task.caveats.net, Scope::none());
688 }
689
690 #[test]
691 fn to_crew_task_never_widens_past_the_parent() {
692 // The leaf REQUESTS fs_read=all, but the parent only GRANTS fs_read=["a/"];
693 // meet clamps the request to the grant — attenuation, never amplify.
694 let toml = r#"
695[[subtask]]
696id = "leaf"
697instruction = "x"
698
699[subtask.caveat_policy]
700fs_read = "all"
701"#;
702 let plan = Plan::from_toml_str(toml).unwrap();
703 let parent = Caveats {
704 fs_read: Scope::only(["a/".to_string()]),
705 ..Caveats::top()
706 };
707 let task = plan.subtasks[0].to_crew_task(&parent);
708 assert_eq!(task.caveats.fs_read, Scope::only(["a/".to_string()]));
709 }
710
711 #[test]
712 fn plan_is_a_drivable_execution_state_machine() {
713 // An overseer-authored DAG: a → b(deps a) → c(deps b), all under "epic".
714 let toml = r#"
715[[subtask]]
716id = "epic"
717instruction = "branch"
718
719[[subtask]]
720id = "a"
721instruction = "step a"
722parent = "epic"
723
724[[subtask]]
725id = "b"
726instruction = "step b"
727parent = "epic"
728deps = ["a"]
729
730[[subtask]]
731id = "c"
732instruction = "step c"
733parent = "epic"
734deps = ["b"]
735"#;
736 let mut plan = Plan::from_toml_str(toml).unwrap();
737 let top = Caveats::top();
738 // The drive loop a real executor runs: dispatch the next ready leaf,
739 // mark it Done, repeat. (Here the "dispatch" is a no-op; a CrewRunner
740 // would run inference. The state machine is what's under test.)
741 let mut order = Vec::new();
742 while let Some((id, task)) = plan.next_dispatch(&top) {
743 assert!(!task.goal.is_empty());
744 plan.mark(&id, SubtaskStatus::Running, None);
745 plan.mark(&id, SubtaskStatus::Done, Some(format!("ran {id}")));
746 order.push(id);
747 }
748 // Walked a → b → c in dependency order; epic (a branch) never dispatched.
749 assert_eq!(order, vec!["a", "b", "c"]);
750 assert!(plan.is_complete());
751 assert_eq!(plan.subtask("a").unwrap().result.as_deref(), Some("ran a"));
752 }
753
754 #[test]
755 fn a_failed_leaf_blocks_its_dependents_and_stops_the_run() {
756 let toml = r#"
757[[subtask]]
758id = "a"
759instruction = "x"
760
761[[subtask]]
762id = "b"
763instruction = "y"
764deps = ["a"]
765"#;
766 let mut plan = Plan::from_toml_str(toml).unwrap();
767 let top = Caveats::top();
768 let (id, _task) = plan.next_dispatch(&top).expect("a is ready");
769 assert_eq!(id, "a");
770 plan.mark(&id, SubtaskStatus::Failed, Some("boom".into()));
771 // b deps on a (now Failed, not Done) → not ready → nothing to dispatch,
772 // so the run stops honestly at the first failure (no separate stop flag).
773 assert!(plan.next_dispatch(&top).is_none());
774 assert!(!plan.is_complete());
775 }
776
777 #[test]
778 fn mark_is_a_noop_for_an_absent_id_and_empty_plan_is_complete() {
779 let mut plan = Plan::from_toml_str("[[subtask]]\nid=\"a\"\ninstruction=\"x\"\n").unwrap();
780 plan.mark("nope", SubtaskStatus::Done, Some("ignored".into()));
781 assert_eq!(plan.subtask("a").unwrap().status, SubtaskStatus::Pending);
782 assert!(Plan::from_toml_str("").unwrap().is_complete());
783 }
784}