Skip to main content

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