1use std::collections::HashMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::orchestration::task_graph::{TaskGraph, TaskStatus};
17use crate::orchestration::tournament::{EntrantId, Match, Tournament, TournamentAction};
18use super::{NodeKind, NodeTrust, WorkflowNode, WorkflowSpec};
19use crate::types::agent::{AgentIsolation, AgentRole, ContextInheritance, IsolationManifest};
20use crate::types::error::DeepStrikeError;
21use crate::types::task::RuntimeTask;
22use crate::types::error::Result;
23use crate::types::result::{LoopResult, TerminationReason};
24
25pub fn node_agent_id(node: usize) -> String {
27 format!("wf-node{node}")
28}
29
30fn parse_loop_iteration_id(id: &str, n_nodes: usize) -> Option<(usize, usize)> {
33 let rest = id.strip_prefix("wf-node")?;
34 let (node_s, k_s) = rest.split_once("-i")?;
35 let node: usize = node_s.parse().ok()?;
36 let k: usize = k_s.parse().ok()?;
37 (node < n_nodes).then_some((node, k))
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct WorkflowSpawnInfo {
46 pub agent_id: String,
47 pub goal: String,
48 pub role: String,
49 pub isolation: String,
50 pub context_inheritance: String,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub model_hint: Option<String>,
53 #[serde(default = "default_trust")]
56 pub trust: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub output_schema: Option<serde_json::Value>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub reducer: Option<String>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub input_agent_ids: Vec<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub judge_match: Option<JudgeMatch>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub loop_max_iters: Option<usize>,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
89 pub classify_labels: Vec<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub token_budget: Option<u64>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub max_turns: Option<u32>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub max_wall_ms: Option<u64>,
101}
102
103fn default_trust() -> String {
104 "trusted".to_string()
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct JudgeMatch {
112 pub left: String,
113 pub right: String,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
122pub struct WorkflowBudget {
123 pub nodes_used: usize,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub nodes_max: Option<usize>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub nodes_remaining: Option<usize>,
132 pub running_subagents: usize,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub max_concurrent_subagents: Option<usize>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub concurrency_remaining: Option<usize>,
141 #[serde(default)]
144 pub tokens_used: u64,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub tokens_max: Option<u64>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub tokens_remaining: Option<u64>,
153}
154
155fn role_label(role: AgentRole) -> &'static str {
156 match role {
157 AgentRole::Explore => "explore",
158 AgentRole::Plan => "plan",
159 AgentRole::Implement => "implement",
160 AgentRole::Verify => "verify",
161 AgentRole::Custom => "custom",
162 }
163}
164
165fn isolation_label(isolation: AgentIsolation) -> &'static str {
166 match isolation {
167 AgentIsolation::Shared => "shared",
168 AgentIsolation::ReadOnly => "read_only",
169 AgentIsolation::Worktree => "worktree",
170 AgentIsolation::Remote => "remote",
171 }
172}
173
174fn inheritance_label(inheritance: ContextInheritance) -> &'static str {
175 match inheritance {
176 ContextInheritance::None => "none",
177 ContextInheritance::SystemOnly => "system_only",
178 ContextInheritance::Full => "full",
179 }
180}
181
182fn trust_label(trust: NodeTrust) -> &'static str {
183 match trust {
184 NodeTrust::Trusted => "trusted",
185 NodeTrust::Quarantined => "quarantined",
186 }
187}
188
189fn resumed_result() -> LoopResult {
191 LoopResult {
192 termination: crate::types::result::TerminationReason::Completed,
193 final_message: None,
194 turns_used: 0,
195 total_tokens_used: 0,
196 loop_continue: None,
197 classify_branch: None,
198 tournament_winner: None,
199 pace_decision: None,
200 }
201}
202
203#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
209pub struct ResumedCompletion {
210 pub agent_id: String,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub classify_branch: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub tournament_winner: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub loop_continue: Option<bool>,
217}
218
219impl ResumedCompletion {
220 pub fn bare(agent_id: impl Into<String>) -> Self {
222 Self {
223 agent_id: agent_id.into(),
224 ..Self::default()
225 }
226 }
227}
228
229struct TournamentState {
233 entrant_nodes: Vec<usize>,
235 entrants_remaining: usize,
237 bracket: Option<Tournament>,
239 judge_nodes: Vec<usize>,
241 judge_winners: Vec<Option<EntrantId>>,
243 judges_remaining: usize,
245}
246
247pub struct WorkflowRun {
249 graph: TaskGraph,
250 nodes: Vec<WorkflowNode>,
251 parent_session_id: String,
253 node_of_agent: HashMap<String, usize>,
255 iter_counts: HashMap<usize, usize>,
258 tournaments: HashMap<usize, TournamentState>,
260 child_controller: HashMap<usize, usize>,
262 judge_matches: HashMap<usize, JudgeMatch>,
264}
265
266impl WorkflowRun {
267 pub fn new(spec: &WorkflowSpec, parent_session_id: &str) -> Result<Self> {
269 Ok(Self {
270 graph: spec.validate()?,
271 nodes: spec.nodes.clone(),
272 parent_session_id: parent_session_id.to_string(),
273 node_of_agent: HashMap::new(),
274 iter_counts: HashMap::new(),
275 tournaments: HashMap::new(),
276 child_controller: HashMap::new(),
277 judge_matches: HashMap::new(),
278 })
279 }
280
281 pub fn resume(
310 spec: &WorkflowSpec,
311 parent_session_id: &str,
312 submissions: &[Vec<WorkflowNode>],
313 submission_bases: &[u32],
314 completed: &[ResumedCompletion],
315 ) -> Result<Self> {
316 let mut run = Self::new(spec, parent_session_id)?;
317 for (i, batch) in submissions.iter().enumerate() {
318 if let Some(&base) = submission_bases.get(i) {
319 let base = base as usize;
320 if base < run.nodes.len() {
321 return Err(DeepStrikeError::InvalidConfig(format!(
322 "resume: submission {i} base {base} below reconstructed graph len {} — corrupt submission record",
323 run.nodes.len()
324 )));
325 }
326 while run.nodes.len() < base {
330 let idx = run.nodes.len();
331 let node = WorkflowNode::new(
332 RuntimeTask::new("[resume placeholder: runtime child slot]"),
333 AgentRole::Implement,
334 );
335 run.graph.add(node.task.clone(), Vec::new());
336 run.nodes.push(node);
337 run.graph.start(idx);
338 run.graph.complete(idx, resumed_result());
339 }
340 }
341 run.submit_nodes(batch.clone());
342 }
343 let n = run.graph.len();
344 let mut loop_cursor: HashMap<usize, (usize, Option<bool>)> = HashMap::new();
346 for rec in completed {
347 let id = rec.agent_id.as_str();
348 if let Some(node) = (0..n).find(|&i| node_agent_id(i) == id) {
349 run.graph.start(node);
350 if let NodeKind::Classify { branches } = &run.nodes[node].kind {
351 let chosen = rec.classify_branch.clone();
354 let prune: Vec<usize> = branches
355 .iter()
356 .filter(|b| Some(&b.label) != chosen.as_ref())
357 .flat_map(|b| b.nodes.iter().copied())
358 .collect();
359 for bn in prune {
360 run.graph.fail(bn);
361 }
362 let result = LoopResult {
363 classify_branch: chosen,
364 ..resumed_result()
365 };
366 run.graph.complete(node, result);
367 } else {
368 let result = LoopResult {
369 tournament_winner: rec.tournament_winner.clone(),
370 ..resumed_result()
371 };
372 run.graph.complete(node, result);
373 }
374 continue;
375 }
376 if let Some((node, k)) = parse_loop_iteration_id(id, n) {
377 if matches!(run.nodes[node].kind, NodeKind::Loop { .. }) {
378 let entry = loop_cursor.entry(node).or_insert((0, None));
379 if k + 1 >= entry.0 {
380 entry.0 = k + 1;
381 entry.1 = rec.loop_continue;
383 }
384 }
385 }
386 }
387 for (node, (done, last_continue)) in loop_cursor {
388 if let NodeKind::Loop { max_iters } = run.nodes[node].kind {
389 let done = run.iter_counts.get(&node).copied().unwrap_or(0).max(done);
390 run.iter_counts.insert(node, done);
391 let stop_recorded = last_continue == Some(false);
392 if done >= max_iters || stop_recorded {
393 run.graph.start(node);
394 run.graph.complete(node, resumed_result());
395 } else {
396 run.graph.set_ready(node);
399 }
400 }
401 }
402 Ok(run)
403 }
404
405 pub fn ready_batch(&self) -> Vec<usize> {
407 self.graph.ready_tasks()
408 }
409
410 pub fn current_agent_id(&self, node: usize) -> String {
415 match self.nodes[node].kind {
416 NodeKind::Loop { .. } => {
417 let k = self.iter_counts.get(&node).copied().unwrap_or(0);
418 format!("{}-i{k}", node_agent_id(node))
419 }
420 NodeKind::Spawn
424 | NodeKind::Classify { .. }
425 | NodeKind::Tournament { .. }
426 | NodeKind::Reduce { .. } => node_agent_id(node),
427 }
428 }
429
430 pub fn manifest_for(&self, node: usize) -> IsolationManifest {
434 let n = &self.nodes[node];
435 IsolationManifest {
436 agent_id: self.current_agent_id(node).into(),
437 parent_session_id: self.parent_session_id.as_str().into(),
438 role: n.role,
439 isolation: n.isolation,
440 context_inheritance: n.context_inheritance,
441 permitted_capability_ids: Vec::new(),
442 }
443 }
444
445 pub fn quarantine_violation(&self, node: usize) -> bool {
451 let n = &self.nodes[node];
452 matches!(n.trust, NodeTrust::Quarantined)
453 && !matches!(n.isolation, AgentIsolation::ReadOnly)
454 }
455
456 pub fn spawn_info(&self, node: usize) -> WorkflowSpawnInfo {
460 let n = &self.nodes[node];
461 let reducer = match &n.kind {
466 NodeKind::Reduce { reducer } => Some(reducer.clone()),
467 _ => None,
468 };
469 let input_agent_ids: Vec<String> =
470 n.depends_on.iter().map(|&d| node_agent_id(d)).collect();
471 let loop_max_iters = match &n.kind {
475 NodeKind::Loop { max_iters } => Some(*max_iters),
476 _ => None,
477 };
478 let classify_labels = match &n.kind {
479 NodeKind::Classify { branches } => branches.iter().map(|b| b.label.clone()).collect(),
480 _ => Vec::new(),
481 };
482 WorkflowSpawnInfo {
483 agent_id: self.current_agent_id(node),
484 goal: n.task.goal.clone(),
485 role: role_label(n.role).to_string(),
486 isolation: isolation_label(n.isolation).to_string(),
487 context_inheritance: inheritance_label(n.context_inheritance).to_string(),
488 model_hint: n.model_hint.clone(),
489 trust: trust_label(n.trust).to_string(),
490 output_schema: n.output_schema.clone(),
491 reducer,
492 input_agent_ids,
493 judge_match: self.judge_matches.get(&node).cloned(),
494 loop_max_iters,
495 classify_labels,
496 token_budget: n.token_budget,
497 max_turns: n.max_turns,
498 max_wall_ms: n.max_wall_ms,
499 }
500 }
501
502 pub fn mark_spawned(&mut self, node: usize, agent_id: &str) {
506 self.graph.start(node);
507 self.node_of_agent.insert(agent_id.to_string(), node);
508 }
509
510 pub fn mark_denied(&mut self, node: usize) {
513 self.graph.fail(node);
514 }
515
516 pub fn record_completion(&mut self, agent_id: &str, result: LoopResult) -> Option<usize> {
524 let node = *self.node_of_agent.get(agent_id)?;
525
526 if let Some(&controller) = self.child_controller.get(&node) {
529 return self.advance_tournament(controller, node, result);
530 }
531
532 match &self.nodes[node].kind {
533 NodeKind::Loop { max_iters } => {
534 let max_iters = *max_iters;
537 let stop_requested = result.loop_continue == Some(false);
538 let done = self.iter_counts.entry(node).or_insert(0);
539 *done += 1;
540 if *done < max_iters && !stop_requested {
541 self.graph.set_ready(node);
543 return Some(node);
544 }
545 }
546 NodeKind::Classify { branches } => {
547 let chosen = result.classify_branch.clone();
551 let prune: Vec<usize> = branches
552 .iter()
553 .filter(|b| Some(&b.label) != chosen.as_ref())
554 .flat_map(|b| b.nodes.iter().copied())
555 .collect();
556 for bn in prune {
557 self.graph.fail(bn);
558 }
559 }
560 NodeKind::Spawn | NodeKind::Tournament { .. } | NodeKind::Reduce { .. } => {}
564 }
565
566 if matches!(result.termination, crate::types::result::TerminationReason::Error) {
573 self.graph.fail(node);
574 } else {
575 self.graph.complete(node, result);
576 }
577 Some(node)
578 }
579
580 fn append_child(&mut self, node: WorkflowNode) -> usize {
586 let idx = self.graph.add(node.task.clone(), Vec::new());
587 debug_assert_eq!(idx, self.nodes.len(), "graph/nodes index drift");
588 self.nodes.push(node);
589 idx
590 }
591
592 pub fn expand_ready_controllers(&mut self) {
597 let pending: Vec<usize> = (0..self.nodes.len())
598 .filter(|i| !self.tournaments.contains_key(i))
599 .filter(|&i| matches!(self.nodes[i].kind, NodeKind::Tournament { .. }))
600 .filter(|&i| self.graph.get(i).map(|n| n.status) == Some(TaskStatus::Ready))
601 .collect();
602 for c in pending {
603 self.expand_tournament(c);
604 }
605 }
606
607 fn expand_tournament(&mut self, c: usize) {
610 let entrants = match &self.nodes[c].kind {
611 NodeKind::Tournament { entrants } => entrants.clone(),
612 _ => return,
613 };
614 let trust = self.nodes[c].trust;
615 self.graph.start(c);
617 if entrants.len() < 2 {
621 self.complete_tournament(c, None);
622 return;
623 }
624 let mut entrant_nodes = Vec::with_capacity(entrants.len());
625 for task in entrants {
626 let child = WorkflowNode::new(task, AgentRole::Custom)
627 .with_isolation(AgentIsolation::ReadOnly)
628 .with_trust(trust);
629 let idx = self.append_child(child);
630 self.child_controller.insert(idx, c);
631 entrant_nodes.push(idx);
632 }
633 let entrants_remaining = entrant_nodes.len();
634 self.tournaments.insert(
635 c,
636 TournamentState {
637 entrant_nodes,
638 entrants_remaining,
639 bracket: None,
640 judge_nodes: Vec::new(),
641 judge_winners: Vec::new(),
642 judges_remaining: 0,
643 },
644 );
645 }
646
647 fn advance_tournament(
650 &mut self,
651 controller: usize,
652 child: usize,
653 result: LoopResult,
654 ) -> Option<usize> {
655 if matches!(result.termination, TerminationReason::Error) {
661 self.graph.fail(child);
662 } else {
663 self.graph.complete(child, result.clone());
664 }
665
666 let in_entrant_phase = self.tournaments.get(&controller)?.bracket.is_none();
667 if in_entrant_phase {
668 let all_in = {
669 let st = self.tournaments.get_mut(&controller)?;
670 st.entrants_remaining = st.entrants_remaining.saturating_sub(1);
671 st.entrants_remaining == 0
672 };
673 if all_in {
674 self.begin_bracket(controller);
675 }
676 } else {
677 let round_done = {
678 let st = self.tournaments.get_mut(&controller)?;
679 if let Some(pos) = st.judge_nodes.iter().position(|&n| n == child) {
680 st.judge_winners[pos] = result.tournament_winner.clone();
681 }
682 st.judges_remaining = st.judges_remaining.saturating_sub(1);
683 st.judges_remaining == 0
684 };
685 if round_done {
686 self.finish_round(controller);
687 }
688 }
689 Some(controller)
690 }
691
692 fn begin_bracket(&mut self, controller: usize) {
694 let entrant_ids: Vec<EntrantId> = self
695 .tournaments
696 .get(&controller)
697 .map(|st| st.entrant_nodes.iter().map(|&n| node_agent_id(n)).collect())
698 .unwrap_or_default();
699 let mut bracket = match Tournament::new(entrant_ids) {
701 Ok(b) => b,
702 Err(_) => return self.complete_tournament(controller, None),
703 };
704 let action = bracket.start();
705 if let Some(st) = self.tournaments.get_mut(&controller) {
706 st.bracket = Some(bracket);
707 }
708 self.apply_action(controller, action);
709 }
710
711 fn finish_round(&mut self, controller: usize) {
713 let winners: Vec<EntrantId> = self
714 .tournaments
715 .get(&controller)
716 .map(|st| st.judge_winners.iter().filter_map(|w| w.clone()).collect())
717 .unwrap_or_default();
718 let action = {
719 let st = match self.tournaments.get_mut(&controller) {
720 Some(st) => st,
721 None => return,
722 };
723 match st.bracket.as_mut() {
724 Some(b) => b.feed_round(winners),
727 None => return,
728 }
729 };
730 match action {
731 Ok(act) => self.apply_action(controller, act),
732 Err(_) => self.complete_tournament(controller, None),
733 }
734 }
735
736 fn apply_action(&mut self, controller: usize, action: TournamentAction) {
738 match action {
739 TournamentAction::JudgeRound { matches, .. } => self.emit_judges(controller, matches),
740 TournamentAction::Done { winner, .. } => {
741 self.complete_tournament(controller, Some(winner))
742 }
743 }
744 }
745
746 fn emit_judges(&mut self, controller: usize, matches: Vec<Match>) {
749 let criterion = self.nodes[controller].task.clone();
750 let trust = self.nodes[controller].trust;
751 let mut judge_nodes = Vec::with_capacity(matches.len());
752 for m in &matches {
753 let judge = WorkflowNode::new(criterion.clone(), AgentRole::Verify).with_trust(trust);
754 let idx = self.append_child(judge);
755 self.child_controller.insert(idx, controller);
756 self.judge_matches.insert(
757 idx,
758 JudgeMatch {
759 left: m.left.clone(),
760 right: m.right.clone(),
761 },
762 );
763 judge_nodes.push(idx);
764 }
765 if let Some(st) = self.tournaments.get_mut(&controller) {
766 st.judge_winners = vec![None; judge_nodes.len()];
767 st.judges_remaining = judge_nodes.len();
768 st.judge_nodes = judge_nodes;
769 }
770 }
771
772 fn complete_tournament(&mut self, controller: usize, winner: Option<EntrantId>) {
778 self.tournaments.remove(&controller);
779 let Some(winner) = winner else {
780 self.graph.fail(controller);
781 return;
782 };
783 let result = LoopResult {
784 termination: TerminationReason::Completed,
785 final_message: None,
786 turns_used: 0,
787 total_tokens_used: 0,
788 loop_continue: None,
789 classify_branch: None,
790 tournament_winner: Some(winner),
791 pace_decision: None,
792 };
793 self.graph.complete(controller, result);
794 }
795
796 pub fn submit_nodes_from(
827 &mut self,
828 submitter: Option<&str>,
829 mut nodes: Vec<WorkflowNode>,
830 ) -> Vec<usize> {
831 let submitter_quarantined = submitter.is_some_and(|s| self.is_agent_quarantined(s));
832 if submitter_quarantined {
833 for node in &mut nodes {
834 node.trust = NodeTrust::Quarantined;
835 }
836 }
837 self.submit_nodes(nodes)
838 }
839
840 pub fn submit_nodes(&mut self, nodes: Vec<WorkflowNode>) -> Vec<usize> {
841 let base = self.nodes.len();
842 let batch_len = nodes.len();
843 let mut ids = Vec::with_capacity(nodes.len());
844 let mut forced_deps: Vec<Vec<usize>> = vec![Vec::new(); batch_len];
849 for (o, node) in nodes.iter().enumerate() {
850 if let NodeKind::Classify { branches } = &node.kind {
851 for b in branches.iter().flat_map(|br| br.nodes.iter().copied()) {
852 if b > o
853 && b < batch_len
854 && !nodes[b].depends_on.contains(&o)
855 && !forced_deps[b].contains(&o)
856 {
857 forced_deps[b].push(o);
858 }
859 }
860 }
861 }
862 for (offset, mut node) in nodes.into_iter().enumerate() {
863 if let NodeKind::Loop { max_iters: 0 } = node.kind {
866 node.kind = NodeKind::Loop { max_iters: 1 };
867 }
868 let deps: Vec<usize> = node
869 .depends_on
870 .iter()
871 .filter(|&&d| d < offset)
872 .chain(forced_deps[offset].iter())
873 .map(|&d| base + d)
874 .collect();
875 node.depends_on = deps.clone();
876 if let NodeKind::Classify { branches } = &mut node.kind {
882 for branch in branches.iter_mut() {
883 branch.nodes = branch
884 .nodes
885 .iter()
886 .filter(|&&d| d < batch_len)
887 .map(|&d| base + d)
888 .collect();
889 }
890 }
891 let idx = self.graph.add(node.task.clone(), deps);
892 debug_assert_eq!(idx, self.nodes.len(), "graph/nodes index drift");
893 self.nodes.push(node);
894 ids.push(idx);
895 }
896 ids
897 }
898
899 pub fn owns_agent(&self, agent_id: &str) -> bool {
901 self.node_of_agent.contains_key(agent_id)
902 }
903
904 pub fn is_agent_quarantined(&self, agent_id: &str) -> bool {
909 self.node_of_agent
910 .get(agent_id)
911 .is_some_and(|&node| matches!(self.nodes[node].trust, NodeTrust::Quarantined))
912 }
913
914 #[cfg(test)]
918 pub(crate) fn batch_drained(&self) -> bool {
919 !(0..self.graph.len()).any(|i| {
920 matches!(self.graph.get(i).map(|n| &n.status), Some(crate::orchestration::task_graph::TaskStatus::Running))
921 })
922 }
923
924 #[cfg(test)]
929 pub(crate) fn is_complete(&self) -> bool {
930 self.graph.all_done()
931 }
932
933 pub fn outcome(&self) -> (Vec<String>, Vec<String>) {
936 let mut completed = Vec::new();
937 let mut failed = Vec::new();
938 for i in 0..self.graph.len() {
939 match self.graph.get(i).map(|n| n.status) {
940 Some(TaskStatus::Completed) => completed.push(node_agent_id(i)),
941 Some(TaskStatus::Failed) => failed.push(node_agent_id(i)),
942 _ => {}
943 }
944 }
945 (completed, failed)
946 }
947
948 pub fn abort_outcome(&self) -> (Vec<String>, Vec<String>) {
952 let mut completed = Vec::new();
953 let mut failed = Vec::new();
954 for i in 0..self.graph.len() {
955 match self.graph.get(i).map(|n| n.status) {
956 Some(TaskStatus::Completed) => completed.push(node_agent_id(i)),
957 _ => failed.push(node_agent_id(i)),
958 }
959 }
960 (completed, failed)
961 }
962
963 pub fn len(&self) -> usize {
965 self.graph.len()
966 }
967
968 }
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973 use crate::orchestration::workflow::fanout_synthesize;
974 use crate::types::result::{LoopResult, TerminationReason};
975 use crate::types::task::RuntimeTask;
976
977 fn done() -> LoopResult {
978 LoopResult {
979 termination: TerminationReason::Completed,
980 final_message: None,
981 turns_used: 1,
982 total_tokens_used: 0,
983 loop_continue: None,
984 classify_branch: None,
985 tournament_winner: None,
986 pace_decision: None,
987 }
988 }
989
990 fn fanout2() -> WorkflowRun {
991 let spec = fanout_synthesize(
993 vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")],
994 RuntimeTask::new("synth"),
995 );
996 WorkflowRun::new(&spec, "parent-sess").unwrap()
997 }
998
999 fn judge_done(winner: &str) -> LoopResult {
1001 LoopResult {
1002 tournament_winner: Some(winner.to_string()),
1003 ..done()
1004 }
1005 }
1006
1007 fn spawn_round(run: &mut WorkflowRun) -> Vec<(usize, String)> {
1010 run.expand_ready_controllers();
1011 let ready = run.ready_batch();
1012 let mut out = Vec::new();
1013 for node in ready {
1014 let id = run.current_agent_id(node);
1015 run.mark_spawned(node, &id);
1016 out.push((node, id));
1017 }
1018 out
1019 }
1020
1021 #[test]
1022 fn first_batch_is_the_workers() {
1023 let run = fanout2();
1024 assert_eq!(run.ready_batch(), vec![0, 1]);
1025 assert_eq!(run.len(), 3);
1026 assert!(!run.is_complete());
1027 }
1028
1029 #[test]
1032 fn submit_nodes_appends_independent_nodes_ready_immediately() {
1033 use crate::orchestration::workflow::WorkflowNode;
1034 use crate::types::agent::AgentRole;
1035
1036 let mut run = fanout2(); assert_eq!(run.len(), 3);
1038 let ids = run.submit_nodes(vec![
1039 WorkflowNode::new(RuntimeTask::new("extra-a"), AgentRole::Implement),
1040 WorkflowNode::new(RuntimeTask::new("extra-b"), AgentRole::Implement),
1041 ]);
1042 assert_eq!(ids, vec![3, 4], "appended after the existing 3 nodes");
1043 assert_eq!(run.len(), 5);
1044 let ready = run.ready_batch();
1045 assert!(
1046 ready.contains(&3) && ready.contains(&4),
1047 "submitted independent nodes are immediately ready: {ready:?}"
1048 );
1049 }
1050
1051 #[test]
1052 fn submitted_nodes_must_complete_before_workflow_is_done() {
1053 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1054 use crate::types::agent::AgentRole;
1055
1056 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1058 RuntimeTask::new("root"),
1059 AgentRole::Implement,
1060 )]);
1061 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1062 let id0 = run.current_agent_id(0);
1063 run.mark_spawned(0, &id0);
1064 run.record_completion(&id0, done());
1065 let ids = run.submit_nodes(vec![WorkflowNode::new(
1066 RuntimeTask::new("more"),
1067 AgentRole::Implement,
1068 )]);
1069 assert_eq!(ids, vec![1]);
1070 assert!(!run.is_complete(), "not complete while the submitted node is pending");
1071 let spawned = spawn_round(&mut run);
1072 assert_eq!(spawned, vec![(1usize, "wf-node1".to_string())]);
1073 run.record_completion("wf-node1", done());
1074 assert!(run.is_complete(), "complete once the submitted node finishes");
1075 }
1076
1077 #[test]
1078 fn reduce_node_carries_reducer_and_inputs_then_completes_like_a_spawn() {
1079 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1080 use crate::types::agent::AgentRole;
1081
1082 let spec = WorkflowSpec::new(vec![
1085 WorkflowNode::new(RuntimeTask::new("worker-a"), AgentRole::Explore),
1086 WorkflowNode::new(RuntimeTask::new("worker-b"), AgentRole::Explore),
1087 WorkflowNode::new(RuntimeTask::new("merge"), AgentRole::Implement)
1088 .with_reduce("dedupe_lines")
1089 .with_depends_on(vec![0, 1]),
1090 ]);
1091 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1092
1093 assert_eq!(run.ready_batch(), vec![0, 1]);
1095 for i in [0usize, 1] {
1096 let id = run.current_agent_id(i);
1097 run.mark_spawned(i, &id);
1098 run.record_completion(&id, done());
1099 }
1100
1101 assert_eq!(run.ready_batch(), vec![2]);
1103 let info = run.spawn_info(2);
1104 assert_eq!(info.reducer.as_deref(), Some("dedupe_lines"));
1105 assert_eq!(info.input_agent_ids, vec!["wf-node0".to_string(), "wf-node1".to_string()]);
1106
1107 run.mark_spawned(2, "wf-node2");
1109 run.record_completion("wf-node2", done());
1110 assert!(run.is_complete());
1111 let (completed, failed) = run.outcome();
1112 assert_eq!(completed, vec!["wf-node0", "wf-node1", "wf-node2"]);
1113 assert!(failed.is_empty());
1114 }
1115
1116 #[test]
1117 fn output_schema_reaches_the_spawn_descriptor() {
1118 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1119 use crate::types::agent::AgentRole;
1120
1121 let schema = serde_json::json!({
1123 "type": "object",
1124 "required": ["verdict"],
1125 "properties": { "verdict": { "type": "string" } }
1126 });
1127 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1128 RuntimeTask::new("judge"),
1129 AgentRole::Verify,
1130 )
1131 .with_output_schema(schema.clone())]);
1132 let run = WorkflowRun::new(&spec, "sess").unwrap();
1133 let info = run.spawn_info(0);
1134 assert_eq!(info.output_schema.as_ref(), Some(&schema));
1135
1136 let json = serde_json::to_string(&info).unwrap();
1138 let back: WorkflowSpawnInfo = serde_json::from_str(&json).unwrap();
1139 assert_eq!(back.output_schema, Some(schema));
1140
1141 let plain = WorkflowSpec::new(vec![WorkflowNode::new(
1143 RuntimeTask::new("x"),
1144 AgentRole::Implement,
1145 )]);
1146 let plain_info = WorkflowRun::new(&plain, "sess").unwrap().spawn_info(0);
1147 assert!(plain_info.output_schema.is_none());
1148 assert!(!serde_json::to_string(&plain_info).unwrap().contains("output_schema"));
1149 }
1150
1151 #[test]
1152 fn quarantined_submitter_taints_submitted_nodes() {
1153 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1154 use crate::types::agent::AgentRole;
1155
1156 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1160 RuntimeTask::new("read-untrusted"),
1161 AgentRole::Explore,
1162 )
1163 .quarantined()]);
1164 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1165 let id0 = run.current_agent_id(0);
1166 run.mark_spawned(0, &id0);
1167 run.record_completion(&id0, done());
1168
1169 let ids = run.submit_nodes_from(
1171 Some(&id0),
1172 vec![WorkflowNode::new(RuntimeTask::new("act"), AgentRole::Implement)],
1173 );
1174 assert_eq!(ids, vec![1]);
1175 let id1 = run.current_agent_id(1);
1176 run.mark_spawned(1, &id1);
1177 assert!(
1178 run.is_agent_quarantined(&id1),
1179 "submitted node inherits the submitter's quarantine (no escalation)"
1180 );
1181
1182 let ids2 = run.submit_nodes_from(
1184 None,
1185 vec![WorkflowNode::new(RuntimeTask::new("trusted-work"), AgentRole::Implement)],
1186 );
1187 let id2 = run.current_agent_id(ids2[0]);
1188 run.mark_spawned(ids2[0], &id2);
1189 assert!(
1190 !run.is_agent_quarantined(&id2),
1191 "no quarantined submitter ⇒ no coercion"
1192 );
1193 }
1194
1195 #[test]
1196 fn submit_nodes_honors_batch_relative_backward_deps() {
1197 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1198 use crate::types::agent::AgentRole;
1199
1200 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1201 RuntimeTask::new("root"),
1202 AgentRole::Implement,
1203 )]);
1204 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1205 let id0 = run.current_agent_id(0);
1206 run.mark_spawned(0, &id0);
1207 run.record_completion(&id0, done());
1208 let ids = run.submit_nodes(vec![
1210 WorkflowNode::new(RuntimeTask::new("extractor"), AgentRole::Implement),
1211 WorkflowNode::new(RuntimeTask::new("dependent"), AgentRole::Implement)
1212 .with_depends_on(vec![0]),
1213 ]);
1214 assert_eq!(ids, vec![1, 2]);
1215 assert_eq!(run.ready_batch(), vec![1], "backward dep keeps the dependent pending");
1216 run.mark_spawned(1, "wf-node1");
1217 run.record_completion("wf-node1", done());
1218 assert_eq!(run.ready_batch(), vec![2], "dependent unblocks after the extractor");
1219 }
1220
1221 #[test]
1222 fn submit_nodes_drops_forward_and_out_of_range_deps() {
1223 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1224 use crate::types::agent::AgentRole;
1225
1226 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1227 RuntimeTask::new("root"),
1228 AgentRole::Implement,
1229 )]);
1230 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1231 let ids = run.submit_nodes(vec![
1233 WorkflowNode::new(RuntimeTask::new("a"), AgentRole::Implement).with_depends_on(vec![5]),
1234 ]);
1235 assert_eq!(ids, vec![1]);
1236 assert!(
1237 run.ready_batch().contains(&1),
1238 "a node whose only dep was dropped is ready, not stranded"
1239 );
1240 }
1241
1242 #[test]
1243 fn submitted_node_can_itself_be_a_loop_control_flow() {
1244 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1249 use crate::types::agent::AgentRole;
1250
1251 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1252 RuntimeTask::new("root"),
1253 AgentRole::Implement,
1254 )]);
1255 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1256 let id0 = run.current_agent_id(0);
1257 run.mark_spawned(0, &id0);
1258 run.record_completion(&id0, done());
1259
1260 let ids = run.submit_nodes(vec![
1262 WorkflowNode::new(RuntimeTask::new("refine"), AgentRole::Implement).with_loop(2),
1263 ]);
1264 assert_eq!(ids, vec![1]);
1265
1266 for k in 0..2 {
1268 assert_eq!(run.ready_batch(), vec![1], "submitted loop ready for iteration {k}");
1269 let id = run.current_agent_id(1);
1270 assert_eq!(id, format!("wf-node1-i{k}"), "submitted loop gets per-iteration ids");
1271 run.mark_spawned(1, &id);
1272 run.record_completion(&id, done());
1273 }
1274 assert!(run.is_complete(), "submitted loop ran its 2 iterations then finished");
1275 }
1276
1277 #[test]
1278 fn submitted_tournament_runs_bracket_then_promotes_submitted_dependent() {
1279 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1283 use crate::types::agent::AgentRole;
1284
1285 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1286 RuntimeTask::new("root"),
1287 AgentRole::Implement,
1288 )]);
1289 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1290 let id0 = run.current_agent_id(0);
1291 run.mark_spawned(0, &id0);
1292 run.record_completion(&id0, done());
1293
1294 let ids = run.submit_nodes(vec![
1296 WorkflowNode::new(RuntimeTask::new("pick best"), AgentRole::Plan)
1297 .with_tournament(vec![RuntimeTask::new("x"), RuntimeTask::new("y")]),
1298 WorkflowNode::new(RuntimeTask::new("use winner"), AgentRole::Implement)
1299 .with_depends_on(vec![0]),
1300 ]);
1301 assert_eq!(ids, vec![1, 2], "appended controller=1, dependent=2");
1302
1303 let entrants = spawn_round(&mut run);
1305 let entrant_nodes: Vec<usize> = entrants.iter().map(|(n, _)| *n).collect();
1306 assert_eq!(entrant_nodes, vec![3, 4], "two entrant children appended after the dependent");
1307 for (_, id) in &entrants {
1308 run.record_completion(id, done());
1309 }
1310
1311 let r1 = spawn_round(&mut run);
1313 assert_eq!(r1.len(), 1, "one judge for two entrants");
1314 let jm = run.spawn_info(r1[0].0).judge_match.expect("judge carries a match");
1315 assert_eq!(jm, JudgeMatch { left: node_agent_id(3), right: node_agent_id(4) });
1316
1317 run.record_completion(&r1[0].1, judge_done(&node_agent_id(3)));
1319 assert_eq!(run.ready_batch(), vec![2], "submitted dependent unblocks after the bracket");
1320 let last = spawn_round(&mut run);
1321 assert_eq!(last, vec![(2, node_agent_id(2))]);
1322 run.record_completion(&last[0].1, done());
1323 assert!(run.is_complete());
1324 }
1325
1326 #[test]
1327 fn submitted_classify_remaps_branch_indices_and_prunes() {
1328 use crate::orchestration::workflow::{ClassifyBranch, NodeKind, WorkflowNode, WorkflowSpec};
1332 use crate::types::agent::AgentRole;
1333
1334 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1335 RuntimeTask::new("root"),
1336 AgentRole::Implement,
1337 )]);
1338 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1339 let id0 = run.current_agent_id(0);
1340 run.mark_spawned(0, &id0);
1341 run.record_completion(&id0, done());
1342
1343 let ids = run.submit_nodes(vec![
1345 WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan).with_classify(vec![
1346 ClassifyBranch { label: "a".into(), nodes: vec![1] },
1347 ClassifyBranch { label: "b".into(), nodes: vec![2] },
1348 ]),
1349 WorkflowNode::new(RuntimeTask::new("branch-a"), AgentRole::Implement)
1350 .with_depends_on(vec![0]),
1351 WorkflowNode::new(RuntimeTask::new("branch-b"), AgentRole::Implement)
1352 .with_depends_on(vec![0]),
1353 ]);
1354 assert_eq!(ids, vec![1, 2, 3], "classify=1, branchA=2, branchB=3");
1355
1356 if let NodeKind::Classify { branches } = &run.nodes[1].kind {
1358 assert_eq!(branches[0].nodes, vec![2], "branch a remapped to absolute node 2");
1359 assert_eq!(branches[1].nodes, vec![3], "branch b remapped to absolute node 3");
1360 } else {
1361 panic!("node 1 should be a classify node");
1362 }
1363
1364 let r = spawn_round(&mut run);
1366 assert_eq!(r, vec![(1, node_agent_id(1))], "classifier runs first");
1367 run.record_completion(&r[0].1, LoopResult { classify_branch: Some("a".into()), ..done() });
1368
1369 assert_eq!(run.ready_batch(), vec![2], "only branch a is enabled");
1370 let (_c, failed) = run.outcome();
1371 assert!(failed.contains(&node_agent_id(3)), "branch b pruned/failed");
1372
1373 let last = spawn_round(&mut run);
1374 assert_eq!(last, vec![(2, node_agent_id(2))]);
1375 run.record_completion(&last[0].1, done());
1376 assert!(run.is_complete());
1377 let (completed, _f) = run.outcome();
1378 assert!(completed.contains(&node_agent_id(1)) && completed.contains(&node_agent_id(2)));
1379 }
1380
1381 #[test]
1382 fn loop_node_iterates_with_distinct_ids_then_promotes_dependent() {
1383 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1384 use crate::types::agent::AgentRole;
1385
1386 let spec = WorkflowSpec::new(vec![
1388 WorkflowNode::new(RuntimeTask::new("refine"), AgentRole::Implement).with_loop(3),
1389 WorkflowNode::new(RuntimeTask::new("finalize"), AgentRole::Implement)
1390 .with_depends_on(vec![0]),
1391 ]);
1392 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1393
1394 for k in 0..3 {
1396 assert_eq!(run.ready_batch(), vec![0], "loop node ready for iteration {k}");
1397 let id = run.current_agent_id(0);
1398 assert_eq!(id, format!("wf-node0-i{k}"), "distinct per-iteration id");
1399 run.mark_spawned(0, &id);
1400 assert!(!run.is_complete());
1401 let node = run.record_completion(&id, done()).unwrap();
1402 assert_eq!(node, 0);
1403 if k < 2 {
1404 assert_eq!(run.ready_batch(), vec![0]);
1406 }
1407 }
1408
1409 assert_eq!(run.ready_batch(), vec![1], "dependent unblocks only after the loop ends");
1411 let id1 = run.current_agent_id(1);
1412 assert_eq!(id1, "wf-node1", "spawn node keeps the plain id");
1413 run.mark_spawned(1, &id1);
1414 run.record_completion(&id1, done());
1415 assert!(run.is_complete());
1416 }
1417
1418 #[test]
1419 fn synth_becomes_ready_only_after_both_workers() {
1420 let mut run = fanout2();
1421 for &n in &[0usize, 1usize] {
1422 let id = node_agent_id(n);
1423 run.mark_spawned(n, &id);
1424 }
1425 assert!(!run.batch_drained());
1426 assert_eq!(run.record_completion(&node_agent_id(0), done()), Some(0));
1428 assert!(!run.batch_drained());
1429 assert!(run.ready_batch().is_empty());
1430 assert_eq!(run.record_completion(&node_agent_id(1), done()), Some(1));
1432 assert!(run.batch_drained());
1433 assert_eq!(run.ready_batch(), vec![2]);
1434 assert!(!run.is_complete());
1435 run.mark_spawned(2, &node_agent_id(2));
1437 run.record_completion(&node_agent_id(2), done());
1438 assert!(run.is_complete());
1439 }
1440
1441 #[test]
1442 fn denied_node_blocks_dependents_and_stalls_progress() {
1443 let mut run = fanout2();
1444 run.mark_spawned(0, &node_agent_id(0));
1446 run.mark_denied(1);
1447 run.record_completion(&node_agent_id(0), done());
1448 assert!(run.batch_drained());
1452 assert!(run.ready_batch().is_empty());
1453 assert!(!run.is_complete());
1454 }
1455
1456 #[test]
1457 fn manifest_preserves_node_isolation_and_inheritance() {
1458 let run = fanout2();
1459 let m = run.manifest_for(0);
1460 assert_eq!(m.agent_id.as_str(), "wf-node0");
1461 assert_eq!(m.parent_session_id.as_str(), "parent-sess");
1462 assert_eq!(m.isolation, crate::types::agent::AgentIsolation::ReadOnly);
1464 assert_eq!(
1465 m.context_inheritance,
1466 crate::types::agent::ContextInheritance::SystemOnly
1467 );
1468 }
1469
1470 #[test]
1471 fn unknown_agent_completion_is_none() {
1472 let mut run = fanout2();
1473 assert_eq!(run.record_completion("not-a-node", done()), None);
1474 }
1475
1476 #[test]
1477 fn resume_skips_already_completed_nodes() {
1478 let spec = fanout_synthesize(
1480 vec![RuntimeTask::new("w0"), RuntimeTask::new("w1")],
1481 RuntimeTask::new("synth"),
1482 );
1483 let run = WorkflowRun::resume(&spec, "sess", &[], &[], &[ResumedCompletion::bare(node_agent_id(0))]).unwrap();
1484 assert_eq!(run.ready_batch(), vec![1]);
1486 assert!(!run.is_complete());
1487 }
1488
1489 #[test]
1490 fn resume_with_all_done_completes() {
1491 let spec = fanout_synthesize(vec![RuntimeTask::new("w0")], RuntimeTask::new("synth"));
1492 let run = WorkflowRun::resume(&spec, "sess", &[], &[], &[ResumedCompletion::bare(node_agent_id(0)), ResumedCompletion::bare(node_agent_id(1))]).unwrap();
1494 assert!(run.ready_batch().is_empty());
1495 assert!(run.is_complete());
1496 }
1497
1498 #[test]
1499 fn resume_applies_submissions_at_recorded_bases_with_placeholder_gap_fill() {
1500 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1506 use crate::types::agent::AgentRole;
1507
1508 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1509 RuntimeTask::new("root"),
1510 AgentRole::Implement,
1511 )]);
1512 let submission = vec![WorkflowNode::new(
1513 RuntimeTask::new("late batch"),
1514 AgentRole::Implement,
1515 )];
1516 let run = WorkflowRun::resume(
1517 &spec,
1518 "sess",
1519 &[submission],
1520 &[3],
1521 &[ResumedCompletion::bare("wf-node2"), ResumedCompletion::bare("wf-node3")],
1522 )
1523 .unwrap();
1524 assert_eq!(run.graph.len(), 4, "spec node + 2 placeholders + 1 submitted");
1525 assert!(run.ready_batch() == vec![0], "only the spec node remains to run");
1527 let spec2 = WorkflowSpec::new(vec![
1529 WorkflowNode::new(RuntimeTask::new("a"), AgentRole::Implement),
1530 WorkflowNode::new(RuntimeTask::new("b"), AgentRole::Implement),
1531 ]);
1532 let bad = WorkflowRun::resume(
1533 &spec2,
1534 "sess",
1535 &[vec![WorkflowNode::new(RuntimeTask::new("x"), AgentRole::Implement)]],
1536 &[1],
1537 &[],
1538 );
1539 assert!(bad.is_err(), "base inside the spec range is a corrupt record");
1540 }
1541
1542 #[test]
1543 fn resume_restores_loop_iteration_cursor_instead_of_restarting() {
1544 use crate::orchestration::workflow::{NodeKind, WorkflowNode, WorkflowSpec};
1547 use crate::types::agent::AgentRole;
1548
1549 let mut node = WorkflowNode::new(RuntimeTask::new("polish until done"), AgentRole::Implement);
1550 node.kind = NodeKind::Loop { max_iters: 3 };
1551 let spec = WorkflowSpec::new(vec![node]);
1552 let run = WorkflowRun::resume(&spec, "sess", &[], &[], &[ResumedCompletion::bare("wf-node0-i0"), ResumedCompletion::bare("wf-node0-i1")],
1553 )
1554 .unwrap();
1555 assert_eq!(run.ready_batch(), vec![0], "loop node re-armed, not complete");
1556 assert_eq!(run.current_agent_id(0), "wf-node0-i2", "cursor advanced past finished work");
1557 assert!(!run.is_complete());
1558 }
1559
1560 #[test]
1561 fn resume_completes_loop_when_all_iterations_recorded() {
1562 use crate::orchestration::workflow::{NodeKind, WorkflowNode, WorkflowSpec};
1563 use crate::types::agent::AgentRole;
1564
1565 let mut node = WorkflowNode::new(RuntimeTask::new("polish"), AgentRole::Implement);
1566 node.kind = NodeKind::Loop { max_iters: 2 };
1567 let spec = WorkflowSpec::new(vec![node]);
1568 let run = WorkflowRun::resume(&spec, "sess", &[], &[], &[ResumedCompletion::bare("wf-node0-i0"), ResumedCompletion::bare("wf-node0-i1")],
1569 )
1570 .unwrap();
1571 assert!(run.ready_batch().is_empty());
1572 assert!(run.is_complete(), "max_iters provably exhausted -> node complete");
1573 }
1574
1575 #[test]
1576 fn resume_reapplies_submissions_to_reconstruct_appended_nodes() {
1577 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1581 use crate::types::agent::AgentRole;
1582
1583 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1584 RuntimeTask::new("root"),
1585 AgentRole::Implement,
1586 )]);
1587 let submission = vec![WorkflowNode::new(RuntimeTask::new("discovered"), AgentRole::Implement)];
1588
1589 let run = WorkflowRun::resume(&spec, "sess", &[submission.clone()], &[], &[ResumedCompletion::bare(node_agent_id(0))]).unwrap();
1591 assert_eq!(run.len(), 2, "base node + re-applied submitted node");
1592 assert_eq!(run.ready_batch(), vec![1], "the re-applied appended node is the remaining work");
1593 assert!(!run.is_complete());
1594
1595 let run2 =
1597 WorkflowRun::resume(&spec, "sess", &[submission], &[], &[ResumedCompletion::bare(node_agent_id(0)), ResumedCompletion::bare(node_agent_id(1))]).unwrap();
1598 assert!(run2.ready_batch().is_empty());
1599 assert!(run2.is_complete());
1600 }
1601
1602 #[test]
1603 fn spawn_info_carries_model_hint_and_trust() {
1604 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1605 use crate::types::agent::AgentRole;
1606
1607 let spec = WorkflowSpec::new(vec![
1608 WorkflowNode::new(RuntimeTask::new("read tickets"), AgentRole::Explore)
1609 .quarantined()
1610 .with_model_hint("haiku"),
1611 WorkflowNode::new(RuntimeTask::new("act"), AgentRole::Implement),
1612 ]);
1613 let run = WorkflowRun::new(&spec, "sess").unwrap();
1614
1615 let q = run.spawn_info(0);
1617 assert_eq!(q.trust, "quarantined");
1618 assert_eq!(q.model_hint.as_deref(), Some("haiku"));
1619 let t = run.spawn_info(1);
1621 assert_eq!(t.trust, "trusted");
1622 assert_eq!(t.model_hint, None);
1623 }
1624
1625 #[test]
1626 fn spawn_info_carries_loop_and_classify_hints() {
1627 use crate::orchestration::workflow::{ClassifyBranch, WorkflowNode, WorkflowSpec};
1628 use crate::types::agent::AgentRole;
1629
1630 let spec = WorkflowSpec::new(vec![
1631 WorkflowNode::new(RuntimeTask::new("refine"), AgentRole::Implement).with_loop(3),
1633 WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan).with_classify(vec![
1635 ClassifyBranch { label: "bug".into(), nodes: vec![] },
1636 ClassifyBranch { label: "feature".into(), nodes: vec![] },
1637 ]),
1638 WorkflowNode::new(RuntimeTask::new("act"), AgentRole::Implement),
1640 ]);
1641 let run = WorkflowRun::new(&spec, "sess").unwrap();
1642
1643 let l = run.spawn_info(0);
1644 assert_eq!(l.loop_max_iters, Some(3));
1645 assert!(l.classify_labels.is_empty());
1646 assert_eq!(l.token_budget, None, "no token budget unless set");
1647
1648 let c = run.spawn_info(1);
1649 assert_eq!(c.classify_labels, vec!["bug".to_string(), "feature".to_string()]);
1650 assert_eq!(c.loop_max_iters, None);
1651
1652 let s = run.spawn_info(2);
1653 assert_eq!(s.loop_max_iters, None);
1654 assert!(s.classify_labels.is_empty());
1655 }
1656
1657 #[test]
1658 fn spawn_info_carries_token_budget() {
1659 use crate::orchestration::workflow::{WorkflowNode, WorkflowSpec};
1660 use crate::types::agent::AgentRole;
1661
1662 let spec = WorkflowSpec::new(vec![
1663 WorkflowNode::new(RuntimeTask::new("expensive"), AgentRole::Implement).with_token_budget(10_000),
1664 WorkflowNode::new(RuntimeTask::new("plain"), AgentRole::Implement),
1665 ]);
1666 let run = WorkflowRun::new(&spec, "sess").unwrap();
1667 assert_eq!(run.spawn_info(0).token_budget, Some(10_000));
1668 assert_eq!(run.spawn_info(1).token_budget, None);
1669 }
1670
1671 use crate::orchestration::workflow::{NodeKind, WorkflowNode, WorkflowSpec};
1674 use crate::types::agent::AgentRole;
1675
1676 #[test]
1680 fn tournament_runs_bracket_then_promotes_dependent() {
1681 let spec = WorkflowSpec::new(vec![
1682 WorkflowNode::new(RuntimeTask::new("pick the best ad"), AgentRole::Plan).with_tournament(
1683 vec![
1684 RuntimeTask::new("ad A"),
1685 RuntimeTask::new("ad B"),
1686 RuntimeTask::new("ad C"),
1687 RuntimeTask::new("ad D"),
1688 ],
1689 ),
1690 WorkflowNode::new(RuntimeTask::new("ship the winner"), AgentRole::Implement)
1691 .with_depends_on(vec![0]),
1692 ]);
1693 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1694
1695 let entrants = spawn_round(&mut run);
1698 let entrant_nodes: Vec<usize> = entrants.iter().map(|(n, _)| *n).collect();
1699 assert_eq!(entrant_nodes, vec![2, 3, 4, 5], "4 entrant children, no controller spawn");
1700 assert!(run.spawn_info(2).judge_match.is_none(), "entrants are not judges");
1701 assert!(!run.is_complete());
1702
1703 for (i, (node, id)) in entrants.iter().enumerate() {
1705 run.record_completion(id, done());
1706 if i < 3 {
1707 assert!(run.ready_batch().is_empty(), "no judges until every entrant is in");
1708 }
1709 let _ = node;
1710 }
1711
1712 let r1 = spawn_round(&mut run);
1714 assert_eq!(r1.len(), 2, "two round-1 judges");
1715 let jm0 = run.spawn_info(r1[0].0).judge_match.expect("judge carries a match");
1716 assert_eq!(jm0, JudgeMatch { left: node_agent_id(2), right: node_agent_id(3) });
1717 let jm1 = run.spawn_info(r1[1].0).judge_match.expect("judge carries a match");
1718 assert_eq!(jm1, JudgeMatch { left: node_agent_id(4), right: node_agent_id(5) });
1719
1720 run.record_completion(&r1[0].1, judge_done(&node_agent_id(2)));
1722 run.record_completion(&r1[1].1, judge_done(&node_agent_id(4)));
1723 assert!(run.ready_batch().iter().all(|&n| n != 1), "dependent gated until the final");
1724
1725 let r2 = spawn_round(&mut run);
1727 assert_eq!(r2.len(), 1, "one final judge");
1728 let jmf = run.spawn_info(r2[0].0).judge_match.expect("final judge carries a match");
1729 assert_eq!(jmf, JudgeMatch { left: node_agent_id(2), right: node_agent_id(4) });
1730
1731 run.record_completion(&r2[0].1, judge_done(&node_agent_id(4)));
1733 let winner = run
1734 .graph
1735 .get(0)
1736 .and_then(|n| n.result.as_ref())
1737 .and_then(|r| r.tournament_winner.clone());
1738 assert_eq!(winner.as_deref(), Some(node_agent_id(4).as_str()), "champion recorded");
1739 assert_eq!(run.ready_batch(), vec![1], "dependent unblocks only after the bracket resolves");
1740
1741 let last = spawn_round(&mut run);
1743 assert_eq!(last, vec![(1, node_agent_id(1))]);
1744 run.record_completion(&last[0].1, done());
1745 assert!(run.is_complete());
1746 }
1747
1748 #[test]
1751 fn tournament_with_bye_resolves() {
1752 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1753 RuntimeTask::new("rank"),
1754 AgentRole::Plan,
1755 )
1756 .with_tournament(vec![
1757 RuntimeTask::new("x"),
1758 RuntimeTask::new("y"),
1759 RuntimeTask::new("z"),
1760 ])]);
1761 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1762
1763 let entrants = spawn_round(&mut run); assert_eq!(entrants.len(), 3);
1765 for (_, id) in &entrants {
1766 run.record_completion(id, done());
1767 }
1768 let r1 = spawn_round(&mut run);
1770 assert_eq!(r1.len(), 1, "one match, one bye");
1771 run.record_completion(&r1[0].1, judge_done(&node_agent_id(1)));
1772 let r2 = spawn_round(&mut run);
1774 assert_eq!(r2.len(), 1);
1775 let jm = run.spawn_info(r2[0].0).judge_match.unwrap();
1776 assert_eq!(jm, JudgeMatch { left: node_agent_id(1), right: node_agent_id(3) });
1777 run.record_completion(&r2[0].1, judge_done(&node_agent_id(3)));
1778 let winner = run.graph.get(0).and_then(|n| n.result.as_ref()).and_then(|r| r.tournament_winner.clone());
1779 assert_eq!(winner.as_deref(), Some(node_agent_id(3).as_str()));
1780 assert!(run.is_complete());
1781 }
1782
1783 #[test]
1786 fn tournament_children_inherit_controller_trust() {
1787 let spec = WorkflowSpec::new(vec![WorkflowNode::new(
1788 RuntimeTask::new("judge untrusted inputs"),
1789 AgentRole::Plan,
1790 )
1791 .quarantined()
1792 .with_tournament(vec![RuntimeTask::new("a"), RuntimeTask::new("b")])]);
1793 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1794
1795 let entrants = spawn_round(&mut run);
1796 for (node, _) in &entrants {
1797 assert_eq!(run.spawn_info(*node).trust, "quarantined", "entrant inherits quarantine");
1798 assert!(!run.quarantine_violation(*node), "read-only entrant is quarantine-clean");
1799 }
1800 for (_, id) in &entrants {
1801 run.record_completion(id, done());
1802 }
1803 let r1 = spawn_round(&mut run);
1804 assert_eq!(run.spawn_info(r1[0].0).trust, "quarantined", "judge inherits quarantine");
1805 assert!(!run.quarantine_violation(r1[0].0));
1806 }
1807
1808 #[test]
1811 fn tournament_controller_never_spawns_itself() {
1812 let spec = WorkflowSpec::new(vec![WorkflowNode::new(RuntimeTask::new("c"), AgentRole::Plan)
1813 .with_tournament(vec![RuntimeTask::new("a"), RuntimeTask::new("b")])]);
1814 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1815 assert!(matches!(run.nodes[0].kind, NodeKind::Tournament { .. }));
1816 let first = spawn_round(&mut run);
1817 assert!(first.iter().all(|(n, _)| *n != 0), "controller node 0 never spawns directly");
1818 }
1819
1820 use crate::orchestration::workflow::ClassifyBranch;
1823
1824 fn classify_spec() -> WorkflowSpec {
1825 let classifier = WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan)
1827 .with_classify(vec![
1828 ClassifyBranch { label: "a".to_string(), nodes: vec![1] },
1829 ClassifyBranch { label: "b".to_string(), nodes: vec![2] },
1830 ]);
1831 WorkflowSpec::new(vec![
1832 classifier,
1833 WorkflowNode::new(RuntimeTask::new("on a"), AgentRole::Implement).with_depends_on(vec![0]),
1834 WorkflowNode::new(RuntimeTask::new("on b"), AgentRole::Implement).with_depends_on(vec![0]),
1835 ])
1836 }
1837
1838 #[test]
1839 fn resume_replays_classify_prune_from_recorded_branch() {
1840 let run = WorkflowRun::resume(
1843 &classify_spec(),
1844 "sess",
1845 &[],
1846 &[],
1847 &[ResumedCompletion {
1848 agent_id: "wf-node0".to_string(),
1849 classify_branch: Some("a".to_string()),
1850 ..ResumedCompletion::default()
1851 }],
1852 )
1853 .unwrap();
1854 assert_eq!(run.ready_batch(), vec![1], "only the chosen branch is armed");
1855 let (_, failed) = run.outcome();
1856 assert_eq!(failed, vec!["wf-node2"], "rejected branch stays pruned across resume");
1857 }
1858
1859 #[test]
1860 fn resume_with_signalless_classify_record_prunes_all_branches() {
1861 let run = WorkflowRun::resume(
1864 &classify_spec(),
1865 "sess",
1866 &[],
1867 &[],
1868 &[ResumedCompletion::bare("wf-node0")],
1869 )
1870 .unwrap();
1871 assert!(run.ready_batch().is_empty());
1872 let (_, failed) = run.outcome();
1873 assert_eq!(failed, vec!["wf-node1", "wf-node2"]);
1874 assert!(run.is_complete());
1875 }
1876
1877 #[test]
1878 fn resume_honors_recorded_loop_stop() {
1879 let mut node = WorkflowNode::new(RuntimeTask::new("polish"), AgentRole::Implement);
1882 node.kind = NodeKind::Loop { max_iters: 3 };
1883 let spec = WorkflowSpec::new(vec![node]);
1884 let run = WorkflowRun::resume(
1885 &spec,
1886 "sess",
1887 &[],
1888 &[],
1889 &[ResumedCompletion {
1890 agent_id: "wf-node0-i0".to_string(),
1891 loop_continue: Some(false),
1892 ..ResumedCompletion::default()
1893 }],
1894 )
1895 .unwrap();
1896 assert!(run.ready_batch().is_empty(), "no re-run of the stopped loop");
1897 assert!(run.is_complete());
1898 }
1899
1900 #[test]
1901 fn errored_tournament_child_is_failed_and_no_champion_fails_controller() {
1902 let spec = WorkflowSpec::new(vec![
1905 WorkflowNode::new(RuntimeTask::new("pick"), AgentRole::Plan)
1906 .with_tournament(vec![RuntimeTask::new("x"), RuntimeTask::new("y")]),
1907 WorkflowNode::new(RuntimeTask::new("use winner"), AgentRole::Implement)
1908 .with_depends_on(vec![0]),
1909 ]);
1910 let mut run = WorkflowRun::new(&spec, "sess").unwrap();
1911 let entrants = spawn_round(&mut run);
1912 assert_eq!(entrants.len(), 2);
1913 run.record_completion(&entrants[0].1, done());
1914 run.record_completion(
1915 &entrants[1].1,
1916 LoopResult { termination: TerminationReason::Error, ..done() },
1917 );
1918 let judges = spawn_round(&mut run);
1920 assert_eq!(judges.len(), 1, "one match for two entrants");
1921 run.record_completion(&judges[0].1, done()); let (_, failed) = run.outcome();
1923 assert!(failed.contains(&entrants[1].1), "errored entrant reported failed");
1924 assert!(failed.contains(&"wf-node0".to_string()), "no-champion controller failed");
1925 assert!(
1926 !run.ready_batch().contains(&1),
1927 "dependent of the failed controller starves"
1928 );
1929 }
1930
1931 #[test]
1932 fn submitted_tournament_with_one_entrant_fails_instead_of_stalling() {
1933 let mut run = fanout2();
1936 let controller = WorkflowNode::new(RuntimeTask::new("pick"), AgentRole::Plan)
1937 .with_tournament(vec![RuntimeTask::new("only")]);
1938 let ids = run.submit_nodes(vec![controller]);
1939 run.expand_ready_controllers();
1940 let (_, failed) = run.outcome();
1941 assert_eq!(failed, vec![node_agent_id(ids[0])]);
1942 }
1943
1944 #[test]
1945 fn submitted_classify_branch_gains_classifier_dependency() {
1946 let mut run = fanout2();
1949 let classifier = WorkflowNode::new(RuntimeTask::new("route"), AgentRole::Plan)
1950 .with_classify(vec![ClassifyBranch { label: "a".to_string(), nodes: vec![1] }]);
1951 let branch = WorkflowNode::new(RuntimeTask::new("on a"), AgentRole::Implement);
1952 let ids = run.submit_nodes(vec![classifier, branch]);
1953 let ready = run.ready_batch();
1954 assert!(ready.contains(&ids[0]), "classifier ready");
1955 assert!(!ready.contains(&ids[1]), "branch gated behind the classifier");
1956 run.mark_spawned(ids[0], &node_agent_id(ids[0]));
1958 run.record_completion(
1959 &node_agent_id(ids[0]),
1960 LoopResult { classify_branch: Some("a".to_string()), ..done() },
1961 );
1962 assert!(run.ready_batch().contains(&ids[1]));
1963 }
1964
1965 #[test]
1966 fn submitted_zero_iter_loop_is_floored_to_one() {
1967 let mut run = fanout2();
1969 let mut node = WorkflowNode::new(RuntimeTask::new("once"), AgentRole::Implement);
1970 node.kind = NodeKind::Loop { max_iters: 0 };
1971 let ids = run.submit_nodes(vec![node]);
1972 assert_eq!(run.nodes[ids[0]].kind, NodeKind::Loop { max_iters: 1 });
1973 }
1974
1975 #[test]
1976 fn spawn_info_carries_dep_ids_and_per_node_caps() {
1977 let spec = WorkflowSpec::new(vec![
1980 WorkflowNode::new(RuntimeTask::new("w"), AgentRole::Explore),
1981 WorkflowNode::new(RuntimeTask::new("synth"), AgentRole::Plan)
1982 .with_depends_on(vec![0])
1983 .with_max_turns(4)
1984 .with_max_wall_ms(30_000),
1985 ]);
1986 let run = WorkflowRun::new(&spec, "sess").unwrap();
1987 let info = run.spawn_info(1);
1988 assert_eq!(info.input_agent_ids, vec!["wf-node0"]);
1989 assert_eq!(info.max_turns, Some(4));
1990 assert_eq!(info.max_wall_ms, Some(30_000));
1991 assert!(info.reducer.is_none(), "plain node stays non-reduce");
1992 let root = run.spawn_info(0);
1993 assert!(root.input_agent_ids.is_empty());
1994 assert_eq!(root.max_turns, None);
1995 }
1996}