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