deepstrike_core/orchestration/workflow/mod.rs
1//! Declarative workflow shapes — the six patterns as composable templates.
2//!
3//! A [`WorkflowSpec`] is a pure, declarative DAG of [`WorkflowNode`]s, each carrying the
4//! per-node execution contract (role / isolation / context inheritance / model hint) that
5//! the SDK turns into an `AgentRunSpec` at spawn time. This is the data the template
6//! constructors below emit, and the shape a future "orchestration-as-syscall" round will
7//! lower into per-step [`crate::syscall::Syscall`]s.
8//!
9//! Three patterns are template constructors here. The dynamic control-flow patterns —
10//! loop-until-done, classify-and-act, and tournament — are now first-class [`NodeKind`] variants
11//! ([`NodeKind::Loop`] / [`NodeKind::Classify`] / [`NodeKind::Tournament`]) driven by the unified
12//! workflow executor; the former standalone `loop_until_done` / `tournament` SDK primitives were
13//! removed in their favor (A#1). The generate→evaluate→retry quality gate is the [`gen_eval`]
14//! template (a `Loop` worker + a `Verify` eval node carrying [`crate::harness::verdict_output_schema`]);
15//! its eval/verdict compute lives in [`crate::harness`].
16//!
17//! Pure: no I/O, no clock, no spawning. Validation reuses [`TaskGraph::topological_sort`].
18
19use serde::{Deserialize, Serialize};
20
21use super::task_graph::TaskGraph;
22use crate::types::agent::{AgentIsolation, AgentRole, ContextInheritance};
23use crate::types::error::{DeepStrikeError, Result};
24use crate::types::task::RuntimeTask;
25
26/// The kernel-resident execution state for an in-flight [`WorkflowSpec`] — the DAG run-queue,
27/// tournament bracket advancement, and per-node spawn descriptors. Was `scheduler/workflow_run.rs`;
28/// folded under `workflow` so the declarative spec and its runtime live in one module.
29pub mod run;
30pub use run::*;
31
32/// W3: a node's trust level. `Quarantined` nodes read untrusted content and must run with no
33/// privileges; their output crosses into the trusted plane only as a structured summary (the SDK
34/// enforces this — the kernel carries the flag to every spawn descriptor).
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum NodeTrust {
38 #[default]
39 Trusted,
40 Quarantined,
41}
42
43/// One branch of a [`NodeKind::Classify`] node: a label and the node indices to enable when the
44/// classifier's result selects that label. The other branches' nodes are pruned (failed) so they
45/// never run — this is how a classify node yields *conditional edges* in an otherwise static DAG.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ClassifyBranch {
48 pub label: String,
49 pub nodes: Vec<usize>,
50}
51
52/// Control-flow kind of a workflow node. `Spawn` (the default) runs the node's agent once.
53/// `Loop` re-runs it until a stop condition; `Classify` routes to one branch by its result;
54/// `Tournament` generates entrants and pairwise-judges them — all dynamic control-flow types.
55/// Additive: existing specs omit `kind` → `Spawn`. (No `Eq`: a `Tournament`'s entrant tasks carry
56/// arbitrary JSON metadata, which is `PartialEq` but not `Eq`.)
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
58#[serde(rename_all = "snake_case", tag = "type")]
59pub enum NodeKind {
60 /// Run the node's agent once (classic spawn node).
61 #[default]
62 Spawn,
63 /// Re-run the node's agent up to `max_iters` times; an iteration reporting
64 /// `loop_continue=Some(false)` stops early (v2 "until done").
65 Loop { max_iters: usize },
66 /// Run the node's agent once as a classifier; its `classify_branch` result selects one branch
67 /// to run and prunes the others. Branch nodes must `depends_on` this classify node.
68 ///
69 /// NOTE (W-11): prefer expressing classify-and-act via *runtime submission* — run the
70 /// classifier as a plain node and have it `submit_workflow_nodes` only the chosen branch. That
71 /// form needs no branch pre-declaration, prune bookkeeping, or resume branch-replay, and is the
72 /// CC-parity model-driven shape. `Classify` stays for declaratively auditable topologies (the
73 /// full branch set is visible up front) but should not grow new capabilities.
74 Classify { branches: Vec<ClassifyBranch> },
75 /// A *controller* node (spawns no agent of its own): it generates `entrants` candidates in
76 /// parallel, then runs a single-elimination bracket of pairwise judges (reusing
77 /// [`super::tournament::Tournament`]) until one survivor remains. The winner's id lands in the
78 /// node's `tournament_winner` result; dependents start only after the bracket resolves.
79 Tournament { entrants: Vec<RuntimeTask> },
80 /// G2 deterministic compute: a *host-compute* node that runs no LLM agent. The kernel schedules
81 /// it like a `Spawn` (deps / ready / completion) but stamps its spawn descriptor with `reducer`
82 /// + the dependency agent ids, and the SDK routes it to a registered pure function over those
83 /// dependencies' outputs (dedupe / filter / merge / early-exit) instead of the model. This is the
84 /// "ordinary code between stages" of the code-orchestration model, expressed as a DAG node — no
85 /// agent burned, fully deterministic. `reducer` names the SDK-side function.
86 Reduce { reducer: String },
87}
88
89/// One node in a workflow DAG: a task plus the contract its agent runs under.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct WorkflowNode {
92 pub task: RuntimeTask,
93 pub role: AgentRole,
94 pub isolation: AgentIsolation,
95 pub context_inheritance: ContextInheritance,
96 /// Optional model preference (e.g. "opus" / "sonnet"); the SDK resolves it. See W4.
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub model_hint: Option<String>,
99 /// W3 trust level. Default `Trusted`.
100 #[serde(default, skip_serializing_if = "is_trusted")]
101 pub trust: NodeTrust,
102 /// G3 structured output: an optional JSON Schema the node's agent output must conform to. The
103 /// kernel is zero-I/O and never validates it — it carries the schema verbatim to the spawn
104 /// descriptor so the SDK can instruct the agent and validate/retry on its result (the structured
105 /// "summary only" contract from image 8 is enforced SDK-side; the kernel owns the contract).
106 /// Additive: omitted on the wire when absent.
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub output_schema: Option<serde_json::Value>,
109 /// Control-flow kind. Default `Spawn` (run once).
110 #[serde(default, skip_serializing_if = "is_spawn")]
111 pub kind: NodeKind,
112 /// M4/G5: optional per-node cumulative token cap. The kernel carries it to the spawn descriptor;
113 /// the SDK sets the node's child-run `max_total_tokens` to it, so an expensive node self-terminates
114 /// at the cap (the "use N tokens" budget, applied per node). Additive: omitted on the wire when
115 /// `None`.
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub token_budget: Option<u64>,
118 /// O3 per-node turn cap: the SDK sets the child run's `max_turns` (falls back to the parent's).
119 /// Mirrors `token_budget` — same hop chain, additive ABI.
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub max_turns: Option<u32>,
122 /// O3 per-node wall-clock cap (ms): the SDK sets the child run's timeout. Additive ABI.
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub max_wall_ms: Option<u64>,
125 /// Indices into [`WorkflowSpec::nodes`] this node depends on.
126 #[serde(default, skip_serializing_if = "Vec::is_empty")]
127 pub depends_on: Vec<usize>,
128}
129
130fn is_trusted(t: &NodeTrust) -> bool {
131 matches!(t, NodeTrust::Trusted)
132}
133
134fn is_spawn(k: &NodeKind) -> bool {
135 matches!(k, NodeKind::Spawn)
136}
137
138impl WorkflowNode {
139 /// A node with role-default isolation/inheritance and no dependencies.
140 pub fn new(task: RuntimeTask, role: AgentRole) -> Self {
141 let (isolation, context_inheritance) = role_defaults(role);
142 Self {
143 task,
144 role,
145 isolation,
146 context_inheritance,
147 model_hint: None,
148 trust: NodeTrust::Trusted,
149 output_schema: None,
150 kind: NodeKind::Spawn,
151 token_budget: None,
152 max_turns: None,
153 max_wall_ms: None,
154 depends_on: Vec::new(),
155 }
156 }
157
158 /// M4/G5: cap this node's child run at `tokens` cumulative tokens.
159 pub fn with_token_budget(mut self, tokens: u64) -> Self {
160 self.token_budget = Some(tokens);
161 self
162 }
163
164 /// O3: cap this node's child run at `turns` provider turns.
165 pub fn with_max_turns(mut self, turns: u32) -> Self {
166 self.max_turns = Some(turns);
167 self
168 }
169
170 /// O3: cap this node's child run at `ms` wall-clock milliseconds.
171 pub fn with_max_wall_ms(mut self, ms: u64) -> Self {
172 self.max_wall_ms = Some(ms);
173 self
174 }
175
176 /// Make this a loop node: re-run the agent up to `max_iters` times before completing.
177 /// Dependents wait for the whole loop to finish.
178 pub fn with_loop(mut self, max_iters: usize) -> Self {
179 self.kind = NodeKind::Loop { max_iters };
180 self
181 }
182
183 /// Make this a classify node: its result selects one of `branches` to run; the rest are pruned.
184 pub fn with_classify(mut self, branches: Vec<ClassifyBranch>) -> Self {
185 self.kind = NodeKind::Classify { branches };
186 self
187 }
188
189 /// Make this a tournament *controller* node: it spawns no agent of its own but generates each
190 /// of `entrants` (in parallel), then pairwise-judges them to a single winner. The node's own
191 /// `task.goal` is the judging criterion handed to every judge. Requires ≥2 entrants.
192 pub fn with_tournament(mut self, entrants: Vec<RuntimeTask>) -> Self {
193 self.kind = NodeKind::Tournament { entrants };
194 self
195 }
196
197 /// G2: make this a deterministic *reduce* node — it runs no LLM agent; the SDK routes it to the
198 /// registered `reducer` function over its dependencies' outputs (dedupe / filter / merge). Give
199 /// it `depends_on` the nodes whose outputs it consumes.
200 pub fn with_reduce(mut self, reducer: impl Into<String>) -> Self {
201 self.kind = NodeKind::Reduce { reducer: reducer.into() };
202 self
203 }
204
205 pub fn with_depends_on(mut self, depends_on: Vec<usize>) -> Self {
206 self.depends_on = depends_on;
207 self
208 }
209
210 pub fn with_isolation(mut self, isolation: AgentIsolation) -> Self {
211 self.isolation = isolation;
212 self
213 }
214
215 pub fn with_model_hint(mut self, hint: impl Into<String>) -> Self {
216 self.model_hint = Some(hint.into());
217 self
218 }
219
220 /// W3: mark this node's trust level. `Quarantined` nodes read untrusted content and are
221 /// kernel-enforced to read-only (a quarantined node declaring write isolation is denied).
222 pub fn with_trust(mut self, trust: NodeTrust) -> Self {
223 self.trust = trust;
224 self
225 }
226
227 /// Mark this node as quarantined (reads untrusted content, runs without privileges).
228 pub fn quarantined(mut self) -> Self {
229 self.trust = NodeTrust::Quarantined;
230 self
231 }
232
233 /// G3: require this node's output to conform to a JSON Schema. The kernel carries it verbatim to
234 /// the spawn descriptor; the SDK instructs the agent and validates/retries on its result.
235 pub fn with_output_schema(mut self, schema: serde_json::Value) -> Self {
236 self.output_schema = Some(schema);
237 self
238 }
239}
240
241/// Role-appropriate defaults for a freshly templated node. Verifiers/explorers run
242/// read-only with minimal inherited context to resist self-preferential bias.
243fn role_defaults(role: AgentRole) -> (AgentIsolation, ContextInheritance) {
244 match role {
245 AgentRole::Explore => (AgentIsolation::ReadOnly, ContextInheritance::SystemOnly),
246 AgentRole::Verify => (AgentIsolation::ReadOnly, ContextInheritance::None),
247 AgentRole::Plan => (AgentIsolation::Shared, ContextInheritance::Full),
248 AgentRole::Implement => (AgentIsolation::Worktree, ContextInheritance::Full),
249 AgentRole::Custom => (AgentIsolation::Shared, ContextInheritance::None),
250 }
251}
252
253/// A declarative workflow DAG.
254#[derive(Debug, Clone, Default, Serialize, Deserialize)]
255pub struct WorkflowSpec {
256 pub nodes: Vec<WorkflowNode>,
257}
258
259impl WorkflowSpec {
260 pub fn new(nodes: Vec<WorkflowNode>) -> Self {
261 Self { nodes }
262 }
263
264 /// Validate dependency indices are in range and the graph is acyclic.
265 pub fn validate(&self) -> Result<TaskGraph> {
266 let n = self.nodes.len();
267 for (i, node) in self.nodes.iter().enumerate() {
268 if let NodeKind::Loop { max_iters: 0 } = node.kind {
269 return Err(DeepStrikeError::InvalidConfig(format!(
270 "node {i} is a loop with max_iters=0 (would never run)"
271 )));
272 }
273 if let NodeKind::Tournament { entrants } = &node.kind {
274 if entrants.len() < 2 {
275 return Err(DeepStrikeError::InvalidConfig(format!(
276 "tournament node {i} needs at least 2 entrants (have {})",
277 entrants.len()
278 )));
279 }
280 }
281 if let NodeKind::Classify { branches } = &node.kind {
282 for branch in branches {
283 for &bn in &branch.nodes {
284 if bn >= n {
285 return Err(DeepStrikeError::InvalidConfig(format!(
286 "classify node {i} branch '{}' references out-of-range node {bn}",
287 branch.label
288 )));
289 }
290 // Branch nodes must be gated by the classifier, else they'd run before
291 // classification and the prune would come too late.
292 if !self.nodes[bn].depends_on.contains(&i) {
293 return Err(DeepStrikeError::InvalidConfig(format!(
294 "classify node {i} branch '{}' node {bn} must depends_on {i}",
295 branch.label
296 )));
297 }
298 }
299 }
300 }
301 for &dep in &node.depends_on {
302 if dep >= n {
303 return Err(DeepStrikeError::InvalidConfig(format!(
304 "node {i} depends on out-of-range node {dep} (have {n})"
305 )));
306 }
307 if dep == i {
308 return Err(DeepStrikeError::InvalidConfig(format!(
309 "node {i} depends on itself"
310 )));
311 }
312 }
313 }
314 // Reuse the executor's cycle detection; hand the built graph back so callers
315 // that need it (WorkflowRun::new) don't lower + range-check a second time.
316 let graph = self.to_task_graph()?;
317 graph.topological_sort()?;
318 Ok(graph)
319 }
320
321 /// Lower into an executable [`TaskGraph`] (preserves node order as task ids).
322 pub fn to_task_graph(&self) -> Result<TaskGraph> {
323 let n = self.nodes.len();
324 let mut graph = TaskGraph::new();
325 for node in &self.nodes {
326 if let Some(&bad) = node.depends_on.iter().find(|&&d| d >= n) {
327 return Err(DeepStrikeError::InvalidConfig(format!(
328 "dependency index {bad} out of range (have {n})"
329 )));
330 }
331 graph.add(node.task.clone(), node.depends_on.clone());
332 }
333 Ok(graph)
334 }
335}
336
337// ---------------------------------------------------------------------------
338// Pattern 1 — Fan-out-and-synthesize
339// ---------------------------------------------------------------------------
340
341/// N parallel workers feeding a single synthesize barrier that depends on all of them.
342///
343/// Workers run as read-only `Explore` agents in the `Retrieve` lane (parallelisable, each
344/// with its own clean context); the synthesizer is a `Plan` agent that merges their
345/// structured outputs.
346pub fn fanout_synthesize(workers: Vec<RuntimeTask>, synthesize: RuntimeTask) -> WorkflowSpec {
347 let mut nodes: Vec<WorkflowNode> = workers
348 .into_iter()
349 .map(|t| WorkflowNode::new(t, AgentRole::Explore))
350 .collect();
351 let worker_ids: Vec<usize> = (0..nodes.len()).collect();
352 nodes.push(
353 WorkflowNode::new(synthesize, AgentRole::Plan)
354 .with_depends_on(worker_ids),
355 );
356 WorkflowSpec::new(nodes)
357}
358
359// ---------------------------------------------------------------------------
360// Pattern 2 — Generate-and-filter
361// ---------------------------------------------------------------------------
362
363/// N parallel generators feeding a single filter/dedupe step that depends on all of them.
364///
365/// Structurally a fan-out barrier, but semantically distinct: generators are `Implement`
366/// agents producing candidates; the filter is a `Verify` agent that ranks/dedupes against
367/// a rubric (pair with the [`gen_eval`] verdict schema for the rubric).
368pub fn generate_and_filter(generators: Vec<RuntimeTask>, filter: RuntimeTask) -> WorkflowSpec {
369 let mut nodes: Vec<WorkflowNode> = generators
370 .into_iter()
371 .map(|t| WorkflowNode::new(t, AgentRole::Implement))
372 .collect();
373 let gen_ids: Vec<usize> = (0..nodes.len()).collect();
374 nodes.push(
375 WorkflowNode::new(filter, AgentRole::Verify)
376 .with_depends_on(gen_ids),
377 );
378 WorkflowSpec::new(nodes)
379}
380
381// ---------------------------------------------------------------------------
382// W2 — Adversarial verification (the default contract)
383// ---------------------------------------------------------------------------
384
385/// One fresh-context verifier per rule/claim, optionally followed by a skeptic that re-checks
386/// every flag to suppress false positives.
387///
388/// This is the article's rule-adherence pattern. Each verifier runs as a `Verify` agent, which
389/// [`role_defaults`] gives `ReadOnly` isolation + [`ContextInheritance::None`] — the verifier does
390/// **not** inherit the author's reasoning, so it cannot rubber-stamp it (the structural defence
391/// against self-preferential bias). The optional `skeptic` depends on all verifiers and reviews
392/// their flags (real violation vs. false positive). Runs on the W0 workflow executor.
393///
394/// For unknown-size rule sets (claim extraction), a dynamic-fan-out variant is a later round; this
395/// covers the case where the rule/claim set is known up front. For the generate→evaluate→retry
396/// quality gate (scoring one author's output against criteria), see [`gen_eval`].
397pub fn verify_rules(rules: Vec<RuntimeTask>, skeptic: Option<RuntimeTask>) -> WorkflowSpec {
398 let mut nodes: Vec<WorkflowNode> = rules
399 .into_iter()
400 .map(|t| WorkflowNode::new(t, AgentRole::Verify))
401 .collect();
402 if let Some(skeptic) = skeptic {
403 let verifier_ids: Vec<usize> = (0..nodes.len()).collect();
404 nodes.push(
405 WorkflowNode::new(skeptic, AgentRole::Verify)
406 .with_depends_on(verifier_ids),
407 );
408 }
409 WorkflowSpec::new(nodes)
410}
411
412// ---------------------------------------------------------------------------
413// Quality gate — generate → evaluate (#6, the EvalPipeline successor)
414// ---------------------------------------------------------------------------
415
416/// The generate→evaluate quality gate as a workflow: a `Loop` **worker** node (the task, re-run up
417/// to `max_iters`, stopping early on a `loop_continue=false` self-signal) followed by a `Verify`
418/// **eval** node that scores the worker's output against the goal/criteria and emits a structured
419/// verdict ([`crate::harness::verdict_output_schema`] as its `output_schema`).
420///
421/// This is the declarative substrate form of the former `EvalPipeline` (0.5.0 fold, OS-axis #6).
422/// The eval node is a `Verify` agent — [`role_defaults`] gives it `ReadOnly` + [`ContextInheritance::None`]
423/// so it does not inherit the worker's reasoning (bias resistance); it evaluates the worker's
424/// *output*, carried in via its task goal. The verdict's `passed` is the gate.
425///
426/// For the **iterative retry-with-feedback** variant (re-run the worker with the eval's feedback
427/// folded into the next attempt), the SDK `HarnessLoop` drives this with the same
428/// [`crate::harness::build_eval_messages`] / [`crate::harness::parse_verdict`] primitives — the
429/// kernel `Loop` re-arms a single node, so per-iteration eval is necessarily SDK-driven.
430pub fn gen_eval(
431 worker: RuntimeTask,
432 eval: RuntimeTask,
433 max_iters: usize,
434 extract_skill_on_pass: bool,
435) -> WorkflowSpec {
436 let worker_node = WorkflowNode::new(
437 worker,
438 AgentRole::Implement,
439 )
440 .with_loop(max_iters.max(1));
441 let eval_node = WorkflowNode::new(
442 eval,
443 AgentRole::Verify,
444 )
445 .with_depends_on(vec![0])
446 .with_output_schema(crate::harness::verdict_output_schema(extract_skill_on_pass));
447 WorkflowSpec::new(vec![worker_node, eval_node])
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 fn task(goal: &str) -> RuntimeTask {
455 RuntimeTask::new(goal)
456 }
457
458 #[test]
459 fn fanout_synthesize_shape() {
460 let spec = fanout_synthesize(
461 vec![task("search A"), task("search B"), task("search C")],
462 task("merge findings"),
463 );
464 assert_eq!(spec.nodes.len(), 4);
465 // synthesize node depends on all three workers
466 assert_eq!(spec.nodes[3].depends_on, vec![0, 1, 2]);
467 assert_eq!(spec.nodes[3].role, AgentRole::Plan);
468 assert_eq!(spec.nodes[0].role, AgentRole::Explore);
469 assert_eq!(spec.nodes[0].isolation, AgentIsolation::ReadOnly);
470 spec.validate().unwrap();
471 // workers are the only ready tasks before any completion
472 let graph = spec.to_task_graph().unwrap();
473 assert_eq!(graph.ready_tasks(), vec![0, 1, 2]);
474 }
475
476 #[test]
477 fn generate_and_filter_shape() {
478 let spec = generate_and_filter(vec![task("idea 1"), task("idea 2")], task("dedupe + rank"));
479 assert_eq!(spec.nodes.len(), 3);
480 assert_eq!(spec.nodes[2].depends_on, vec![0, 1]);
481 assert_eq!(spec.nodes[2].role, AgentRole::Verify);
482 assert_eq!(spec.nodes[2].context_inheritance, ContextInheritance::None);
483 assert_eq!(spec.nodes[0].role, AgentRole::Implement);
484 spec.validate().unwrap();
485 }
486
487 #[test]
488 fn verify_rules_with_skeptic_shape() {
489 let spec = verify_rules(
490 vec![task("money is integer cents"), task("errors propagate"), task("utc timestamps")],
491 Some(task("skeptic: real violation or false positive?")),
492 );
493 assert_eq!(spec.nodes.len(), 4);
494 // skeptic depends on every verifier
495 assert_eq!(spec.nodes[3].depends_on, vec![0, 1, 2]);
496 assert_eq!(spec.nodes[3].role, AgentRole::Verify);
497 spec.validate().unwrap();
498 // verifiers are the ready set; skeptic gated behind them
499 assert_eq!(spec.to_task_graph().unwrap().ready_tasks(), vec![0, 1, 2]);
500 }
501
502 #[test]
503 fn verify_rules_verifiers_are_bias_resistant() {
504 // The default contract: every verifier runs with no inherited author context.
505 let spec = verify_rules(vec![task("rule a"), task("rule b")], None);
506 assert_eq!(spec.nodes.len(), 2); // no skeptic → just the verifiers
507 for node in &spec.nodes {
508 assert_eq!(node.role, AgentRole::Verify);
509 assert_eq!(node.context_inheritance, ContextInheritance::None);
510 assert_eq!(node.isolation, AgentIsolation::ReadOnly);
511 assert!(node.depends_on.is_empty()); // all parallel
512 }
513 spec.validate().unwrap();
514 }
515
516 #[test]
517 fn gen_eval_shape() {
518 // Worker loops; eval is a bias-resistant Verify node gated on the worker, carrying the
519 // verdict output_schema.
520 let spec = gen_eval(task("implement feature"), task("score against criteria"), 3, true);
521 assert_eq!(spec.nodes.len(), 2);
522
523 let worker = &spec.nodes[0];
524 assert_eq!(worker.role, AgentRole::Implement);
525 assert_eq!(worker.kind, NodeKind::Loop { max_iters: 3 });
526 assert!(worker.depends_on.is_empty());
527
528 let eval = &spec.nodes[1];
529 assert_eq!(eval.role, AgentRole::Verify);
530 assert_eq!(eval.context_inheritance, ContextInheritance::None);
531 assert_eq!(eval.isolation, AgentIsolation::ReadOnly);
532 assert_eq!(eval.depends_on, vec![0]);
533 let schema = eval.output_schema.as_ref().expect("eval node carries verdict schema");
534 assert!(schema["properties"]["passed"].is_object());
535 assert!(schema["properties"]["skill"].is_object()); // extract_skill_on_pass=true
536
537 spec.validate().unwrap();
538 // Worker is the only initially-ready node; eval is gated.
539 assert_eq!(spec.to_task_graph().unwrap().ready_tasks(), vec![0]);
540 }
541
542 #[test]
543 fn gen_eval_max_iters_floor_and_no_skill() {
544 // max_iters=0 would be an invalid loop; the template floors it to 1.
545 let spec = gen_eval(task("w"), task("e"), 0, false);
546 assert_eq!(spec.nodes[0].kind, NodeKind::Loop { max_iters: 1 });
547 // extract_skill_on_pass=false ⇒ no skill property in the verdict schema.
548 let schema = spec.nodes[1].output_schema.as_ref().unwrap();
549 assert!(schema["properties"]["skill"].is_null());
550 spec.validate().unwrap();
551 }
552
553 #[test]
554 fn verify_rules_empty_with_skeptic_is_just_skeptic() {
555 // No rules → skeptic has nothing to depend on; still a valid single-node spec.
556 let spec = verify_rules(vec![], Some(task("skeptic")));
557 assert_eq!(spec.nodes.len(), 1);
558 assert!(spec.nodes[0].depends_on.is_empty());
559 spec.validate().unwrap();
560 }
561
562 #[test]
563 fn validate_rejects_out_of_range_dep() {
564 let spec = WorkflowSpec::new(vec![
565 WorkflowNode::new(task("a"), AgentRole::Explore),
566 WorkflowNode::new(task("b"), AgentRole::Plan).with_depends_on(vec![5]),
567 ]);
568 assert!(spec.validate().is_err());
569 }
570
571 #[test]
572 fn validate_rejects_self_dependency() {
573 let spec = WorkflowSpec::new(vec![
574 WorkflowNode::new(task("a"), AgentRole::Plan).with_depends_on(vec![0]),
575 ]);
576 assert!(spec.validate().is_err());
577 }
578
579 #[test]
580 fn validate_rejects_cycle() {
581 // 0 -> 1 -> 0 forms a cycle (both reference each other)
582 let spec = WorkflowSpec::new(vec![
583 WorkflowNode::new(task("a"), AgentRole::Plan).with_depends_on(vec![1]),
584 WorkflowNode::new(task("b"), AgentRole::Plan).with_depends_on(vec![0]),
585 ]);
586 assert!(spec.validate().is_err());
587 }
588
589 #[test]
590 fn tournament_node_requires_two_entrants() {
591 // ≥2 entrants is valid; <2 is a spec error (no contest).
592 let ok = WorkflowSpec::new(vec![WorkflowNode::new(task("rank"), AgentRole::Plan)
593 .with_tournament(vec![task("a"), task("b")])]);
594 ok.validate().unwrap();
595
596 let one = WorkflowSpec::new(vec![WorkflowNode::new(task("rank"), AgentRole::Plan)
597 .with_tournament(vec![task("only")])]);
598 assert!(one.validate().is_err());
599 }
600
601 #[test]
602 fn tournament_node_kind_round_trips_and_gates_dependents() {
603 let spec = WorkflowSpec::new(vec![
604 WorkflowNode::new(task("pick best"), AgentRole::Plan)
605 .with_tournament(vec![task("x"), task("y"), task("z")]),
606 WorkflowNode::new(task("use winner"), AgentRole::Implement).with_depends_on(vec![0]),
607 ]);
608 spec.validate().unwrap();
609 // Only the controller is ready up front; the dependent waits for the bracket.
610 assert_eq!(spec.to_task_graph().unwrap().ready_tasks(), vec![0]);
611 // serde keeps the entrants under the tagged `tournament` kind.
612 let json = serde_json::to_string(&spec.nodes[0].kind).unwrap();
613 assert!(json.contains("\"type\":\"tournament\""), "{json}");
614 let back: NodeKind = serde_json::from_str(&json).unwrap();
615 assert_eq!(back, spec.nodes[0].kind);
616 }
617
618 #[test]
619 fn node_builder_overrides_defaults() {
620 let n = WorkflowNode::new(task("x"), AgentRole::Verify)
621 .with_isolation(AgentIsolation::Worktree)
622 .with_model_hint("opus");
623 assert_eq!(n.isolation, AgentIsolation::Worktree);
624 assert_eq!(n.model_hint.as_deref(), Some("opus"));
625 // default inheritance for Verify is None (bias-resistant)
626 assert_eq!(n.context_inheritance, ContextInheritance::None);
627 }
628}