Skip to main content

deepstrike_core/orchestration/workflow/
run.rs

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