Skip to main content

deepstrike_core/orchestration/workflow/
run.rs

1//! W0: a kernel-resident workflow run — the DAG state for one in-flight [`WorkflowSpec`].
2//!
3//! Pure data + pure advance logic, no I/O and no syscall: the [`crate::scheduler::state_machine::
4//! LoopStateMachine`] drives this, gating each ready node's spawn through
5//! `evaluate_syscall(Syscall::Spawn)` and reusing the existing batch-await barrier
6//! (`SuspendState::SubAgentAwait`). This module only tracks *which* nodes are ready, spawned,
7//! complete, partially complete, failed, or skipped, and builds each node's [`IsolationManifest`].
8//!
9//! Lifecycle: `ready_batch()` → (gate each) `mark_spawned` / `mark_denied` → on completion
10//! `record_completion` → repeat until `is_complete()`.
11
12use std::collections::HashMap;
13
14use serde::{Deserialize, Serialize};
15
16use super::{DependencyPolicy, NodeKind, NodeTrust, WorkflowNode, WorkflowSpec};
17use crate::orchestration::task_graph::{TaskGraph, TaskStatus};
18use crate::orchestration::tournament::{EntrantId, Match, Tournament, TournamentAction};
19use crate::types::agent::{AgentIsolation, AgentRole, ContextInheritance, IsolationManifest};
20use crate::types::error::DeepStrikeError;
21use crate::types::error::Result;
22use crate::types::result::{LoopResult, TerminationReason};
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct WorkflowSubmissionError {
26    pub node_index: usize,
27    pub reason: String,
28}
29
30impl std::fmt::Display for WorkflowSubmissionError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "node {}: {}", self.node_index, self.reason)
33    }
34}
35
36impl std::error::Error for WorkflowSubmissionError {}
37
38/// Terminal state of one workflow node. Non-terminal graph states are never exposed as outcomes.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum WorkflowNodeStatus {
42    Completed,
43    CompletedPartial,
44    Failed,
45    SkippedUpstreamFailed,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct WorkflowNodeOutcome {
50    pub node_id: String,
51    pub status: WorkflowNodeStatus,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub termination: Option<TerminationReason>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub output: Option<crate::types::message::Message>,
56}
57
58/// Deterministic kernel agent id for a workflow node (stable across resume / audit).
59pub fn node_agent_id(node: usize) -> String {
60    format!("wf-node{node}")
61}
62
63/// Enough to run one spawned workflow node, carried to the SDK in the `WorkflowBatchSpawned`
64/// observation. Role/isolation/inheritance are canonical snake_case strings (serde names) so the
65/// host SDK can rebuild an agent run spec — the kernel generates these specs internally, so this
66/// is how the goal reaches the SDK that actually executes the node.
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct WorkflowSpawnInfo {
69    pub agent_id: String,
70    pub goal: String,
71    pub role: String,
72    pub isolation: String,
73    pub context_inheritance: String,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub model_hint: Option<String>,
76    /// W3 trust level (`"trusted"` | `"quarantined"`) — the SDK runs quarantined nodes without
77    /// privileges and crosses their output back only as a structured summary.
78    #[serde(default = "default_trust")]
79    pub trust: String,
80    /// G3 structured output: the JSON Schema the node's output must conform to, carried verbatim
81    /// from [`WorkflowNode::output_schema`]. The SDK instructs the agent with it and validates +
82    /// retries on its result. `None` when the node declared no schema. Additive ABI.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub output_schema: Option<serde_json::Value>,
85    /// G2 deterministic compute: present only for a [`NodeKind::Reduce`] node — the name of the
86    /// SDK-registered pure function the SDK runs (over `input_agent_ids`' outputs) instead of an LLM
87    /// agent. `None` for every ordinary node. Additive ABI.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub reducer: Option<String>,
90    /// G2: the dependency agent ids whose outputs a [`NodeKind::Reduce`] node consumes (its
91    /// `depends_on`, resolved to stable agent ids). Empty for non-reduce nodes. Additive ABI.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub input_agent_ids: Vec<String>,
94    /// Present only for a tournament *judge* spawn (A#2): the two entrant agent ids whose outputs
95    /// this judge must compare. The SDK looks up those entrants' produced candidates, runs the
96    /// judge, and reports the winner in the result's `tournament_winner`. `None` for every ordinary
97    /// (entrant / spawn / loop / classify) node. Additive ABI: omitted on the wire when `None`.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub judge_match: Option<JudgeMatch>,
100    /// Present only for a [`NodeKind::Loop`] iteration spawn (A#2 v2): the loop's `max_iters`. It
101    /// both *marks* the spawn as a loop iteration — so the SDK knows to solicit and report a
102    /// `loop_continue` stop signal from the agent — and gives the cap for the agent's prompt. `None`
103    /// for every non-loop node. Mirrors how `reducer` / `judge_match` distinguish reduce / judge
104    /// spawns. Additive ABI: omitted on the wire when `None`.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub loop_max_iters: Option<usize>,
107    /// Present only for a [`NodeKind::Classify`] spawn (A#2): the branch labels the classifier must
108    /// choose among. Non-empty *marks* the spawn as a classifier — the SDK instructs the agent to
109    /// pick exactly one label and reports it in the result's `classify_branch`. Empty for every
110    /// non-classify node. Additive ABI: omitted on the wire when empty.
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub classify_labels: Vec<String>,
113    /// M4/G5: the node's per-node cumulative token cap, if set. The SDK sets the child run's
114    /// `max_total_tokens` to this so the node self-terminates at the cap. Additive ABI: omitted 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 child run's `max_turns`. Additive ABI: omitted when `None`.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub max_turns: Option<u32>,
121    /// O3: per-node wall-clock cap (ms) → the child run's timeout. Additive ABI: omitted when `None`.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub max_wall_ms: Option<u64>,
124}
125
126fn default_trust() -> String {
127    "trusted".to_string()
128}
129
130/// A pairwise judge assignment carried to the SDK on a tournament judge's `WorkflowSpawnInfo`:
131/// the two entrant agent ids whose produced outputs are to be compared. The SDK maps each id back
132/// to that entrant's candidate and asks the judge which is better.
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134pub struct JudgeMatch {
135    pub left: String,
136    pub right: String,
137}
138
139/// G4 budget-as-signal: a snapshot of the workflow's remaining headroom under the active resource
140/// quota, carried to the SDK on every `WorkflowBatchSpawned`. A coordinator/submitter node reads it
141/// to *scale its next submission to what is actually available* — the analogue of the host-side
142/// `budget.remaining()` in the code-orchestration model — instead of blindly hitting the cap and
143/// eating a `Deny`. `None` remaining fields mean that dimension is unbounded (no quota set).
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
145pub struct WorkflowBudget {
146    /// Nodes currently in the DAG (spec + every runtime submission so far).
147    pub nodes_used: usize,
148    /// `ResourceQuota::max_workflow_nodes`, if set.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub nodes_max: Option<usize>,
151    /// `nodes_max - nodes_used` (saturating), if a node cap is set — how many more nodes may be
152    /// submitted before the `max_workflow_nodes` backstop denies further growth.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub nodes_remaining: Option<usize>,
155    /// Sub-agents currently in the `running` state.
156    pub running_subagents: usize,
157    /// `ResourceQuota::max_concurrent_subagents`, if set.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub max_concurrent_subagents: Option<usize>,
160    /// `max_concurrent_subagents - running_subagents` (saturating), if a concurrency cap is set —
161    /// how many of a submission's nodes can spawn *immediately* rather than deferring for a slot.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub concurrency_remaining: Option<usize>,
164    /// M4/G5: cumulative tokens spent across the run so far (the scheduler's `total_tokens`).
165    /// `#[serde(default)]` keeps older JSON (without this field) deserializing to 0 — additive ABI.
166    #[serde(default)]
167    pub tokens_used: u64,
168    /// M4/G5: `SchedulerBudget::max_total_tokens` — the run's cumulative token cap.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub tokens_max: Option<u64>,
171    /// M4/G5: `tokens_max - tokens_used` (saturating) — how many tokens remain before the run-level
172    /// token budget terminates the workflow. Lets a coordinator scale its next submission to token
173    /// headroom (the analogue of "use 10k tokens").
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub tokens_remaining: Option<u64>,
176}
177
178fn role_label(role: AgentRole) -> &'static str {
179    match role {
180        AgentRole::Explore => "explore",
181        AgentRole::Plan => "plan",
182        AgentRole::Implement => "implement",
183        AgentRole::Verify => "verify",
184        AgentRole::Custom => "custom",
185    }
186}
187
188fn isolation_label(isolation: AgentIsolation) -> &'static str {
189    match isolation {
190        AgentIsolation::Shared => "shared",
191        AgentIsolation::ReadOnly => "read_only",
192        AgentIsolation::Worktree => "worktree",
193        AgentIsolation::Remote => "remote",
194    }
195}
196
197fn inheritance_label(inheritance: ContextInheritance) -> &'static str {
198    match inheritance {
199        ContextInheritance::None => "none",
200        ContextInheritance::SystemOnly => "system_only",
201        ContextInheritance::Full => "full",
202    }
203}
204
205fn trust_label(trust: NodeTrust) -> &'static str {
206    match trust {
207        NodeTrust::Trusted => "trusted",
208        NodeTrust::Quarantined => "quarantined",
209    }
210}
211
212/// In-flight bracket state for one `NodeKind::Tournament` controller node. Entrant and judge
213/// children are appended as ordinary graph nodes (so they flow through the unchanged spawn loop);
214/// this just tracks the phase and the current round's judges so completions advance the bracket.
215struct TournamentState {
216    /// Entrant child node indices (the generators), in entrant order.
217    entrant_nodes: Vec<usize>,
218    /// Entrants still generating; the bracket starts when this reaches 0.
219    entrants_remaining: usize,
220    /// Single-elimination bracket — `None` during the entrant phase, `Some` once judging begins.
221    bracket: Option<Tournament>,
222    /// Current round's judge child node indices, aligned to the bracket's pending matches.
223    judge_nodes: Vec<usize>,
224    /// Winner reported per current-round match (aligned to `judge_nodes`); `None` until judged.
225    judge_winners: Vec<Option<EntrantId>>,
226    /// Judges still deliberating this round; the round resolves when this reaches 0.
227    judges_remaining: usize,
228}
229
230/// The state of one in-flight workflow execution.
231pub struct WorkflowRun {
232    graph: TaskGraph,
233    nodes: Vec<WorkflowNode>,
234    scheduler_policy: crate::scheduler::policy::SchedulerPolicyConfig,
235    /// Completed-event lookup: kernel agent id → DAG node index.
236    node_of_agent: HashMap<String, usize>,
237    /// Completed-iteration count per `Loop` node (absent / 0 = no iterations finished yet). The
238    /// in-flight iteration's agent id is `wf-node{N}-i{iter_counts[N]}`.
239    iter_counts: HashMap<usize, usize>,
240    /// In-flight bracket state per `NodeKind::Tournament` controller node index.
241    tournaments: HashMap<usize, TournamentState>,
242    /// Reverse map: an appended entrant/judge child node index → its controller node index.
243    child_controller: HashMap<usize, usize>,
244    /// Judge-match descriptor per judge child node index (read by `spawn_info`).
245    judge_matches: HashMap<usize, JudgeMatch>,
246}
247
248/// Semantic workflow-node state used by logical checkpoint projection.
249///
250/// This is deliberately not serializable and exposes none of `TaskGraph`'s private indexes. The
251/// checkpoint layer maps it onto its own stable DTO; restore rebuilds the indexes from this state.
252#[derive(Debug, Clone)]
253pub(crate) struct WorkflowRuntimeNodeState {
254    pub node: WorkflowNode,
255    pub status: TaskStatus,
256    pub result: Option<LoopResult>,
257    pub active_agent_id: Option<String>,
258    pub iterations_completed: usize,
259}
260
261impl WorkflowRun {
262    /// Build from a spec. Validates dependency indices + acyclicity (reuses `WorkflowSpec`).
263    pub fn new(spec: &WorkflowSpec) -> Result<Self> {
264        let mut run = Self {
265            graph: spec.validate()?,
266            nodes: spec.nodes.clone(),
267            scheduler_policy: crate::scheduler::policy::SchedulerPolicyConfig::default(),
268            node_of_agent: HashMap::new(),
269            iter_counts: HashMap::new(),
270            tournaments: HashMap::new(),
271            child_controller: HashMap::new(),
272            judge_matches: HashMap::new(),
273        };
274        run.refresh_scheduling();
275        run.resolve_dependency_outcomes();
276        Ok(run)
277    }
278
279    /// Rebuild an active run from the semantic state carried by a logical checkpoint.
280    pub(crate) fn restore_from_checkpoint(
281        spec: &WorkflowSpec,
282        states: &[WorkflowRuntimeNodeState],
283    ) -> Result<Self> {
284        let mut run = Self::new(spec)?;
285        let graph_states: Vec<(TaskStatus, Option<LoopResult>)> = states
286            .iter()
287            .map(|state| (state.status, state.result.clone()))
288            .collect();
289        run.graph
290            .restore_runtime_state(&graph_states)
291            .map_err(DeepStrikeError::InvalidConfig)?;
292
293        for (node, state) in states.iter().enumerate() {
294            if state.iterations_completed > 0 {
295                run.iter_counts.insert(node, state.iterations_completed);
296            }
297            match (state.status, state.active_agent_id.as_deref()) {
298                (TaskStatus::Running, Some(agent_id)) => {
299                    run.node_of_agent.insert(agent_id.to_string(), node);
300                }
301                (TaskStatus::Running, None) => {
302                    return Err(DeepStrikeError::InvalidConfig(format!(
303                        "running workflow node {node} carries no active agent id"
304                    )));
305                }
306                (_, Some(agent_id)) => {
307                    return Err(DeepStrikeError::InvalidConfig(format!(
308                        "non-running workflow node {node} carries active agent id {agent_id:?}"
309                    )));
310                }
311                (_, None) => {}
312            }
313        }
314        run.refresh_scheduling();
315        Ok(run)
316    }
317
318    /// Node indices whose dependencies are satisfied and that have not yet started.
319    pub fn ready_batch(&mut self) -> Vec<usize> {
320        self.graph.ready_tasks()
321    }
322
323    pub fn set_scheduler_policy(
324        &mut self,
325        policy: crate::scheduler::policy::SchedulerPolicyConfig,
326    ) {
327        self.scheduler_policy = policy;
328        self.refresh_scheduling();
329    }
330
331    fn refresh_scheduling(&mut self) {
332        let token_costs: Vec<u64> = self
333            .nodes
334            .iter()
335            .map(|node| u64::from(node.token_budget.unwrap_or(0)))
336            .collect();
337        self.graph
338            .configure_scheduling(self.scheduler_policy, &token_costs);
339    }
340
341    /// The agent id for a node's *current* spawn. For a `Spawn` node this is the stable
342    /// `wf-node{N}`; for a `Loop` node it is `wf-node{N}-i{k}` where `k` is the count of iterations
343    /// already finished — so each iteration gets a distinct id without any new ABI (the SDK simply
344    /// spawns the id it is given and feeds it back as a `sub_agent_completed`).
345    pub fn current_agent_id(&self, node: usize) -> String {
346        match self.nodes[node].kind {
347            NodeKind::Loop { .. } => {
348                let k = self.iter_counts.get(&node).copied().unwrap_or(0);
349                format!("{}-i{k}", node_agent_id(node))
350            }
351            // Spawn / Classify run once, a Tournament controller never spawns its own agent (its
352            // entrant/judge children are separate Spawn nodes), and a Reduce node runs once as host
353            // compute → stable plain id.
354            NodeKind::Spawn
355            | NodeKind::Classify { .. }
356            | NodeKind::Tournament { .. }
357            | NodeKind::Reduce { .. } => node_agent_id(node),
358        }
359    }
360
361    /// Build the isolation manifest for a node's current spawn, preserving its explicit isolation +
362    /// context-inheritance (the `AgentRunSpec`→`from_spec` path would overwrite these with
363    /// role defaults). Capability inheritance for workflow nodes is left to a later round.
364    pub fn manifest_for(&self, node: usize) -> IsolationManifest {
365        let n = &self.nodes[node];
366        IsolationManifest {
367            agent_id: self.current_agent_id(node).into(),
368            role: n.role,
369            isolation: n.isolation,
370            context_inheritance: n.context_inheritance,
371            permitted_capability_ids: Vec::new(),
372        }
373    }
374
375    /// The goal text for a node (for the spawn's run spec / context injection).
376    /// W3 quarantine invariant: a quarantined node reads untrusted content and must run read-only.
377    /// Returns `true` if the node is `Quarantined` yet declares a write-capable isolation
378    /// (`Shared`/`Worktree`/`Remote`) — a privilege contradiction the kernel refuses to spawn,
379    /// turning the SDK's "self-discipline" quarantine into an in-kernel, auditable enforcement.
380    pub fn quarantine_violation(&self, node: usize) -> bool {
381        let n = &self.nodes[node];
382        matches!(n.trust, NodeTrust::Quarantined)
383            && !matches!(n.isolation, AgentIsolation::ReadOnly)
384    }
385
386    /// The SDK-facing spawn descriptor for a node (agent id + goal + canonical role/isolation/
387    /// inheritance strings + model hint). The kernel owns the spec; this is how the goal reaches
388    /// the host that runs the node.
389    pub fn spawn_info(&self, node: usize) -> WorkflowSpawnInfo {
390        let n = &self.nodes[node];
391        // The stable agent ids of this node's dependencies. A Reduce node's registered function
392        // consumes them (G2); EVERY other dependent node gets them too, so the SDK can put the
393        // dependency outputs in the node's context — a DAG edge carries data, not just ordering
394        // (without this, fan-out→synthesize produced an uninformed synthesis).
395        let reducer = match &n.kind {
396            NodeKind::Reduce { reducer } => Some(reducer.clone()),
397            _ => None,
398        };
399        let input_agent_ids: Vec<String> = n.depends_on.iter().map(|&d| node_agent_id(d)).collect();
400        // A#2 v2 / classify: surface the control-flow kind so the SDK can solicit + report the
401        // matching result signal (`loop_continue` / `classify_branch`), mirroring how `reducer` /
402        // `judge_match` distinguish reduce / judge spawns.
403        let loop_max_iters = match &n.kind {
404            NodeKind::Loop { max_iters } => Some(*max_iters),
405            _ => None,
406        };
407        let classify_labels = match &n.kind {
408            NodeKind::Classify { branches } => branches.iter().map(|b| b.label.clone()).collect(),
409            _ => Vec::new(),
410        };
411        WorkflowSpawnInfo {
412            agent_id: self.current_agent_id(node),
413            goal: n.task.goal.clone(),
414            role: role_label(n.role).to_string(),
415            isolation: isolation_label(n.isolation).to_string(),
416            context_inheritance: inheritance_label(n.context_inheritance).to_string(),
417            model_hint: n.model_hint.clone(),
418            trust: trust_label(n.trust).to_string(),
419            output_schema: n.output_schema.clone(),
420            reducer,
421            input_agent_ids,
422            judge_match: self.judge_matches.get(&node).cloned(),
423            loop_max_iters,
424            classify_labels,
425            token_budget: n.token_budget,
426            max_turns: n.max_turns,
427            max_wall_ms: n.max_wall_ms,
428        }
429    }
430
431    /// Mark a node as spawned: start it in the graph and map its kernel agent id back
432    /// to the node for completion routing. (The live in-flight set is the executor's
433    /// `SuspendState::SubAgentAwait.agent_ids` — the single source of in-flight truth.)
434    pub fn mark_spawned(&mut self, node: usize, agent_id: &str) {
435        self.graph.start(node);
436        self.node_of_agent.insert(agent_id.to_string(), node);
437    }
438
439    /// Mark a node as denied by the syscall gate: fail it in the graph (dependents stay pending
440    /// and will never become ready). Does not enter the live batch.
441    pub fn mark_denied(&mut self, node: usize) {
442        self.graph.fail(node);
443        self.resolve_dependency_outcomes();
444    }
445
446    /// A host rejected or failed a spawn after the kernel reserved the node.
447    /// Remove its completion route and fail the graph node so dependents cannot
448    /// run on work that never started.
449    pub fn mark_spawn_failed(&mut self, agent_id: &str) -> Option<usize> {
450        let node = self.node_of_agent.remove(agent_id)?;
451        self.graph.fail(node);
452        self.resolve_dependency_outcomes();
453        Some(node)
454    }
455
456    /// Record a completed sub-agent against its node. Returns the node index if `agent_id`
457    /// belonged to this workflow, else `None`.
458    ///
459    /// For a `Loop` node this counts the finished iteration: while more iterations remain
460    /// (`< max_iters`) the node is re-armed (`set_ready`) — so the next `ready_batch`/spawn round
461    /// runs `wf-node{N}-i{k+1}` — and the node stays non-terminal, keeping its dependents pending.
462    /// Only when the loop is exhausted is the node `complete`d, promoting its dependents.
463    pub fn record_completion(&mut self, agent_id: &str, result: LoopResult) -> Option<usize> {
464        let node = *self.node_of_agent.get(agent_id)?;
465
466        // A tournament entrant/judge child: route the completion into its controller's bracket
467        // rather than treating it as an ordinary node (it has no dependents of its own).
468        if let Some(&controller) = self.child_controller.get(&node) {
469            return self.advance_tournament(controller, node, result);
470        }
471
472        // A loop iteration with a non-success termination is itself terminal. Retrying it would
473        // erase the partial/failure semantics behind a later successful iteration.
474        if matches!(self.nodes[node].kind, NodeKind::Loop { .. })
475            && result.termination != TerminationReason::Completed
476        {
477            self.settle_result(node, result);
478            return Some(node);
479        }
480
481        match &self.nodes[node].kind {
482            NodeKind::Loop { max_iters } => {
483                // v2 semantic stop: the iteration may signal "done" (`loop_continue == Some(false)`),
484                // ending the loop before `max_iters`. `None`/`Some(true)` run to the cap (v1 behavior).
485                let max_iters = *max_iters;
486                let stop_requested = result.loop_continue == Some(false);
487                let done = self.iter_counts.entry(node).or_insert(0);
488                *done += 1;
489                if *done < max_iters && !stop_requested {
490                    // More iterations: re-arm the node, keep it (and its dependents) in flight.
491                    self.graph.set_ready(node);
492                    return Some(node);
493                }
494            }
495            NodeKind::Classify { branches } => {
496                // Route to the branch matching the classifier's reported label; prune every other
497                // branch's nodes (fail them) *before* completing this node, so that `complete`'s
498                // dependent-promotion only arms the chosen branch (failed nodes are never re-armed).
499                let chosen = result.classify_branch.clone();
500                let prune: Vec<usize> = branches
501                    .iter()
502                    .filter(|b| Some(&b.label) != chosen.as_ref())
503                    .flat_map(|b| b.nodes.iter().copied())
504                    .collect();
505                for bn in prune {
506                    self.graph.fail(bn);
507                }
508            }
509            // A Tournament controller never reaches here (it spawns no agent of its own; its
510            // children route through `child_controller` above). A Reduce node completes like a Spawn
511            // (its host-compute result feeds back as an ordinary completion). Defensive no-op.
512            NodeKind::Spawn | NodeKind::Tournament { .. } | NodeKind::Reduce { .. } => {}
513        }
514
515        // Spawn node, loop's final iteration, or a completed classifier. Map the run termination
516        // into an explicit node terminal state, then apply each dependent's declared policy.
517        self.settle_result(node, result);
518        Some(node)
519    }
520
521    fn settle_result(&mut self, node: usize, result: LoopResult) {
522        match result.termination {
523            TerminationReason::Completed => self.graph.complete(node, result),
524            TerminationReason::MaxTurns
525            | TerminationReason::TokenBudget
526            | TerminationReason::Timeout
527            | TerminationReason::MilestoneExceeded
528            | TerminationReason::ContextOverflow
529            | TerminationReason::NoProgress => self.graph.complete_partial(node, result),
530            TerminationReason::Error | TerminationReason::UserAbort => {
531                self.graph.fail_with_result(node, result)
532            }
533        }
534        self.resolve_dependency_outcomes();
535    }
536
537    /// Resolve every pending node whose dependency state is now decisive. Skips cascade until the
538    /// graph reaches a fixed point, so a failed ancestor cannot leave hidden pending descendants.
539    fn resolve_dependency_outcomes(&mut self) {
540        loop {
541            let mut changed = false;
542            for node in 0..self.nodes.len() {
543                if self.graph.get(node).map(|n| n.status) != Some(TaskStatus::Pending) {
544                    continue;
545                }
546                let policy = self.nodes[node].dep_policy;
547                if policy == DependencyPolicy::Optional {
548                    self.graph.set_ready(node);
549                    changed = true;
550                    continue;
551                }
552                let statuses: Vec<TaskStatus> = self.nodes[node]
553                    .depends_on
554                    .iter()
555                    .filter_map(|&dep| self.graph.get(dep).map(|n| n.status))
556                    .collect();
557                let all_terminal = statuses.iter().all(|status| status.is_terminal());
558                let impossible = match policy {
559                    DependencyPolicy::AllSuccess => statuses.iter().any(|status| {
560                        matches!(
561                            status,
562                            TaskStatus::CompletedPartial
563                                | TaskStatus::Failed
564                                | TaskStatus::SkippedUpstreamFailed
565                        )
566                    }),
567                    DependencyPolicy::AcceptPartial => statuses.iter().any(|status| {
568                        matches!(
569                            status,
570                            TaskStatus::Failed | TaskStatus::SkippedUpstreamFailed
571                        )
572                    }),
573                    DependencyPolicy::AllTerminal | DependencyPolicy::Optional => false,
574                };
575                if impossible {
576                    self.graph.skip_upstream_failed(node);
577                    changed = true;
578                } else if all_terminal {
579                    self.graph.set_ready(node);
580                    changed = true;
581                }
582            }
583            if !changed {
584                break;
585            }
586        }
587    }
588
589    // ── Tournament controller (A#2) ─────────────────────────────────────────────────────────────
590
591    /// Append an entrant/judge *child* node (no dependencies → immediately Ready) and return its
592    /// index. Keeps `self.nodes` and `self.graph` index-aligned (both grow in lockstep), so the
593    /// child flows through the unchanged spawn loop as an ordinary `wf-node{idx}` spawn.
594    fn append_child(&mut self, node: WorkflowNode) -> usize {
595        let idx = self.graph.add(node.task.clone(), Vec::new());
596        debug_assert_eq!(idx, self.nodes.len(), "graph/nodes index drift");
597        self.nodes.push(node);
598        self.refresh_scheduling();
599        idx
600    }
601
602    /// Expand every tournament controller node whose dependencies are now satisfied (status
603    /// `Ready`) into its entrant children. The controller is moved to `Running` (it spawns no agent
604    /// of its own) and stays non-terminal until its bracket resolves. Called by the executor before
605    /// each spawn round, so a controller behind upstream deps expands the moment those complete.
606    pub fn expand_ready_controllers(&mut self) {
607        let pending: Vec<usize> = (0..self.nodes.len())
608            .filter(|i| !self.tournaments.contains_key(i))
609            .filter(|&i| matches!(self.nodes[i].kind, NodeKind::Tournament { .. }))
610            .filter(|&i| self.graph.get(i).map(|n| n.status) == Some(TaskStatus::Ready))
611            .collect();
612        for c in pending {
613            self.expand_tournament(c);
614        }
615    }
616
617    /// Fan a controller out into its entrant generators. Entrants run independent + read-only (a
618    /// clean context per candidate, quarantine-safe), inheriting the controller's trust.
619    fn expand_tournament(&mut self, c: usize) {
620        let entrants = match &self.nodes[c].kind {
621            NodeKind::Tournament { entrants } => entrants.clone(),
622            _ => return,
623        };
624        let trust = self.nodes[c].trust;
625        // Controller spawns no agent of its own → take it out of the ready set until we complete it.
626        self.graph.start(c);
627        // W-2: a runtime-submitted controller bypasses `WorkflowSpec::validate`, so the ≥2-entrant
628        // invariant is re-checked here. A contest that cannot form fails the controller outright
629        // (no champion) instead of stalling Running forever with no child ever reporting back.
630        if entrants.len() < 2 {
631            self.complete_tournament(c, None);
632            return;
633        }
634        let mut entrant_nodes = Vec::with_capacity(entrants.len());
635        for task in entrants {
636            let child = WorkflowNode::new(task, AgentRole::Custom)
637                .with_isolation(AgentIsolation::ReadOnly)
638                .with_trust(trust);
639            let idx = self.append_child(child);
640            self.child_controller.insert(idx, c);
641            entrant_nodes.push(idx);
642        }
643        let entrants_remaining = entrant_nodes.len();
644        self.tournaments.insert(
645            c,
646            TournamentState {
647                entrant_nodes,
648                entrants_remaining,
649                bracket: None,
650                judge_nodes: Vec::new(),
651                judge_winners: Vec::new(),
652                judges_remaining: 0,
653            },
654        );
655    }
656
657    /// A tournament child (entrant or judge) completed: advance the controller's bracket. Returns
658    /// the controller node index (the node that conceptually progressed).
659    fn advance_tournament(
660        &mut self,
661        controller: usize,
662        child: usize,
663        result: LoopResult,
664    ) -> Option<usize> {
665        // The child has no dependents; mark it terminal so the graph's done/outcome accounting
666        // works. An `Error`-terminated child is *failed* — the same contract as an ordinary node
667        // (`record_completion`) — so outcome() reports it honestly. The bracket still advances:
668        // an errored entrant simply fields an empty candidate (judges see it and prefer the other
669        // side); an errored judge reports no winner, which surfaces as a no-champion bracket below.
670        match result.termination {
671            TerminationReason::Completed => self.graph.complete(child, result.clone()),
672            TerminationReason::MaxTurns
673            | TerminationReason::TokenBudget
674            | TerminationReason::Timeout
675            | TerminationReason::MilestoneExceeded
676            | TerminationReason::ContextOverflow
677            | TerminationReason::NoProgress => self.graph.complete_partial(child, result.clone()),
678            TerminationReason::Error | TerminationReason::UserAbort => {
679                self.graph.fail_with_result(child, result.clone())
680            }
681        }
682
683        let in_entrant_phase = self.tournaments.get(&controller)?.bracket.is_none();
684        if in_entrant_phase {
685            let all_in = {
686                let st = self.tournaments.get_mut(&controller)?;
687                st.entrants_remaining = st.entrants_remaining.saturating_sub(1);
688                st.entrants_remaining == 0
689            };
690            if all_in {
691                self.begin_bracket(controller);
692            }
693        } else {
694            let round_done = {
695                let st = self.tournaments.get_mut(&controller)?;
696                if let Some(pos) = st.judge_nodes.iter().position(|&n| n == child) {
697                    st.judge_winners[pos] = result.tournament_winner.clone();
698                }
699                st.judges_remaining = st.judges_remaining.saturating_sub(1);
700                st.judges_remaining == 0
701            };
702            if round_done {
703                self.finish_round(controller);
704            }
705        }
706        Some(controller)
707    }
708
709    /// All entrants are in: embed the bracket over their agent ids and emit round 1's judges.
710    fn begin_bracket(&mut self, controller: usize) {
711        let entrant_ids: Vec<EntrantId> = self
712            .tournaments
713            .get(&controller)
714            .map(|st| st.entrant_nodes.iter().map(|&n| node_agent_id(n)).collect())
715            .unwrap_or_default();
716        // ≥2 entrants is guaranteed by `validate`; `Tournament::new` only rejects an empty field.
717        let mut bracket = match Tournament::new(entrant_ids) {
718            Ok(b) => b,
719            Err(_) => return self.complete_tournament(controller, None),
720        };
721        let action = bracket.start();
722        if let Some(st) = self.tournaments.get_mut(&controller) {
723            st.bracket = Some(bracket);
724        }
725        self.apply_action(controller, action);
726    }
727
728    /// This round's judges all reported: feed the winners to the bracket and act on what comes next.
729    fn finish_round(&mut self, controller: usize) {
730        let winners: Vec<EntrantId> = self
731            .tournaments
732            .get(&controller)
733            .map(|st| st.judge_winners.iter().filter_map(|w| w.clone()).collect())
734            .unwrap_or_default();
735        let action = {
736            let st = match self.tournaments.get_mut(&controller) {
737                Some(st) => st,
738                None => return,
739            };
740            match st.bracket.as_mut() {
741                // A judge that reported no winner shrinks `winners` below the match count, so
742                // `feed_round` errors — we surface that as a tournament with no champion.
743                Some(b) => b.feed_round(winners),
744                None => return,
745            }
746        };
747        match action {
748            Ok(act) => self.apply_action(controller, act),
749            Err(_) => self.complete_tournament(controller, None),
750        }
751    }
752
753    /// Act on a bracket step: spawn the round's judges, or finish with the champion.
754    fn apply_action(&mut self, controller: usize, action: TournamentAction) {
755        match action {
756            TournamentAction::JudgeRound { matches, .. } => self.emit_judges(controller, matches),
757            TournamentAction::Done { winner, .. } => {
758                self.complete_tournament(controller, Some(winner))
759            }
760        }
761    }
762
763    /// Append one judge child per match (bias-resistant `Verify`: read-only, no inherited context),
764    /// each carrying its `JudgeMatch`. The controller's own goal is the judging criterion.
765    fn emit_judges(&mut self, controller: usize, matches: Vec<Match>) {
766        let criterion = self.nodes[controller].task.clone();
767        let trust = self.nodes[controller].trust;
768        let mut judge_nodes = Vec::with_capacity(matches.len());
769        for m in &matches {
770            let judge = WorkflowNode::new(criterion.clone(), AgentRole::Verify).with_trust(trust);
771            let idx = self.append_child(judge);
772            self.child_controller.insert(idx, controller);
773            self.judge_matches.insert(
774                idx,
775                JudgeMatch {
776                    left: m.left.clone(),
777                    right: m.right.clone(),
778                },
779            );
780            judge_nodes.push(idx);
781        }
782        if let Some(st) = self.tournaments.get_mut(&controller) {
783            st.judge_winners = vec![None; judge_nodes.len()];
784            st.judges_remaining = judge_nodes.len();
785            st.judge_nodes = judge_nodes;
786        }
787    }
788
789    /// Resolve the controller: drop its bracket state and `complete` it with the champion's id in
790    /// `tournament_winner`, promoting its dependents. A bracket with NO champion (a judge reported
791    /// no winner, or the bracket could not form) *fails* the controller instead — dependents that
792    /// would consume `tournament_winner` must starve rather than run on a missing input, exactly
793    /// like the dependents of an `Error`-terminated Spawn node.
794    fn complete_tournament(&mut self, controller: usize, winner: Option<EntrantId>) {
795        self.tournaments.remove(&controller);
796        let Some(winner) = winner else {
797            self.graph.fail(controller);
798            self.resolve_dependency_outcomes();
799            return;
800        };
801        let result = LoopResult {
802            termination: TerminationReason::Completed,
803            final_message: None,
804            turns_used: 0,
805            total_tokens_used: 0,
806            loop_continue: None,
807            classify_branch: None,
808            tournament_winner: Some(winner),
809            pace_decision: None,
810        };
811        self.graph.complete(controller, result);
812        self.resolve_dependency_outcomes();
813    }
814
815    // ── R3-1: runtime node submission (true loop-until-done / dynamic fan-out) ────────────────────
816
817    /// Append a batch of nodes to the in-flight DAG at runtime — the kernel side of the dynamic
818    /// "submit nodes" capability, generalizing the tournament's [`Self::append_child`]. A running
819    /// node, on completion, can ask for more work to be spawned: unknown-size discovery
820    /// (loop-until-done) and per-item fan-out (e.g. a claim-extractor spawning one verifier per
821    /// claim) both reduce to "append these nodes now".
822    ///
823    /// Each submitted node's `depends_on` is batch-relative: both forward and backward edges are
824    /// accepted when the complete batch is acyclic. Validation precedes mutation, so malformed
825    /// references or control-flow nodes reject the entire batch atomically.
826    ///
827    /// Pure graph mutation: the caller (state machine) is responsible for routing the trigger
828    /// through `evaluate_syscall` before calling this, keeping the kernel's zero-I/O contract.
829    ///
830    /// G1 no-privilege-escalation: when `submitter` names a [`NodeTrust::Quarantined`] node, every
831    /// node in this submission is coerced to `Quarantined` before append. A quarantined agent read
832    /// untrusted content (which may be adversarial), so the topology it asks for is itself untrusted:
833    /// it must not be able to launch a *trusted* (or write-capable) child and thereby escape its
834    /// sandbox. This is transitive taint — a quarantined origin's descendants inherit quarantine —
835    /// the topological analogue of a process spawned by an untrusted process inheriting its label.
836    /// Trusted (or absent) submitters pass through unchanged. The coercion is enforced here in the
837    /// kernel rather than trusting the SDK, and composes with the spawn-time
838    /// [`Self::quarantine_violation`] gate (a coerced node that also asked for write isolation is
839    /// then denied at spawn).
840    pub fn submit_nodes_from(
841        &mut self,
842        submitter: Option<&str>,
843        mut nodes: Vec<WorkflowNode>,
844    ) -> std::result::Result<Vec<usize>, WorkflowSubmissionError> {
845        let submitter_quarantined = submitter.is_some_and(|s| self.is_agent_quarantined(s));
846        if submitter_quarantined {
847            for node in &mut nodes {
848                node.trust = NodeTrust::Quarantined;
849            }
850        }
851        self.submit_nodes(nodes)
852    }
853
854    pub fn submit_nodes(
855        &mut self,
856        mut nodes: Vec<WorkflowNode>,
857    ) -> std::result::Result<Vec<usize>, WorkflowSubmissionError> {
858        let base = self.nodes.len();
859        let batch_len = nodes.len();
860        for (node_index, node) in nodes.iter().enumerate() {
861            if matches!(node.kind, NodeKind::Loop { max_iters: 0 }) {
862                return Err(WorkflowSubmissionError {
863                    node_index,
864                    reason: "loop max_iters must be greater than zero".to_string(),
865                });
866            }
867            if matches!(&node.kind, NodeKind::Tournament { entrants } if entrants.len() < 2) {
868                return Err(WorkflowSubmissionError {
869                    node_index,
870                    reason: "tournament requires at least two entrants".to_string(),
871                });
872            }
873            for &dependency in &node.depends_on {
874                if dependency >= batch_len {
875                    return Err(WorkflowSubmissionError {
876                        node_index,
877                        reason: format!(
878                            "dependency {dependency} out of range for batch of {batch_len} nodes"
879                        ),
880                    });
881                }
882                if dependency == node_index {
883                    return Err(WorkflowSubmissionError {
884                        node_index,
885                        reason: "node depends on itself".to_string(),
886                    });
887                }
888            }
889            if let NodeKind::Classify { branches } = &node.kind {
890                for branch_node in branches.iter().flat_map(|br| br.nodes.iter().copied()) {
891                    if branch_node >= batch_len {
892                        return Err(WorkflowSubmissionError {
893                            node_index,
894                            reason: format!("classify branch node {branch_node} out of range"),
895                        });
896                    }
897                    if branch_node == node_index {
898                        return Err(WorkflowSubmissionError {
899                            node_index,
900                            reason: "classifier cannot select itself as a branch node".to_string(),
901                        });
902                    }
903                    if !nodes[branch_node].depends_on.contains(&node_index) {
904                        return Err(WorkflowSubmissionError {
905                            node_index: branch_node,
906                            reason: format!(
907                                "classify branch node must depend on classifier {node_index}"
908                            ),
909                        });
910                    }
911                }
912            }
913        }
914
915        if WorkflowSpec::new(nodes.clone()).validate().is_err() {
916            return Err(WorkflowSubmissionError {
917                node_index: 0,
918                reason: "submission introduces a dependency cycle".to_string(),
919            });
920        }
921
922        for node in &mut nodes {
923            node.depends_on = node.depends_on.iter().map(|dep| base + dep).collect();
924            if let NodeKind::Classify { branches } = &mut node.kind {
925                for branch in branches {
926                    branch.nodes = branch.nodes.iter().map(|node| base + node).collect();
927                }
928            }
929        }
930
931        let mut ids = Vec::with_capacity(nodes.len());
932        for node in nodes {
933            let deps = node.depends_on.clone();
934            let idx = self.graph.add(node.task.clone(), deps);
935            debug_assert_eq!(idx, self.nodes.len(), "graph/nodes index drift");
936            self.nodes.push(node);
937            ids.push(idx);
938        }
939        self.refresh_scheduling();
940        self.resolve_dependency_outcomes();
941        Ok(ids)
942    }
943
944    /// Whether `agent_id` belongs to this workflow.
945    pub fn owns_agent(&self, agent_id: &str) -> bool {
946        self.node_of_agent.contains_key(agent_id)
947    }
948
949    /// R3-3: whether the node behind `agent_id` is `Quarantined` (it read untrusted content). The
950    /// kernel uses this to label that node's output as untrusted-origin when it crosses into the
951    /// trusted parent context — the provenance half of the cross-boundary contract (shaping the
952    /// output into a structured summary stays the SDK's job; the kernel cannot inspect content).
953    pub fn is_agent_quarantined(&self, agent_id: &str) -> bool {
954        self.node_of_agent
955            .get(agent_id)
956            .is_some_and(|&node| matches!(self.nodes[node].trust, NodeTrust::Quarantined))
957    }
958
959    /// Test instrument: mark a spawned node quarantined after the fact.
960    ///
961    /// Production quarantine is declared on the spec (`WorkflowNode::quarantined`). The canonical
962    /// wire `WorkflowNode` has no trust field yet — see the SPEC-ISSUE in the canonical driver — so
963    /// tests of the quarantine refusal reach the state through here instead of through the wire.
964    #[cfg(test)]
965    pub(crate) fn quarantine_agent(&mut self, agent_id: &str) -> bool {
966        match self.node_of_agent.get(agent_id).copied() {
967            Some(node) => {
968                self.nodes[node].trust = NodeTrust::Quarantined;
969                true
970            }
971            None => false,
972        }
973    }
974
975    /// Test instrument: true when no node is currently `Running` — the spawned batch has
976    /// fully reported back. Derived from the graph; the executor's in-flight truth is
977    /// `SuspendState::SubAgentAwait.agent_ids`.
978    #[cfg(test)]
979    pub(crate) fn batch_drained(&self) -> bool {
980        !(0..self.graph.len()).any(|i| {
981            matches!(
982                self.graph.get(i).map(|n| &n.status),
983                Some(crate::orchestration::task_graph::TaskStatus::Running)
984            )
985        })
986    }
987
988    /// Test instrument: every node reached one of the four terminal statuses.
989    #[cfg(test)]
990    pub(crate) fn is_complete(&self) -> bool {
991        self.graph.all_done()
992    }
993
994    /// Close a workflow and return exactly one typed terminal outcome per graph node.
995    pub fn finish(&mut self) -> Vec<WorkflowNodeOutcome> {
996        for node in 0..self.graph.len() {
997            match self.graph.get(node).map(|node| node.status) {
998                Some(TaskStatus::Pending | TaskStatus::Ready) => {
999                    self.graph.skip_upstream_failed(node)
1000                }
1001                Some(TaskStatus::Running) => self.graph.fail(node),
1002                _ => {}
1003            }
1004        }
1005        self.node_outcomes()
1006    }
1007
1008    pub fn node_outcomes(&self) -> Vec<WorkflowNodeOutcome> {
1009        (0..self.graph.len())
1010            .filter_map(|node| {
1011                let graph_node = self.graph.get(node)?;
1012                let status = match graph_node.status {
1013                    TaskStatus::Completed => WorkflowNodeStatus::Completed,
1014                    TaskStatus::CompletedPartial => WorkflowNodeStatus::CompletedPartial,
1015                    TaskStatus::Failed => WorkflowNodeStatus::Failed,
1016                    TaskStatus::SkippedUpstreamFailed => WorkflowNodeStatus::SkippedUpstreamFailed,
1017                    TaskStatus::Pending | TaskStatus::Ready | TaskStatus::Running => return None,
1018                };
1019                Some(WorkflowNodeOutcome {
1020                    node_id: node_agent_id(node),
1021                    status,
1022                    termination: graph_node.result.as_ref().map(|result| result.termination),
1023                    output: graph_node
1024                        .result
1025                        .as_ref()
1026                        .and_then(|result| result.final_message.clone()),
1027                })
1028            })
1029            .collect()
1030    }
1031
1032    /// #2-B abort: produce the same typed terminal contract as ordinary completion. Running/ready
1033    /// nodes fail with `UserAbort`; nodes that never started are skipped behind the aborted graph.
1034    pub fn abort_outcomes(&self) -> Vec<WorkflowNodeOutcome> {
1035        (0..self.graph.len())
1036            .filter_map(|node| {
1037                let graph_node = self.graph.get(node)?;
1038                let (status, termination) = match graph_node.status {
1039                    TaskStatus::Completed => (
1040                        WorkflowNodeStatus::Completed,
1041                        graph_node.result.as_ref().map(|result| result.termination),
1042                    ),
1043                    TaskStatus::CompletedPartial => (
1044                        WorkflowNodeStatus::CompletedPartial,
1045                        graph_node.result.as_ref().map(|result| result.termination),
1046                    ),
1047                    TaskStatus::Pending | TaskStatus::SkippedUpstreamFailed => {
1048                        (WorkflowNodeStatus::SkippedUpstreamFailed, None)
1049                    }
1050                    TaskStatus::Ready | TaskStatus::Running | TaskStatus::Failed => (
1051                        WorkflowNodeStatus::Failed,
1052                        Some(
1053                            graph_node
1054                                .result
1055                                .as_ref()
1056                                .map_or(TerminationReason::UserAbort, |result| result.termination),
1057                        ),
1058                    ),
1059                };
1060                Some(WorkflowNodeOutcome {
1061                    node_id: node_agent_id(node),
1062                    status,
1063                    termination,
1064                    output: graph_node
1065                        .result
1066                        .as_ref()
1067                        .and_then(|result| result.final_message.clone()),
1068                })
1069            })
1070            .collect()
1071    }
1072
1073    /// Total node count.
1074    pub fn len(&self) -> usize {
1075        self.graph.len()
1076    }
1077
1078    /// Project the active DAG without exposing any private graph indexes.
1079    pub(crate) fn checkpoint_nodes(&self) -> Vec<WorkflowRuntimeNodeState> {
1080        (0..self.graph.len())
1081            .filter_map(|node| {
1082                let graph_node = self.graph.get(node)?;
1083                let active_agent_id = (graph_node.status == TaskStatus::Running)
1084                    .then(|| {
1085                        self.node_of_agent.iter().find_map(|(agent_id, &owner)| {
1086                            (owner == node).then(|| agent_id.clone())
1087                        })
1088                    })
1089                    .flatten();
1090                Some(WorkflowRuntimeNodeState {
1091                    node: self.nodes[node].clone(),
1092                    status: graph_node.status,
1093                    result: graph_node.result.clone(),
1094                    active_agent_id,
1095                    iterations_completed: self.iter_counts.get(&node).copied().unwrap_or(0),
1096                })
1097            })
1098            .collect()
1099    }
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104    use super::*;
1105    use crate::orchestration::workflow::{ClassifyBranch, fanout_synthesize};
1106    use crate::types::result::{LoopResult, TerminationReason};
1107    use crate::types::task::RuntimeTask;
1108
1109    fn done() -> LoopResult {
1110        LoopResult {
1111            termination: TerminationReason::Completed,
1112            final_message: None,
1113            turns_used: 1,
1114            total_tokens_used: 0,
1115            loop_continue: None,
1116            classify_branch: None,
1117            tournament_winner: None,
1118            pace_decision: None,
1119        }
1120    }
1121
1122    fn terminated(termination: TerminationReason) -> LoopResult {
1123        LoopResult {
1124            termination,
1125            ..done()
1126        }
1127    }
1128
1129    fn fanout2() -> WorkflowRun {
1130        // 2 workers (nodes 0,1) → synthesize (node 2, depends on both)
1131        let spec = fanout_synthesize(
1132            vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")],
1133            RuntimeTask::new("synth"),
1134        );
1135        WorkflowRun::new(&spec).unwrap()
1136    }
1137
1138    /// A judge completion reporting its winning entrant id.
1139    fn judge_done(winner: &str) -> LoopResult {
1140        LoopResult {
1141            tournament_winner: Some(winner.to_string()),
1142            ..done()
1143        }
1144    }
1145
1146    /// Mimic one executor spawn round on a `WorkflowRun`: expand any ready controllers, then mark
1147    /// every ready node spawned (mapping its current agent id). Returns the spawned `(node, id)`s.
1148    fn spawn_round(run: &mut WorkflowRun) -> Vec<(usize, String)> {
1149        run.expand_ready_controllers();
1150        let ready = run.ready_batch();
1151        let mut out = Vec::new();
1152        for node in ready {
1153            let id = run.current_agent_id(node);
1154            run.mark_spawned(node, &id);
1155            out.push((node, id));
1156        }
1157        out
1158    }
1159
1160    fn outcome_ids(run: &WorkflowRun, status: WorkflowNodeStatus) -> Vec<String> {
1161        run.node_outcomes()
1162            .into_iter()
1163            .filter(|outcome| outcome.status == status)
1164            .map(|outcome| outcome.node_id)
1165            .collect()
1166    }
1167
1168    #[test]
1169    fn first_batch_is_the_workers() {
1170        let mut run = fanout2();
1171        assert_eq!(run.ready_batch(), vec![0, 1]);
1172        assert_eq!(run.len(), 3);
1173        assert!(!run.is_complete());
1174    }
1175
1176    // ── R3-1: runtime node submission ────────────────────────────────────────────────────────
1177
1178    #[test]
1179    fn submit_nodes_appends_independent_nodes_ready_immediately() {
1180        use crate::orchestration::workflow::WorkflowNode;
1181        use crate::types::agent::AgentRole;
1182
1183        let mut run = fanout2(); // nodes 0,1 (workers) → 2 (synth)
1184        assert_eq!(run.len(), 3);
1185        let ids = run
1186            .submit_nodes(vec![
1187                WorkflowNode::new(RuntimeTask::new("extra-a"), AgentRole::Implement),
1188                WorkflowNode::new(RuntimeTask::new("extra-b"), AgentRole::Implement),
1189            ])
1190            .unwrap();
1191        assert_eq!(ids, vec![3, 4], "appended after the existing 3 nodes");
1192        assert_eq!(run.len(), 5);
1193        let ready = run.ready_batch();
1194        assert!(
1195            ready.contains(&3) && ready.contains(&4),
1196            "submitted independent nodes are immediately ready: {ready:?}"
1197        );
1198    }
1199
1200    #[test]
1201    fn submitted_nodes_must_complete_before_workflow_is_done() {
1202        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1203        use crate::types::agent::AgentRole;
1204
1205        // A single spawn node that, on completion, submits more work (loop-until-done shape).
1206        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1207            RuntimeTask::new("root"),
1208            AgentRole::Implement,
1209        )]);
1210        let mut run = WorkflowRun::new(&spec).unwrap();
1211        let id0 = run.current_agent_id(0);
1212        run.mark_spawned(0, &id0);
1213        run.record_completion(&id0, done());
1214        let ids = run
1215            .submit_nodes(vec![WorkflowNode::new(
1216                RuntimeTask::new("more"),
1217                AgentRole::Implement,
1218            )])
1219            .unwrap();
1220        assert_eq!(ids, vec![1]);
1221        assert!(
1222            !run.is_complete(),
1223            "not complete while the submitted node is pending"
1224        );
1225        let spawned = spawn_round(&mut run);
1226        assert_eq!(spawned, vec![(1usize, "wf-node1".to_string())]);
1227        run.record_completion("wf-node1", done());
1228        assert!(
1229            run.is_complete(),
1230            "complete once the submitted node finishes"
1231        );
1232    }
1233
1234    #[test]
1235    fn reduce_node_carries_reducer_and_inputs_then_completes_like_a_spawn() {
1236        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1237        use crate::types::agent::AgentRole;
1238
1239        // G2: two fan-out workers feed a deterministic reduce node (dedupe). The reduce node runs no
1240        // agent; its descriptor names the reducer + its inputs, and it completes like a spawn.
1241        let spec = WorkflowSpec::new(vec![
1242            WorkflowNode::new(RuntimeTask::new("worker-a"), AgentRole::Explore),
1243            WorkflowNode::new(RuntimeTask::new("worker-b"), AgentRole::Explore),
1244            WorkflowNode::new(RuntimeTask::new("merge"), AgentRole::Implement)
1245                .with_reduce("dedupe_lines")
1246                .with_depends_on(vec![0, 1]),
1247        ]);
1248        let mut run = WorkflowRun::new(&spec).unwrap();
1249
1250        // Only the two workers are ready first (the reduce node waits on both).
1251        assert_eq!(run.ready_batch(), vec![0, 1]);
1252        for i in [0usize, 1] {
1253            let id = run.current_agent_id(i);
1254            run.mark_spawned(i, &id);
1255            run.record_completion(&id, done());
1256        }
1257
1258        // Now the reduce node is ready; its descriptor carries the reducer name + both input ids.
1259        assert_eq!(run.ready_batch(), vec![2]);
1260        let info = run.spawn_info(2);
1261        assert_eq!(info.reducer.as_deref(), Some("dedupe_lines"));
1262        assert_eq!(
1263            info.input_agent_ids,
1264            vec!["wf-node0".to_string(), "wf-node1".to_string()]
1265        );
1266
1267        // The reduce node's (SDK-computed) result feeds back as an ordinary completion → DAG done.
1268        run.mark_spawned(2, "wf-node2");
1269        run.record_completion("wf-node2", done());
1270        assert!(run.is_complete());
1271        let completed = outcome_ids(&run, WorkflowNodeStatus::Completed);
1272        assert_eq!(completed, vec!["wf-node0", "wf-node1", "wf-node2"]);
1273        assert_eq!(run.node_outcomes().len(), completed.len());
1274    }
1275
1276    #[test]
1277    fn output_schema_reaches_the_spawn_descriptor() {
1278        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1279        use crate::types::agent::AgentRole;
1280
1281        // G3: a node declaring an output schema carries it verbatim to the SDK spawn descriptor.
1282        let schema = serde_json::json!({
1283            "type": "object",
1284            "required": ["verdict"],
1285            "properties": { "verdict": { "type": "string" } }
1286        });
1287        let spec = WorkflowSpec::new(vec![
1288            WorkflowNode::new(RuntimeTask::new("judge"), AgentRole::Verify)
1289                .with_output_schema(schema.clone()),
1290        ]);
1291        let run = WorkflowRun::new(&spec).unwrap();
1292        let info = run.spawn_info(0);
1293        assert_eq!(info.output_schema.as_ref(), Some(&schema));
1294
1295        // Full serde round-trip preserves it (additive ABI).
1296        let json = serde_json::to_string(&info).unwrap();
1297        let back: WorkflowSpawnInfo = serde_json::from_str(&json).unwrap();
1298        assert_eq!(back.output_schema, Some(schema));
1299
1300        // A node without a schema omits the field entirely on the wire.
1301        let plain = WorkflowSpec::new(vec![WorkflowNode::new(
1302            RuntimeTask::new("x"),
1303            AgentRole::Implement,
1304        )]);
1305        let plain_info = WorkflowRun::new(&plain).unwrap().spawn_info(0);
1306        assert!(plain_info.output_schema.is_none());
1307        assert!(
1308            !serde_json::to_string(&plain_info)
1309                .unwrap()
1310                .contains("output_schema")
1311        );
1312    }
1313
1314    #[test]
1315    fn quarantined_submitter_taints_submitted_nodes() {
1316        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1317        use crate::types::agent::AgentRole;
1318
1319        // G1: a quarantined root reads untrusted content, then tries to submit a node it declares
1320        // "trusted" (and write-capable). The kernel must coerce that node to quarantined — a
1321        // quarantined origin cannot escalate its descendants out of the sandbox.
1322        let spec = WorkflowSpec::new(vec![
1323            WorkflowNode::new(RuntimeTask::new("read-untrusted"), AgentRole::Explore).quarantined(),
1324        ]);
1325        let mut run = WorkflowRun::new(&spec).unwrap();
1326        let id0 = run.current_agent_id(0);
1327        run.mark_spawned(0, &id0);
1328        run.record_completion(&id0, done());
1329
1330        // Submitted node claims Trusted; the quarantined submitter cannot grant that.
1331        let ids = run
1332            .submit_nodes_from(
1333                Some(&id0),
1334                vec![WorkflowNode::new(
1335                    RuntimeTask::new("act"),
1336                    AgentRole::Implement,
1337                )],
1338            )
1339            .unwrap();
1340        assert_eq!(ids, vec![1]);
1341        let id1 = run.current_agent_id(1);
1342        run.mark_spawned(1, &id1);
1343        assert!(
1344            run.is_agent_quarantined(&id1),
1345            "submitted node inherits the submitter's quarantine (no escalation)"
1346        );
1347
1348        // A trusted / unknown submitter does NOT coerce — only quarantined origins taint.
1349        let ids2 = run
1350            .submit_nodes_from(
1351                None,
1352                vec![WorkflowNode::new(
1353                    RuntimeTask::new("trusted-work"),
1354                    AgentRole::Implement,
1355                )],
1356            )
1357            .unwrap();
1358        let id2 = run.current_agent_id(ids2[0]);
1359        run.mark_spawned(ids2[0], &id2);
1360        assert!(
1361            !run.is_agent_quarantined(&id2),
1362            "no quarantined submitter ⇒ no coercion"
1363        );
1364    }
1365
1366    #[test]
1367    fn submit_nodes_honors_batch_relative_backward_deps() {
1368        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1369        use crate::types::agent::AgentRole;
1370
1371        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1372            RuntimeTask::new("root"),
1373            AgentRole::Implement,
1374        )]);
1375        let mut run = WorkflowRun::new(&spec).unwrap();
1376        let id0 = run.current_agent_id(0);
1377        run.mark_spawned(0, &id0);
1378        run.record_completion(&id0, done());
1379        // [extractor @offset 0, dependent @offset 1 depends on 0].
1380        let ids = run
1381            .submit_nodes(vec![
1382                WorkflowNode::new(RuntimeTask::new("extractor"), AgentRole::Implement),
1383                WorkflowNode::new(RuntimeTask::new("dependent"), AgentRole::Implement)
1384                    .with_depends_on(vec![0]),
1385            ])
1386            .unwrap();
1387        assert_eq!(ids, vec![1, 2]);
1388        assert_eq!(
1389            run.ready_batch(),
1390            vec![1],
1391            "backward dep keeps the dependent pending"
1392        );
1393        run.mark_spawned(1, "wf-node1");
1394        run.record_completion("wf-node1", done());
1395        assert_eq!(
1396            run.ready_batch(),
1397            vec![2],
1398            "dependent unblocks after the extractor"
1399        );
1400    }
1401
1402    #[test]
1403    fn submit_nodes_accepts_acyclic_forward_dependencies() {
1404        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1405        use crate::types::agent::AgentRole;
1406
1407        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1408            RuntimeTask::new("root"),
1409            AgentRole::Implement,
1410        )]);
1411        let mut run = WorkflowRun::new(&spec).unwrap();
1412        // Node 0 in the batch depends on node 1: a valid forward edge. Only node 1 starts.
1413        let ids = run
1414            .submit_nodes(vec![
1415                WorkflowNode::new(RuntimeTask::new("consumer"), AgentRole::Implement)
1416                    .with_depends_on(vec![1]),
1417                WorkflowNode::new(RuntimeTask::new("producer"), AgentRole::Implement),
1418            ])
1419            .unwrap();
1420        assert_eq!(ids, vec![1, 2]);
1421        assert_eq!(
1422            run.ready_batch(),
1423            vec![2, 0],
1424            "the producer is on the longer critical path and runs before the independent root"
1425        );
1426        run.mark_spawned(2, "wf-node2");
1427        run.record_completion("wf-node2", done());
1428        assert!(run.ready_batch().contains(&1));
1429    }
1430
1431    #[test]
1432    fn submit_nodes_rejects_malformed_batches_atomically() {
1433        use crate::orchestration::workflow::WorkflowNode;
1434        use crate::types::agent::AgentRole;
1435
1436        for nodes in [
1437            vec![
1438                WorkflowNode::new(RuntimeTask::new("bad-range"), AgentRole::Implement)
1439                    .with_depends_on(vec![1]),
1440            ],
1441            vec![
1442                WorkflowNode::new(RuntimeTask::new("self"), AgentRole::Implement)
1443                    .with_depends_on(vec![0]),
1444            ],
1445            vec![
1446                WorkflowNode::new(RuntimeTask::new("a"), AgentRole::Implement)
1447                    .with_depends_on(vec![1]),
1448                WorkflowNode::new(RuntimeTask::new("b"), AgentRole::Implement)
1449                    .with_depends_on(vec![0]),
1450            ],
1451        ] {
1452            let mut run = fanout2();
1453            let before = run.len();
1454            assert!(run.submit_nodes(nodes).is_err());
1455            assert_eq!(run.len(), before, "rejection must precede every mutation");
1456        }
1457    }
1458
1459    #[test]
1460    fn submitted_node_can_itself_be_a_loop_control_flow() {
1461        // R3-2: control flow *composes* through dynamic submission — a submitted node can itself be
1462        // a Loop (or Tournament), executing its full control flow. This delivers nested control flow
1463        // without changing `NodeKind::Tournament`'s entrant type: the submitter just hands over a
1464        // node whose `kind` the unchanged completion machinery already honors.
1465        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1466        use crate::types::agent::AgentRole;
1467
1468        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1469            RuntimeTask::new("root"),
1470            AgentRole::Implement,
1471        )]);
1472        let mut run = WorkflowRun::new(&spec).unwrap();
1473        let id0 = run.current_agent_id(0);
1474        run.mark_spawned(0, &id0);
1475        run.record_completion(&id0, done());
1476
1477        // Submit a Loop{2} node mid-run.
1478        let ids = run
1479            .submit_nodes(vec![
1480                WorkflowNode::new(RuntimeTask::new("refine"), AgentRole::Implement).with_loop(2),
1481            ])
1482            .unwrap();
1483        assert_eq!(ids, vec![1]);
1484
1485        // It iterates with distinct per-iteration ids, then completes — its control flow runs.
1486        for k in 0..2 {
1487            assert_eq!(
1488                run.ready_batch(),
1489                vec![1],
1490                "submitted loop ready for iteration {k}"
1491            );
1492            let id = run.current_agent_id(1);
1493            assert_eq!(
1494                id,
1495                format!("wf-node1-i{k}"),
1496                "submitted loop gets per-iteration ids"
1497            );
1498            run.mark_spawned(1, &id);
1499            run.record_completion(&id, done());
1500        }
1501        assert!(
1502            run.is_complete(),
1503            "submitted loop ran its 2 iterations then finished"
1504        );
1505    }
1506
1507    #[test]
1508    fn submitted_tournament_runs_bracket_then_promotes_submitted_dependent() {
1509        // M2: an agent can submit a Tournament *controller* (plus a dependent) at runtime. The
1510        // controller expands into entrant children + a judge via the same bracket machinery, and the
1511        // dependent's batch-relative `depends_on` links it to the submitted controller.
1512        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1513        use crate::types::agent::AgentRole;
1514
1515        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1516            RuntimeTask::new("root"),
1517            AgentRole::Implement,
1518        )]);
1519        let mut run = WorkflowRun::new(&spec).unwrap();
1520        let id0 = run.current_agent_id(0);
1521        run.mark_spawned(0, &id0);
1522        run.record_completion(&id0, done());
1523
1524        // Submit [tournament@batch0, dependent@batch1 depends_on [0]] (batch-relative).
1525        let ids = run
1526            .submit_nodes(vec![
1527                WorkflowNode::new(RuntimeTask::new("pick best"), AgentRole::Plan)
1528                    .with_tournament(vec![RuntimeTask::new("x"), RuntimeTask::new("y")]),
1529                WorkflowNode::new(RuntimeTask::new("use winner"), AgentRole::Implement)
1530                    .with_depends_on(vec![0]),
1531            ])
1532            .unwrap();
1533        assert_eq!(ids, vec![1, 2], "appended controller=1, dependent=2");
1534
1535        // Controller (node 1) expands into 2 entrant children (3,4); spawns no agent of its own.
1536        let entrants = spawn_round(&mut run);
1537        let entrant_nodes: Vec<usize> = entrants.iter().map(|(n, _)| *n).collect();
1538        assert_eq!(
1539            entrant_nodes,
1540            vec![3, 4],
1541            "two entrant children appended after the dependent"
1542        );
1543        for (_, id) in &entrants {
1544            run.record_completion(id, done());
1545        }
1546
1547        // One judge over the two entrants; dependent (node 2) gated until the bracket resolves.
1548        let r1 = spawn_round(&mut run);
1549        assert_eq!(r1.len(), 1, "one judge for two entrants");
1550        let jm = run
1551            .spawn_info(r1[0].0)
1552            .judge_match
1553            .expect("judge carries a match");
1554        assert_eq!(
1555            jm,
1556            JudgeMatch {
1557                left: node_agent_id(3),
1558                right: node_agent_id(4)
1559            }
1560        );
1561
1562        // Entrant 3 wins → controller completes with the champion → dependent unblocks.
1563        run.record_completion(&r1[0].1, judge_done(&node_agent_id(3)));
1564        assert_eq!(
1565            run.ready_batch(),
1566            vec![2],
1567            "submitted dependent unblocks after the bracket"
1568        );
1569        let last = spawn_round(&mut run);
1570        assert_eq!(last, vec![(2, node_agent_id(2))]);
1571        run.record_completion(&last[0].1, done());
1572        assert!(run.is_complete());
1573    }
1574
1575    #[test]
1576    fn submitted_classify_remaps_branch_indices_and_prunes() {
1577        // M2: a submitted Classify node's branch `nodes` are batch-relative; `submit_nodes` remaps
1578        // them to absolute indices so the chosen branch runs and the rest are pruned. Without the
1579        // remap a runtime-submitted classifier would prune the wrong nodes.
1580        use crate::orchestration::workflow::{
1581            ClassifyBranch, NodeKind, WorkflowNode, WorkflowSpec,
1582        };
1583        use crate::types::agent::AgentRole;
1584
1585        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1586            RuntimeTask::new("root"),
1587            AgentRole::Implement,
1588        )]);
1589        let mut run = WorkflowRun::new(&spec).unwrap();
1590        let id0 = run.current_agent_id(0);
1591        run.mark_spawned(0, &id0);
1592        run.record_completion(&id0, done());
1593
1594        // Submit [classify@batch0 (a→[1] b→[2]), branchA@batch1 dep[0], branchB@batch2 dep[0]].
1595        let ids = run
1596            .submit_nodes(vec![
1597                WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan).with_classify(vec![
1598                    ClassifyBranch {
1599                        label: "a".into(),
1600                        nodes: vec![1],
1601                    },
1602                    ClassifyBranch {
1603                        label: "b".into(),
1604                        nodes: vec![2],
1605                    },
1606                ]),
1607                WorkflowNode::new(RuntimeTask::new("branch-a"), AgentRole::Implement)
1608                    .with_depends_on(vec![0]),
1609                WorkflowNode::new(RuntimeTask::new("branch-b"), AgentRole::Implement)
1610                    .with_depends_on(vec![0]),
1611            ])
1612            .unwrap();
1613        assert_eq!(ids, vec![1, 2, 3], "classify=1, branchA=2, branchB=3");
1614
1615        // Branch indices were remapped batch-relative → absolute: a→[2], b→[3].
1616        if let NodeKind::Classify { branches } = &run.nodes[1].kind {
1617            assert_eq!(
1618                branches[0].nodes,
1619                vec![2],
1620                "branch a remapped to absolute node 2"
1621            );
1622            assert_eq!(
1623                branches[1].nodes,
1624                vec![3],
1625                "branch b remapped to absolute node 3"
1626            );
1627        } else {
1628            panic!("node 1 should be a classify node");
1629        }
1630
1631        // Classifier picks "a" → branch-a (node 2) runs, branch-b (node 3) is pruned/failed.
1632        let r = spawn_round(&mut run);
1633        assert_eq!(r, vec![(1, node_agent_id(1))], "classifier runs first");
1634        run.record_completion(
1635            &r[0].1,
1636            LoopResult {
1637                classify_branch: Some("a".into()),
1638                ..done()
1639            },
1640        );
1641
1642        assert_eq!(run.ready_batch(), vec![2], "only branch a is enabled");
1643        let failed = outcome_ids(&run, WorkflowNodeStatus::Failed);
1644        assert!(
1645            failed.contains(&node_agent_id(3)),
1646            "branch b is explicitly failed by routing"
1647        );
1648
1649        let last = spawn_round(&mut run);
1650        assert_eq!(last, vec![(2, node_agent_id(2))]);
1651        run.record_completion(&last[0].1, done());
1652        assert!(run.is_complete());
1653        let completed = outcome_ids(&run, WorkflowNodeStatus::Completed);
1654        assert!(completed.contains(&node_agent_id(1)) && completed.contains(&node_agent_id(2)));
1655    }
1656
1657    #[test]
1658    fn loop_node_iterates_with_distinct_ids_then_promotes_dependent() {
1659        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1660        use crate::types::agent::AgentRole;
1661
1662        // node 0 = Loop{3}; node 1 depends on node 0 (must wait for the whole loop).
1663        let spec = WorkflowSpec::new(vec![
1664            WorkflowNode::new(RuntimeTask::new("refine"), AgentRole::Implement).with_loop(3),
1665            WorkflowNode::new(RuntimeTask::new("finalize"), AgentRole::Implement)
1666                .with_depends_on(vec![0]),
1667        ]);
1668        let mut run = WorkflowRun::new(&spec).unwrap();
1669
1670        // Three iterations, each with a distinct agent id; the dependent stays unready throughout.
1671        for k in 0..3 {
1672            assert_eq!(
1673                run.ready_batch(),
1674                vec![0],
1675                "loop node ready for iteration {k}"
1676            );
1677            let id = run.current_agent_id(0);
1678            assert_eq!(id, format!("wf-node0-i{k}"), "distinct per-iteration id");
1679            run.mark_spawned(0, &id);
1680            assert!(!run.is_complete());
1681            let node = run.record_completion(&id, done()).unwrap();
1682            assert_eq!(node, 0);
1683            if k < 2 {
1684                // Loop continues: node 0 re-armed, dependent NOT yet ready.
1685                assert_eq!(run.ready_batch(), vec![0]);
1686            }
1687        }
1688
1689        // Loop exhausted → node 0 complete → dependent (node 1) becomes ready.
1690        assert_eq!(
1691            run.ready_batch(),
1692            vec![1],
1693            "dependent unblocks only after the loop ends"
1694        );
1695        let id1 = run.current_agent_id(1);
1696        assert_eq!(id1, "wf-node1", "spawn node keeps the plain id");
1697        run.mark_spawned(1, &id1);
1698        run.record_completion(&id1, done());
1699        assert!(run.is_complete());
1700    }
1701
1702    #[test]
1703    fn synth_becomes_ready_only_after_both_workers() {
1704        let mut run = fanout2();
1705        for &n in &[0usize, 1usize] {
1706            let id = node_agent_id(n);
1707            run.mark_spawned(n, &id);
1708        }
1709        assert!(!run.batch_drained());
1710        // first worker completes → synth not ready yet, batch not drained
1711        assert_eq!(run.record_completion(&node_agent_id(0), done()), Some(0));
1712        assert!(!run.batch_drained());
1713        assert!(run.ready_batch().is_empty());
1714        // second worker completes → batch drained, synth now ready
1715        assert_eq!(run.record_completion(&node_agent_id(1), done()), Some(1));
1716        assert!(run.batch_drained());
1717        assert_eq!(run.ready_batch(), vec![2]);
1718        assert!(!run.is_complete());
1719        // spawn + complete synth → workflow complete
1720        run.mark_spawned(2, &node_agent_id(2));
1721        run.record_completion(&node_agent_id(2), done());
1722        assert!(run.is_complete());
1723    }
1724
1725    #[test]
1726    fn denied_node_skips_dependents_and_closes_outcome() {
1727        let mut run = fanout2();
1728        // node 0 spawned + completes; node 1 denied by the gate
1729        run.mark_spawned(0, &node_agent_id(0));
1730        run.mark_denied(1);
1731        run.record_completion(&node_agent_id(0), done());
1732        assert!(run.batch_drained());
1733        assert!(run.ready_batch().is_empty());
1734        assert!(run.is_complete());
1735        let outcomes = run.finish();
1736        assert_eq!(outcomes.len(), 3);
1737        assert_eq!(outcomes[0].status, WorkflowNodeStatus::Completed);
1738        assert_eq!(outcomes[1].status, WorkflowNodeStatus::Failed);
1739        assert_eq!(
1740            outcomes[2].status,
1741            WorkflowNodeStatus::SkippedUpstreamFailed
1742        );
1743    }
1744
1745    #[test]
1746    fn terminal_mapping_and_dependency_policies_are_explicit() {
1747        use crate::orchestration::workflow::{DependencyPolicy, WorkflowNode, WorkflowSpec};
1748        use crate::types::agent::AgentRole;
1749
1750        let cases = [
1751            (TerminationReason::Completed, WorkflowNodeStatus::Completed),
1752            (
1753                TerminationReason::MaxTurns,
1754                WorkflowNodeStatus::CompletedPartial,
1755            ),
1756            (
1757                TerminationReason::TokenBudget,
1758                WorkflowNodeStatus::CompletedPartial,
1759            ),
1760            (
1761                TerminationReason::Timeout,
1762                WorkflowNodeStatus::CompletedPartial,
1763            ),
1764            (
1765                TerminationReason::ContextOverflow,
1766                WorkflowNodeStatus::CompletedPartial,
1767            ),
1768            (
1769                TerminationReason::NoProgress,
1770                WorkflowNodeStatus::CompletedPartial,
1771            ),
1772            (
1773                TerminationReason::MilestoneExceeded,
1774                WorkflowNodeStatus::CompletedPartial,
1775            ),
1776            (TerminationReason::Error, WorkflowNodeStatus::Failed),
1777            (TerminationReason::UserAbort, WorkflowNodeStatus::Failed),
1778        ];
1779        for (termination, expected) in cases {
1780            let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1781                RuntimeTask::new("node"),
1782                AgentRole::Implement,
1783            )]);
1784            let mut run = WorkflowRun::new(&spec).unwrap();
1785            run.mark_spawned(0, "wf-node0");
1786            run.record_completion("wf-node0", terminated(termination));
1787            let outcome = run.finish().remove(0);
1788            assert_eq!(outcome.status, expected);
1789            assert_eq!(outcome.termination, Some(termination));
1790        }
1791
1792        let spec = WorkflowSpec::new(vec![
1793            WorkflowNode::new(RuntimeTask::new("upstream"), AgentRole::Implement),
1794            WorkflowNode::new(RuntimeTask::new("strict"), AgentRole::Implement)
1795                .with_depends_on(vec![0]),
1796            WorkflowNode::new(RuntimeTask::new("partial-ok"), AgentRole::Implement)
1797                .with_depends_on(vec![0])
1798                .with_dependency_policy(DependencyPolicy::AcceptPartial),
1799        ]);
1800        let mut run = WorkflowRun::new(&spec).unwrap();
1801        run.mark_spawned(0, "wf-node0");
1802        run.record_completion("wf-node0", terminated(TerminationReason::Timeout));
1803        assert_eq!(run.ready_batch(), vec![2]);
1804        assert_eq!(
1805            run.node_outcomes()[1].status,
1806            WorkflowNodeStatus::SkippedUpstreamFailed
1807        );
1808    }
1809
1810    #[test]
1811    fn all_terminal_and_optional_have_distinct_waiting_semantics() {
1812        use crate::orchestration::workflow::{DependencyPolicy, WorkflowNode, WorkflowSpec};
1813        use crate::types::agent::AgentRole;
1814
1815        let spec = WorkflowSpec::new(vec![
1816            WorkflowNode::new(RuntimeTask::new("upstream"), AgentRole::Implement),
1817            WorkflowNode::new(RuntimeTask::new("cleanup"), AgentRole::Implement)
1818                .with_depends_on(vec![0])
1819                .with_dependency_policy(DependencyPolicy::AllTerminal),
1820            WorkflowNode::new(RuntimeTask::new("best-effort"), AgentRole::Implement)
1821                .with_depends_on(vec![0])
1822                .with_dependency_policy(DependencyPolicy::Optional),
1823        ]);
1824        let mut run = WorkflowRun::new(&spec).unwrap();
1825        assert_eq!(run.ready_batch(), vec![0, 2]);
1826        run.mark_spawned(0, "wf-node0");
1827        run.record_completion("wf-node0", terminated(TerminationReason::Error));
1828        assert!(run.ready_batch().contains(&1));
1829    }
1830
1831    #[test]
1832    fn loop_terminal_result_is_not_retried() {
1833        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1834        use crate::types::agent::AgentRole;
1835
1836        let spec = WorkflowSpec::new(vec![
1837            WorkflowNode::new(RuntimeTask::new("loop"), AgentRole::Implement).with_loop(3),
1838        ]);
1839        let mut run = WorkflowRun::new(&spec).unwrap();
1840        run.mark_spawned(0, "wf-node0-i0");
1841        run.record_completion("wf-node0-i0", terminated(TerminationReason::Timeout));
1842        assert!(run.ready_batch().is_empty());
1843        assert_eq!(run.finish()[0].status, WorkflowNodeStatus::CompletedPartial);
1844    }
1845
1846    #[test]
1847    fn manifest_preserves_node_isolation_and_inheritance() {
1848        let run = fanout2();
1849        let m = run.manifest_for(0);
1850        assert_eq!(m.agent_id.as_str(), "wf-node0");
1851        // fanout workers are Explore → ReadOnly + SystemOnly (workflow role_defaults)
1852        assert_eq!(m.isolation, crate::types::agent::AgentIsolation::ReadOnly);
1853        assert_eq!(
1854            m.context_inheritance,
1855            crate::types::agent::ContextInheritance::SystemOnly
1856        );
1857    }
1858
1859    #[test]
1860    fn unknown_agent_completion_is_none() {
1861        let mut run = fanout2();
1862        assert_eq!(run.record_completion("not-a-node", done()), None);
1863    }
1864
1865    #[test]
1866    fn spawn_info_carries_model_hint_and_trust() {
1867        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1868        use crate::types::agent::AgentRole;
1869
1870        let spec = WorkflowSpec::new(vec![
1871            WorkflowNode::new(RuntimeTask::new("read tickets"), AgentRole::Explore)
1872                .quarantined()
1873                .with_model_hint("haiku"),
1874            WorkflowNode::new(RuntimeTask::new("act"), AgentRole::Implement),
1875        ]);
1876        let run = WorkflowRun::new(&spec).unwrap();
1877
1878        // W3: quarantined node + W4: model hint both reach the spawn descriptor.
1879        let q = run.spawn_info(0);
1880        assert_eq!(q.trust, "quarantined");
1881        assert_eq!(q.model_hint.as_deref(), Some("haiku"));
1882        // default node is trusted, no model hint.
1883        let t = run.spawn_info(1);
1884        assert_eq!(t.trust, "trusted");
1885        assert_eq!(t.model_hint, None);
1886    }
1887
1888    #[test]
1889    fn spawn_info_carries_loop_and_classify_hints() {
1890        use crate::orchestration::workflow::{ClassifyBranch, WorkflowNode, WorkflowSpec};
1891        use crate::types::agent::AgentRole;
1892
1893        let spec = WorkflowSpec::new(vec![
1894            // 0: loop node → descriptor carries the cap so the SDK knows to solicit `loop_continue`.
1895            WorkflowNode::new(RuntimeTask::new("refine"), AgentRole::Implement).with_loop(3),
1896            // 1: classify node → descriptor carries the branch labels so the SDK can instruct + report.
1897            WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan).with_classify(vec![
1898                ClassifyBranch {
1899                    label: "bug".into(),
1900                    nodes: vec![],
1901                },
1902                ClassifyBranch {
1903                    label: "feature".into(),
1904                    nodes: vec![],
1905                },
1906            ]),
1907            // 2: plain spawn → neither hint present.
1908            WorkflowNode::new(RuntimeTask::new("act"), AgentRole::Implement),
1909        ]);
1910        let run = WorkflowRun::new(&spec).unwrap();
1911
1912        let l = run.spawn_info(0);
1913        assert_eq!(l.loop_max_iters, Some(3));
1914        assert!(l.classify_labels.is_empty());
1915        assert_eq!(l.token_budget, None, "no token budget unless set");
1916
1917        let c = run.spawn_info(1);
1918        assert_eq!(
1919            c.classify_labels,
1920            vec!["bug".to_string(), "feature".to_string()]
1921        );
1922        assert_eq!(c.loop_max_iters, None);
1923
1924        let s = run.spawn_info(2);
1925        assert_eq!(s.loop_max_iters, None);
1926        assert!(s.classify_labels.is_empty());
1927    }
1928
1929    #[test]
1930    fn spawn_info_carries_token_budget() {
1931        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1932        use crate::types::agent::AgentRole;
1933
1934        let spec = WorkflowSpec::new(vec![
1935            WorkflowNode::new(RuntimeTask::new("expensive"), AgentRole::Implement)
1936                .with_token_budget(10_000),
1937            WorkflowNode::new(RuntimeTask::new("plain"), AgentRole::Implement),
1938        ]);
1939        let run = WorkflowRun::new(&spec).unwrap();
1940        assert_eq!(run.spawn_info(0).token_budget, Some(10_000));
1941        assert_eq!(run.spawn_info(1).token_budget, None);
1942    }
1943
1944    // ── Tournament node (A#2) ───────────────────────────────────────────────────────────────────
1945
1946    use crate::orchestration::workflow::{NodeKind, WorkflowNode, WorkflowSpec};
1947    use crate::types::agent::AgentRole;
1948
1949    /// A 4-entrant tournament controller (node 0) gating a dependent (node 1). Drives the whole
1950    /// bracket: 4 entrants generate, then 2 round-1 judges, then 1 final judge — and only then does
1951    /// the dependent unblock, carrying the champion in the controller's `tournament_winner`.
1952    #[test]
1953    fn tournament_runs_bracket_then_promotes_dependent() {
1954        let spec = WorkflowSpec::new(vec![
1955            WorkflowNode::new(RuntimeTask::new("pick the best ad"), AgentRole::Plan)
1956                .with_tournament(vec![
1957                    RuntimeTask::new("ad A"),
1958                    RuntimeTask::new("ad B"),
1959                    RuntimeTask::new("ad C"),
1960                    RuntimeTask::new("ad D"),
1961                ]),
1962            WorkflowNode::new(RuntimeTask::new("ship the winner"), AgentRole::Implement)
1963                .with_depends_on(vec![0]),
1964        ]);
1965        let mut run = WorkflowRun::new(&spec).unwrap();
1966
1967        // Round 1 of spawning expands the controller into 4 entrant children (nodes 2..=5); the
1968        // controller spawns no agent of its own and the dependent stays gated.
1969        let entrants = spawn_round(&mut run);
1970        let entrant_nodes: Vec<usize> = entrants.iter().map(|(n, _)| *n).collect();
1971        assert_eq!(
1972            entrant_nodes,
1973            vec![2, 3, 4, 5],
1974            "4 entrant children, no controller spawn"
1975        );
1976        assert!(
1977            run.spawn_info(2).judge_match.is_none(),
1978            "entrants are not judges"
1979        );
1980        assert!(!run.is_complete());
1981
1982        // All entrants generate → bracket begins; nothing else spawns until they're all in.
1983        for (i, (node, id)) in entrants.iter().enumerate() {
1984            run.record_completion(id, done());
1985            if i < 3 {
1986                assert!(
1987                    run.ready_batch().is_empty(),
1988                    "no judges until every entrant is in"
1989                );
1990            }
1991            let _ = node;
1992        }
1993
1994        // Round 1 judges: 2 matches over the 4 entrants, each carrying its pair.
1995        let r1 = spawn_round(&mut run);
1996        assert_eq!(r1.len(), 2, "two round-1 judges");
1997        let jm0 = run
1998            .spawn_info(r1[0].0)
1999            .judge_match
2000            .expect("judge carries a match");
2001        assert_eq!(
2002            jm0,
2003            JudgeMatch {
2004                left: node_agent_id(2),
2005                right: node_agent_id(3)
2006            }
2007        );
2008        let jm1 = run
2009            .spawn_info(r1[1].0)
2010            .judge_match
2011            .expect("judge carries a match");
2012        assert_eq!(
2013            jm1,
2014            JudgeMatch {
2015                left: node_agent_id(4),
2016                right: node_agent_id(5)
2017            }
2018        );
2019
2020        // Entrant 2 beats 3; entrant 4 beats 5. Dependent still gated mid-bracket.
2021        run.record_completion(&r1[0].1, judge_done(&node_agent_id(2)));
2022        run.record_completion(&r1[1].1, judge_done(&node_agent_id(4)));
2023        assert!(
2024            run.ready_batch().iter().all(|&n| n != 1),
2025            "dependent gated until the final"
2026        );
2027
2028        // Final round: a single judge over the two survivors.
2029        let r2 = spawn_round(&mut run);
2030        assert_eq!(r2.len(), 1, "one final judge");
2031        let jmf = run
2032            .spawn_info(r2[0].0)
2033            .judge_match
2034            .expect("final judge carries a match");
2035        assert_eq!(
2036            jmf,
2037            JudgeMatch {
2038                left: node_agent_id(2),
2039                right: node_agent_id(4)
2040            }
2041        );
2042
2043        // Entrant 4 wins it all → controller completes with the champion, dependent unblocks.
2044        run.record_completion(&r2[0].1, judge_done(&node_agent_id(4)));
2045        let winner = run
2046            .graph
2047            .get(0)
2048            .and_then(|n| n.result.as_ref())
2049            .and_then(|r| r.tournament_winner.clone());
2050        assert_eq!(
2051            winner.as_deref(),
2052            Some(node_agent_id(4).as_str()),
2053            "champion recorded"
2054        );
2055        assert_eq!(
2056            run.ready_batch(),
2057            vec![1],
2058            "dependent unblocks only after the bracket resolves"
2059        );
2060
2061        // Ship the winner → workflow complete.
2062        let last = spawn_round(&mut run);
2063        assert_eq!(last, vec![(1, node_agent_id(1))]);
2064        run.record_completion(&last[0].1, done());
2065        assert!(run.is_complete());
2066    }
2067
2068    /// An odd entrant count gives one entrant a bye in round 1 (no judge for it), and the bracket
2069    /// still resolves to a single champion.
2070    #[test]
2071    fn tournament_with_bye_resolves() {
2072        let spec = WorkflowSpec::new(vec![
2073            WorkflowNode::new(RuntimeTask::new("rank"), AgentRole::Plan).with_tournament(vec![
2074                RuntimeTask::new("x"),
2075                RuntimeTask::new("y"),
2076                RuntimeTask::new("z"),
2077            ]),
2078        ]);
2079        let mut run = WorkflowRun::new(&spec).unwrap();
2080
2081        let entrants = spawn_round(&mut run); // nodes 1,2,3
2082        assert_eq!(entrants.len(), 3);
2083        for (_, id) in &entrants {
2084            run.record_completion(id, done());
2085        }
2086        // Round 1: only (entrant1, entrant2) plays; entrant3 draws a bye.
2087        let r1 = spawn_round(&mut run);
2088        assert_eq!(r1.len(), 1, "one match, one bye");
2089        run.record_completion(&r1[0].1, judge_done(&node_agent_id(1)));
2090        // Round 2: survivor of the match vs the bye entrant.
2091        let r2 = spawn_round(&mut run);
2092        assert_eq!(r2.len(), 1);
2093        let jm = run.spawn_info(r2[0].0).judge_match.unwrap();
2094        assert_eq!(
2095            jm,
2096            JudgeMatch {
2097                left: node_agent_id(1),
2098                right: node_agent_id(3)
2099            }
2100        );
2101        run.record_completion(&r2[0].1, judge_done(&node_agent_id(3)));
2102        let winner = run
2103            .graph
2104            .get(0)
2105            .and_then(|n| n.result.as_ref())
2106            .and_then(|r| r.tournament_winner.clone());
2107        assert_eq!(winner.as_deref(), Some(node_agent_id(3).as_str()));
2108        assert!(run.is_complete());
2109    }
2110
2111    /// A quarantined tournament keeps its entrant + judge children quarantined, and (being
2112    /// read-only) they pass the quarantine invariant rather than tripping it.
2113    #[test]
2114    fn tournament_children_inherit_controller_trust() {
2115        let spec = WorkflowSpec::new(vec![
2116            WorkflowNode::new(RuntimeTask::new("judge untrusted inputs"), AgentRole::Plan)
2117                .quarantined()
2118                .with_tournament(vec![RuntimeTask::new("a"), RuntimeTask::new("b")]),
2119        ]);
2120        let mut run = WorkflowRun::new(&spec).unwrap();
2121
2122        let entrants = spawn_round(&mut run);
2123        for (node, _) in &entrants {
2124            assert_eq!(
2125                run.spawn_info(*node).trust,
2126                "quarantined",
2127                "entrant inherits quarantine"
2128            );
2129            assert!(
2130                !run.quarantine_violation(*node),
2131                "read-only entrant is quarantine-clean"
2132            );
2133        }
2134        for (_, id) in &entrants {
2135            run.record_completion(id, done());
2136        }
2137        let r1 = spawn_round(&mut run);
2138        assert_eq!(
2139            run.spawn_info(r1[0].0).trust,
2140            "quarantined",
2141            "judge inherits quarantine"
2142        );
2143        assert!(!run.quarantine_violation(r1[0].0));
2144    }
2145
2146    /// Sanity: the controller node is itself a Tournament kind and never appears in a spawn batch
2147    /// (entrants/judges carry the work).
2148    #[test]
2149    fn tournament_controller_never_spawns_itself() {
2150        let spec = WorkflowSpec::new(vec![
2151            WorkflowNode::new(RuntimeTask::new("c"), AgentRole::Plan)
2152                .with_tournament(vec![RuntimeTask::new("a"), RuntimeTask::new("b")]),
2153        ]);
2154        let mut run = WorkflowRun::new(&spec).unwrap();
2155        assert!(matches!(run.nodes[0].kind, NodeKind::Tournament { .. }));
2156        let first = spawn_round(&mut run);
2157        assert!(
2158            first.iter().all(|(n, _)| *n != 0),
2159            "controller node 0 never spawns directly"
2160        );
2161    }
2162
2163    #[test]
2164    fn errored_tournament_child_is_failed_and_no_champion_fails_controller() {
2165        // W-3: an Error-terminated entrant is FAILED (same contract as record_completion), and a
2166        // bracket with no champion FAILS the controller so dependents starve on the missing winner.
2167        let spec = WorkflowSpec::new(vec![
2168            WorkflowNode::new(RuntimeTask::new("pick"), AgentRole::Plan)
2169                .with_tournament(vec![RuntimeTask::new("x"), RuntimeTask::new("y")]),
2170            WorkflowNode::new(RuntimeTask::new("use winner"), AgentRole::Implement)
2171                .with_depends_on(vec![0]),
2172        ]);
2173        let mut run = WorkflowRun::new(&spec).unwrap();
2174        let entrants = spawn_round(&mut run);
2175        assert_eq!(entrants.len(), 2);
2176        run.record_completion(&entrants[0].1, done());
2177        run.record_completion(
2178            &entrants[1].1,
2179            LoopResult {
2180                termination: TerminationReason::Error,
2181                ..done()
2182            },
2183        );
2184        // Bracket forms; the single judge reports NO winner (e.g. it errored too).
2185        let judges = spawn_round(&mut run);
2186        assert_eq!(judges.len(), 1, "one match for two entrants");
2187        run.record_completion(&judges[0].1, done()); // tournament_winner: None
2188        let failed = outcome_ids(&run, WorkflowNodeStatus::Failed);
2189        assert!(
2190            failed.contains(&entrants[1].1),
2191            "errored entrant reported failed"
2192        );
2193        assert!(
2194            failed.contains(&"wf-node0".to_string()),
2195            "no-champion controller failed"
2196        );
2197        assert!(
2198            !run.ready_batch().contains(&1),
2199            "dependent of the failed controller starves"
2200        );
2201    }
2202
2203    #[test]
2204    fn submitted_tournament_with_one_entrant_is_rejected_atomically() {
2205        let mut run = fanout2();
2206        let before = run.len();
2207        let controller = WorkflowNode::new(RuntimeTask::new("pick"), AgentRole::Plan)
2208            .with_tournament(vec![RuntimeTask::new("only")]);
2209        assert!(run.submit_nodes(vec![controller]).is_err());
2210        assert_eq!(run.len(), before);
2211    }
2212
2213    #[test]
2214    fn submitted_classify_branch_without_classifier_dependency_is_rejected() {
2215        let mut run = fanout2();
2216        let before = run.len();
2217        let classifier = WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan)
2218            .with_classify(vec![ClassifyBranch {
2219                label: "a".to_string(),
2220                nodes: vec![1],
2221            }]);
2222        let branch = WorkflowNode::new(RuntimeTask::new("on a"), AgentRole::Implement);
2223        assert!(run.submit_nodes(vec![classifier, branch]).is_err());
2224        assert_eq!(run.len(), before);
2225    }
2226
2227    #[test]
2228    fn submitted_zero_iter_loop_is_rejected() {
2229        let mut run = fanout2();
2230        let before = run.len();
2231        let mut node = WorkflowNode::new(RuntimeTask::new("once"), AgentRole::Implement);
2232        node.kind = NodeKind::Loop { max_iters: 0 };
2233        assert!(run.submit_nodes(vec![node]).is_err());
2234        assert_eq!(run.len(), before);
2235    }
2236
2237    #[test]
2238    fn spawn_info_carries_dep_ids_and_per_node_caps() {
2239        // W-N2: EVERY dependent node carries its dependencies' agent ids (a DAG edge carries data);
2240        // W-N7: per-node max_turns/max_wall_ms ride the same hop chain as token_budget.
2241        let spec = WorkflowSpec::new(vec![
2242            WorkflowNode::new(RuntimeTask::new("w"), AgentRole::Explore),
2243            WorkflowNode::new(RuntimeTask::new("synth"), AgentRole::Plan)
2244                .with_depends_on(vec![0])
2245                .with_max_turns(4)
2246                .with_max_wall_ms(30_000),
2247        ]);
2248        let run = WorkflowRun::new(&spec).unwrap();
2249        let info = run.spawn_info(1);
2250        assert_eq!(info.input_agent_ids, vec!["wf-node0"]);
2251        assert_eq!(info.max_turns, Some(4));
2252        assert_eq!(info.max_wall_ms, Some(30_000));
2253        assert!(info.reducer.is_none(), "plain node stays non-reduce");
2254        let root = run.spawn_info(0);
2255        assert!(root.input_agent_ids.is_empty());
2256        assert_eq!(root.max_turns, None);
2257    }
2258
2259    // ── P3 DAG scheduler scenarios (F1 critical-path / F2 loop fairness / F3 failure propagation) ─
2260    //
2261    // These make the deferred orchestration A/B concrete: with `scheduler_policy` now a first-class
2262    // config axis, scheduling behavior is single-variable testable instead of agent-driven. Each
2263    // scenario drives the deterministic scheduler and asserts the property the audit called out.
2264
2265    /// F1 — critical-path skew: among ready siblings, the one heading the longest downstream chain is
2266    /// scheduled first, overriding node-id order.
2267    #[test]
2268    fn f1_critical_path_node_is_scheduled_before_a_lower_id_leaf() {
2269        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
2270        use crate::scheduler::policy::SchedulerPolicyConfig;
2271        use crate::types::agent::AgentRole;
2272
2273        // node 0 = a leaf (critical path 1). node 1 = root of chain 1→2→3 (critical path 3).
2274        // Both are ready at the start; the longer critical path must win over the smaller id.
2275        let spec = WorkflowSpec::new(vec![
2276            WorkflowNode::new(RuntimeTask::new("leaf"), AgentRole::Implement),
2277            WorkflowNode::new(RuntimeTask::new("chain-root"), AgentRole::Implement),
2278            WorkflowNode::new(RuntimeTask::new("mid"), AgentRole::Implement)
2279                .with_depends_on(vec![1]),
2280            WorkflowNode::new(RuntimeTask::new("tail"), AgentRole::Implement)
2281                .with_depends_on(vec![2]),
2282        ]);
2283        let mut run = WorkflowRun::new(&spec).unwrap();
2284        run.set_scheduler_policy(SchedulerPolicyConfig::default());
2285
2286        assert_eq!(
2287            run.ready_batch(),
2288            vec![1, 0],
2289            "the deeper critical path (node 1) outranks the lower-id leaf (node 0)"
2290        );
2291    }
2292
2293    /// F2 — loop fairness: a re-arming loop node does not starve an independent ready node. The
2294    /// audit's starvation case (concurrency 1, loop with a smaller id) must let the independent node
2295    /// run between iterations.
2296    #[test]
2297    fn f2_rearming_loop_does_not_starve_an_independent_node() {
2298        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
2299        use crate::scheduler::policy::SchedulerPolicyConfig;
2300        use crate::types::agent::AgentRole;
2301
2302        // node 0 = Loop{5} (smaller id), node 1 = independent plain node. Nothing depends on either.
2303        let spec = WorkflowSpec::new(vec![
2304            WorkflowNode::new(RuntimeTask::new("loop"), AgentRole::Implement).with_loop(5),
2305            WorkflowNode::new(RuntimeTask::new("independent"), AgentRole::Implement),
2306        ]);
2307        let mut run = WorkflowRun::new(&spec).unwrap();
2308        run.set_scheduler_policy(SchedulerPolicyConfig::default());
2309
2310        // Concurrency 1: run only the head of each ready batch. First round the loop wins the tie
2311        // (enqueued first); after it re-arms, the independent node — waiting since round 0 — must be
2312        // scheduled before the loop's next iteration.
2313        let first = run.ready_batch();
2314        assert_eq!(first[0], 0, "loop takes the first slot on the initial tie");
2315        let id = run.current_agent_id(0);
2316        run.mark_spawned(0, &id);
2317        run.record_completion(&id, done()); // finishes iteration 0, re-arms the loop
2318
2319        assert_eq!(
2320            run.ready_batch()[0],
2321            1,
2322            "the independent node runs before the loop's second iteration (no starvation)"
2323        );
2324    }
2325
2326    /// F3 — failure propagation: a failure skips its transitive successors, and partial results gate
2327    /// per the downstream node's dependency policy.
2328    #[test]
2329    fn f3_failure_and_partial_propagate_transitively_by_policy() {
2330        use crate::orchestration::workflow::{DependencyPolicy, WorkflowNode, WorkflowSpec};
2331        use crate::types::agent::AgentRole;
2332
2333        // Transitive skip: A(0) fails → B(1) depends on A → C(2) depends on B. Both B and C must
2334        // close as SkippedUpstreamFailed, not strand in Pending.
2335        let spec = WorkflowSpec::new(vec![
2336            WorkflowNode::new(RuntimeTask::new("a"), AgentRole::Implement),
2337            WorkflowNode::new(RuntimeTask::new("b"), AgentRole::Implement).with_depends_on(vec![0]),
2338            WorkflowNode::new(RuntimeTask::new("c"), AgentRole::Implement).with_depends_on(vec![1]),
2339        ]);
2340        let mut run = WorkflowRun::new(&spec).unwrap();
2341        run.mark_spawned(0, "wf-node0");
2342        run.record_completion("wf-node0", terminated(TerminationReason::Error));
2343        let outcomes = run.finish();
2344        assert_eq!(outcomes[0].status, WorkflowNodeStatus::Failed);
2345        assert_eq!(
2346            outcomes[1].status,
2347            WorkflowNodeStatus::SkippedUpstreamFailed
2348        );
2349        assert_eq!(
2350            outcomes[2].status,
2351            WorkflowNodeStatus::SkippedUpstreamFailed,
2352            "the failure propagates through the whole chain"
2353        );
2354
2355        // Partial gates by policy: an upstream partial blocks an AllSuccess dependent but satisfies
2356        // an AcceptPartial one.
2357        let spec = WorkflowSpec::new(vec![
2358            WorkflowNode::new(RuntimeTask::new("up"), AgentRole::Implement),
2359            WorkflowNode::new(RuntimeTask::new("strict"), AgentRole::Implement)
2360                .with_depends_on(vec![0]),
2361            WorkflowNode::new(RuntimeTask::new("lenient"), AgentRole::Implement)
2362                .with_depends_on(vec![0])
2363                .with_dependency_policy(DependencyPolicy::AcceptPartial),
2364        ]);
2365        let mut run = WorkflowRun::new(&spec).unwrap();
2366        run.mark_spawned(0, "wf-node0");
2367        run.record_completion("wf-node0", terminated(TerminationReason::Timeout)); // partial
2368        assert_eq!(
2369            run.ready_batch(),
2370            vec![2],
2371            "only the AcceptPartial dependent runs"
2372        );
2373        assert_eq!(
2374            run.node_outcomes()[1].status,
2375            WorkflowNodeStatus::SkippedUpstreamFailed,
2376            "the AllSuccess dependent is skipped behind the partial upstream"
2377        );
2378    }
2379
2380    // ── P3 (P0-4) generative property: every node lands in exactly one terminal set ─────────────
2381    //
2382    // The audit flagged that totality ("∀ node ∈ exactly one terminal state") held structurally but
2383    // had no generative coverage — the old executor could strand blocked successors in Pending,
2384    // appearing in neither the completed nor failed audit set. This drives many pseudo-random DAGs
2385    // with random dependency policies to completion under random per-node terminations (including
2386    // denials) and asserts `finish()` closes every node into exactly one terminal status. A
2387    // regression means the workflow audit is no longer closed.
2388
2389    struct Lcg(u64);
2390    impl Lcg {
2391        fn below(&mut self, n: u64) -> u64 {
2392            self.0 = self
2393                .0
2394                .wrapping_mul(6364136223846793005)
2395                .wrapping_add(1442695040888963407);
2396            (self.0 ^ (self.0 >> 33)) % n.max(1)
2397        }
2398    }
2399
2400    #[test]
2401    fn finish_closes_every_node_into_exactly_one_terminal_state_over_random_dags() {
2402        use crate::orchestration::workflow::{DependencyPolicy, WorkflowNode, WorkflowSpec};
2403        use crate::types::agent::AgentRole;
2404        use std::collections::BTreeSet;
2405
2406        let terminations = [
2407            TerminationReason::Completed,
2408            TerminationReason::MaxTurns,
2409            TerminationReason::TokenBudget,
2410            TerminationReason::Timeout,
2411            TerminationReason::ContextOverflow,
2412            TerminationReason::NoProgress,
2413            TerminationReason::MilestoneExceeded,
2414            TerminationReason::Error,
2415            TerminationReason::UserAbort,
2416        ];
2417        let policies = [
2418            DependencyPolicy::AllSuccess,
2419            DependencyPolicy::AcceptPartial,
2420            DependencyPolicy::AllTerminal,
2421            DependencyPolicy::Optional,
2422        ];
2423
2424        for seed in 0..300u64 {
2425            let mut rng = Lcg(seed.wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(1));
2426            let n = 2 + rng.below(7) as usize;
2427
2428            // Random DAG: node i depends on a random subset of earlier nodes (backward edges only,
2429            // so it is always acyclic), with a random dependency policy.
2430            let mut nodes = Vec::new();
2431            for i in 0..n {
2432                let mut deps = Vec::new();
2433                for j in 0..i {
2434                    if rng.below(3) == 0 {
2435                        deps.push(j);
2436                    }
2437                }
2438                let policy = policies[rng.below(policies.len() as u64) as usize];
2439                nodes.push(
2440                    WorkflowNode::new(RuntimeTask::new(format!("n{i}")), AgentRole::Implement)
2441                        .with_depends_on(deps)
2442                        .with_dependency_policy(policy),
2443                );
2444            }
2445            let spec = WorkflowSpec::new(nodes);
2446            let mut run = WorkflowRun::new(&spec).unwrap();
2447
2448            // Drive to a fixpoint: spawn each ready node, then either complete it with a random
2449            // termination or deny it. Bounded so a bug cannot hang the test.
2450            for _ in 0..(n * 4 + 4) {
2451                let ready = run.ready_batch();
2452                if ready.is_empty() {
2453                    break;
2454                }
2455                for node in ready {
2456                    let agent = node_agent_id(node);
2457                    run.mark_spawned(node, &agent);
2458                    if rng.below(5) == 0 {
2459                        run.mark_denied(node);
2460                    } else {
2461                        let termination =
2462                            terminations[rng.below(terminations.len() as u64) as usize];
2463                        run.record_completion(&agent, terminated(termination));
2464                    }
2465                }
2466            }
2467
2468            let outcomes = run.finish();
2469            // Totality: exactly one terminal outcome per node, covering the whole node set.
2470            assert_eq!(outcomes.len(), n, "seed {seed}: every node has an outcome");
2471            let ids: BTreeSet<String> = outcomes.iter().map(|o| o.node_id.clone()).collect();
2472            assert_eq!(ids.len(), n, "seed {seed}: node ids are unique");
2473            for node in 0..n {
2474                assert!(
2475                    ids.contains(&node_agent_id(node)),
2476                    "seed {seed}: node {node} is in the closed outcome set"
2477                );
2478            }
2479            for outcome in &outcomes {
2480                assert!(
2481                    matches!(
2482                        outcome.status,
2483                        WorkflowNodeStatus::Completed
2484                            | WorkflowNodeStatus::CompletedPartial
2485                            | WorkflowNodeStatus::Failed
2486                            | WorkflowNodeStatus::SkippedUpstreamFailed
2487                    ),
2488                    "seed {seed}: {} is terminal",
2489                    outcome.node_id
2490                );
2491            }
2492        }
2493    }
2494}