1use 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#[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
59pub fn node_agent_id(node: usize) -> String {
61 format!("wf-node{node}")
62}
63
64fn 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#[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 #[serde(default = "default_trust")]
90 pub trust: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub output_schema: Option<serde_json::Value>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub reducer: Option<String>,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub input_agent_ids: Vec<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub judge_match: Option<JudgeMatch>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub loop_max_iters: Option<usize>,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub classify_labels: Vec<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub token_budget: Option<u64>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub max_turns: Option<u32>,
132 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub struct JudgeMatch {
146 pub left: String,
147 pub right: String,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
156pub struct WorkflowBudget {
157 pub nodes_used: usize,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub nodes_max: Option<usize>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub nodes_remaining: Option<usize>,
166 pub running_subagents: usize,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub max_concurrent_subagents: Option<usize>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub concurrency_remaining: Option<usize>,
175 #[serde(default)]
178 pub tokens_used: u64,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub tokens_max: Option<u64>,
182 #[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
223fn 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#[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
269struct TournamentState {
273 entrant_nodes: Vec<usize>,
275 entrants_remaining: usize,
277 bracket: Option<Tournament>,
279 judge_nodes: Vec<usize>,
281 judge_winners: Vec<Option<EntrantId>>,
283 judges_remaining: usize,
285}
286
287pub struct WorkflowRun {
289 graph: TaskGraph,
290 nodes: Vec<WorkflowNode>,
291 scheduler_policy: crate::scheduler::policy::SchedulerPolicyConfig,
292 parent_session_id: String,
294 node_of_agent: HashMap<String, usize>,
296 iter_counts: HashMap<usize, usize>,
299 tournaments: HashMap<usize, TournamentState>,
301 child_controller: HashMap<usize, usize>,
303 judge_matches: HashMap<usize, JudgeMatch>,
305}
306
307impl WorkflowRun {
308 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 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 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 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 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 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 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 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 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 NodeKind::Spawn
553 | NodeKind::Classify { .. }
554 | NodeKind::Tournament { .. }
555 | NodeKind::Reduce { .. } => node_agent_id(node),
556 }
557 }
558
559 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 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 pub fn spawn_info(&self, node: usize) -> WorkflowSpawnInfo {
589 let n = &self.nodes[node];
590 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 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 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 pub fn mark_denied(&mut self, node: usize) {
641 self.graph.fail(node);
642 self.resolve_dependency_outcomes();
643 }
644
645 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 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 if let Some(&controller) = self.child_controller.get(&node) {
668 return self.advance_tournament(controller, node, result);
669 }
670
671 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 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 self.graph.set_ready(node);
691 return Some(node);
692 }
693 }
694 NodeKind::Classify { branches } => {
695 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 NodeKind::Spawn | NodeKind::Tournament { .. } | NodeKind::Reduce { .. } => {}
712 }
713
714 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 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 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 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 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 self.graph.start(c);
826 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 fn advance_tournament(
859 &mut self,
860 controller: usize,
861 child: usize,
862 result: LoopResult,
863 ) -> Option<usize> {
864 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 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 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 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 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 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 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 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 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 pub fn owns_agent(&self, agent_id: &str) -> bool {
1145 self.node_of_agent.contains_key(agent_id)
1146 }
1147
1148 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 #[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 #[cfg(test)]
1173 pub(crate) fn is_complete(&self) -> bool {
1174 self.graph.all_done()
1175 }
1176
1177 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 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 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 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 fn judge_done(winner: &str) -> LoopResult {
1300 LoopResult {
1301 tournament_winner: Some(winner.to_string()),
1302 ..done()
1303 }
1304 }
1305
1306 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 #[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(); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(run.ready_batch(), vec![0]);
1846 }
1847 }
1848
1849 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 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 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 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 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 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 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 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 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 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 assert!(
2160 run.ready_batch() == vec![0],
2161 "only the spec node remains to run"
2162 );
2163 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 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 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 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 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 let q = run.spawn_info(0);
2324 assert_eq!(q.trust, "quarantined");
2325 assert_eq!(q.model_hint.as_deref(), Some("haiku"));
2326 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 WorkflowNode::new(RuntimeTask::new("refine"), AgentRole::Implement).with_loop(3),
2340 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 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 use crate::orchestration::workflow::{NodeKind, WorkflowNode, WorkflowSpec};
2391 use crate::types::agent::AgentRole;
2392
2393 #[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 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 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 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 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 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 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 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 #[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); assert_eq!(entrants.len(), 3);
2527 for (_, id) in &entrants {
2528 run.record_completion(id, done());
2529 }
2530 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 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 #[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 #[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 use crate::orchestration::workflow::ClassifyBranch;
2610
2611 fn classify_spec() -> WorkflowSpec {
2612 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 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 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 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 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 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()); 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 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 #[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 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 #[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 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 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()); 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 #[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 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 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)); 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 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 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 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 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}