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//! done, or denied, 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 crate::orchestration::task_graph::{TaskGraph, TaskStatus};
17use crate::orchestration::tournament::{EntrantId, Match, Tournament, TournamentAction};
18use super::{NodeKind, NodeTrust, WorkflowNode, WorkflowSpec};
19use crate::types::agent::{AgentIsolation, AgentRole, ContextInheritance, IsolationManifest};
20use crate::types::error::DeepStrikeError;
21use crate::types::task::RuntimeTask;
22use crate::types::error::Result;
23use crate::types::result::{LoopResult, TerminationReason};
24
25/// Deterministic kernel agent id for a workflow node (stable across resume / audit).
26pub fn node_agent_id(node: usize) -> String {
27    format!("wf-node{node}")
28}
29
30/// Parse a loop-iteration agent id `wf-node{N}-i{k}` into `(N, k)`; `None` for plain
31/// node ids, malformed ids, or an out-of-range node index.
32fn parse_loop_iteration_id(id: &str, n_nodes: usize) -> Option<(usize, usize)> {
33    let rest = id.strip_prefix("wf-node")?;
34    let (node_s, k_s) = rest.split_once("-i")?;
35    let node: usize = node_s.parse().ok()?;
36    let k: usize = k_s.parse().ok()?;
37    (node < n_nodes).then_some((node, k))
38}
39
40/// Enough to run one spawned workflow node, carried to the SDK in the `WorkflowBatchSpawned`
41/// observation. Role/isolation/inheritance are canonical snake_case strings (serde names) so the
42/// host SDK can rebuild an agent run spec — the kernel generates these specs internally, so this
43/// is how the goal reaches the SDK that actually executes the node.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct WorkflowSpawnInfo {
46    pub agent_id: String,
47    pub goal: String,
48    pub role: String,
49    pub isolation: String,
50    pub context_inheritance: String,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub model_hint: Option<String>,
53    /// W3 trust level (`"trusted"` | `"quarantined"`) — the SDK runs quarantined nodes without
54    /// privileges and crosses their output back only as a structured summary.
55    #[serde(default = "default_trust")]
56    pub trust: String,
57    /// G3 structured output: the JSON Schema the node's output must conform to, carried verbatim
58    /// from [`WorkflowNode::output_schema`]. The SDK instructs the agent with it and validates +
59    /// retries on its result. `None` when the node declared no schema. Additive ABI.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub output_schema: Option<serde_json::Value>,
62    /// G2 deterministic compute: present only for a [`NodeKind::Reduce`] node — the name of the
63    /// SDK-registered pure function the SDK runs (over `input_agent_ids`' outputs) instead of an LLM
64    /// agent. `None` for every ordinary node. Additive ABI.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub reducer: Option<String>,
67    /// G2: the dependency agent ids whose outputs a [`NodeKind::Reduce`] node consumes (its
68    /// `depends_on`, resolved to stable agent ids). Empty for non-reduce nodes. Additive ABI.
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub input_agent_ids: Vec<String>,
71    /// Present only for a tournament *judge* spawn (A#2): the two entrant agent ids whose outputs
72    /// this judge must compare. The SDK looks up those entrants' produced candidates, runs the
73    /// judge, and reports the winner in the result's `tournament_winner`. `None` for every ordinary
74    /// (entrant / spawn / loop / classify) node. Additive ABI: omitted on the wire when `None`.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub judge_match: Option<JudgeMatch>,
77    /// Present only for a [`NodeKind::Loop`] iteration spawn (A#2 v2): the loop's `max_iters`. It
78    /// both *marks* the spawn as a loop iteration — so the SDK knows to solicit and report a
79    /// `loop_continue` stop signal from the agent — and gives the cap for the agent's prompt. `None`
80    /// for every non-loop node. Mirrors how `reducer` / `judge_match` distinguish reduce / judge
81    /// spawns. Additive ABI: omitted on the wire when `None`.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub loop_max_iters: Option<usize>,
84    /// Present only for a [`NodeKind::Classify`] spawn (A#2): the branch labels the classifier must
85    /// choose among. Non-empty *marks* the spawn as a classifier — the SDK instructs the agent to
86    /// pick exactly one label and reports it in the result's `classify_branch`. Empty for every
87    /// non-classify node. Additive ABI: omitted on the wire when empty.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub classify_labels: Vec<String>,
90    /// M4/G5: the node's per-node cumulative token cap, if set. The SDK sets the child run's
91    /// `max_total_tokens` to this so the node self-terminates at the cap. Additive ABI: omitted when
92    /// `None`.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub token_budget: Option<u64>,
95    /// O3: per-node turn cap → the child run's `max_turns`. Additive ABI: omitted when `None`.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub max_turns: Option<u32>,
98    /// O3: per-node wall-clock cap (ms) → the child run's timeout. Additive ABI: omitted when `None`.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub max_wall_ms: Option<u64>,
101}
102
103fn default_trust() -> String {
104    "trusted".to_string()
105}
106
107/// A pairwise judge assignment carried to the SDK on a tournament judge's `WorkflowSpawnInfo`:
108/// the two entrant agent ids whose produced outputs are to be compared. The SDK maps each id back
109/// to that entrant's candidate and asks the judge which is better.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct JudgeMatch {
112    pub left: String,
113    pub right: String,
114}
115
116/// G4 budget-as-signal: a snapshot of the workflow's remaining headroom under the active resource
117/// quota, carried to the SDK on every `WorkflowBatchSpawned`. A coordinator/submitter node reads it
118/// to *scale its next submission to what is actually available* — the analogue of the host-side
119/// `budget.remaining()` in the code-orchestration model — instead of blindly hitting the cap and
120/// eating a `Deny`. `None` remaining fields mean that dimension is unbounded (no quota set).
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
122pub struct WorkflowBudget {
123    /// Nodes currently in the DAG (spec + every runtime submission so far).
124    pub nodes_used: usize,
125    /// `ResourceQuota::max_workflow_nodes`, if set.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub nodes_max: Option<usize>,
128    /// `nodes_max - nodes_used` (saturating), if a node cap is set — how many more nodes may be
129    /// submitted before the `max_workflow_nodes` backstop denies further growth.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub nodes_remaining: Option<usize>,
132    /// Sub-agents currently in the `running` state.
133    pub running_subagents: usize,
134    /// `ResourceQuota::max_concurrent_subagents`, if set.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub max_concurrent_subagents: Option<usize>,
137    /// `max_concurrent_subagents - running_subagents` (saturating), if a concurrency cap is set —
138    /// how many of a submission's nodes can spawn *immediately* rather than deferring for a slot.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub concurrency_remaining: Option<usize>,
141    /// M4/G5: cumulative tokens spent across the run so far (the scheduler's `total_tokens`).
142    /// `#[serde(default)]` keeps older JSON (without this field) deserializing to 0 — additive ABI.
143    #[serde(default)]
144    pub tokens_used: u64,
145    /// M4/G5: `SchedulerBudget::max_total_tokens` — the run's cumulative token cap.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub tokens_max: Option<u64>,
148    /// M4/G5: `tokens_max - tokens_used` (saturating) — how many tokens remain before the run-level
149    /// token budget terminates the workflow. Lets a coordinator scale its next submission to token
150    /// headroom (the analogue of "use 10k tokens").
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub tokens_remaining: Option<u64>,
153}
154
155fn role_label(role: AgentRole) -> &'static str {
156    match role {
157        AgentRole::Explore => "explore",
158        AgentRole::Plan => "plan",
159        AgentRole::Implement => "implement",
160        AgentRole::Verify => "verify",
161        AgentRole::Custom => "custom",
162    }
163}
164
165fn isolation_label(isolation: AgentIsolation) -> &'static str {
166    match isolation {
167        AgentIsolation::Shared => "shared",
168        AgentIsolation::ReadOnly => "read_only",
169        AgentIsolation::Worktree => "worktree",
170        AgentIsolation::Remote => "remote",
171    }
172}
173
174fn inheritance_label(inheritance: ContextInheritance) -> &'static str {
175    match inheritance {
176        ContextInheritance::None => "none",
177        ContextInheritance::SystemOnly => "system_only",
178        ContextInheritance::Full => "full",
179    }
180}
181
182fn trust_label(trust: NodeTrust) -> &'static str {
183    match trust {
184        NodeTrust::Trusted => "trusted",
185        NodeTrust::Quarantined => "quarantined",
186    }
187}
188
189/// Synthetic terminal result for a node recovered as already-completed during resume.
190fn resumed_result() -> LoopResult {
191    LoopResult {
192        termination: crate::types::result::TerminationReason::Completed,
193        final_message: None,
194        turns_used: 0,
195        total_tokens_used: 0,
196        loop_continue: None,
197        classify_branch: None,
198        tournament_winner: None,
199        pace_decision: None,
200    }
201}
202
203/// One recovered node completion for [`WorkflowRun::resume`]: the agent id plus the result-borne
204/// control signals the DAG needs to replay faithfully. Without `classify_branch` a resumed
205/// classifier cannot re-prune its losing branches (the rejected branch would RUN after resume);
206/// without `loop_continue` a semantic early-stop is unprovable from ids alone and the final
207/// iteration re-runs. All fields additive; a bare agent id (legacy logs) means "no signals".
208#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
209pub struct ResumedCompletion {
210    pub agent_id: String,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub classify_branch: Option<String>,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub tournament_winner: Option<String>,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub loop_continue: Option<bool>,
217}
218
219impl ResumedCompletion {
220    /// A signal-less completion (legacy `resumed_completed` string entries).
221    pub fn bare(agent_id: impl Into<String>) -> Self {
222        Self {
223            agent_id: agent_id.into(),
224            ..Self::default()
225        }
226    }
227}
228
229/// In-flight bracket state for one `NodeKind::Tournament` controller node. Entrant and judge
230/// children are appended as ordinary graph nodes (so they flow through the unchanged spawn loop);
231/// this just tracks the phase and the current round's judges so completions advance the bracket.
232struct TournamentState {
233    /// Entrant child node indices (the generators), in entrant order.
234    entrant_nodes: Vec<usize>,
235    /// Entrants still generating; the bracket starts when this reaches 0.
236    entrants_remaining: usize,
237    /// Single-elimination bracket — `None` during the entrant phase, `Some` once judging begins.
238    bracket: Option<Tournament>,
239    /// Current round's judge child node indices, aligned to the bracket's pending matches.
240    judge_nodes: Vec<usize>,
241    /// Winner reported per current-round match (aligned to `judge_nodes`); `None` until judged.
242    judge_winners: Vec<Option<EntrantId>>,
243    /// Judges still deliberating this round; the round resolves when this reaches 0.
244    judges_remaining: usize,
245}
246
247/// The state of one in-flight workflow execution.
248pub struct WorkflowRun {
249    graph: TaskGraph,
250    nodes: Vec<WorkflowNode>,
251    /// Parent session id stamped onto each node's spawned-agent manifest.
252    parent_session_id: String,
253    /// Completed-event lookup: kernel agent id → DAG node index.
254    node_of_agent: HashMap<String, usize>,
255    /// Completed-iteration count per `Loop` node (absent / 0 = no iterations finished yet). The
256    /// in-flight iteration's agent id is `wf-node{N}-i{iter_counts[N]}`.
257    iter_counts: HashMap<usize, usize>,
258    /// In-flight bracket state per `NodeKind::Tournament` controller node index.
259    tournaments: HashMap<usize, TournamentState>,
260    /// Reverse map: an appended entrant/judge child node index → its controller node index.
261    child_controller: HashMap<usize, usize>,
262    /// Judge-match descriptor per judge child node index (read by `spawn_info`).
263    judge_matches: HashMap<usize, JudgeMatch>,
264}
265
266impl WorkflowRun {
267    /// Build from a spec. Validates dependency indices + acyclicity (reuses `WorkflowSpec`).
268    pub fn new(spec: &WorkflowSpec, parent_session_id: &str) -> Result<Self> {
269        Ok(Self {
270            graph: spec.validate()?,
271            nodes: spec.nodes.clone(),
272            parent_session_id: parent_session_id.to_string(),
273            node_of_agent: HashMap::new(),
274            iter_counts: HashMap::new(),
275            tournaments: HashMap::new(),
276            child_controller: HashMap::new(),
277            judge_matches: HashMap::new(),
278        })
279    }
280
281    /// W0-ABI resume: rebuild an in-flight run by replaying which node agent-ids already completed
282    /// (e.g. recovered from the session log after an interruption). Those nodes are pre-marked
283    /// done so [`ready_batch`](Self::ready_batch) returns only the remaining work — the kernel then
284    /// continues the DAG from where it left off.
285    ///
286    /// R3-1: `submissions` are the runtime [`Self::submit_nodes`] batches recorded (in order) before
287    /// the interruption, re-applied **first** so dynamically-appended nodes reconstruct. When
288    /// `submission_bases` records each batch's original base index (from the
289    /// `WorkflowNodesSubmitted` observation), batches are re-applied at those EXACT indices,
290    /// gap-filling any interleaved runtime children (tournament entrants/judges) with inert
291    /// completed placeholders — so a later batch never shifts onto a child's old index and every
292    /// completed id maps to the node it originally named. Without bases (legacy logs) batches
293    /// replay in order, which is exact only when submissions were the sole runtime appends.
294    ///
295    /// Loop iterations complete under `wf-node{N}-i{k}`: replay advances the iteration cursor to
296    /// the highest recorded `k+1` instead of discarding the finished work — the node re-arms at the
297    /// next iteration. It completes when `max_iters` is provably exhausted OR a recorded
298    /// `loop_continue == false` proves the semantic early stop (legacy signal-less logs still
299    /// re-run the final iteration).
300    ///
301    /// A completed `Classify` node replays its recorded branch choice through the same prune logic
302    /// as [`Self::record_completion`]; with no recorded choice (legacy logs) every branch is pruned
303    /// — the same "no recognizable choice" contract as the live path, and strictly safer than
304    /// running a branch the original classification rejected.
305    ///
306    /// A completed `Tournament` controller is NOT replayed (its children are runtime appends the
307    /// SDK never logs as node completions): a bracket unresolved at the interruption re-expands and
308    /// re-runs from its entrants — wasteful but faithful.
309    pub fn resume(
310        spec: &WorkflowSpec,
311        parent_session_id: &str,
312        submissions: &[Vec<WorkflowNode>],
313        submission_bases: &[u32],
314        completed: &[ResumedCompletion],
315    ) -> Result<Self> {
316        let mut run = Self::new(spec, parent_session_id)?;
317        for (i, batch) in submissions.iter().enumerate() {
318            if let Some(&base) = submission_bases.get(i) {
319                let base = base as usize;
320                if base < run.nodes.len() {
321                    return Err(DeepStrikeError::InvalidConfig(format!(
322                        "resume: submission {i} base {base} below reconstructed graph len {} — corrupt submission record",
323                        run.nodes.len()
324                    )));
325                }
326                // Gap = runtime children appended between batches (a restarting bracket).
327                // Fill with inert completed placeholders so indices stay faithful; a child's
328                // completed id then lands on its placeholder instead of a shifted later batch.
329                while run.nodes.len() < base {
330                    let idx = run.nodes.len();
331                    let node = WorkflowNode::new(
332                        RuntimeTask::new("[resume placeholder: runtime child slot]"),
333                        AgentRole::Implement,
334                    );
335                    run.graph.add(node.task.clone(), Vec::new());
336                    run.nodes.push(node);
337                    run.graph.start(idx);
338                    run.graph.complete(idx, resumed_result());
339                }
340            }
341            run.submit_nodes(batch.clone());
342        }
343        let n = run.graph.len();
344        // Highest finished iteration + last recorded loop_continue per Loop node.
345        let mut loop_cursor: HashMap<usize, (usize, Option<bool>)> = HashMap::new();
346        for rec in completed {
347            let id = rec.agent_id.as_str();
348            if let Some(node) = (0..n).find(|&i| node_agent_id(i) == id) {
349                run.graph.start(node);
350                if let NodeKind::Classify { branches } = &run.nodes[node].kind {
351                    // Re-prune exactly as record_completion: fail every branch the recorded
352                    // choice did not select BEFORE completing, so promotion arms only the winner.
353                    let chosen = rec.classify_branch.clone();
354                    let prune: Vec<usize> = branches
355                        .iter()
356                        .filter(|b| Some(&b.label) != chosen.as_ref())
357                        .flat_map(|b| b.nodes.iter().copied())
358                        .collect();
359                    for bn in prune {
360                        run.graph.fail(bn);
361                    }
362                    let result = LoopResult {
363                        classify_branch: chosen,
364                        ..resumed_result()
365                    };
366                    run.graph.complete(node, result);
367                } else {
368                    let result = LoopResult {
369                        tournament_winner: rec.tournament_winner.clone(),
370                        ..resumed_result()
371                    };
372                    run.graph.complete(node, result);
373                }
374                continue;
375            }
376            if let Some((node, k)) = parse_loop_iteration_id(id, n) {
377                if matches!(run.nodes[node].kind, NodeKind::Loop { .. }) {
378                    let entry = loop_cursor.entry(node).or_insert((0, None));
379                    if k + 1 >= entry.0 {
380                        entry.0 = k + 1;
381                        // The HIGHEST iteration's signal decides (later records override).
382                        entry.1 = rec.loop_continue;
383                    }
384                }
385            }
386        }
387        for (node, (done, last_continue)) in loop_cursor {
388            if let NodeKind::Loop { max_iters } = run.nodes[node].kind {
389                let done = run.iter_counts.get(&node).copied().unwrap_or(0).max(done);
390                run.iter_counts.insert(node, done);
391                let stop_recorded = last_continue == Some(false);
392                if done >= max_iters || stop_recorded {
393                    run.graph.start(node);
394                    run.graph.complete(node, resumed_result());
395                } else {
396                    // Re-arm at the recorded cursor: the next spawn round runs
397                    // `wf-node{node}-i{done}` instead of restarting from i0.
398                    run.graph.set_ready(node);
399                }
400            }
401        }
402        Ok(run)
403    }
404
405    /// Node indices whose dependencies are satisfied and that have not yet started.
406    pub fn ready_batch(&self) -> Vec<usize> {
407        self.graph.ready_tasks()
408    }
409
410    /// The agent id for a node's *current* spawn. For a `Spawn` node this is the stable
411    /// `wf-node{N}`; for a `Loop` node it is `wf-node{N}-i{k}` where `k` is the count of iterations
412    /// already finished — so each iteration gets a distinct id without any new ABI (the SDK simply
413    /// spawns the id it is given and feeds it back as a `sub_agent_completed`).
414    pub fn current_agent_id(&self, node: usize) -> String {
415        match self.nodes[node].kind {
416            NodeKind::Loop { .. } => {
417                let k = self.iter_counts.get(&node).copied().unwrap_or(0);
418                format!("{}-i{k}", node_agent_id(node))
419            }
420            // Spawn / Classify run once, a Tournament controller never spawns its own agent (its
421            // entrant/judge children are separate Spawn nodes), and a Reduce node runs once as host
422            // compute → stable plain id.
423            NodeKind::Spawn
424            | NodeKind::Classify { .. }
425            | NodeKind::Tournament { .. }
426            | NodeKind::Reduce { .. } => node_agent_id(node),
427        }
428    }
429
430    /// Build the isolation manifest for a node's current spawn, preserving its explicit isolation +
431    /// context-inheritance (the `AgentRunSpec`→`from_spec` path would overwrite these with
432    /// role defaults). Capability inheritance for workflow nodes is left to a later round.
433    pub fn manifest_for(&self, node: usize) -> IsolationManifest {
434        let n = &self.nodes[node];
435        IsolationManifest {
436            agent_id: self.current_agent_id(node).into(),
437            parent_session_id: self.parent_session_id.as_str().into(),
438            role: n.role,
439            isolation: n.isolation,
440            context_inheritance: n.context_inheritance,
441            permitted_capability_ids: Vec::new(),
442        }
443    }
444
445    /// The goal text for a node (for the spawn's run spec / context injection).
446        /// W3 quarantine invariant: a quarantined node reads untrusted content and must run read-only.
447    /// Returns `true` if the node is `Quarantined` yet declares a write-capable isolation
448    /// (`Shared`/`Worktree`/`Remote`) — a privilege contradiction the kernel refuses to spawn,
449    /// turning the SDK's "self-discipline" quarantine into an in-kernel, auditable enforcement.
450    pub fn quarantine_violation(&self, node: usize) -> bool {
451        let n = &self.nodes[node];
452        matches!(n.trust, NodeTrust::Quarantined)
453            && !matches!(n.isolation, AgentIsolation::ReadOnly)
454    }
455
456    /// The SDK-facing spawn descriptor for a node (agent id + goal + canonical role/isolation/
457    /// inheritance strings + model hint). The kernel owns the spec; this is how the goal reaches
458    /// the host that runs the node.
459    pub fn spawn_info(&self, node: usize) -> WorkflowSpawnInfo {
460        let n = &self.nodes[node];
461        // The stable agent ids of this node's dependencies. A Reduce node's registered function
462        // consumes them (G2); EVERY other dependent node gets them too, so the SDK can put the
463        // dependency outputs in the node's context — a DAG edge carries data, not just ordering
464        // (without this, fan-out→synthesize produced an uninformed synthesis).
465        let reducer = match &n.kind {
466            NodeKind::Reduce { reducer } => Some(reducer.clone()),
467            _ => None,
468        };
469        let input_agent_ids: Vec<String> =
470            n.depends_on.iter().map(|&d| node_agent_id(d)).collect();
471        // A#2 v2 / classify: surface the control-flow kind so the SDK can solicit + report the
472        // matching result signal (`loop_continue` / `classify_branch`), mirroring how `reducer` /
473        // `judge_match` distinguish reduce / judge spawns.
474        let loop_max_iters = match &n.kind {
475            NodeKind::Loop { max_iters } => Some(*max_iters),
476            _ => None,
477        };
478        let classify_labels = match &n.kind {
479            NodeKind::Classify { branches } => branches.iter().map(|b| b.label.clone()).collect(),
480            _ => Vec::new(),
481        };
482        WorkflowSpawnInfo {
483            agent_id: self.current_agent_id(node),
484            goal: n.task.goal.clone(),
485            role: role_label(n.role).to_string(),
486            isolation: isolation_label(n.isolation).to_string(),
487            context_inheritance: inheritance_label(n.context_inheritance).to_string(),
488            model_hint: n.model_hint.clone(),
489            trust: trust_label(n.trust).to_string(),
490            output_schema: n.output_schema.clone(),
491            reducer,
492            input_agent_ids,
493            judge_match: self.judge_matches.get(&node).cloned(),
494            loop_max_iters,
495            classify_labels,
496            token_budget: n.token_budget,
497            max_turns: n.max_turns,
498            max_wall_ms: n.max_wall_ms,
499        }
500    }
501
502    /// Mark a node as spawned: start it in the graph and map its kernel agent id back
503    /// to the node for completion routing. (The live in-flight set is the executor's
504    /// `SuspendState::SubAgentAwait.agent_ids` — the single source of in-flight truth.)
505    pub fn mark_spawned(&mut self, node: usize, agent_id: &str) {
506        self.graph.start(node);
507        self.node_of_agent.insert(agent_id.to_string(), node);
508    }
509
510    /// Mark a node as denied by the syscall gate: fail it in the graph (dependents stay pending
511    /// and will never become ready). Does not enter the live batch.
512    pub fn mark_denied(&mut self, node: usize) {
513        self.graph.fail(node);
514    }
515
516    /// Record a completed sub-agent against its node. Returns the node index if `agent_id`
517    /// belonged to this workflow, else `None`.
518    ///
519    /// For a `Loop` node this counts the finished iteration: while more iterations remain
520    /// (`< max_iters`) the node is re-armed (`set_ready`) — so the next `ready_batch`/spawn round
521    /// runs `wf-node{N}-i{k+1}` — and the node stays non-terminal, keeping its dependents pending.
522    /// Only when the loop is exhausted is the node `complete`d, promoting its dependents.
523    pub fn record_completion(&mut self, agent_id: &str, result: LoopResult) -> Option<usize> {
524        let node = *self.node_of_agent.get(agent_id)?;
525
526        // A tournament entrant/judge child: route the completion into its controller's bracket
527        // rather than treating it as an ordinary node (it has no dependents of its own).
528        if let Some(&controller) = self.child_controller.get(&node) {
529            return self.advance_tournament(controller, node, result);
530        }
531
532        match &self.nodes[node].kind {
533            NodeKind::Loop { max_iters } => {
534                // v2 semantic stop: the iteration may signal "done" (`loop_continue == Some(false)`),
535                // ending the loop before `max_iters`. `None`/`Some(true)` run to the cap (v1 behavior).
536                let max_iters = *max_iters;
537                let stop_requested = result.loop_continue == Some(false);
538                let done = self.iter_counts.entry(node).or_insert(0);
539                *done += 1;
540                if *done < max_iters && !stop_requested {
541                    // More iterations: re-arm the node, keep it (and its dependents) in flight.
542                    self.graph.set_ready(node);
543                    return Some(node);
544                }
545            }
546            NodeKind::Classify { branches } => {
547                // Route to the branch matching the classifier's reported label; prune every other
548                // branch's nodes (fail them) *before* completing this node, so that `complete`'s
549                // dependent-promotion only arms the chosen branch (failed nodes are never re-armed).
550                let chosen = result.classify_branch.clone();
551                let prune: Vec<usize> = branches
552                    .iter()
553                    .filter(|b| Some(&b.label) != chosen.as_ref())
554                    .flat_map(|b| b.nodes.iter().copied())
555                    .collect();
556                for bn in prune {
557                    self.graph.fail(bn);
558                }
559            }
560            // A Tournament controller never reaches here (it spawns no agent of its own; its
561            // children route through `child_controller` above). A Reduce node completes like a Spawn
562            // (its host-compute result feeds back as an ordinary completion). Defensive no-op.
563            NodeKind::Spawn | NodeKind::Tournament { .. } | NodeKind::Reduce { .. } => {}
564        }
565
566        // Spawn node, loop's final iteration, or a completed classifier. A node whose agent
567        // terminated in `Error` is *failed* (its dependents starve) rather than completed — an
568        // errored agent must not promote dependents that would run on missing/garbage input. This
569        // is also the SDK's only lever to fail a node from a result: G3 schema enforcement returns
570        // an `Error`-terminated result when output never conforms, failing the node here. Other
571        // terminations (max-turns / budget / timeout) still complete — they may carry partial output.
572        if matches!(result.termination, crate::types::result::TerminationReason::Error) {
573            self.graph.fail(node);
574        } else {
575            self.graph.complete(node, result);
576        }
577        Some(node)
578    }
579
580    // ── Tournament controller (A#2) ─────────────────────────────────────────────────────────────
581
582    /// Append an entrant/judge *child* node (no dependencies → immediately Ready) and return its
583    /// index. Keeps `self.nodes` and `self.graph` index-aligned (both grow in lockstep), so the
584    /// child flows through the unchanged spawn loop as an ordinary `wf-node{idx}` spawn.
585    fn append_child(&mut self, node: WorkflowNode) -> usize {
586        let idx = self.graph.add(node.task.clone(), Vec::new());
587        debug_assert_eq!(idx, self.nodes.len(), "graph/nodes index drift");
588        self.nodes.push(node);
589        idx
590    }
591
592    /// Expand every tournament controller node whose dependencies are now satisfied (status
593    /// `Ready`) into its entrant children. The controller is moved to `Running` (it spawns no agent
594    /// of its own) and stays non-terminal until its bracket resolves. Called by the executor before
595    /// each spawn round, so a controller behind upstream deps expands the moment those complete.
596    pub fn expand_ready_controllers(&mut self) {
597        let pending: Vec<usize> = (0..self.nodes.len())
598            .filter(|i| !self.tournaments.contains_key(i))
599            .filter(|&i| matches!(self.nodes[i].kind, NodeKind::Tournament { .. }))
600            .filter(|&i| self.graph.get(i).map(|n| n.status) == Some(TaskStatus::Ready))
601            .collect();
602        for c in pending {
603            self.expand_tournament(c);
604        }
605    }
606
607    /// Fan a controller out into its entrant generators. Entrants run independent + read-only (a
608    /// clean context per candidate, quarantine-safe), inheriting the controller's trust.
609    fn expand_tournament(&mut self, c: usize) {
610        let entrants = match &self.nodes[c].kind {
611            NodeKind::Tournament { entrants } => entrants.clone(),
612            _ => return,
613        };
614        let trust = self.nodes[c].trust;
615        // Controller spawns no agent of its own → take it out of the ready set until we complete it.
616        self.graph.start(c);
617        // W-2: a runtime-submitted controller bypasses `WorkflowSpec::validate`, so the ≥2-entrant
618        // invariant is re-checked here. A contest that cannot form fails the controller outright
619        // (no champion) instead of stalling Running forever with no child ever reporting back.
620        if entrants.len() < 2 {
621            self.complete_tournament(c, None);
622            return;
623        }
624        let mut entrant_nodes = Vec::with_capacity(entrants.len());
625        for task in entrants {
626            let child = WorkflowNode::new(task, AgentRole::Custom)
627                .with_isolation(AgentIsolation::ReadOnly)
628                .with_trust(trust);
629            let idx = self.append_child(child);
630            self.child_controller.insert(idx, c);
631            entrant_nodes.push(idx);
632        }
633        let entrants_remaining = entrant_nodes.len();
634        self.tournaments.insert(
635            c,
636            TournamentState {
637                entrant_nodes,
638                entrants_remaining,
639                bracket: None,
640                judge_nodes: Vec::new(),
641                judge_winners: Vec::new(),
642                judges_remaining: 0,
643            },
644        );
645    }
646
647    /// A tournament child (entrant or judge) completed: advance the controller's bracket. Returns
648    /// the controller node index (the node that conceptually progressed).
649    fn advance_tournament(
650        &mut self,
651        controller: usize,
652        child: usize,
653        result: LoopResult,
654    ) -> Option<usize> {
655        // The child has no dependents; mark it terminal so the graph's done/outcome accounting
656        // works. An `Error`-terminated child is *failed* — the same contract as an ordinary node
657        // (`record_completion`) — so outcome() reports it honestly. The bracket still advances:
658        // an errored entrant simply fields an empty candidate (judges see it and prefer the other
659        // side); an errored judge reports no winner, which surfaces as a no-champion bracket below.
660        if matches!(result.termination, TerminationReason::Error) {
661            self.graph.fail(child);
662        } else {
663            self.graph.complete(child, result.clone());
664        }
665
666        let in_entrant_phase = self.tournaments.get(&controller)?.bracket.is_none();
667        if in_entrant_phase {
668            let all_in = {
669                let st = self.tournaments.get_mut(&controller)?;
670                st.entrants_remaining = st.entrants_remaining.saturating_sub(1);
671                st.entrants_remaining == 0
672            };
673            if all_in {
674                self.begin_bracket(controller);
675            }
676        } else {
677            let round_done = {
678                let st = self.tournaments.get_mut(&controller)?;
679                if let Some(pos) = st.judge_nodes.iter().position(|&n| n == child) {
680                    st.judge_winners[pos] = result.tournament_winner.clone();
681                }
682                st.judges_remaining = st.judges_remaining.saturating_sub(1);
683                st.judges_remaining == 0
684            };
685            if round_done {
686                self.finish_round(controller);
687            }
688        }
689        Some(controller)
690    }
691
692    /// All entrants are in: embed the bracket over their agent ids and emit round 1's judges.
693    fn begin_bracket(&mut self, controller: usize) {
694        let entrant_ids: Vec<EntrantId> = self
695            .tournaments
696            .get(&controller)
697            .map(|st| st.entrant_nodes.iter().map(|&n| node_agent_id(n)).collect())
698            .unwrap_or_default();
699        // ≥2 entrants is guaranteed by `validate`; `Tournament::new` only rejects an empty field.
700        let mut bracket = match Tournament::new(entrant_ids) {
701            Ok(b) => b,
702            Err(_) => return self.complete_tournament(controller, None),
703        };
704        let action = bracket.start();
705        if let Some(st) = self.tournaments.get_mut(&controller) {
706            st.bracket = Some(bracket);
707        }
708        self.apply_action(controller, action);
709    }
710
711    /// This round's judges all reported: feed the winners to the bracket and act on what comes next.
712    fn finish_round(&mut self, controller: usize) {
713        let winners: Vec<EntrantId> = self
714            .tournaments
715            .get(&controller)
716            .map(|st| st.judge_winners.iter().filter_map(|w| w.clone()).collect())
717            .unwrap_or_default();
718        let action = {
719            let st = match self.tournaments.get_mut(&controller) {
720                Some(st) => st,
721                None => return,
722            };
723            match st.bracket.as_mut() {
724                // A judge that reported no winner shrinks `winners` below the match count, so
725                // `feed_round` errors — we surface that as a tournament with no champion.
726                Some(b) => b.feed_round(winners),
727                None => return,
728            }
729        };
730        match action {
731            Ok(act) => self.apply_action(controller, act),
732            Err(_) => self.complete_tournament(controller, None),
733        }
734    }
735
736    /// Act on a bracket step: spawn the round's judges, or finish with the champion.
737    fn apply_action(&mut self, controller: usize, action: TournamentAction) {
738        match action {
739            TournamentAction::JudgeRound { matches, .. } => self.emit_judges(controller, matches),
740            TournamentAction::Done { winner, .. } => {
741                self.complete_tournament(controller, Some(winner))
742            }
743        }
744    }
745
746    /// Append one judge child per match (bias-resistant `Verify`: read-only, no inherited context),
747    /// each carrying its `JudgeMatch`. The controller's own goal is the judging criterion.
748    fn emit_judges(&mut self, controller: usize, matches: Vec<Match>) {
749        let criterion = self.nodes[controller].task.clone();
750        let trust = self.nodes[controller].trust;
751        let mut judge_nodes = Vec::with_capacity(matches.len());
752        for m in &matches {
753            let judge = WorkflowNode::new(criterion.clone(), AgentRole::Verify).with_trust(trust);
754            let idx = self.append_child(judge);
755            self.child_controller.insert(idx, controller);
756            self.judge_matches.insert(
757                idx,
758                JudgeMatch {
759                    left: m.left.clone(),
760                    right: m.right.clone(),
761                },
762            );
763            judge_nodes.push(idx);
764        }
765        if let Some(st) = self.tournaments.get_mut(&controller) {
766            st.judge_winners = vec![None; judge_nodes.len()];
767            st.judges_remaining = judge_nodes.len();
768            st.judge_nodes = judge_nodes;
769        }
770    }
771
772    /// Resolve the controller: drop its bracket state and `complete` it with the champion's id in
773    /// `tournament_winner`, promoting its dependents. A bracket with NO champion (a judge reported
774    /// no winner, or the bracket could not form) *fails* the controller instead — dependents that
775    /// would consume `tournament_winner` must starve rather than run on a missing input, exactly
776    /// like the dependents of an `Error`-terminated Spawn node.
777    fn complete_tournament(&mut self, controller: usize, winner: Option<EntrantId>) {
778        self.tournaments.remove(&controller);
779        let Some(winner) = winner else {
780            self.graph.fail(controller);
781            return;
782        };
783        let result = LoopResult {
784            termination: TerminationReason::Completed,
785            final_message: None,
786            turns_used: 0,
787            total_tokens_used: 0,
788            loop_continue: None,
789            classify_branch: None,
790            tournament_winner: Some(winner),
791            pace_decision: None,
792        };
793        self.graph.complete(controller, result);
794    }
795
796    // ── R3-1: runtime node submission (true loop-until-done / dynamic fan-out) ────────────────────
797
798    /// Append a batch of nodes to the in-flight DAG at runtime — the kernel side of the dynamic
799    /// "submit nodes" capability, generalizing the tournament's [`Self::append_child`]. A running
800    /// node, on completion, can ask for more work to be spawned: unknown-size discovery
801    /// (loop-until-done) and per-item fan-out (e.g. a claim-extractor spawning one verifier per
802    /// claim) both reduce to "append these nodes now".
803    ///
804    /// Each submitted node's `depends_on` is interpreted **batch-relative and backward-only**: index
805    /// `d` refers to the `d`-th node of *this* submission, and only `d < this node's position` is
806    /// honored — so a submission can carry its own internal forward chain (extractor → dependents)
807    /// while forward/self/out-of-range references are dropped rather than stranding the node behind
808    /// an unsatisfiable dependency. Nodes with no (remaining) deps are immediately `Ready`, exactly
809    /// like tournament entrants, and flow through the unchanged gated spawn loop — so quota / depth /
810    /// quarantine apply per node with **no new gate**. Returns the appended node indices (their
811    /// agent ids are the deterministic `wf-node{idx}`).
812    ///
813    /// Pure graph mutation: the caller (state machine) is responsible for routing the trigger
814    /// through `evaluate_syscall` before calling this, keeping the kernel's zero-I/O contract.
815    ///
816    /// G1 no-privilege-escalation: when `submitter` names a [`NodeTrust::Quarantined`] node, every
817    /// node in this submission is coerced to `Quarantined` before append. A quarantined agent read
818    /// untrusted content (which may be adversarial), so the topology it asks for is itself untrusted:
819    /// it must not be able to launch a *trusted* (or write-capable) child and thereby escape its
820    /// sandbox. This is transitive taint — a quarantined origin's descendants inherit quarantine —
821    /// the topological analogue of a process spawned by an untrusted process inheriting its label.
822    /// Trusted (or absent) submitters pass through unchanged. The coercion is enforced here in the
823    /// kernel rather than trusting the SDK, and composes with the spawn-time
824    /// [`Self::quarantine_violation`] gate (a coerced node that also asked for write isolation is
825    /// then denied at spawn).
826    pub fn submit_nodes_from(
827        &mut self,
828        submitter: Option<&str>,
829        mut nodes: Vec<WorkflowNode>,
830    ) -> Vec<usize> {
831        let submitter_quarantined = submitter.is_some_and(|s| self.is_agent_quarantined(s));
832        if submitter_quarantined {
833            for node in &mut nodes {
834                node.trust = NodeTrust::Quarantined;
835            }
836        }
837        self.submit_nodes(nodes)
838    }
839
840    pub fn submit_nodes(&mut self, nodes: Vec<WorkflowNode>) -> Vec<usize> {
841        let base = self.nodes.len();
842        let batch_len = nodes.len();
843        let mut ids = Vec::with_capacity(nodes.len());
844        // W-2: runtime submissions bypass `WorkflowSpec::validate`, so the classify gating invariant
845        // ("branch nodes must depends_on the classifier, else they run before classification") is
846        // COERCED here instead: every batch-relative branch reference gains a dependency on its
847        // classifier. Forward-only (b > o), matching the batch-relative backward-deps convention.
848        let mut forced_deps: Vec<Vec<usize>> = vec![Vec::new(); batch_len];
849        for (o, node) in nodes.iter().enumerate() {
850            if let NodeKind::Classify { branches } = &node.kind {
851                for b in branches.iter().flat_map(|br| br.nodes.iter().copied()) {
852                    if b > o
853                        && b < batch_len
854                        && !nodes[b].depends_on.contains(&o)
855                        && !forced_deps[b].contains(&o)
856                    {
857                        forced_deps[b].push(o);
858                    }
859                }
860            }
861        }
862        for (offset, mut node) in nodes.into_iter().enumerate() {
863            // W-2: `Loop { max_iters: 0 }` would never run (validate rejects it on a spec); floor a
864            // runtime submission to one iteration, mirroring the `gen_eval` template's floor.
865            if let NodeKind::Loop { max_iters: 0 } = node.kind {
866                node.kind = NodeKind::Loop { max_iters: 1 };
867            }
868            let deps: Vec<usize> = node
869                .depends_on
870                .iter()
871                .filter(|&&d| d < offset)
872                .chain(forced_deps[offset].iter())
873                .map(|&d| base + d)
874                .collect();
875            node.depends_on = deps.clone();
876            // A#2/G2: a submitted `Classify` node's branch indices are *batch-relative* — they point
877            // at other nodes in this same submission, whose absolute graph index the submitter cannot
878            // know. Remap each branch node index `d` (0-based within the batch) to its absolute index
879            // `base + d`, dropping out-of-range references. Mirrors the `depends_on` batch-relative
880            // convention; without it a runtime-submitted classifier would prune the wrong nodes.
881            if let NodeKind::Classify { branches } = &mut node.kind {
882                for branch in branches.iter_mut() {
883                    branch.nodes = branch
884                        .nodes
885                        .iter()
886                        .filter(|&&d| d < batch_len)
887                        .map(|&d| base + d)
888                        .collect();
889                }
890            }
891            let idx = self.graph.add(node.task.clone(), deps);
892            debug_assert_eq!(idx, self.nodes.len(), "graph/nodes index drift");
893            self.nodes.push(node);
894            ids.push(idx);
895        }
896        ids
897    }
898
899    /// Whether `agent_id` belongs to this workflow.
900    pub fn owns_agent(&self, agent_id: &str) -> bool {
901        self.node_of_agent.contains_key(agent_id)
902    }
903
904    /// R3-3: whether the node behind `agent_id` is `Quarantined` (it read untrusted content). The
905    /// kernel uses this to label that node's output as untrusted-origin when it crosses into the
906    /// trusted parent context — the provenance half of the cross-boundary contract (shaping the
907    /// output into a structured summary stays the SDK's job; the kernel cannot inspect content).
908    pub fn is_agent_quarantined(&self, agent_id: &str) -> bool {
909        self.node_of_agent
910            .get(agent_id)
911            .is_some_and(|&node| matches!(self.nodes[node].trust, NodeTrust::Quarantined))
912    }
913
914    /// Test instrument: true when no node is currently `Running` — the spawned batch has
915    /// fully reported back. Derived from the graph; the executor's in-flight truth is
916    /// `SuspendState::SubAgentAwait.agent_ids`.
917    #[cfg(test)]
918    pub(crate) fn batch_drained(&self) -> bool {
919        !(0..self.graph.len()).any(|i| {
920            matches!(self.graph.get(i).map(|n| &n.status), Some(crate::orchestration::task_graph::TaskStatus::Running))
921        })
922    }
923
924    /// Test instrument: every node reached a terminal status (completed or failed) and
925    /// nothing is running. NOTE the executor's own finish rule is looser — "nothing
926    /// running && nothing newly spawnable" — so a stalled DAG (dependents of a denied
927    /// node stay `Pending` forever) terminates the run while this stays false.
928    #[cfg(test)]
929    pub(crate) fn is_complete(&self) -> bool {
930        self.graph.all_done()
931    }
932
933    /// Outcome at finish: `(completed_agent_ids, failed_agent_ids)` by node. Nodes left
934    /// `Pending`/`Ready` (stalled behind a gated dependency) appear in neither.
935    pub fn outcome(&self) -> (Vec<String>, Vec<String>) {
936        let mut completed = Vec::new();
937        let mut failed = Vec::new();
938        for i in 0..self.graph.len() {
939            match self.graph.get(i).map(|n| n.status) {
940                Some(TaskStatus::Completed) => completed.push(node_agent_id(i)),
941                Some(TaskStatus::Failed) => failed.push(node_agent_id(i)),
942                _ => {}
943            }
944        }
945        (completed, failed)
946    }
947
948    /// #2-B abort: outcome when the workflow is preempted — every node that has not already
949    /// `Completed` counts as `failed` (running / ready / pending all abort). Used to emit a terminal
950    /// `WorkflowCompleted` when an `InterruptNow` tears the whole `WorkflowRun` down.
951    pub fn abort_outcome(&self) -> (Vec<String>, Vec<String>) {
952        let mut completed = Vec::new();
953        let mut failed = Vec::new();
954        for i in 0..self.graph.len() {
955            match self.graph.get(i).map(|n| n.status) {
956                Some(TaskStatus::Completed) => completed.push(node_agent_id(i)),
957                _ => failed.push(node_agent_id(i)),
958            }
959        }
960        (completed, failed)
961    }
962
963    /// Total node count.
964    pub fn len(&self) -> usize {
965        self.graph.len()
966    }
967
968    }
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973    use crate::orchestration::workflow::fanout_synthesize;
974    use crate::types::result::{LoopResult, TerminationReason};
975    use crate::types::task::RuntimeTask;
976
977    fn done() -> LoopResult {
978        LoopResult {
979            termination: TerminationReason::Completed,
980            final_message: None,
981            turns_used: 1,
982            total_tokens_used: 0,
983            loop_continue: None,
984            classify_branch: None,
985            tournament_winner: None,
986            pace_decision: None,
987        }
988    }
989
990    fn fanout2() -> WorkflowRun {
991        // 2 workers (nodes 0,1) → synthesize (node 2, depends on both)
992        let spec = fanout_synthesize(
993            vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")],
994            RuntimeTask::new("synth"),
995        );
996        WorkflowRun::new(&spec, "parent-sess").unwrap()
997    }
998
999    /// A judge completion reporting its winning entrant id.
1000    fn judge_done(winner: &str) -> LoopResult {
1001        LoopResult {
1002            tournament_winner: Some(winner.to_string()),
1003            ..done()
1004        }
1005    }
1006
1007    /// Mimic one executor spawn round on a `WorkflowRun`: expand any ready controllers, then mark
1008    /// every ready node spawned (mapping its current agent id). Returns the spawned `(node, id)`s.
1009    fn spawn_round(run: &mut WorkflowRun) -> Vec<(usize, String)> {
1010        run.expand_ready_controllers();
1011        let ready = run.ready_batch();
1012        let mut out = Vec::new();
1013        for node in ready {
1014            let id = run.current_agent_id(node);
1015            run.mark_spawned(node, &id);
1016            out.push((node, id));
1017        }
1018        out
1019    }
1020
1021    #[test]
1022    fn first_batch_is_the_workers() {
1023        let run = fanout2();
1024        assert_eq!(run.ready_batch(), vec![0, 1]);
1025        assert_eq!(run.len(), 3);
1026        assert!(!run.is_complete());
1027    }
1028
1029    // ── R3-1: runtime node submission ────────────────────────────────────────────────────────
1030
1031    #[test]
1032    fn submit_nodes_appends_independent_nodes_ready_immediately() {
1033        use crate::orchestration::workflow::WorkflowNode;
1034        use crate::types::agent::AgentRole;
1035
1036        let mut run = fanout2(); // nodes 0,1 (workers) → 2 (synth)
1037        assert_eq!(run.len(), 3);
1038        let ids = run.submit_nodes(vec![
1039            WorkflowNode::new(RuntimeTask::new("extra-a"), AgentRole::Implement),
1040            WorkflowNode::new(RuntimeTask::new("extra-b"), AgentRole::Implement),
1041        ]);
1042        assert_eq!(ids, vec![3, 4], "appended after the existing 3 nodes");
1043        assert_eq!(run.len(), 5);
1044        let ready = run.ready_batch();
1045        assert!(
1046            ready.contains(&3) && ready.contains(&4),
1047            "submitted independent nodes are immediately ready: {ready:?}"
1048        );
1049    }
1050
1051    #[test]
1052    fn submitted_nodes_must_complete_before_workflow_is_done() {
1053        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1054        use crate::types::agent::AgentRole;
1055
1056        // A single spawn node that, on completion, submits more work (loop-until-done shape).
1057        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1058            RuntimeTask::new("root"),
1059            AgentRole::Implement,
1060        )]);
1061        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1062        let id0 = run.current_agent_id(0);
1063        run.mark_spawned(0, &id0);
1064        run.record_completion(&id0, done());
1065        let ids = run.submit_nodes(vec![WorkflowNode::new(
1066            RuntimeTask::new("more"),
1067            AgentRole::Implement,
1068        )]);
1069        assert_eq!(ids, vec![1]);
1070        assert!(!run.is_complete(), "not complete while the submitted node is pending");
1071        let spawned = spawn_round(&mut run);
1072        assert_eq!(spawned, vec![(1usize, "wf-node1".to_string())]);
1073        run.record_completion("wf-node1", done());
1074        assert!(run.is_complete(), "complete once the submitted node finishes");
1075    }
1076
1077    #[test]
1078    fn reduce_node_carries_reducer_and_inputs_then_completes_like_a_spawn() {
1079        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1080        use crate::types::agent::AgentRole;
1081
1082        // G2: two fan-out workers feed a deterministic reduce node (dedupe). The reduce node runs no
1083        // agent; its descriptor names the reducer + its inputs, and it completes like a spawn.
1084        let spec = WorkflowSpec::new(vec![
1085            WorkflowNode::new(RuntimeTask::new("worker-a"), AgentRole::Explore),
1086            WorkflowNode::new(RuntimeTask::new("worker-b"), AgentRole::Explore),
1087            WorkflowNode::new(RuntimeTask::new("merge"), AgentRole::Implement)
1088                .with_reduce("dedupe_lines")
1089                .with_depends_on(vec![0, 1]),
1090        ]);
1091        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1092
1093        // Only the two workers are ready first (the reduce node waits on both).
1094        assert_eq!(run.ready_batch(), vec![0, 1]);
1095        for i in [0usize, 1] {
1096            let id = run.current_agent_id(i);
1097            run.mark_spawned(i, &id);
1098            run.record_completion(&id, done());
1099        }
1100
1101        // Now the reduce node is ready; its descriptor carries the reducer name + both input ids.
1102        assert_eq!(run.ready_batch(), vec![2]);
1103        let info = run.spawn_info(2);
1104        assert_eq!(info.reducer.as_deref(), Some("dedupe_lines"));
1105        assert_eq!(info.input_agent_ids, vec!["wf-node0".to_string(), "wf-node1".to_string()]);
1106
1107        // The reduce node's (SDK-computed) result feeds back as an ordinary completion → DAG done.
1108        run.mark_spawned(2, "wf-node2");
1109        run.record_completion("wf-node2", done());
1110        assert!(run.is_complete());
1111        let (completed, failed) = run.outcome();
1112        assert_eq!(completed, vec!["wf-node0", "wf-node1", "wf-node2"]);
1113        assert!(failed.is_empty());
1114    }
1115
1116    #[test]
1117    fn output_schema_reaches_the_spawn_descriptor() {
1118        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1119        use crate::types::agent::AgentRole;
1120
1121        // G3: a node declaring an output schema carries it verbatim to the SDK spawn descriptor.
1122        let schema = serde_json::json!({
1123            "type": "object",
1124            "required": ["verdict"],
1125            "properties": { "verdict": { "type": "string" } }
1126        });
1127        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1128            RuntimeTask::new("judge"),
1129            AgentRole::Verify,
1130        )
1131        .with_output_schema(schema.clone())]);
1132        let run = WorkflowRun::new(&spec, "sess").unwrap();
1133        let info = run.spawn_info(0);
1134        assert_eq!(info.output_schema.as_ref(), Some(&schema));
1135
1136        // Full serde round-trip preserves it (additive ABI).
1137        let json = serde_json::to_string(&info).unwrap();
1138        let back: WorkflowSpawnInfo = serde_json::from_str(&json).unwrap();
1139        assert_eq!(back.output_schema, Some(schema));
1140
1141        // A node without a schema omits the field entirely on the wire.
1142        let plain = WorkflowSpec::new(vec![WorkflowNode::new(
1143            RuntimeTask::new("x"),
1144            AgentRole::Implement,
1145        )]);
1146        let plain_info = WorkflowRun::new(&plain, "sess").unwrap().spawn_info(0);
1147        assert!(plain_info.output_schema.is_none());
1148        assert!(!serde_json::to_string(&plain_info).unwrap().contains("output_schema"));
1149    }
1150
1151    #[test]
1152    fn quarantined_submitter_taints_submitted_nodes() {
1153        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1154        use crate::types::agent::AgentRole;
1155
1156        // G1: a quarantined root reads untrusted content, then tries to submit a node it declares
1157        // "trusted" (and write-capable). The kernel must coerce that node to quarantined — a
1158        // quarantined origin cannot escalate its descendants out of the sandbox.
1159        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1160            RuntimeTask::new("read-untrusted"),
1161            AgentRole::Explore,
1162        )
1163        .quarantined()]);
1164        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1165        let id0 = run.current_agent_id(0);
1166        run.mark_spawned(0, &id0);
1167        run.record_completion(&id0, done());
1168
1169        // Submitted node claims Trusted; the quarantined submitter cannot grant that.
1170        let ids = run.submit_nodes_from(
1171            Some(&id0),
1172            vec![WorkflowNode::new(RuntimeTask::new("act"), AgentRole::Implement)],
1173        );
1174        assert_eq!(ids, vec![1]);
1175        let id1 = run.current_agent_id(1);
1176        run.mark_spawned(1, &id1);
1177        assert!(
1178            run.is_agent_quarantined(&id1),
1179            "submitted node inherits the submitter's quarantine (no escalation)"
1180        );
1181
1182        // A trusted / unknown submitter does NOT coerce — only quarantined origins taint.
1183        let ids2 = run.submit_nodes_from(
1184            None,
1185            vec![WorkflowNode::new(RuntimeTask::new("trusted-work"), AgentRole::Implement)],
1186        );
1187        let id2 = run.current_agent_id(ids2[0]);
1188        run.mark_spawned(ids2[0], &id2);
1189        assert!(
1190            !run.is_agent_quarantined(&id2),
1191            "no quarantined submitter ⇒ no coercion"
1192        );
1193    }
1194
1195    #[test]
1196    fn submit_nodes_honors_batch_relative_backward_deps() {
1197        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1198        use crate::types::agent::AgentRole;
1199
1200        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1201            RuntimeTask::new("root"),
1202            AgentRole::Implement,
1203        )]);
1204        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1205        let id0 = run.current_agent_id(0);
1206        run.mark_spawned(0, &id0);
1207        run.record_completion(&id0, done());
1208        // [extractor @offset 0, dependent @offset 1 depends on 0].
1209        let ids = run.submit_nodes(vec![
1210            WorkflowNode::new(RuntimeTask::new("extractor"), AgentRole::Implement),
1211            WorkflowNode::new(RuntimeTask::new("dependent"), AgentRole::Implement)
1212                .with_depends_on(vec![0]),
1213        ]);
1214        assert_eq!(ids, vec![1, 2]);
1215        assert_eq!(run.ready_batch(), vec![1], "backward dep keeps the dependent pending");
1216        run.mark_spawned(1, "wf-node1");
1217        run.record_completion("wf-node1", done());
1218        assert_eq!(run.ready_batch(), vec![2], "dependent unblocks after the extractor");
1219    }
1220
1221    #[test]
1222    fn submit_nodes_drops_forward_and_out_of_range_deps() {
1223        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1224        use crate::types::agent::AgentRole;
1225
1226        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1227            RuntimeTask::new("root"),
1228            AgentRole::Implement,
1229        )]);
1230        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1231        // Only dep is a forward/out-of-range ref → dropped, so the node must not be stranded.
1232        let ids = run.submit_nodes(vec![
1233            WorkflowNode::new(RuntimeTask::new("a"), AgentRole::Implement).with_depends_on(vec![5]),
1234        ]);
1235        assert_eq!(ids, vec![1]);
1236        assert!(
1237            run.ready_batch().contains(&1),
1238            "a node whose only dep was dropped is ready, not stranded"
1239        );
1240    }
1241
1242    #[test]
1243    fn submitted_node_can_itself_be_a_loop_control_flow() {
1244        // R3-2: control flow *composes* through dynamic submission — a submitted node can itself be
1245        // a Loop (or Tournament), executing its full control flow. This delivers nested control flow
1246        // without changing `NodeKind::Tournament`'s entrant type: the submitter just hands over a
1247        // node whose `kind` the unchanged completion machinery already honors.
1248        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1249        use crate::types::agent::AgentRole;
1250
1251        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1252            RuntimeTask::new("root"),
1253            AgentRole::Implement,
1254        )]);
1255        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1256        let id0 = run.current_agent_id(0);
1257        run.mark_spawned(0, &id0);
1258        run.record_completion(&id0, done());
1259
1260        // Submit a Loop{2} node mid-run.
1261        let ids = run.submit_nodes(vec![
1262            WorkflowNode::new(RuntimeTask::new("refine"), AgentRole::Implement).with_loop(2),
1263        ]);
1264        assert_eq!(ids, vec![1]);
1265
1266        // It iterates with distinct per-iteration ids, then completes — its control flow runs.
1267        for k in 0..2 {
1268            assert_eq!(run.ready_batch(), vec![1], "submitted loop ready for iteration {k}");
1269            let id = run.current_agent_id(1);
1270            assert_eq!(id, format!("wf-node1-i{k}"), "submitted loop gets per-iteration ids");
1271            run.mark_spawned(1, &id);
1272            run.record_completion(&id, done());
1273        }
1274        assert!(run.is_complete(), "submitted loop ran its 2 iterations then finished");
1275    }
1276
1277    #[test]
1278    fn submitted_tournament_runs_bracket_then_promotes_submitted_dependent() {
1279        // M2: an agent can submit a Tournament *controller* (plus a dependent) at runtime. The
1280        // controller expands into entrant children + a judge via the same bracket machinery, and the
1281        // dependent's batch-relative `depends_on` links it to the submitted controller.
1282        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1283        use crate::types::agent::AgentRole;
1284
1285        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1286            RuntimeTask::new("root"),
1287            AgentRole::Implement,
1288        )]);
1289        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1290        let id0 = run.current_agent_id(0);
1291        run.mark_spawned(0, &id0);
1292        run.record_completion(&id0, done());
1293
1294        // Submit [tournament@batch0, dependent@batch1 depends_on [0]] (batch-relative).
1295        let ids = run.submit_nodes(vec![
1296            WorkflowNode::new(RuntimeTask::new("pick best"), AgentRole::Plan)
1297                .with_tournament(vec![RuntimeTask::new("x"), RuntimeTask::new("y")]),
1298            WorkflowNode::new(RuntimeTask::new("use winner"), AgentRole::Implement)
1299                .with_depends_on(vec![0]),
1300        ]);
1301        assert_eq!(ids, vec![1, 2], "appended controller=1, dependent=2");
1302
1303        // Controller (node 1) expands into 2 entrant children (3,4); spawns no agent of its own.
1304        let entrants = spawn_round(&mut run);
1305        let entrant_nodes: Vec<usize> = entrants.iter().map(|(n, _)| *n).collect();
1306        assert_eq!(entrant_nodes, vec![3, 4], "two entrant children appended after the dependent");
1307        for (_, id) in &entrants {
1308            run.record_completion(id, done());
1309        }
1310
1311        // One judge over the two entrants; dependent (node 2) gated until the bracket resolves.
1312        let r1 = spawn_round(&mut run);
1313        assert_eq!(r1.len(), 1, "one judge for two entrants");
1314        let jm = run.spawn_info(r1[0].0).judge_match.expect("judge carries a match");
1315        assert_eq!(jm, JudgeMatch { left: node_agent_id(3), right: node_agent_id(4) });
1316
1317        // Entrant 3 wins → controller completes with the champion → dependent unblocks.
1318        run.record_completion(&r1[0].1, judge_done(&node_agent_id(3)));
1319        assert_eq!(run.ready_batch(), vec![2], "submitted dependent unblocks after the bracket");
1320        let last = spawn_round(&mut run);
1321        assert_eq!(last, vec![(2, node_agent_id(2))]);
1322        run.record_completion(&last[0].1, done());
1323        assert!(run.is_complete());
1324    }
1325
1326    #[test]
1327    fn submitted_classify_remaps_branch_indices_and_prunes() {
1328        // M2: a submitted Classify node's branch `nodes` are batch-relative; `submit_nodes` remaps
1329        // them to absolute indices so the chosen branch runs and the rest are pruned. Without the
1330        // remap a runtime-submitted classifier would prune the wrong nodes.
1331        use crate::orchestration::workflow::{ClassifyBranch, NodeKind, WorkflowNode, WorkflowSpec};
1332        use crate::types::agent::AgentRole;
1333
1334        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1335            RuntimeTask::new("root"),
1336            AgentRole::Implement,
1337        )]);
1338        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1339        let id0 = run.current_agent_id(0);
1340        run.mark_spawned(0, &id0);
1341        run.record_completion(&id0, done());
1342
1343        // Submit [classify@batch0 (a→[1] b→[2]), branchA@batch1 dep[0], branchB@batch2 dep[0]].
1344        let ids = run.submit_nodes(vec![
1345            WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan).with_classify(vec![
1346                ClassifyBranch { label: "a".into(), nodes: vec![1] },
1347                ClassifyBranch { label: "b".into(), nodes: vec![2] },
1348            ]),
1349            WorkflowNode::new(RuntimeTask::new("branch-a"), AgentRole::Implement)
1350                .with_depends_on(vec![0]),
1351            WorkflowNode::new(RuntimeTask::new("branch-b"), AgentRole::Implement)
1352                .with_depends_on(vec![0]),
1353        ]);
1354        assert_eq!(ids, vec![1, 2, 3], "classify=1, branchA=2, branchB=3");
1355
1356        // Branch indices were remapped batch-relative → absolute: a→[2], b→[3].
1357        if let NodeKind::Classify { branches } = &run.nodes[1].kind {
1358            assert_eq!(branches[0].nodes, vec![2], "branch a remapped to absolute node 2");
1359            assert_eq!(branches[1].nodes, vec![3], "branch b remapped to absolute node 3");
1360        } else {
1361            panic!("node 1 should be a classify node");
1362        }
1363
1364        // Classifier picks "a" → branch-a (node 2) runs, branch-b (node 3) is pruned/failed.
1365        let r = spawn_round(&mut run);
1366        assert_eq!(r, vec![(1, node_agent_id(1))], "classifier runs first");
1367        run.record_completion(&r[0].1, LoopResult { classify_branch: Some("a".into()), ..done() });
1368
1369        assert_eq!(run.ready_batch(), vec![2], "only branch a is enabled");
1370        let (_c, failed) = run.outcome();
1371        assert!(failed.contains(&node_agent_id(3)), "branch b pruned/failed");
1372
1373        let last = spawn_round(&mut run);
1374        assert_eq!(last, vec![(2, node_agent_id(2))]);
1375        run.record_completion(&last[0].1, done());
1376        assert!(run.is_complete());
1377        let (completed, _f) = run.outcome();
1378        assert!(completed.contains(&node_agent_id(1)) && completed.contains(&node_agent_id(2)));
1379    }
1380
1381    #[test]
1382    fn loop_node_iterates_with_distinct_ids_then_promotes_dependent() {
1383        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1384        use crate::types::agent::AgentRole;
1385
1386        // node 0 = Loop{3}; node 1 depends on node 0 (must wait for the whole loop).
1387        let spec = WorkflowSpec::new(vec![
1388            WorkflowNode::new(RuntimeTask::new("refine"), AgentRole::Implement).with_loop(3),
1389            WorkflowNode::new(RuntimeTask::new("finalize"), AgentRole::Implement)
1390                .with_depends_on(vec![0]),
1391        ]);
1392        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1393
1394        // Three iterations, each with a distinct agent id; the dependent stays unready throughout.
1395        for k in 0..3 {
1396            assert_eq!(run.ready_batch(), vec![0], "loop node ready for iteration {k}");
1397            let id = run.current_agent_id(0);
1398            assert_eq!(id, format!("wf-node0-i{k}"), "distinct per-iteration id");
1399            run.mark_spawned(0, &id);
1400            assert!(!run.is_complete());
1401            let node = run.record_completion(&id, done()).unwrap();
1402            assert_eq!(node, 0);
1403            if k < 2 {
1404                // Loop continues: node 0 re-armed, dependent NOT yet ready.
1405                assert_eq!(run.ready_batch(), vec![0]);
1406            }
1407        }
1408
1409        // Loop exhausted → node 0 complete → dependent (node 1) becomes ready.
1410        assert_eq!(run.ready_batch(), vec![1], "dependent unblocks only after the loop ends");
1411        let id1 = run.current_agent_id(1);
1412        assert_eq!(id1, "wf-node1", "spawn node keeps the plain id");
1413        run.mark_spawned(1, &id1);
1414        run.record_completion(&id1, done());
1415        assert!(run.is_complete());
1416    }
1417
1418    #[test]
1419    fn synth_becomes_ready_only_after_both_workers() {
1420        let mut run = fanout2();
1421        for &n in &[0usize, 1usize] {
1422            let id = node_agent_id(n);
1423            run.mark_spawned(n, &id);
1424        }
1425        assert!(!run.batch_drained());
1426        // first worker completes → synth not ready yet, batch not drained
1427        assert_eq!(run.record_completion(&node_agent_id(0), done()), Some(0));
1428        assert!(!run.batch_drained());
1429        assert!(run.ready_batch().is_empty());
1430        // second worker completes → batch drained, synth now ready
1431        assert_eq!(run.record_completion(&node_agent_id(1), done()), Some(1));
1432        assert!(run.batch_drained());
1433        assert_eq!(run.ready_batch(), vec![2]);
1434        assert!(!run.is_complete());
1435        // spawn + complete synth → workflow complete
1436        run.mark_spawned(2, &node_agent_id(2));
1437        run.record_completion(&node_agent_id(2), done());
1438        assert!(run.is_complete());
1439    }
1440
1441    #[test]
1442    fn denied_node_blocks_dependents_and_stalls_progress() {
1443        let mut run = fanout2();
1444        // node 0 spawned + completes; node 1 denied by the gate
1445        run.mark_spawned(0, &node_agent_id(0));
1446        run.mark_denied(1);
1447        run.record_completion(&node_agent_id(0), done());
1448        // synth depends on node 1 (failed) → never ready; batch drained, nothing more to run.
1449        // The state machine finishes a workflow on "drained && ready_batch empty" (here true),
1450        // even though `is_complete()` is false (node 2 stays Pending forever).
1451        assert!(run.batch_drained());
1452        assert!(run.ready_batch().is_empty());
1453        assert!(!run.is_complete());
1454    }
1455
1456    #[test]
1457    fn manifest_preserves_node_isolation_and_inheritance() {
1458        let run = fanout2();
1459        let m = run.manifest_for(0);
1460        assert_eq!(m.agent_id.as_str(), "wf-node0");
1461        assert_eq!(m.parent_session_id.as_str(), "parent-sess");
1462        // fanout workers are Explore → ReadOnly + SystemOnly (workflow role_defaults)
1463        assert_eq!(m.isolation, crate::types::agent::AgentIsolation::ReadOnly);
1464        assert_eq!(
1465            m.context_inheritance,
1466            crate::types::agent::ContextInheritance::SystemOnly
1467        );
1468    }
1469
1470    #[test]
1471    fn unknown_agent_completion_is_none() {
1472        let mut run = fanout2();
1473        assert_eq!(run.record_completion("not-a-node", done()), None);
1474    }
1475
1476    #[test]
1477    fn resume_skips_already_completed_nodes() {
1478        // fanout2: workers 0,1 → synth 2. Resume with worker 0 already done.
1479        let spec = fanout_synthesize(
1480            vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")],
1481            RuntimeTask::new("synth"),
1482        );
1483        let run = WorkflowRun::resume(&spec, "sess", &[], &[], &[ResumedCompletion::bare(node_agent_id(0))]).unwrap();
1484        // only the remaining worker (node 1) is ready; node 0 is already complete, synth still gated.
1485        assert_eq!(run.ready_batch(), vec![1]);
1486        assert!(!run.is_complete());
1487    }
1488
1489    #[test]
1490    fn resume_with_all_done_completes() {
1491        let spec = fanout_synthesize(vec![RuntimeTask::new("w0")], RuntimeTask::new("synth"));
1492        // both nodes (worker 0, synth 1) recovered as done.
1493        let run = WorkflowRun::resume(&spec, "sess", &[], &[], &[ResumedCompletion::bare(node_agent_id(0)), ResumedCompletion::bare(node_agent_id(1))]).unwrap();
1494        assert!(run.ready_batch().is_empty());
1495        assert!(run.is_complete());
1496    }
1497
1498    #[test]
1499    fn resume_applies_submissions_at_recorded_bases_with_placeholder_gap_fill() {
1500        // The interleave bug: original run = spec [node0] → tournament children at 1,2 →
1501        // runtime submission at base 3. Without bases, the submission replays at index 1 and
1502        // the child's completed id "wf-node2" would mark the WRONG node. With the recorded
1503        // base, indices stay faithful: 1,2 become inert completed placeholders, the batch
1504        // lands at 3, and every completed id maps to the node it originally named.
1505        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1506        use crate::types::agent::AgentRole;
1507
1508        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1509            RuntimeTask::new("root"),
1510            AgentRole::Implement,
1511        )]);
1512        let submission = vec![WorkflowNode::new(
1513            RuntimeTask::new("late batch"),
1514            AgentRole::Implement,
1515        )];
1516        let run = WorkflowRun::resume(
1517            &spec,
1518            "sess",
1519            &[submission],
1520            &[3],
1521            &[ResumedCompletion::bare("wf-node2"), ResumedCompletion::bare("wf-node3")],
1522        )
1523        .unwrap();
1524        assert_eq!(run.graph.len(), 4, "spec node + 2 placeholders + 1 submitted");
1525        // The submitted node (3) is complete because ITS id was recorded — not shifted.
1526        assert!(run.ready_batch() == vec![0], "only the spec node remains to run");
1527        // A base below the reconstructed length is corrupt, not silently reinterpreted.
1528        let spec2 = WorkflowSpec::new(vec![
1529            WorkflowNode::new(RuntimeTask::new("a"), AgentRole::Implement),
1530            WorkflowNode::new(RuntimeTask::new("b"), AgentRole::Implement),
1531        ]);
1532        let bad = WorkflowRun::resume(
1533            &spec2,
1534            "sess",
1535            &[vec![WorkflowNode::new(RuntimeTask::new("x"), AgentRole::Implement)]],
1536            &[1],
1537            &[],
1538        );
1539        assert!(bad.is_err(), "base inside the spec range is a corrupt record");
1540    }
1541
1542    #[test]
1543    fn resume_restores_loop_iteration_cursor_instead_of_restarting() {
1544        // A 3-iteration loop with iterations 0 and 1 already finished pre-interruption:
1545        // resume must re-arm the node at i2 (not silently restart from i0).
1546        use crate::orchestration::workflow::{NodeKind, WorkflowNode, WorkflowSpec};
1547        use crate::types::agent::AgentRole;
1548
1549        let mut node = WorkflowNode::new(RuntimeTask::new("polish until done"), AgentRole::Implement);
1550        node.kind = NodeKind::Loop { max_iters: 3 };
1551        let spec = WorkflowSpec::new(vec![node]);
1552        let run = WorkflowRun::resume(&spec, "sess", &[], &[], &[ResumedCompletion::bare("wf-node0-i0"), ResumedCompletion::bare("wf-node0-i1")],
1553        )
1554        .unwrap();
1555        assert_eq!(run.ready_batch(), vec![0], "loop node re-armed, not complete");
1556        assert_eq!(run.current_agent_id(0), "wf-node0-i2", "cursor advanced past finished work");
1557        assert!(!run.is_complete());
1558    }
1559
1560    #[test]
1561    fn resume_completes_loop_when_all_iterations_recorded() {
1562        use crate::orchestration::workflow::{NodeKind, WorkflowNode, WorkflowSpec};
1563        use crate::types::agent::AgentRole;
1564
1565        let mut node = WorkflowNode::new(RuntimeTask::new("polish"), AgentRole::Implement);
1566        node.kind = NodeKind::Loop { max_iters: 2 };
1567        let spec = WorkflowSpec::new(vec![node]);
1568        let run = WorkflowRun::resume(&spec, "sess", &[], &[], &[ResumedCompletion::bare("wf-node0-i0"), ResumedCompletion::bare("wf-node0-i1")],
1569        )
1570        .unwrap();
1571        assert!(run.ready_batch().is_empty());
1572        assert!(run.is_complete(), "max_iters provably exhausted -> node complete");
1573    }
1574
1575    #[test]
1576    fn resume_reapplies_submissions_to_reconstruct_appended_nodes() {
1577        // R3-1: a workflow that dynamically appended a node (wf-node1) is resumed by re-applying the
1578        // recorded submission, so the appended node exists again and its completed id matches —
1579        // without this, the appended node (not in the spec) would vanish on resume.
1580        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1581        use crate::types::agent::AgentRole;
1582
1583        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1584            RuntimeTask::new("root"),
1585            AgentRole::Implement,
1586        )]);
1587        let submission = vec![WorkflowNode::new(RuntimeTask::new("discovered"), AgentRole::Implement)];
1588
1589        // root done, submission re-applied, but the appended node not yet completed.
1590        let run = WorkflowRun::resume(&spec, "sess", &[submission.clone()], &[], &[ResumedCompletion::bare(node_agent_id(0))]).unwrap();
1591        assert_eq!(run.len(), 2, "base node + re-applied submitted node");
1592        assert_eq!(run.ready_batch(), vec![1], "the re-applied appended node is the remaining work");
1593        assert!(!run.is_complete());
1594
1595        // both recovered as done → resume finishes.
1596        let run2 =
1597            WorkflowRun::resume(&spec, "sess", &[submission], &[], &[ResumedCompletion::bare(node_agent_id(0)), ResumedCompletion::bare(node_agent_id(1))]).unwrap();
1598        assert!(run2.ready_batch().is_empty());
1599        assert!(run2.is_complete());
1600    }
1601
1602    #[test]
1603    fn spawn_info_carries_model_hint_and_trust() {
1604        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1605        use crate::types::agent::AgentRole;
1606
1607        let spec = WorkflowSpec::new(vec![
1608            WorkflowNode::new(RuntimeTask::new("read tickets"), AgentRole::Explore)
1609                .quarantined()
1610                .with_model_hint("haiku"),
1611            WorkflowNode::new(RuntimeTask::new("act"), AgentRole::Implement),
1612        ]);
1613        let run = WorkflowRun::new(&spec, "sess").unwrap();
1614
1615        // W3: quarantined node + W4: model hint both reach the spawn descriptor.
1616        let q = run.spawn_info(0);
1617        assert_eq!(q.trust, "quarantined");
1618        assert_eq!(q.model_hint.as_deref(), Some("haiku"));
1619        // default node is trusted, no model hint.
1620        let t = run.spawn_info(1);
1621        assert_eq!(t.trust, "trusted");
1622        assert_eq!(t.model_hint, None);
1623    }
1624
1625    #[test]
1626    fn spawn_info_carries_loop_and_classify_hints() {
1627        use crate::orchestration::workflow::{ClassifyBranch, WorkflowNode, WorkflowSpec};
1628        use crate::types::agent::AgentRole;
1629
1630        let spec = WorkflowSpec::new(vec![
1631            // 0: loop node → descriptor carries the cap so the SDK knows to solicit `loop_continue`.
1632            WorkflowNode::new(RuntimeTask::new("refine"), AgentRole::Implement).with_loop(3),
1633            // 1: classify node → descriptor carries the branch labels so the SDK can instruct + report.
1634            WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan).with_classify(vec![
1635                ClassifyBranch { label: "bug".into(), nodes: vec![] },
1636                ClassifyBranch { label: "feature".into(), nodes: vec![] },
1637            ]),
1638            // 2: plain spawn → neither hint present.
1639            WorkflowNode::new(RuntimeTask::new("act"), AgentRole::Implement),
1640        ]);
1641        let run = WorkflowRun::new(&spec, "sess").unwrap();
1642
1643        let l = run.spawn_info(0);
1644        assert_eq!(l.loop_max_iters, Some(3));
1645        assert!(l.classify_labels.is_empty());
1646        assert_eq!(l.token_budget, None, "no token budget unless set");
1647
1648        let c = run.spawn_info(1);
1649        assert_eq!(c.classify_labels, vec!["bug".to_string(), "feature".to_string()]);
1650        assert_eq!(c.loop_max_iters, None);
1651
1652        let s = run.spawn_info(2);
1653        assert_eq!(s.loop_max_iters, None);
1654        assert!(s.classify_labels.is_empty());
1655    }
1656
1657    #[test]
1658    fn spawn_info_carries_token_budget() {
1659        use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1660        use crate::types::agent::AgentRole;
1661
1662        let spec = WorkflowSpec::new(vec![
1663            WorkflowNode::new(RuntimeTask::new("expensive"), AgentRole::Implement).with_token_budget(10_000),
1664            WorkflowNode::new(RuntimeTask::new("plain"), AgentRole::Implement),
1665        ]);
1666        let run = WorkflowRun::new(&spec, "sess").unwrap();
1667        assert_eq!(run.spawn_info(0).token_budget, Some(10_000));
1668        assert_eq!(run.spawn_info(1).token_budget, None);
1669    }
1670
1671    // ── Tournament node (A#2) ───────────────────────────────────────────────────────────────────
1672
1673    use crate::orchestration::workflow::{NodeKind, WorkflowNode, WorkflowSpec};
1674    use crate::types::agent::AgentRole;
1675
1676    /// A 4-entrant tournament controller (node 0) gating a dependent (node 1). Drives the whole
1677    /// bracket: 4 entrants generate, then 2 round-1 judges, then 1 final judge — and only then does
1678    /// the dependent unblock, carrying the champion in the controller's `tournament_winner`.
1679    #[test]
1680    fn tournament_runs_bracket_then_promotes_dependent() {
1681        let spec = WorkflowSpec::new(vec![
1682            WorkflowNode::new(RuntimeTask::new("pick the best ad"), AgentRole::Plan).with_tournament(
1683                vec![
1684                    RuntimeTask::new("ad A"),
1685                    RuntimeTask::new("ad B"),
1686                    RuntimeTask::new("ad C"),
1687                    RuntimeTask::new("ad D"),
1688                ],
1689            ),
1690            WorkflowNode::new(RuntimeTask::new("ship the winner"), AgentRole::Implement)
1691                .with_depends_on(vec![0]),
1692        ]);
1693        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1694
1695        // Round 1 of spawning expands the controller into 4 entrant children (nodes 2..=5); the
1696        // controller spawns no agent of its own and the dependent stays gated.
1697        let entrants = spawn_round(&mut run);
1698        let entrant_nodes: Vec<usize> = entrants.iter().map(|(n, _)| *n).collect();
1699        assert_eq!(entrant_nodes, vec![2, 3, 4, 5], "4 entrant children, no controller spawn");
1700        assert!(run.spawn_info(2).judge_match.is_none(), "entrants are not judges");
1701        assert!(!run.is_complete());
1702
1703        // All entrants generate → bracket begins; nothing else spawns until they're all in.
1704        for (i, (node, id)) in entrants.iter().enumerate() {
1705            run.record_completion(id, done());
1706            if i < 3 {
1707                assert!(run.ready_batch().is_empty(), "no judges until every entrant is in");
1708            }
1709            let _ = node;
1710        }
1711
1712        // Round 1 judges: 2 matches over the 4 entrants, each carrying its pair.
1713        let r1 = spawn_round(&mut run);
1714        assert_eq!(r1.len(), 2, "two round-1 judges");
1715        let jm0 = run.spawn_info(r1[0].0).judge_match.expect("judge carries a match");
1716        assert_eq!(jm0, JudgeMatch { left: node_agent_id(2), right: node_agent_id(3) });
1717        let jm1 = run.spawn_info(r1[1].0).judge_match.expect("judge carries a match");
1718        assert_eq!(jm1, JudgeMatch { left: node_agent_id(4), right: node_agent_id(5) });
1719
1720        // Entrant 2 beats 3; entrant 4 beats 5. Dependent still gated mid-bracket.
1721        run.record_completion(&r1[0].1, judge_done(&node_agent_id(2)));
1722        run.record_completion(&r1[1].1, judge_done(&node_agent_id(4)));
1723        assert!(run.ready_batch().iter().all(|&n| n != 1), "dependent gated until the final");
1724
1725        // Final round: a single judge over the two survivors.
1726        let r2 = spawn_round(&mut run);
1727        assert_eq!(r2.len(), 1, "one final judge");
1728        let jmf = run.spawn_info(r2[0].0).judge_match.expect("final judge carries a match");
1729        assert_eq!(jmf, JudgeMatch { left: node_agent_id(2), right: node_agent_id(4) });
1730
1731        // Entrant 4 wins it all → controller completes with the champion, dependent unblocks.
1732        run.record_completion(&r2[0].1, judge_done(&node_agent_id(4)));
1733        let winner = run
1734            .graph
1735            .get(0)
1736            .and_then(|n| n.result.as_ref())
1737            .and_then(|r| r.tournament_winner.clone());
1738        assert_eq!(winner.as_deref(), Some(node_agent_id(4).as_str()), "champion recorded");
1739        assert_eq!(run.ready_batch(), vec![1], "dependent unblocks only after the bracket resolves");
1740
1741        // Ship the winner → workflow complete.
1742        let last = spawn_round(&mut run);
1743        assert_eq!(last, vec![(1, node_agent_id(1))]);
1744        run.record_completion(&last[0].1, done());
1745        assert!(run.is_complete());
1746    }
1747
1748    /// An odd entrant count gives one entrant a bye in round 1 (no judge for it), and the bracket
1749    /// still resolves to a single champion.
1750    #[test]
1751    fn tournament_with_bye_resolves() {
1752        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1753            RuntimeTask::new("rank"),
1754            AgentRole::Plan,
1755        )
1756        .with_tournament(vec![
1757            RuntimeTask::new("x"),
1758            RuntimeTask::new("y"),
1759            RuntimeTask::new("z"),
1760        ])]);
1761        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1762
1763        let entrants = spawn_round(&mut run); // nodes 1,2,3
1764        assert_eq!(entrants.len(), 3);
1765        for (_, id) in &entrants {
1766            run.record_completion(id, done());
1767        }
1768        // Round 1: only (entrant1, entrant2) plays; entrant3 draws a bye.
1769        let r1 = spawn_round(&mut run);
1770        assert_eq!(r1.len(), 1, "one match, one bye");
1771        run.record_completion(&r1[0].1, judge_done(&node_agent_id(1)));
1772        // Round 2: survivor of the match vs the bye entrant.
1773        let r2 = spawn_round(&mut run);
1774        assert_eq!(r2.len(), 1);
1775        let jm = run.spawn_info(r2[0].0).judge_match.unwrap();
1776        assert_eq!(jm, JudgeMatch { left: node_agent_id(1), right: node_agent_id(3) });
1777        run.record_completion(&r2[0].1, judge_done(&node_agent_id(3)));
1778        let winner = run.graph.get(0).and_then(|n| n.result.as_ref()).and_then(|r| r.tournament_winner.clone());
1779        assert_eq!(winner.as_deref(), Some(node_agent_id(3).as_str()));
1780        assert!(run.is_complete());
1781    }
1782
1783    /// A quarantined tournament keeps its entrant + judge children quarantined, and (being
1784    /// read-only) they pass the quarantine invariant rather than tripping it.
1785    #[test]
1786    fn tournament_children_inherit_controller_trust() {
1787        let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1788            RuntimeTask::new("judge untrusted inputs"),
1789            AgentRole::Plan,
1790        )
1791        .quarantined()
1792        .with_tournament(vec![RuntimeTask::new("a"), RuntimeTask::new("b")])]);
1793        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1794
1795        let entrants = spawn_round(&mut run);
1796        for (node, _) in &entrants {
1797            assert_eq!(run.spawn_info(*node).trust, "quarantined", "entrant inherits quarantine");
1798            assert!(!run.quarantine_violation(*node), "read-only entrant is quarantine-clean");
1799        }
1800        for (_, id) in &entrants {
1801            run.record_completion(id, done());
1802        }
1803        let r1 = spawn_round(&mut run);
1804        assert_eq!(run.spawn_info(r1[0].0).trust, "quarantined", "judge inherits quarantine");
1805        assert!(!run.quarantine_violation(r1[0].0));
1806    }
1807
1808    /// Sanity: the controller node is itself a Tournament kind and never appears in a spawn batch
1809    /// (entrants/judges carry the work).
1810    #[test]
1811    fn tournament_controller_never_spawns_itself() {
1812        let spec = WorkflowSpec::new(vec![WorkflowNode::new(RuntimeTask::new("c"), AgentRole::Plan)
1813            .with_tournament(vec![RuntimeTask::new("a"), RuntimeTask::new("b")])]);
1814        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1815        assert!(matches!(run.nodes[0].kind, NodeKind::Tournament { .. }));
1816        let first = spawn_round(&mut run);
1817        assert!(first.iter().all(|(n, _)| *n != 0), "controller node 0 never spawns directly");
1818    }
1819
1820    // ── dynamic-workflow optimization batch (W-1..W-6, W-N2/N7) ─────────────────────────────────
1821
1822    use crate::orchestration::workflow::ClassifyBranch;
1823
1824    fn classify_spec() -> WorkflowSpec {
1825        // node0 classifies into branch "a" → node1, branch "b" → node2.
1826        let classifier = WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan)
1827            .with_classify(vec![
1828                ClassifyBranch { label: "a".to_string(), nodes: vec![1] },
1829                ClassifyBranch { label: "b".to_string(), nodes: vec![2] },
1830            ]);
1831        WorkflowSpec::new(vec![
1832            classifier,
1833            WorkflowNode::new(RuntimeTask::new("on a"), AgentRole::Implement).with_depends_on(vec![0]),
1834            WorkflowNode::new(RuntimeTask::new("on b"), AgentRole::Implement).with_depends_on(vec![0]),
1835        ])
1836    }
1837
1838    #[test]
1839    fn resume_replays_classify_prune_from_recorded_branch() {
1840        // W-1: pre-crash the classifier chose "a" (node2 was pruned). Resume must re-prune node2
1841        // from the recorded signal — without it the REJECTED branch would run after resume.
1842        let run = WorkflowRun::resume(
1843            &classify_spec(),
1844            "sess",
1845            &[],
1846            &[],
1847            &[ResumedCompletion {
1848                agent_id: "wf-node0".to_string(),
1849                classify_branch: Some("a".to_string()),
1850                ..ResumedCompletion::default()
1851            }],
1852        )
1853        .unwrap();
1854        assert_eq!(run.ready_batch(), vec![1], "only the chosen branch is armed");
1855        let (_, failed) = run.outcome();
1856        assert_eq!(failed, vec!["wf-node2"], "rejected branch stays pruned across resume");
1857    }
1858
1859    #[test]
1860    fn resume_with_signalless_classify_record_prunes_all_branches() {
1861        // Legacy log (bare id, no recorded branch): prune every branch — the live path's
1862        // "no recognizable choice" contract, and strictly safer than running a rejected branch.
1863        let run = WorkflowRun::resume(
1864            &classify_spec(),
1865            "sess",
1866            &[],
1867            &[],
1868            &[ResumedCompletion::bare("wf-node0")],
1869        )
1870        .unwrap();
1871        assert!(run.ready_batch().is_empty());
1872        let (_, failed) = run.outcome();
1873        assert_eq!(failed, vec!["wf-node1", "wf-node2"]);
1874        assert!(run.is_complete());
1875    }
1876
1877    #[test]
1878    fn resume_honors_recorded_loop_stop() {
1879        // W-1: iteration 0 recorded `loop_continue=false` — the semantic stop is now provable from
1880        // the log, so the node completes instead of re-running the final iteration.
1881        let mut node = WorkflowNode::new(RuntimeTask::new("polish"), AgentRole::Implement);
1882        node.kind = NodeKind::Loop { max_iters: 3 };
1883        let spec = WorkflowSpec::new(vec![node]);
1884        let run = WorkflowRun::resume(
1885            &spec,
1886            "sess",
1887            &[],
1888            &[],
1889            &[ResumedCompletion {
1890                agent_id: "wf-node0-i0".to_string(),
1891                loop_continue: Some(false),
1892                ..ResumedCompletion::default()
1893            }],
1894        )
1895        .unwrap();
1896        assert!(run.ready_batch().is_empty(), "no re-run of the stopped loop");
1897        assert!(run.is_complete());
1898    }
1899
1900    #[test]
1901    fn errored_tournament_child_is_failed_and_no_champion_fails_controller() {
1902        // W-3: an Error-terminated entrant is FAILED (same contract as record_completion), and a
1903        // bracket with no champion FAILS the controller so dependents starve on the missing winner.
1904        let spec = WorkflowSpec::new(vec![
1905            WorkflowNode::new(RuntimeTask::new("pick"), AgentRole::Plan)
1906                .with_tournament(vec![RuntimeTask::new("x"), RuntimeTask::new("y")]),
1907            WorkflowNode::new(RuntimeTask::new("use winner"), AgentRole::Implement)
1908                .with_depends_on(vec![0]),
1909        ]);
1910        let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1911        let entrants = spawn_round(&mut run);
1912        assert_eq!(entrants.len(), 2);
1913        run.record_completion(&entrants[0].1, done());
1914        run.record_completion(
1915            &entrants[1].1,
1916            LoopResult { termination: TerminationReason::Error, ..done() },
1917        );
1918        // Bracket forms; the single judge reports NO winner (e.g. it errored too).
1919        let judges = spawn_round(&mut run);
1920        assert_eq!(judges.len(), 1, "one match for two entrants");
1921        run.record_completion(&judges[0].1, done()); // tournament_winner: None
1922        let (_, failed) = run.outcome();
1923        assert!(failed.contains(&entrants[1].1), "errored entrant reported failed");
1924        assert!(failed.contains(&"wf-node0".to_string()), "no-champion controller failed");
1925        assert!(
1926            !run.ready_batch().contains(&1),
1927            "dependent of the failed controller starves"
1928        );
1929    }
1930
1931    #[test]
1932    fn submitted_tournament_with_one_entrant_fails_instead_of_stalling() {
1933        // W-2: runtime submissions bypass spec validation; a 1-entrant contest cannot form and must
1934        // FAIL the controller (previously it sat Running forever and vanished from the outcome).
1935        let mut run = fanout2();
1936        let controller = WorkflowNode::new(RuntimeTask::new("pick"), AgentRole::Plan)
1937            .with_tournament(vec![RuntimeTask::new("only")]);
1938        let ids = run.submit_nodes(vec![controller]);
1939        run.expand_ready_controllers();
1940        let (_, failed) = run.outcome();
1941        assert_eq!(failed, vec![node_agent_id(ids[0])]);
1942    }
1943
1944    #[test]
1945    fn submitted_classify_branch_gains_classifier_dependency() {
1946        // W-2: a runtime-submitted classifier's branch nodes are coerced to depend on it, so a
1947        // branch can never run before classification (the race validate() exists to prevent).
1948        let mut run = fanout2();
1949        let classifier = WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan)
1950            .with_classify(vec![ClassifyBranch { label: "a".to_string(), nodes: vec![1] }]);
1951        let branch = WorkflowNode::new(RuntimeTask::new("on a"), AgentRole::Implement);
1952        let ids = run.submit_nodes(vec![classifier, branch]);
1953        let ready = run.ready_batch();
1954        assert!(ready.contains(&ids[0]), "classifier ready");
1955        assert!(!ready.contains(&ids[1]), "branch gated behind the classifier");
1956        // The classifier picks "a" → the branch is promoted.
1957        run.mark_spawned(ids[0], &node_agent_id(ids[0]));
1958        run.record_completion(
1959            &node_agent_id(ids[0]),
1960            LoopResult { classify_branch: Some("a".to_string()), ..done() },
1961        );
1962        assert!(run.ready_batch().contains(&ids[1]));
1963    }
1964
1965    #[test]
1966    fn submitted_zero_iter_loop_is_floored_to_one() {
1967        // W-2: Loop{max_iters:0} would never run; a runtime submission floors it to one iteration.
1968        let mut run = fanout2();
1969        let mut node = WorkflowNode::new(RuntimeTask::new("once"), AgentRole::Implement);
1970        node.kind = NodeKind::Loop { max_iters: 0 };
1971        let ids = run.submit_nodes(vec![node]);
1972        assert_eq!(run.nodes[ids[0]].kind, NodeKind::Loop { max_iters: 1 });
1973    }
1974
1975    #[test]
1976    fn spawn_info_carries_dep_ids_and_per_node_caps() {
1977        // W-N2: EVERY dependent node carries its dependencies' agent ids (a DAG edge carries data);
1978        // W-N7: per-node max_turns/max_wall_ms ride the same hop chain as token_budget.
1979        let spec = WorkflowSpec::new(vec![
1980            WorkflowNode::new(RuntimeTask::new("w"), AgentRole::Explore),
1981            WorkflowNode::new(RuntimeTask::new("synth"), AgentRole::Plan)
1982                .with_depends_on(vec![0])
1983                .with_max_turns(4)
1984                .with_max_wall_ms(30_000),
1985        ]);
1986        let run = WorkflowRun::new(&spec, "sess").unwrap();
1987        let info = run.spawn_info(1);
1988        assert_eq!(info.input_agent_ids, vec!["wf-node0"]);
1989        assert_eq!(info.max_turns, Some(4));
1990        assert_eq!(info.max_wall_ms, Some(30_000));
1991        assert!(info.reducer.is_none(), "plain node stays non-reduce");
1992        let root = run.spawn_info(0);
1993        assert!(root.input_agent_ids.is_empty());
1994        assert_eq!(root.max_turns, None);
1995    }
1996}