1mod js_authoring;
8mod model_policy;
9mod replay;
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17pub use js_authoring::{
18 JavascriptWorkflowError, JavascriptWorkflowResult, compile_javascript_workflow,
19 compile_typescript_workflow,
20};
21pub use model_policy::*;
22pub use replay::*;
23
24pub const DEFAULT_FLEET_WORKFLOW_MAX_AGENTS: usize = 100;
25pub const DEFAULT_FLEET_WORKFLOW_MAX_DEPTH: usize = 5;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct WorkflowConfig {
29 pub goal: String,
30 #[serde(default = "default_max_concurrent")]
31 pub max_concurrent: u8,
32 #[serde(default)]
33 pub description: Option<String>,
34 #[serde(default)]
35 pub phases: Vec<Phase>,
36}
37
38impl WorkflowConfig {
39 pub fn validate(&self) -> Result<(), WorkflowValidationError> {
40 WorkflowPlan::from_config(self).map(|_| ())
41 }
42
43 pub fn compile(&self) -> Result<WorkflowPlan, WorkflowValidationError> {
44 WorkflowPlan::from_config(self)
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct WorkflowSpec {
50 #[serde(default)]
51 pub id: Option<String>,
52 pub goal: String,
53 #[serde(default)]
54 pub description: Option<String>,
55 #[serde(default)]
56 pub budget: BudgetSpec,
57 #[serde(default)]
58 pub permissions: PermissionSpec,
59 #[serde(default)]
60 pub model_policy: ModelPolicy,
61 #[serde(default)]
62 pub promotion_policy: PromotionPolicy,
63 #[serde(default)]
64 pub nodes: Vec<WorkflowNode>,
65}
66
67impl WorkflowSpec {
68 pub fn validate_for_fleet(&self) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
69 self.validate_for_fleet_with_limits(WorkflowFleetLimits::default())
70 }
71
72 pub fn validate_for_fleet_with_limits(
73 &self,
74 limits: WorkflowFleetLimits,
75 ) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
76 validate_workflow_nodes(&self.nodes)
77 .map_err(|source| WorkflowFleetLimitError::InvalidWorkflow { source })?;
78 let shape = estimate_fleet_shape(&self.nodes)?;
79 if shape.total_agents > limits.max_total_agents {
80 return Err(WorkflowFleetLimitError::TooManyAgents {
81 total_agents: shape.total_agents,
82 max_total_agents: limits.max_total_agents,
83 });
84 }
85 if shape.max_depth > limits.max_depth {
86 return Err(WorkflowFleetLimitError::RecursionTooDeep {
87 depth: shape.max_depth,
88 max_depth: limits.max_depth,
89 });
90 }
91 Ok(shape)
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(tag = "kind", content = "spec", rename_all = "snake_case")]
97pub enum WorkflowNode {
98 BranchSet(BranchSpec),
99 Leaf(LeafSpec),
100 Sequence(SequenceSpec),
101 Reduce(ReduceSpec),
102 TeacherReview(TeacherReviewSpec),
103 LoopUntil(LoopUntilSpec),
104 Cond(CondSpec),
105 Expand(ExpandSpec),
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct BranchSpec {
110 pub id: String,
111 #[serde(default)]
112 pub description: Option<String>,
113 #[serde(default)]
114 pub parallel: bool,
115 #[serde(default)]
116 pub budget: BudgetSpec,
117 #[serde(default)]
118 pub permissions: PermissionSpec,
119 #[serde(default)]
120 pub model_policy: ModelPolicy,
121 #[serde(default)]
122 pub children: Vec<WorkflowNode>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct LeafSpec {
127 pub id: String,
128 pub prompt: String,
129 #[serde(default)]
130 pub agent_type: AgentType,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub profile: Option<String>,
137 #[serde(default)]
138 pub mode: TaskMode,
139 #[serde(default)]
140 pub isolation: IsolationMode,
141 #[serde(default)]
142 pub file_scope: Vec<String>,
143 #[serde(default)]
144 pub depends_on_results: Vec<String>,
145 #[serde(default)]
146 pub budget: BudgetSpec,
147 #[serde(default)]
148 pub permissions: PermissionSpec,
149 #[serde(default)]
150 pub model_policy: ModelPolicy,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct SequenceSpec {
155 pub id: String,
156 #[serde(default)]
157 pub children: Vec<WorkflowNode>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ReduceSpec {
162 pub id: String,
163 #[serde(default)]
164 pub inputs: Vec<String>,
165 pub prompt: String,
166 #[serde(default)]
167 pub model_policy: ModelPolicy,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct TeacherReviewSpec {
172 pub id: String,
173 #[serde(default)]
174 pub candidates: Vec<String>,
175 #[serde(default)]
176 pub promotion_policy: PromotionPolicy,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct LoopUntilSpec {
181 pub id: String,
182 pub condition: String,
183 #[serde(default)]
184 pub max_iterations: Option<u32>,
185 #[serde(default)]
186 pub children: Vec<WorkflowNode>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct CondSpec {
191 pub id: String,
192 pub condition: String,
193 #[serde(default)]
194 pub then_nodes: Vec<WorkflowNode>,
195 #[serde(default)]
196 pub else_nodes: Vec<WorkflowNode>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct ExpandSpec {
201 pub id: String,
202 pub source: String,
203 #[serde(default)]
204 pub max_children: Option<usize>,
205 #[serde(default)]
206 pub template: Option<Box<WorkflowNode>>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
210pub struct BudgetSpec {
211 #[serde(default)]
212 pub max_steps: Option<u32>,
213 #[serde(default)]
214 pub timeout_secs: Option<u64>,
215 #[serde(default)]
216 pub max_parallel: Option<u8>,
217 #[serde(default)]
218 pub max_tokens: Option<u64>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
222pub struct PermissionSpec {
223 #[serde(default)]
224 pub allow_write: bool,
225 #[serde(default)]
226 pub allow_network: bool,
227 #[serde(default)]
228 pub allowed_tools: Vec<String>,
229 #[serde(default)]
230 pub file_scope: Vec<String>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
234pub struct ModelPolicy {
235 #[serde(default)]
236 pub provider: Option<String>,
237 #[serde(default)]
238 pub model: Option<String>,
239 #[serde(default)]
240 pub fallback_models: Vec<String>,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
244pub struct PromotionPolicy {
245 #[serde(default)]
246 pub strategy: PromotionStrategy,
247 #[serde(default)]
248 pub require_teacher_review: bool,
249 #[serde(default)]
250 pub min_successful_branches: Option<u32>,
251 #[serde(default)]
252 pub promotion_gate: PromotionGate,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
256#[serde(rename_all = "snake_case")]
257pub enum PromotionStrategy {
258 #[default]
259 All,
260 FirstSuccess,
261 BestScore,
262 TeacherSelected,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct WorkflowPlan {
267 goal: String,
268 max_concurrent: u8,
269 phases: Vec<PhasePlan>,
270}
271
272impl WorkflowPlan {
273 pub fn from_config(config: &WorkflowConfig) -> Result<Self, WorkflowValidationError> {
274 validate_non_empty("workflow goal", &config.goal)?;
275 if !(1..=20).contains(&config.max_concurrent) {
276 return Err(WorkflowValidationError::InvalidMaxConcurrent {
277 value: config.max_concurrent,
278 });
279 }
280 if config.phases.is_empty() {
281 return Err(WorkflowValidationError::EmptyWorkflow);
282 }
283
284 let mut phase_indices = BTreeMap::new();
285 let mut all_tasks = BTreeMap::new();
286 let mut task_phase = BTreeMap::new();
287
288 for (phase_index, phase) in config.phases.iter().enumerate() {
289 validate_non_empty("phase name", &phase.name)?;
290 if phase.tasks.is_empty() {
291 return Err(WorkflowValidationError::EmptyPhase {
292 phase: phase.name.clone(),
293 });
294 }
295 if phase_indices
296 .insert(phase.name.clone(), phase_index)
297 .is_some()
298 {
299 return Err(WorkflowValidationError::DuplicatePhase {
300 phase: phase.name.clone(),
301 });
302 }
303
304 for task in &phase.tasks {
305 validate_non_empty("task id", &task.id)?;
306 validate_non_empty("task prompt", &task.prompt)?;
307 if all_tasks.insert(task.id.clone(), task).is_some() {
308 return Err(WorkflowValidationError::DuplicateTask {
309 task: task.id.clone(),
310 });
311 }
312 task_phase.insert(task.id.clone(), phase.name.clone());
313 }
314 }
315
316 for phase in &config.phases {
317 for dependency in &phase.depends_on {
318 if dependency == &phase.name || !phase_indices.contains_key(dependency) {
319 return Err(WorkflowValidationError::InvalidPhaseDependency {
320 phase: phase.name.clone(),
321 dependency: dependency.clone(),
322 });
323 }
324 }
325 validate_parallel_write_scope(phase)?;
326 }
327
328 let ordered_phase_names = ordered_phases(config, &phase_indices)?;
329 let phase_order: BTreeMap<_, _> = ordered_phase_names
330 .iter()
331 .enumerate()
332 .map(|(index, phase)| (phase.clone(), index))
333 .collect();
334
335 for phase in &config.phases {
336 for task in &phase.tasks {
337 for dependency in &task.depends_on_results {
338 let Some(dependency_phase) = task_phase.get(dependency) else {
339 return Err(WorkflowValidationError::InvalidTaskResultDependency {
340 task: task.id.clone(),
341 dependency: dependency.clone(),
342 });
343 };
344 if phase_order[dependency_phase] >= phase_order[&phase.name] {
345 return Err(WorkflowValidationError::UnavailableTaskResultDependency {
346 task: task.id.clone(),
347 dependency: dependency.clone(),
348 dependency_phase: dependency_phase.clone(),
349 task_phase: phase.name.clone(),
350 });
351 }
352 }
353 }
354 }
355
356 let phases = ordered_phase_names
357 .iter()
358 .map(|phase_name| {
359 let phase = &config.phases[phase_indices[phase_name]];
360 PhasePlan {
361 name: phase.name.clone(),
362 parallel: phase.parallel,
363 on_failure: phase.on_failure,
364 tasks: phase.tasks.clone(),
365 }
366 })
367 .collect();
368
369 Ok(Self {
370 goal: config.goal.clone(),
371 max_concurrent: config.max_concurrent,
372 phases,
373 })
374 }
375
376 pub fn goal(&self) -> &str {
377 &self.goal
378 }
379
380 pub fn max_concurrent(&self) -> u8 {
381 self.max_concurrent
382 }
383
384 pub fn phases(&self) -> &[PhasePlan] {
385 &self.phases
386 }
387
388 pub fn phase_names(&self) -> impl Iterator<Item = &str> {
389 self.phases.iter().map(|phase| phase.name.as_str())
390 }
391}
392
393pub type WorkflowIr = WorkflowPlan;
394
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct PhasePlan {
397 pub name: String,
398 pub parallel: bool,
399 pub on_failure: FailurePolicy,
400 pub tasks: Vec<Task>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct Phase {
405 pub name: String,
406 #[serde(default)]
407 pub description: Option<String>,
408 #[serde(default)]
409 pub depends_on: Vec<String>,
410 #[serde(default)]
411 pub parallel: bool,
412 #[serde(default)]
413 pub on_failure: FailurePolicy,
414 #[serde(default)]
415 pub tasks: Vec<Task>,
416}
417
418pub type WorkflowPhase = Phase;
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
421#[serde(rename_all = "snake_case")]
422pub enum FailurePolicy {
423 #[default]
424 SkipContinue,
425 Abort,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct Task {
430 pub id: String,
431 pub prompt: String,
432 #[serde(default)]
433 pub agent_type: AgentType,
434 #[serde(default)]
435 pub mode: TaskMode,
436 #[serde(default)]
437 pub isolation: IsolationMode,
438 #[serde(default)]
439 pub file_scope: Vec<String>,
440 #[serde(default)]
441 pub depends_on_results: Vec<String>,
442 #[serde(default)]
443 pub max_steps: Option<u32>,
444 #[serde(default)]
445 pub timeout_secs: Option<u64>,
446}
447
448pub type WorkflowTask = Task;
449pub type WorkflowRole = AgentType;
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
452#[serde(rename_all = "snake_case")]
453pub enum AgentType {
454 #[default]
455 General,
456 Explore,
457 Plan,
458 Review,
459 Implementer,
460 Verifier,
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
464#[serde(rename_all = "snake_case")]
465pub enum TaskMode {
466 #[default]
467 ReadOnly,
468 ReadWrite,
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
472#[serde(rename_all = "snake_case")]
473pub enum IsolationMode {
474 #[default]
475 Shared,
476 Worktree,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480pub struct BranchResult {
481 pub branch_id: String,
482 pub task_id: String,
483 pub status: WorkflowRunStatus,
484 #[serde(default)]
485 pub usage: WorkflowUsage,
486 #[serde(default)]
487 pub memo_usage: WorkflowMemoUsage,
488 #[serde(default)]
489 pub artifacts: Vec<String>,
490 #[serde(default)]
491 pub notes: Option<String>,
492}
493
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495pub struct LeafResult {
496 pub leaf_id: String,
497 pub task_id: String,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub profile: Option<String>,
501 pub status: WorkflowRunStatus,
502 #[serde(default)]
503 pub usage: WorkflowUsage,
504 #[serde(default)]
505 pub memo_usage: WorkflowMemoUsage,
506 #[serde(default)]
507 pub output: Option<String>,
508 #[serde(default)]
509 pub artifacts: Vec<String>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub schema_error: Option<String>,
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
516pub struct WorkflowUsage {
517 #[serde(default)]
518 pub input_tokens: u64,
519 #[serde(default)]
520 pub output_tokens: u64,
521 #[serde(default)]
522 pub cost_microusd: u64,
523}
524
525impl WorkflowUsage {
526 #[must_use]
527 pub fn total_tokens(self) -> u64 {
528 self.input_tokens.saturating_add(self.output_tokens)
529 }
530
531 pub(crate) fn add_assign(&mut self, other: Self) {
532 self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
533 self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
534 self.cost_microusd = self.cost_microusd.saturating_add(other.cost_microusd);
535 }
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
539pub struct WorkflowMemoUsage {
540 #[serde(default)]
541 pub armh_hits: u64,
542 #[serde(default)]
543 pub armh_misses: u64,
544 #[serde(default)]
545 pub armh_saved_estimated_tokens: u64,
546 #[serde(default)]
547 pub provider_prompt_cache_hits: u64,
548 #[serde(default)]
549 pub provider_prompt_cache_misses: u64,
550}
551
552impl WorkflowMemoUsage {
553 pub(crate) fn add_assign(&mut self, other: Self) {
554 self.armh_hits = self.armh_hits.saturating_add(other.armh_hits);
555 self.armh_misses = self.armh_misses.saturating_add(other.armh_misses);
556 self.armh_saved_estimated_tokens = self
557 .armh_saved_estimated_tokens
558 .saturating_add(other.armh_saved_estimated_tokens);
559 self.provider_prompt_cache_hits = self
560 .provider_prompt_cache_hits
561 .saturating_add(other.provider_prompt_cache_hits);
562 self.provider_prompt_cache_misses = self
563 .provider_prompt_cache_misses
564 .saturating_add(other.provider_prompt_cache_misses);
565 }
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
569pub struct ControlNodeResult {
570 pub node_id: String,
571 pub kind: ControlNodeKind,
572 pub status: WorkflowRunStatus,
573 #[serde(default)]
574 pub selected_children: Vec<String>,
575 #[serde(default)]
576 pub summary: Option<String>,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
580#[serde(rename_all = "snake_case")]
581pub enum WorkflowRunStatus {
582 #[default]
583 Pending,
584 Running,
585 Succeeded,
586 Failed,
587 Cancelled,
588 BudgetExceeded,
589 ReplayDiverged,
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
593#[serde(rename_all = "snake_case")]
594pub enum ControlNodeKind {
595 BranchSet,
596 Leaf,
597 Sequence,
598 Reduce,
599 TeacherReview,
600 LoopUntil,
601 Cond,
602 Expand,
603}
604
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
606pub struct WorkflowExecution {
607 pub status: WorkflowRunStatus,
608 #[serde(default)]
609 pub usage: WorkflowUsage,
610 #[serde(default)]
611 pub memo_usage: WorkflowMemoUsage,
612 #[serde(default)]
613 pub leaf_results: Vec<LeafResult>,
614 #[serde(default)]
615 pub branch_results: Vec<BranchResult>,
616 #[serde(default)]
617 pub control_node_results: Vec<ControlNodeResult>,
618}
619
620impl Default for WorkflowExecution {
621 fn default() -> Self {
622 Self {
623 status: WorkflowRunStatus::Succeeded,
624 usage: WorkflowUsage::default(),
625 memo_usage: WorkflowMemoUsage::default(),
626 leaf_results: Vec::new(),
627 branch_results: Vec::new(),
628 control_node_results: Vec::new(),
629 }
630 }
631}
632
633impl WorkflowExecution {
634 pub fn mark_failed(&mut self) {
635 self.status = WorkflowRunStatus::Failed;
636 }
637
638 pub fn mark_cancelled(&mut self) {
639 self.status = WorkflowRunStatus::Cancelled;
640 }
641
642 pub fn mark_budget_exceeded(&mut self) {
643 self.status = WorkflowRunStatus::BudgetExceeded;
644 }
645
646 pub(crate) fn mark_replay_diverged(&mut self) {
647 self.status = WorkflowRunStatus::ReplayDiverged;
648 }
649
650 fn should_stop_mock_execution(&self) -> bool {
651 matches!(
652 self.status,
653 WorkflowRunStatus::Cancelled | WorkflowRunStatus::BudgetExceeded
654 )
655 }
656}
657
658#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
659pub struct MockLeafOutcome {
660 pub status: WorkflowRunStatus,
661 #[serde(default)]
662 pub usage: WorkflowUsage,
663 #[serde(default)]
664 pub memo_usage: WorkflowMemoUsage,
665 #[serde(default)]
666 pub output: Option<String>,
667 #[serde(default)]
668 pub artifacts: Vec<String>,
669}
670
671impl MockLeafOutcome {
672 pub fn succeeded(output: impl Into<String>) -> Self {
673 Self {
674 status: WorkflowRunStatus::Succeeded,
675 usage: WorkflowUsage::default(),
676 memo_usage: WorkflowMemoUsage::default(),
677 output: Some(output.into()),
678 artifacts: Vec::new(),
679 }
680 }
681
682 pub fn failed(output: impl Into<String>) -> Self {
683 Self {
684 status: WorkflowRunStatus::Failed,
685 usage: WorkflowUsage::default(),
686 memo_usage: WorkflowMemoUsage::default(),
687 output: Some(output.into()),
688 artifacts: Vec::new(),
689 }
690 }
691
692 pub fn with_usage(mut self, usage: WorkflowUsage) -> Self {
693 self.usage = usage;
694 self
695 }
696
697 pub fn with_memo_usage(mut self, memo_usage: WorkflowMemoUsage) -> Self {
698 self.memo_usage = memo_usage;
699 self
700 }
701}
702
703#[derive(Debug, Default, Clone)]
704pub struct MockWorkflowExecutor {
705 leaf_outcomes: BTreeMap<String, MockLeafOutcome>,
706 predicate_results: BTreeMap<String, Vec<bool>>,
707 generated_nodes: BTreeMap<String, Vec<WorkflowNode>>,
708 cancelled: bool,
709 max_leaf_steps: Option<u32>,
710 leaf_steps_executed: u32,
711 max_leaf_tokens: Option<u64>,
712 leaf_tokens_used: u64,
713}
714
715impl MockWorkflowExecutor {
716 pub fn new() -> Self {
717 Self::default()
718 }
719
720 pub fn with_leaf_outcome(
721 mut self,
722 leaf_id: impl Into<String>,
723 outcome: MockLeafOutcome,
724 ) -> Self {
725 self.leaf_outcomes.insert(leaf_id.into(), outcome);
726 self
727 }
728
729 pub fn with_predicate_results(
730 mut self,
731 node_id: impl Into<String>,
732 results: Vec<bool>,
733 ) -> Self {
734 self.predicate_results.insert(node_id.into(), results);
735 self
736 }
737
738 pub fn with_generated_nodes(
739 mut self,
740 node_id: impl Into<String>,
741 nodes: Vec<WorkflowNode>,
742 ) -> Self {
743 self.generated_nodes.insert(node_id.into(), nodes);
744 self
745 }
746
747 pub fn with_cancelled(mut self) -> Self {
748 self.cancelled = true;
749 self
750 }
751
752 pub fn with_max_leaf_steps(mut self, max_leaf_steps: u32) -> Self {
753 self.max_leaf_steps = Some(max_leaf_steps);
754 self
755 }
756
757 pub fn with_max_leaf_tokens(mut self, max_leaf_tokens: u64) -> Self {
758 self.max_leaf_tokens = Some(max_leaf_tokens);
759 self
760 }
761
762 pub fn run(
763 &mut self,
764 spec: &WorkflowSpec,
765 ) -> Result<WorkflowExecution, WorkflowExecutionError> {
766 validate_workflow_nodes(&spec.nodes)?;
767 let mut execution = WorkflowExecution::default();
768 self.execute_nodes(&spec.nodes, &mut execution)?;
769 Ok(execution)
770 }
771
772 fn execute_nodes(
773 &mut self,
774 nodes: &[WorkflowNode],
775 execution: &mut WorkflowExecution,
776 ) -> Result<(), WorkflowExecutionError> {
777 for node in nodes {
778 if execution.should_stop_mock_execution() {
779 break;
780 }
781 self.execute_node(node, execution)?;
782 }
783 Ok(())
784 }
785
786 fn execute_node(
787 &mut self,
788 node: &WorkflowNode,
789 execution: &mut WorkflowExecution,
790 ) -> Result<(), WorkflowExecutionError> {
791 match node {
792 WorkflowNode::BranchSet(spec) => self.execute_branch_set(spec, execution),
793 WorkflowNode::Leaf(spec) => {
794 self.execute_leaf(spec, execution);
795 Ok(())
796 }
797 WorkflowNode::Sequence(spec) => {
798 self.execute_nodes(&spec.children, execution)?;
799 execution.control_node_results.push(ControlNodeResult {
800 node_id: spec.id.clone(),
801 kind: ControlNodeKind::Sequence,
802 status: execution.status,
803 selected_children: spec.children.iter().map(node_id).collect(),
804 summary: Some("sequence executed in declaration order".to_string()),
805 });
806 Ok(())
807 }
808 WorkflowNode::Reduce(spec) => {
809 execution.control_node_results.push(ControlNodeResult {
810 node_id: spec.id.clone(),
811 kind: ControlNodeKind::Reduce,
812 status: WorkflowRunStatus::Succeeded,
813 selected_children: spec.inputs.clone(),
814 summary: Some(spec.prompt.clone()),
815 });
816 Ok(())
817 }
818 WorkflowNode::TeacherReview(spec) => {
819 execution.control_node_results.push(ControlNodeResult {
820 node_id: spec.id.clone(),
821 kind: ControlNodeKind::TeacherReview,
822 status: WorkflowRunStatus::Succeeded,
823 selected_children: spec.candidates.clone(),
824 summary: Some(
825 "teacher review scaffold selected declared candidates".to_string(),
826 ),
827 });
828 Ok(())
829 }
830 WorkflowNode::LoopUntil(spec) => self.execute_loop_until(spec, execution),
831 WorkflowNode::Cond(spec) => self.execute_cond(spec, execution),
832 WorkflowNode::Expand(spec) => self.execute_expand(spec, execution),
833 }
834 }
835
836 fn execute_branch_set(
837 &mut self,
838 spec: &BranchSpec,
839 execution: &mut WorkflowExecution,
840 ) -> Result<(), WorkflowExecutionError> {
841 let before = execution.leaf_results.len();
842 self.execute_nodes(&spec.children, execution)?;
843 let status = aggregate_mock_status(&execution.leaf_results[before..]);
844 let mut usage = WorkflowUsage::default();
845 let mut memo_usage = WorkflowMemoUsage::default();
846 for result in &execution.leaf_results[before..] {
847 usage.add_assign(result.usage);
848 memo_usage.add_assign(result.memo_usage);
849 }
850 mark_execution_for_status(execution, status);
851 execution.branch_results.push(BranchResult {
852 branch_id: spec.id.clone(),
853 task_id: spec.id.clone(),
854 status,
855 usage,
856 memo_usage,
857 artifacts: Vec::new(),
858 notes: Some("mock branch set executed without runtime fanout".to_string()),
859 });
860 execution.control_node_results.push(ControlNodeResult {
861 node_id: spec.id.clone(),
862 kind: ControlNodeKind::BranchSet,
863 status,
864 selected_children: spec.children.iter().map(node_id).collect(),
865 summary: Some("branch set scaffold executed children deterministically".to_string()),
866 });
867 Ok(())
868 }
869
870 fn execute_leaf(&mut self, spec: &LeafSpec, execution: &mut WorkflowExecution) {
871 let outcome = self.mock_leaf_outcome(spec);
872 mark_execution_for_status(execution, outcome.status);
873 execution.usage.add_assign(outcome.usage);
874 execution.memo_usage.add_assign(outcome.memo_usage);
875 execution.leaf_results.push(LeafResult {
876 leaf_id: spec.id.clone(),
877 task_id: spec.id.clone(),
878 profile: spec.profile.clone(),
879 status: outcome.status,
880 usage: outcome.usage,
881 memo_usage: outcome.memo_usage,
882 output: outcome.output,
883 artifacts: outcome.artifacts,
884 schema_error: None,
885 });
886 }
887
888 fn execute_loop_until(
889 &mut self,
890 spec: &LoopUntilSpec,
891 execution: &mut WorkflowExecution,
892 ) -> Result<(), WorkflowExecutionError> {
893 let max_iterations = spec.max_iterations.unwrap_or(1).max(1);
894 let mut iterations = 0;
895 let mut passed = false;
896 while iterations < max_iterations {
897 if execution.should_stop_mock_execution() {
898 break;
899 }
900 iterations += 1;
901 self.execute_nodes(&spec.children, execution)?;
902 if execution.should_stop_mock_execution() {
903 break;
904 }
905 if self.next_predicate_result(&spec.id) {
906 passed = true;
907 break;
908 }
909 }
910 let status = if execution.should_stop_mock_execution() {
911 execution.status
912 } else if passed {
913 WorkflowRunStatus::Succeeded
914 } else {
915 WorkflowRunStatus::Failed
916 };
917 mark_execution_for_status(execution, status);
918 execution.control_node_results.push(ControlNodeResult {
919 node_id: spec.id.clone(),
920 kind: ControlNodeKind::LoopUntil,
921 status,
922 selected_children: spec.children.iter().map(node_id).collect(),
923 summary: Some(format!("loop_until iterations={iterations}")),
924 });
925 Ok(())
926 }
927
928 fn execute_cond(
929 &mut self,
930 spec: &CondSpec,
931 execution: &mut WorkflowExecution,
932 ) -> Result<(), WorkflowExecutionError> {
933 let passed = self.next_predicate_result(&spec.id);
934 let selected_nodes = if passed {
935 &spec.then_nodes
936 } else {
937 &spec.else_nodes
938 };
939 self.execute_nodes(selected_nodes, execution)?;
940 let status = if execution.should_stop_mock_execution() {
941 execution.status
942 } else {
943 WorkflowRunStatus::Succeeded
944 };
945 execution.control_node_results.push(ControlNodeResult {
946 node_id: spec.id.clone(),
947 kind: ControlNodeKind::Cond,
948 status,
949 selected_children: selected_nodes.iter().map(node_id).collect(),
950 summary: Some(format!("predicate_result={passed}")),
951 });
952 Ok(())
953 }
954
955 fn execute_expand(
956 &mut self,
957 spec: &ExpandSpec,
958 execution: &mut WorkflowExecution,
959 ) -> Result<(), WorkflowExecutionError> {
960 let mut nodes = self.generated_nodes.remove(&spec.id).unwrap_or_default();
961 if let Some(max_children) = spec.max_children {
962 nodes.truncate(max_children);
963 }
964 validate_workflow_node_shapes(&nodes)?;
965 self.execute_nodes(&nodes, execution)?;
966 let status = if execution.should_stop_mock_execution() {
967 execution.status
968 } else {
969 WorkflowRunStatus::Succeeded
970 };
971 execution.control_node_results.push(ControlNodeResult {
972 node_id: spec.id.clone(),
973 kind: ControlNodeKind::Expand,
974 status,
975 selected_children: nodes.iter().map(node_id).collect(),
976 summary: Some(format!("expanded_from={}", spec.source)),
977 });
978 Ok(())
979 }
980
981 fn mock_leaf_outcome(&mut self, spec: &LeafSpec) -> MockLeafOutcome {
982 if self.cancelled {
983 return MockLeafOutcome {
984 status: WorkflowRunStatus::Cancelled,
985 usage: WorkflowUsage::default(),
986 memo_usage: WorkflowMemoUsage::default(),
987 output: Some("mock workflow cancelled before leaf execution".to_string()),
988 artifacts: Vec::new(),
989 };
990 }
991 if self.max_leaf_steps == Some(self.leaf_steps_executed) || spec.budget.max_steps == Some(0)
992 {
993 return MockLeafOutcome {
994 status: WorkflowRunStatus::BudgetExceeded,
995 usage: WorkflowUsage::default(),
996 memo_usage: WorkflowMemoUsage::default(),
997 output: Some("mock workflow leaf step budget exhausted".to_string()),
998 artifacts: Vec::new(),
999 };
1000 }
1001 if self
1002 .max_leaf_tokens
1003 .is_some_and(|max| self.leaf_tokens_used >= max)
1004 || spec.budget.max_tokens == Some(0)
1005 {
1006 return MockLeafOutcome {
1007 status: WorkflowRunStatus::BudgetExceeded,
1008 usage: WorkflowUsage::default(),
1009 memo_usage: WorkflowMemoUsage::default(),
1010 output: Some("mock workflow leaf token budget exhausted".to_string()),
1011 artifacts: Vec::new(),
1012 };
1013 }
1014 self.leaf_steps_executed = self.leaf_steps_executed.saturating_add(1);
1015 let outcome = self
1016 .leaf_outcomes
1017 .remove(&spec.id)
1018 .unwrap_or_else(|| MockLeafOutcome::succeeded(format!("mock leaf {}", spec.id)));
1019 let tokens = outcome.usage.total_tokens();
1020 if let Some(per_leaf_token_cap) = spec.budget.max_tokens
1021 && tokens > per_leaf_token_cap
1022 {
1023 return MockLeafOutcome {
1024 status: WorkflowRunStatus::BudgetExceeded,
1025 usage: outcome.usage,
1026 memo_usage: outcome.memo_usage,
1027 output: Some(format!(
1028 "mock workflow leaf token budget exhausted ({tokens} > {per_leaf_token_cap})"
1029 )),
1030 artifacts: outcome.artifacts,
1031 };
1032 }
1033 self.leaf_tokens_used = self.leaf_tokens_used.saturating_add(tokens);
1034 outcome
1035 }
1036
1037 fn next_predicate_result(&mut self, node_id: &str) -> bool {
1038 let Some(results) = self.predicate_results.get_mut(node_id) else {
1039 return false;
1040 };
1041 if results.is_empty() {
1042 return false;
1043 }
1044 results.remove(0)
1045 }
1046}
1047
1048fn aggregate_mock_status(results: &[LeafResult]) -> WorkflowRunStatus {
1049 if results
1050 .iter()
1051 .any(|result| result.status == WorkflowRunStatus::Cancelled)
1052 {
1053 WorkflowRunStatus::Cancelled
1054 } else if results
1055 .iter()
1056 .any(|result| result.status == WorkflowRunStatus::BudgetExceeded)
1057 {
1058 WorkflowRunStatus::BudgetExceeded
1059 } else if results
1060 .iter()
1061 .any(|result| result.status != WorkflowRunStatus::Succeeded)
1062 {
1063 WorkflowRunStatus::Failed
1064 } else {
1065 WorkflowRunStatus::Succeeded
1066 }
1067}
1068
1069fn mark_execution_for_status(execution: &mut WorkflowExecution, status: WorkflowRunStatus) {
1070 match status {
1071 WorkflowRunStatus::Succeeded | WorkflowRunStatus::Pending | WorkflowRunStatus::Running => {}
1072 WorkflowRunStatus::Failed => execution.mark_failed(),
1073 WorkflowRunStatus::Cancelled => execution.mark_cancelled(),
1074 WorkflowRunStatus::BudgetExceeded => execution.mark_budget_exceeded(),
1075 WorkflowRunStatus::ReplayDiverged => execution.mark_replay_diverged(),
1076 }
1077}
1078
1079#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1080pub struct BranchCandidate {
1081 pub branch_id: String,
1082 pub status: WorkflowRunStatus,
1083 pub score: u32,
1084 pub cost: u64,
1085 #[serde(default)]
1086 pub diversity_key: Option<String>,
1087}
1088
1089#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1090#[serde(rename_all = "snake_case")]
1091pub enum TeacherCandidateKind {
1092 Note,
1093 WorkflowRecipe,
1094 SkillPatch,
1095 RegressionTest,
1096 CachePolicyPatch,
1097 BranchHeuristic,
1098 AuthoringPromptPatch,
1099}
1100
1101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1102#[serde(rename_all = "snake_case")]
1103pub enum TeacherCandidateStatus {
1104 #[default]
1105 Proposed,
1106 Accepted,
1107 Rejected,
1108 Promoted,
1109}
1110
1111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1112pub struct TeacherCandidate {
1113 pub candidate_id: String,
1114 pub kind: TeacherCandidateKind,
1115 #[serde(default)]
1116 pub status: TeacherCandidateStatus,
1117 pub source_node_id: String,
1118 #[serde(default)]
1119 pub source_branch_id: Option<String>,
1120 pub summary: String,
1121 #[serde(default)]
1122 pub evidence: Vec<String>,
1123 #[serde(default)]
1124 pub replay_results: Vec<StudentReplayResult>,
1125}
1126
1127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1128pub struct StudentReplayMetrics {
1129 #[serde(default)]
1130 pub score: i32,
1131 #[serde(default)]
1132 pub cost_microusd: u64,
1133}
1134
1135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1136pub struct StudentReplayTestResult {
1137 pub name: String,
1138 pub passed: bool,
1139}
1140
1141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1142pub struct StudentReplayResult {
1143 pub trace_id: String,
1144 pub candidate_id: String,
1145 pub baseline: StudentReplayMetrics,
1146 pub candidate: StudentReplayMetrics,
1147 #[serde(default)]
1148 pub required_tests: Vec<StudentReplayTestResult>,
1149 #[serde(default)]
1150 pub policy_violations: Vec<String>,
1151 #[serde(default)]
1152 pub stale: bool,
1153 #[serde(default)]
1154 pub notes: Option<String>,
1155}
1156
1157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1158pub struct PromotionGate {
1159 #[serde(default = "default_min_replay_score_delta")]
1160 pub min_score_delta: i32,
1161 #[serde(default)]
1162 pub max_cost_delta_microusd: Option<i64>,
1163 #[serde(default = "default_true")]
1164 pub require_all_tests_pass: bool,
1165 #[serde(default = "default_true")]
1166 pub reject_policy_violations: bool,
1167 #[serde(default = "default_true")]
1168 pub reject_stale_replay: bool,
1169}
1170
1171impl Default for PromotionGate {
1172 fn default() -> Self {
1173 Self {
1174 min_score_delta: default_min_replay_score_delta(),
1175 max_cost_delta_microusd: None,
1176 require_all_tests_pass: true,
1177 reject_policy_violations: true,
1178 reject_stale_replay: true,
1179 }
1180 }
1181}
1182
1183impl PromotionGate {
1184 pub fn evaluate_candidate(&self, candidate: &TeacherCandidate) -> PromotionGateDecision {
1185 let Some(replay) = candidate.replay_results.last() else {
1186 return PromotionGateDecision {
1187 candidate_id: candidate.candidate_id.clone(),
1188 status: TeacherCandidateStatus::Rejected,
1189 score_delta: 0,
1190 cost_delta_microusd: 0,
1191 reasons: vec!["no student replay result recorded".to_string()],
1192 };
1193 };
1194 self.evaluate_replay(&candidate.candidate_id, replay)
1195 }
1196
1197 pub fn evaluate_replay(
1198 &self,
1199 candidate_id: &str,
1200 replay: &StudentReplayResult,
1201 ) -> PromotionGateDecision {
1202 let score_delta = replay.score_delta();
1203 let cost_delta_microusd = replay.cost_delta_microusd();
1204 let mut reasons = Vec::new();
1205
1206 if score_delta < self.min_score_delta {
1207 reasons.push(format!(
1208 "score delta {score_delta} is below required {}",
1209 self.min_score_delta
1210 ));
1211 }
1212 if let Some(max_cost_delta) = self.max_cost_delta_microusd
1213 && cost_delta_microusd > max_cost_delta
1214 {
1215 reasons.push(format!(
1216 "cost delta {cost_delta_microusd} exceeds allowed {max_cost_delta}"
1217 ));
1218 }
1219 if self.require_all_tests_pass {
1220 for test in replay.required_tests.iter().filter(|test| !test.passed) {
1221 reasons.push(format!("required test `{}` failed", test.name));
1222 }
1223 }
1224 if self.reject_policy_violations {
1225 for violation in &replay.policy_violations {
1226 reasons.push(format!("policy violation: {violation}"));
1227 }
1228 }
1229 if self.reject_stale_replay && replay.stale {
1230 reasons.push("student replay result is stale".to_string());
1231 }
1232
1233 let status = if reasons.is_empty() {
1234 TeacherCandidateStatus::Promoted
1235 } else {
1236 TeacherCandidateStatus::Rejected
1237 };
1238 PromotionGateDecision {
1239 candidate_id: candidate_id.to_string(),
1240 status,
1241 score_delta,
1242 cost_delta_microusd,
1243 reasons,
1244 }
1245 }
1246}
1247
1248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1249pub struct PromotionGateDecision {
1250 pub candidate_id: String,
1251 pub status: TeacherCandidateStatus,
1252 pub score_delta: i32,
1253 pub cost_delta_microusd: i64,
1254 #[serde(default)]
1255 pub reasons: Vec<String>,
1256}
1257
1258impl PromotionGateDecision {
1259 pub fn promoted(&self) -> bool {
1260 self.status == TeacherCandidateStatus::Promoted
1261 }
1262}
1263
1264impl StudentReplayResult {
1265 pub fn score_delta(&self) -> i32 {
1266 self.candidate.score.saturating_sub(self.baseline.score)
1267 }
1268
1269 pub fn cost_delta_microusd(&self) -> i64 {
1270 signed_u64_delta(self.candidate.cost_microusd, self.baseline.cost_microusd)
1271 }
1272}
1273
1274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1275pub struct TeacherReviewReport {
1276 pub review_node_id: String,
1277 #[serde(default)]
1278 pub candidates: Vec<TeacherCandidate>,
1279}
1280
1281impl TeacherReviewReport {
1282 pub fn from_execution(review: &TeacherReviewSpec, execution: &WorkflowExecution) -> Self {
1283 let candidates = teacher_candidates_from_execution(review, execution);
1284 Self {
1285 review_node_id: review.id.clone(),
1286 candidates,
1287 }
1288 }
1289}
1290
1291pub fn teacher_candidates_from_execution(
1292 review: &TeacherReviewSpec,
1293 execution: &WorkflowExecution,
1294) -> Vec<TeacherCandidate> {
1295 let mut candidates = Vec::new();
1296 for source in &review.candidates {
1297 if let Some(branch) = execution
1298 .branch_results
1299 .iter()
1300 .find(|branch| branch.branch_id == *source || branch.task_id == *source)
1301 {
1302 candidates.push(teacher_candidate_from_branch(review, branch));
1303 continue;
1304 }
1305 if let Some(leaf) = execution
1306 .leaf_results
1307 .iter()
1308 .find(|leaf| leaf.leaf_id == *source || leaf.task_id == *source)
1309 {
1310 candidates.push(teacher_candidate_from_leaf(review, leaf));
1311 continue;
1312 }
1313 if let Some(control) = execution
1314 .control_node_results
1315 .iter()
1316 .find(|control| control.node_id == *source)
1317 {
1318 candidates.push(teacher_candidate_from_control(review, control));
1319 }
1320 }
1321 candidates
1322}
1323
1324fn teacher_candidate_from_branch(
1325 review: &TeacherReviewSpec,
1326 branch: &BranchResult,
1327) -> TeacherCandidate {
1328 let kind =
1329 if branch.memo_usage.armh_hits > 0 || branch.memo_usage.provider_prompt_cache_hits > 0 {
1330 TeacherCandidateKind::CachePolicyPatch
1331 } else if branch.status == WorkflowRunStatus::Succeeded {
1332 TeacherCandidateKind::WorkflowRecipe
1333 } else {
1334 TeacherCandidateKind::BranchHeuristic
1335 };
1336 let mut evidence = vec![format!("status={:?}", branch.status)];
1337 if branch.usage.total_tokens() > 0 || branch.usage.cost_microusd > 0 {
1338 evidence.push(format!(
1339 "tokens={}, cost_microusd={}",
1340 branch.usage.total_tokens(),
1341 branch.usage.cost_microusd
1342 ));
1343 }
1344 if branch.memo_usage.armh_hits > 0 || branch.memo_usage.provider_prompt_cache_hits > 0 {
1345 evidence.push(format!(
1346 "armh_hits={}, provider_prompt_cache_hits={}",
1347 branch.memo_usage.armh_hits, branch.memo_usage.provider_prompt_cache_hits
1348 ));
1349 }
1350 if let Some(notes) = branch.notes.as_deref() {
1351 evidence.push(format!("notes={notes}"));
1352 }
1353 TeacherCandidate {
1354 candidate_id: format!("{}:{}", review.id, branch.branch_id),
1355 kind,
1356 status: TeacherCandidateStatus::Proposed,
1357 source_node_id: branch.task_id.clone(),
1358 source_branch_id: Some(branch.branch_id.clone()),
1359 summary: format!(
1360 "TeacherReview candidate from branch `{}` with {:?} status.",
1361 branch.branch_id, branch.status
1362 ),
1363 evidence,
1364 replay_results: Vec::new(),
1365 }
1366}
1367
1368fn teacher_candidate_from_leaf(review: &TeacherReviewSpec, leaf: &LeafResult) -> TeacherCandidate {
1369 let kind = if leaf.status == WorkflowRunStatus::Failed {
1370 TeacherCandidateKind::RegressionTest
1371 } else if leaf.memo_usage.armh_hits > 0 || leaf.memo_usage.provider_prompt_cache_hits > 0 {
1372 TeacherCandidateKind::CachePolicyPatch
1373 } else {
1374 TeacherCandidateKind::Note
1375 };
1376 let mut evidence = vec![format!("status={:?}", leaf.status)];
1377 if let Some(output) = leaf.output.as_deref() {
1378 evidence.push(format!("output={}", truncate_evidence(output)));
1379 }
1380 TeacherCandidate {
1381 candidate_id: format!("{}:{}", review.id, leaf.leaf_id),
1382 kind,
1383 status: TeacherCandidateStatus::Proposed,
1384 source_node_id: leaf.leaf_id.clone(),
1385 source_branch_id: None,
1386 summary: format!(
1387 "TeacherReview candidate from leaf `{}` with {:?} status.",
1388 leaf.leaf_id, leaf.status
1389 ),
1390 evidence,
1391 replay_results: Vec::new(),
1392 }
1393}
1394
1395fn teacher_candidate_from_control(
1396 review: &TeacherReviewSpec,
1397 control: &ControlNodeResult,
1398) -> TeacherCandidate {
1399 let mut evidence = vec![format!("status={:?}", control.status)];
1400 if !control.selected_children.is_empty() {
1401 evidence.push(format!(
1402 "selected_children={}",
1403 control.selected_children.join(",")
1404 ));
1405 }
1406 if let Some(summary) = control.summary.as_deref() {
1407 evidence.push(format!("summary={}", truncate_evidence(summary)));
1408 }
1409 TeacherCandidate {
1410 candidate_id: format!("{}:{}", review.id, control.node_id),
1411 kind: TeacherCandidateKind::AuthoringPromptPatch,
1412 status: TeacherCandidateStatus::Proposed,
1413 source_node_id: control.node_id.clone(),
1414 source_branch_id: None,
1415 summary: format!(
1416 "TeacherReview candidate from control node `{}` ({:?}).",
1417 control.node_id, control.kind
1418 ),
1419 evidence,
1420 replay_results: Vec::new(),
1421 }
1422}
1423
1424fn default_min_replay_score_delta() -> i32 {
1425 1
1426}
1427
1428fn default_true() -> bool {
1429 true
1430}
1431
1432fn signed_u64_delta(candidate: u64, baseline: u64) -> i64 {
1433 if candidate >= baseline {
1434 i64::try_from(candidate - baseline).unwrap_or(i64::MAX)
1435 } else {
1436 -i64::try_from(baseline - candidate).unwrap_or(i64::MAX)
1437 }
1438}
1439
1440fn truncate_evidence(value: &str) -> String {
1441 const MAX_EVIDENCE_CHARS: usize = 240;
1442 if value.chars().count() <= MAX_EVIDENCE_CHARS {
1443 return value.to_string();
1444 }
1445 let mut truncated = value
1446 .chars()
1447 .take(MAX_EVIDENCE_CHARS.saturating_sub(1))
1448 .collect::<String>();
1449 truncated.push_str("...");
1450 truncated
1451}
1452
1453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1454pub struct BranchTournament {
1455 #[serde(default)]
1456 pub min_score: u32,
1457}
1458
1459impl BranchTournament {
1460 pub fn select(&self, candidates: &[BranchCandidate]) -> Option<BranchCandidate> {
1461 candidates
1462 .iter()
1463 .filter(|candidate| {
1464 candidate.status == WorkflowRunStatus::Succeeded
1465 && candidate.score >= self.min_score
1466 })
1467 .min_by_key(|candidate| (candidate.cost, std::cmp::Reverse(candidate.score)))
1468 .cloned()
1469 }
1470}
1471
1472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1473pub struct ParetoFrontier {
1474 #[serde(default = "default_frontier_limit")]
1475 pub max_items: usize,
1476}
1477
1478impl Default for ParetoFrontier {
1479 fn default() -> Self {
1480 Self {
1481 max_items: default_frontier_limit(),
1482 }
1483 }
1484}
1485
1486impl ParetoFrontier {
1487 pub fn select(&self, candidates: &[BranchCandidate]) -> Vec<BranchCandidate> {
1488 let mut frontier: Vec<_> = candidates
1489 .iter()
1490 .filter(|candidate| candidate.status == WorkflowRunStatus::Succeeded)
1491 .filter(|candidate| {
1492 !candidates.iter().any(|other| {
1493 other.status == WorkflowRunStatus::Succeeded
1494 && other.score >= candidate.score
1495 && other.cost <= candidate.cost
1496 && (other.score > candidate.score || other.cost < candidate.cost)
1497 })
1498 })
1499 .cloned()
1500 .collect();
1501 frontier.sort_by_key(|candidate| (std::cmp::Reverse(candidate.score), candidate.cost));
1502 frontier.truncate(self.max_items.max(1));
1503 frontier
1504 }
1505}
1506
1507#[derive(Debug, Clone, PartialEq, Eq, Error)]
1508pub enum WorkflowExecutionError {
1509 #[error("{kind} node id must not be empty")]
1510 EmptyNodeId { kind: &'static str },
1511 #[error("leaf `{leaf}` prompt must not be empty")]
1512 EmptyLeafPrompt { leaf: String },
1513 #[error(
1514 "leaf `{leaf}` profile `{profile}` must be a non-empty token without whitespace, quotes, or `=`"
1515 )]
1516 InvalidLeafProfile { leaf: String, profile: String },
1517 #[error("duplicate workflow node `{node}`")]
1518 DuplicateNodeId { node: String },
1519 #[error("workflow node `{node}` has unknown {field} reference `{reference}`")]
1520 UnknownNodeReference {
1521 node: String,
1522 field: &'static str,
1523 reference: String,
1524 },
1525}
1526
1527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1528pub struct WorkflowFleetLimits {
1529 pub max_total_agents: usize,
1530 pub max_depth: usize,
1531}
1532
1533impl Default for WorkflowFleetLimits {
1534 fn default() -> Self {
1535 Self {
1536 max_total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS,
1537 max_depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH,
1538 }
1539 }
1540}
1541
1542#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1543pub struct WorkflowFleetShape {
1544 pub total_agents: usize,
1545 pub max_depth: usize,
1546}
1547
1548impl WorkflowFleetShape {
1549 fn add(self, other: Self) -> Self {
1550 Self {
1551 total_agents: self.total_agents.saturating_add(other.total_agents),
1552 max_depth: self.max_depth.max(other.max_depth),
1553 }
1554 }
1555
1556 fn repeat(self, times: usize) -> Self {
1557 Self {
1558 total_agents: self.total_agents.saturating_mul(times),
1559 max_depth: self.max_depth,
1560 }
1561 }
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Eq, Error)]
1565pub enum WorkflowFleetLimitError {
1566 #[error("workflow IR is invalid for Fleet: {source}")]
1567 InvalidWorkflow {
1568 #[from]
1569 source: WorkflowExecutionError,
1570 },
1571 #[error(
1572 "workflow would launch {total_agents} agents; Fleet Workflow limit is {max_total_agents}"
1573 )]
1574 TooManyAgents {
1575 total_agents: usize,
1576 max_total_agents: usize,
1577 },
1578 #[error("workflow reaches recursion depth {depth}; Fleet Workflow limit is {max_depth}")]
1579 RecursionTooDeep { depth: usize, max_depth: usize },
1580 #[error("expand node `{node}` must declare max_children before Fleet launch")]
1581 UnboundedExpand { node: String },
1582 #[error("expand node `{node}` must include a template before Fleet launch")]
1583 MissingExpandTemplate { node: String },
1584 #[error("loop_until node `{node}` must declare max_iterations before Fleet launch")]
1585 UnboundedLoop { node: String },
1586}
1587
1588fn estimate_fleet_shape(
1589 nodes: &[WorkflowNode],
1590) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
1591 estimate_fleet_shape_at_depth(nodes, 1)
1592}
1593
1594fn estimate_fleet_shape_at_depth(
1595 nodes: &[WorkflowNode],
1596 depth: usize,
1597) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
1598 nodes
1599 .iter()
1600 .try_fold(WorkflowFleetShape::default(), |shape, node| {
1601 Ok(shape.add(estimate_node_fleet_shape(node, depth)?))
1602 })
1603}
1604
1605fn estimate_node_fleet_shape(
1606 node: &WorkflowNode,
1607 depth: usize,
1608) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
1609 match node {
1610 WorkflowNode::Leaf(_) => Ok(WorkflowFleetShape {
1611 total_agents: 1,
1612 max_depth: depth,
1613 }),
1614 WorkflowNode::BranchSet(spec) => estimate_fleet_shape_at_depth(&spec.children, depth + 1),
1615 WorkflowNode::Sequence(spec) => estimate_fleet_shape_at_depth(&spec.children, depth),
1616 WorkflowNode::Reduce(_) | WorkflowNode::TeacherReview(_) => Ok(WorkflowFleetShape {
1617 total_agents: 0,
1618 max_depth: 0,
1619 }),
1620 WorkflowNode::LoopUntil(spec) => {
1621 let iterations =
1622 spec.max_iterations
1623 .ok_or_else(|| WorkflowFleetLimitError::UnboundedLoop {
1624 node: spec.id.clone(),
1625 })? as usize;
1626 Ok(estimate_fleet_shape_at_depth(&spec.children, depth)?.repeat(iterations.max(1)))
1627 }
1628 WorkflowNode::Cond(spec) => Ok(estimate_fleet_shape_at_depth(&spec.then_nodes, depth)?
1629 .add(estimate_fleet_shape_at_depth(&spec.else_nodes, depth)?)),
1630 WorkflowNode::Expand(spec) => {
1631 let max_children =
1632 spec.max_children
1633 .ok_or_else(|| WorkflowFleetLimitError::UnboundedExpand {
1634 node: spec.id.clone(),
1635 })?;
1636 let template = spec.template.as_deref().ok_or_else(|| {
1637 WorkflowFleetLimitError::MissingExpandTemplate {
1638 node: spec.id.clone(),
1639 }
1640 })?;
1641 validate_workflow_node_shapes(std::slice::from_ref(template))
1642 .map_err(|source| WorkflowFleetLimitError::InvalidWorkflow { source })?;
1643 Ok(estimate_node_fleet_shape(template, depth)?.repeat(max_children))
1644 }
1645 }
1646}
1647
1648fn default_frontier_limit() -> usize {
1649 8
1650}
1651
1652fn node_id(node: &WorkflowNode) -> String {
1653 match node {
1654 WorkflowNode::BranchSet(spec) => spec.id.clone(),
1655 WorkflowNode::Leaf(spec) => spec.id.clone(),
1656 WorkflowNode::Sequence(spec) => spec.id.clone(),
1657 WorkflowNode::Reduce(spec) => spec.id.clone(),
1658 WorkflowNode::TeacherReview(spec) => spec.id.clone(),
1659 WorkflowNode::LoopUntil(spec) => spec.id.clone(),
1660 WorkflowNode::Cond(spec) => spec.id.clone(),
1661 WorkflowNode::Expand(spec) => spec.id.clone(),
1662 }
1663}
1664
1665pub(crate) fn validate_workflow_nodes(
1666 nodes: &[WorkflowNode],
1667) -> Result<(), WorkflowExecutionError> {
1668 let mut seen = BTreeSet::new();
1669 validate_workflow_nodes_inner(nodes, &mut seen)?;
1670 validate_workflow_references(nodes, &seen)
1671}
1672
1673pub(crate) fn validate_workflow_node_shapes(
1674 nodes: &[WorkflowNode],
1675) -> Result<(), WorkflowExecutionError> {
1676 let mut seen = BTreeSet::new();
1677 validate_workflow_nodes_inner(nodes, &mut seen)
1678}
1679
1680fn validate_workflow_nodes_inner(
1681 nodes: &[WorkflowNode],
1682 seen: &mut BTreeSet<String>,
1683) -> Result<(), WorkflowExecutionError> {
1684 for node in nodes {
1685 let id = node_id(node);
1686 let kind = control_kind_name(node);
1687 if id.trim().is_empty() {
1688 return Err(WorkflowExecutionError::EmptyNodeId { kind });
1689 }
1690 if !seen.insert(id.clone()) {
1691 return Err(WorkflowExecutionError::DuplicateNodeId { node: id });
1692 }
1693 match node {
1694 WorkflowNode::BranchSet(spec) => validate_workflow_nodes_inner(&spec.children, seen)?,
1695 WorkflowNode::Leaf(spec) => {
1696 if spec.prompt.trim().is_empty() {
1697 return Err(WorkflowExecutionError::EmptyLeafPrompt {
1698 leaf: spec.id.clone(),
1699 });
1700 }
1701 if let Some(profile) = spec.profile.as_deref() {
1702 validate_leaf_profile(&spec.id, profile)?;
1703 }
1704 }
1705 WorkflowNode::Sequence(spec) => validate_workflow_nodes_inner(&spec.children, seen)?,
1706 WorkflowNode::LoopUntil(spec) => validate_workflow_nodes_inner(&spec.children, seen)?,
1707 WorkflowNode::Cond(spec) => {
1708 validate_workflow_nodes_inner(&spec.then_nodes, seen)?;
1709 validate_workflow_nodes_inner(&spec.else_nodes, seen)?;
1710 }
1711 WorkflowNode::Reduce(_) | WorkflowNode::TeacherReview(_) | WorkflowNode::Expand(_) => {}
1712 }
1713 }
1714 Ok(())
1715}
1716
1717fn validate_workflow_references(
1718 nodes: &[WorkflowNode],
1719 known_ids: &BTreeSet<String>,
1720) -> Result<(), WorkflowExecutionError> {
1721 for node in nodes {
1722 match node {
1723 WorkflowNode::BranchSet(spec) => {
1724 validate_workflow_references(&spec.children, known_ids)?;
1725 }
1726 WorkflowNode::Leaf(spec) => {
1727 validate_known_references(
1728 spec.id.as_str(),
1729 "depends_on_results",
1730 &spec.depends_on_results,
1731 known_ids,
1732 )?;
1733 }
1734 WorkflowNode::Sequence(spec) => {
1735 validate_workflow_references(&spec.children, known_ids)?;
1736 }
1737 WorkflowNode::Reduce(spec) => {
1738 validate_known_references(spec.id.as_str(), "inputs", &spec.inputs, known_ids)?;
1739 }
1740 WorkflowNode::TeacherReview(spec) => {
1741 validate_known_references(
1742 spec.id.as_str(),
1743 "candidates",
1744 &spec.candidates,
1745 known_ids,
1746 )?;
1747 }
1748 WorkflowNode::LoopUntil(spec) => {
1749 validate_workflow_references(&spec.children, known_ids)?;
1750 }
1751 WorkflowNode::Cond(spec) => {
1752 validate_workflow_references(&spec.then_nodes, known_ids)?;
1753 validate_workflow_references(&spec.else_nodes, known_ids)?;
1754 }
1755 WorkflowNode::Expand(_) => {}
1756 }
1757 }
1758 Ok(())
1759}
1760
1761fn validate_leaf_profile(leaf: &str, profile: &str) -> Result<(), WorkflowExecutionError> {
1764 let invalid = profile.is_empty()
1765 || profile
1766 .chars()
1767 .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='));
1768 if invalid {
1769 return Err(WorkflowExecutionError::InvalidLeafProfile {
1770 leaf: leaf.to_string(),
1771 profile: profile.to_string(),
1772 });
1773 }
1774 Ok(())
1775}
1776
1777fn validate_known_references(
1778 node: &str,
1779 field: &'static str,
1780 references: &[String],
1781 known_ids: &BTreeSet<String>,
1782) -> Result<(), WorkflowExecutionError> {
1783 for reference in references {
1784 if !known_ids.contains(reference) {
1785 return Err(WorkflowExecutionError::UnknownNodeReference {
1786 node: node.to_string(),
1787 field,
1788 reference: reference.clone(),
1789 });
1790 }
1791 }
1792 Ok(())
1793}
1794
1795fn control_kind_name(node: &WorkflowNode) -> &'static str {
1796 match node {
1797 WorkflowNode::BranchSet(_) => "branch_set",
1798 WorkflowNode::Leaf(_) => "leaf",
1799 WorkflowNode::Sequence(_) => "sequence",
1800 WorkflowNode::Reduce(_) => "reduce",
1801 WorkflowNode::TeacherReview(_) => "teacher_review",
1802 WorkflowNode::LoopUntil(_) => "loop_until",
1803 WorkflowNode::Cond(_) => "cond",
1804 WorkflowNode::Expand(_) => "expand",
1805 }
1806}
1807
1808#[derive(Debug, Clone, PartialEq, Eq, Error)]
1809pub enum WorkflowValidationError {
1810 #[error("{field} must not be empty")]
1811 EmptyField { field: &'static str },
1812 #[error("workflow must contain at least one phase")]
1813 EmptyWorkflow,
1814 #[error("phase `{phase}` must contain at least one task")]
1815 EmptyPhase { phase: String },
1816 #[error("max_concurrent must be between 1 and 20, got {value}")]
1817 InvalidMaxConcurrent { value: u8 },
1818 #[error("duplicate workflow phase `{phase}`")]
1819 DuplicatePhase { phase: String },
1820 #[error("duplicate workflow task `{task}`")]
1821 DuplicateTask { task: String },
1822 #[error("phase `{phase}` has invalid dependency `{dependency}`")]
1823 InvalidPhaseDependency { phase: String, dependency: String },
1824 #[error("phase dependency cycle includes `{phase}`")]
1825 PhaseDependencyCycle { phase: String },
1826 #[error("task `{task}` has invalid result dependency `{dependency}`")]
1827 InvalidTaskResultDependency { task: String, dependency: String },
1828 #[error(
1829 "task `{task}` depends on result `{dependency}` from unavailable phase `{dependency_phase}` while running in `{task_phase}`"
1830 )]
1831 UnavailableTaskResultDependency {
1832 task: String,
1833 dependency: String,
1834 dependency_phase: String,
1835 task_phase: String,
1836 },
1837 #[error("parallel read-write task `{task}` must declare a file_scope")]
1838 MissingParallelWriteScope { task: String },
1839 #[error("parallel read-write tasks `{left}` and `{right}` have overlapping file scopes")]
1840 OverlappingParallelWriteScope { left: String, right: String },
1841}
1842
1843fn default_max_concurrent() -> u8 {
1844 4
1845}
1846
1847fn validate_non_empty(field: &'static str, value: &str) -> Result<(), WorkflowValidationError> {
1848 if value.trim().is_empty() {
1849 return Err(WorkflowValidationError::EmptyField { field });
1850 }
1851 Ok(())
1852}
1853
1854fn ordered_phases(
1855 config: &WorkflowConfig,
1856 phase_indices: &BTreeMap<String, usize>,
1857) -> Result<Vec<String>, WorkflowValidationError> {
1858 let mut visiting = BTreeSet::new();
1859 let mut visited = BTreeSet::new();
1860 let mut ordered = Vec::with_capacity(config.phases.len());
1861
1862 for phase in &config.phases {
1863 visit_phase(
1864 &phase.name,
1865 config,
1866 phase_indices,
1867 &mut visiting,
1868 &mut visited,
1869 &mut ordered,
1870 )?;
1871 }
1872
1873 Ok(ordered)
1874}
1875
1876fn visit_phase(
1877 phase_name: &str,
1878 config: &WorkflowConfig,
1879 phase_indices: &BTreeMap<String, usize>,
1880 visiting: &mut BTreeSet<String>,
1881 visited: &mut BTreeSet<String>,
1882 ordered: &mut Vec<String>,
1883) -> Result<(), WorkflowValidationError> {
1884 if visited.contains(phase_name) {
1885 return Ok(());
1886 }
1887 if !visiting.insert(phase_name.to_string()) {
1888 return Err(WorkflowValidationError::PhaseDependencyCycle {
1889 phase: phase_name.to_string(),
1890 });
1891 }
1892
1893 let phase = &config.phases[phase_indices[phase_name]];
1894 for dependency in &phase.depends_on {
1895 visit_phase(
1896 dependency,
1897 config,
1898 phase_indices,
1899 visiting,
1900 visited,
1901 ordered,
1902 )?;
1903 }
1904
1905 visiting.remove(phase_name);
1906 visited.insert(phase_name.to_string());
1907 ordered.push(phase_name.to_string());
1908 Ok(())
1909}
1910
1911fn validate_parallel_write_scope(phase: &Phase) -> Result<(), WorkflowValidationError> {
1912 if !phase.parallel {
1913 return Ok(());
1914 }
1915
1916 let write_tasks: Vec<_> = phase
1917 .tasks
1918 .iter()
1919 .filter(|task| task.mode == TaskMode::ReadWrite)
1920 .collect();
1921
1922 for task in &write_tasks {
1923 if task.file_scope.is_empty() {
1924 return Err(WorkflowValidationError::MissingParallelWriteScope {
1925 task: task.id.clone(),
1926 });
1927 }
1928 }
1929
1930 for (left_index, left) in write_tasks.iter().enumerate() {
1931 for right in write_tasks.iter().skip(left_index + 1) {
1932 if scopes_overlap(&left.file_scope, &right.file_scope) {
1933 return Err(WorkflowValidationError::OverlappingParallelWriteScope {
1934 left: left.id.clone(),
1935 right: right.id.clone(),
1936 });
1937 }
1938 }
1939 }
1940
1941 Ok(())
1942}
1943
1944pub fn scopes_overlap(left: &[String], right: &[String]) -> bool {
1945 left.iter().any(|left_scope| {
1946 right
1947 .iter()
1948 .any(|right_scope| scope_overlaps(left_scope, right_scope))
1949 })
1950}
1951
1952fn scope_overlaps(left: &str, right: &str) -> bool {
1953 let left = normalize_scope(left);
1954 let right = normalize_scope(right);
1955
1956 if left == right || left == "." || right == "." {
1957 return true;
1958 }
1959
1960 if left.contains('*') || right.contains('*') {
1961 return glob_prefix(&left) == glob_prefix(&right);
1962 }
1963
1964 let left_path = Path::new(&left);
1965 let right_path = Path::new(&right);
1966 left_path.starts_with(right_path) || right_path.starts_with(left_path)
1967}
1968
1969fn normalize_scope(scope: &str) -> String {
1970 let trimmed = scope.trim().trim_start_matches("./").trim_end_matches('/');
1971 trimmed
1972 .strip_suffix("/**")
1973 .or_else(|| trimmed.strip_suffix("/*"))
1974 .unwrap_or(trimmed)
1975 .to_string()
1976}
1977
1978fn glob_prefix(scope: &str) -> String {
1979 scope
1980 .split('*')
1981 .next()
1982 .unwrap_or(scope)
1983 .trim_end_matches('/')
1984 .to_string()
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989 use super::*;
1990
1991 fn task(id: &str) -> Task {
1992 Task {
1993 id: id.to_string(),
1994 prompt: format!("run {id}"),
1995 agent_type: AgentType::General,
1996 mode: TaskMode::ReadOnly,
1997 isolation: IsolationMode::Shared,
1998 file_scope: Vec::new(),
1999 depends_on_results: Vec::new(),
2000 max_steps: None,
2001 timeout_secs: None,
2002 }
2003 }
2004
2005 fn config(phases: Vec<Phase>) -> WorkflowConfig {
2006 WorkflowConfig {
2007 goal: "cache-change".to_string(),
2008 max_concurrent: 4,
2009 description: None,
2010 phases,
2011 }
2012 }
2013
2014 fn phase(name: &str, depends_on: &[&str], tasks: Vec<Task>) -> Phase {
2015 Phase {
2016 name: name.to_string(),
2017 description: None,
2018 depends_on: depends_on.iter().map(|value| value.to_string()).collect(),
2019 parallel: false,
2020 on_failure: FailurePolicy::SkipContinue,
2021 tasks,
2022 }
2023 }
2024
2025 fn leaf_node(id: &str) -> WorkflowNode {
2026 WorkflowNode::Leaf(LeafSpec {
2027 id: id.to_string(),
2028 prompt: format!("run {id}"),
2029 agent_type: AgentType::General,
2030 profile: None,
2031 mode: TaskMode::ReadOnly,
2032 isolation: IsolationMode::Shared,
2033 file_scope: Vec::new(),
2034 depends_on_results: Vec::new(),
2035 budget: BudgetSpec::default(),
2036 permissions: PermissionSpec::default(),
2037 model_policy: ModelPolicy::default(),
2038 })
2039 }
2040
2041 fn leaf_node_with_budget(id: &str, budget: BudgetSpec) -> WorkflowNode {
2042 WorkflowNode::Leaf(LeafSpec {
2043 id: id.to_string(),
2044 prompt: format!("run {id}"),
2045 agent_type: AgentType::General,
2046 profile: None,
2047 mode: TaskMode::ReadOnly,
2048 isolation: IsolationMode::Shared,
2049 file_scope: Vec::new(),
2050 depends_on_results: Vec::new(),
2051 budget,
2052 permissions: PermissionSpec::default(),
2053 model_policy: ModelPolicy::default(),
2054 })
2055 }
2056
2057 fn invalid_leaf_node(id: &str) -> WorkflowNode {
2058 WorkflowNode::Leaf(LeafSpec {
2059 id: id.to_string(),
2060 prompt: " ".to_string(),
2061 agent_type: AgentType::General,
2062 profile: None,
2063 mode: TaskMode::ReadOnly,
2064 isolation: IsolationMode::Shared,
2065 file_scope: Vec::new(),
2066 depends_on_results: Vec::new(),
2067 budget: BudgetSpec::default(),
2068 permissions: PermissionSpec::default(),
2069 model_policy: ModelPolicy::default(),
2070 })
2071 }
2072
2073 fn workflow_spec(nodes: Vec<WorkflowNode>) -> WorkflowSpec {
2074 WorkflowSpec {
2075 id: Some("mock-workflow".to_string()),
2076 goal: "prove mock executor control flow".to_string(),
2077 description: None,
2078 budget: BudgetSpec::default(),
2079 permissions: PermissionSpec::default(),
2080 model_policy: ModelPolicy::default(),
2081 promotion_policy: PromotionPolicy::default(),
2082 nodes,
2083 }
2084 }
2085
2086 fn control_result<'a>(
2087 execution: &'a WorkflowExecution,
2088 node_id: &str,
2089 ) -> &'a ControlNodeResult {
2090 execution
2091 .control_node_results
2092 .iter()
2093 .find(|result| result.node_id == node_id)
2094 .expect("control node result should exist")
2095 }
2096
2097 fn candidate(
2098 branch_id: &str,
2099 status: WorkflowRunStatus,
2100 score: u32,
2101 cost: u64,
2102 diversity_key: &str,
2103 ) -> BranchCandidate {
2104 BranchCandidate {
2105 branch_id: branch_id.to_string(),
2106 status,
2107 score,
2108 cost,
2109 diversity_key: Some(diversity_key.to_string()),
2110 }
2111 }
2112
2113 #[test]
2114 fn independent_phases_preserve_declaration_order() {
2115 let workflow = config(vec![
2116 phase("discover", &[], vec![task("scan")]),
2117 phase("report", &[], vec![task("summarize")]),
2118 ]);
2119
2120 let plan = workflow.compile().expect("workflow should compile");
2121
2122 assert_eq!(
2123 plan.phase_names().collect::<Vec<_>>(),
2124 vec!["discover", "report"]
2125 );
2126 }
2127
2128 #[test]
2129 fn dependencies_override_declaration_order_deterministically() {
2130 let workflow = config(vec![
2131 phase("review", &["implement"], vec![task("review-results")]),
2132 phase("discover", &[], vec![task("scan")]),
2133 phase("implement", &["discover"], vec![task("patch")]),
2134 phase("report", &["review"], vec![task("summarize")]),
2135 ]);
2136
2137 let plan = workflow.compile().expect("workflow should compile");
2138
2139 assert_eq!(
2140 plan.phase_names().collect::<Vec<_>>(),
2141 vec!["discover", "implement", "review", "report"]
2142 );
2143 }
2144
2145 #[test]
2146 fn rejects_empty_workflow() {
2147 let err = config(Vec::new())
2148 .validate()
2149 .expect_err("empty workflow should fail");
2150
2151 assert_eq!(err, WorkflowValidationError::EmptyWorkflow);
2152 }
2153
2154 #[test]
2155 fn rejects_empty_phase() {
2156 let err = config(vec![phase("empty", &[], Vec::new())])
2157 .validate()
2158 .expect_err("empty phase should fail");
2159
2160 assert_eq!(
2161 err,
2162 WorkflowValidationError::EmptyPhase {
2163 phase: "empty".to_string()
2164 }
2165 );
2166 }
2167
2168 #[test]
2169 fn rejects_invalid_max_concurrent() {
2170 let mut workflow = config(vec![phase("discover", &[], vec![task("scan")])]);
2171 workflow.max_concurrent = 0;
2172
2173 let err = workflow
2174 .validate()
2175 .expect_err("zero concurrency should fail");
2176
2177 assert_eq!(
2178 err,
2179 WorkflowValidationError::InvalidMaxConcurrent { value: 0 }
2180 );
2181 }
2182
2183 #[test]
2184 fn rejects_duplicate_phase_names() {
2185 let err = config(vec![
2186 phase("discover", &[], vec![task("scan")]),
2187 phase("discover", &[], vec![task("scan-again")]),
2188 ])
2189 .validate()
2190 .expect_err("duplicate phase should fail");
2191
2192 assert!(matches!(
2193 err,
2194 WorkflowValidationError::DuplicatePhase { .. }
2195 ));
2196 }
2197
2198 #[test]
2199 fn rejects_duplicate_task_ids() {
2200 let err = config(vec![
2201 phase("discover", &[], vec![task("scan")]),
2202 phase("report", &[], vec![task("scan")]),
2203 ])
2204 .validate()
2205 .expect_err("duplicate task should fail");
2206
2207 assert!(matches!(err, WorkflowValidationError::DuplicateTask { .. }));
2208 }
2209
2210 #[test]
2211 fn rejects_unknown_phase_dependency() {
2212 let err = config(vec![phase("report", &["missing"], vec![task("summarize")])])
2213 .validate()
2214 .expect_err("unknown dependency should fail");
2215
2216 assert!(matches!(
2217 err,
2218 WorkflowValidationError::InvalidPhaseDependency { .. }
2219 ));
2220 }
2221
2222 #[test]
2223 fn rejects_phase_dependency_cycles() {
2224 let workflow = config(vec![
2225 phase("a", &["b"], vec![task("a-task")]),
2226 phase("b", &["a"], vec![task("b-task")]),
2227 ]);
2228
2229 let err = workflow.validate().expect_err("cycle should fail");
2230
2231 assert!(matches!(
2232 err,
2233 WorkflowValidationError::PhaseDependencyCycle { .. }
2234 ));
2235 }
2236
2237 #[test]
2238 fn rejects_task_result_dependency_from_same_parallel_phase() {
2239 let mut first = task("first");
2240 first.depends_on_results.push("second".to_string());
2241 let mut parallel = phase("parallel", &[], vec![first, task("second")]);
2242 parallel.parallel = true;
2243
2244 let err = config(vec![parallel])
2245 .validate()
2246 .expect_err("same-phase result dependency should fail");
2247
2248 assert!(matches!(
2249 err,
2250 WorkflowValidationError::UnavailableTaskResultDependency { .. }
2251 ));
2252 }
2253
2254 #[test]
2255 fn rejects_task_result_dependency_from_later_phase() {
2256 let mut summarize = task("summarize");
2257 summarize.depends_on_results.push("scan".to_string());
2258 let workflow = config(vec![
2259 phase("report", &[], vec![summarize]),
2260 phase("discover", &[], vec![task("scan")]),
2261 ]);
2262
2263 let err = workflow
2264 .validate()
2265 .expect_err("later-phase result dependency should fail");
2266
2267 assert!(matches!(
2268 err,
2269 WorkflowValidationError::UnavailableTaskResultDependency { .. }
2270 ));
2271 }
2272
2273 #[test]
2274 fn allows_task_result_dependency_from_earlier_phase() {
2275 let upstream = phase("discover", &[], vec![task("scan")]);
2276 let mut summarize = task("summarize");
2277 summarize.depends_on_results.push("scan".to_string());
2278 let downstream = phase("report", &["discover"], vec![summarize]);
2279
2280 config(vec![upstream, downstream])
2281 .validate()
2282 .expect("earlier-phase result should be available");
2283 }
2284
2285 #[test]
2286 fn rejects_parallel_read_write_without_file_scope() {
2287 let mut write = task("write");
2288 write.mode = TaskMode::ReadWrite;
2289 let mut parallel = phase("parallel", &[], vec![write]);
2290 parallel.parallel = true;
2291
2292 let err = config(vec![parallel])
2293 .validate()
2294 .expect_err("write task needs a scope");
2295
2296 assert!(matches!(
2297 err,
2298 WorkflowValidationError::MissingParallelWriteScope { .. }
2299 ));
2300 }
2301
2302 #[test]
2303 fn detects_overlapping_parallel_write_scopes_with_path_boundaries() {
2304 let mut left = task("auth");
2305 left.mode = TaskMode::ReadWrite;
2306 left.file_scope = vec!["src/auth/**".to_string()];
2307 let mut right = task("auth-login");
2308 right.mode = TaskMode::ReadWrite;
2309 right.file_scope = vec!["src/auth/login.rs".to_string()];
2310 let mut parallel = phase("parallel", &[], vec![left, right]);
2311 parallel.parallel = true;
2312
2313 let err = config(vec![parallel])
2314 .validate()
2315 .expect_err("nested scopes should overlap");
2316
2317 assert!(matches!(
2318 err,
2319 WorkflowValidationError::OverlappingParallelWriteScope { .. }
2320 ));
2321 }
2322
2323 #[test]
2324 fn does_not_confuse_path_prefixes_for_overlapping_scopes() {
2325 let mut left = task("auth");
2326 left.mode = TaskMode::ReadWrite;
2327 left.file_scope = vec!["src/auth/**".to_string()];
2328 let mut right = task("auth-admin");
2329 right.mode = TaskMode::ReadWrite;
2330 right.file_scope = vec!["src/auth_admin/**".to_string()];
2331 let mut parallel = phase("parallel", &[], vec![left, right]);
2332 parallel.parallel = true;
2333
2334 config(vec![parallel])
2335 .validate()
2336 .expect("component boundary scopes should not overlap");
2337 }
2338
2339 #[test]
2340 fn json_roundtrip_keeps_snake_case_enum_names() {
2341 let mut task = task("patch");
2342 task.agent_type = AgentType::Implementer;
2343 task.mode = TaskMode::ReadWrite;
2344 task.isolation = IsolationMode::Worktree;
2345 task.file_scope = vec!["src/auth/**".to_string()];
2346 let mut parallel = phase("implement", &[], vec![task]);
2347 parallel.parallel = true;
2348 parallel.on_failure = FailurePolicy::Abort;
2349 let workflow = config(vec![parallel]);
2350
2351 let json = serde_json::to_string(&workflow).expect("serialize workflow");
2352
2353 assert!(json.contains("\"agent_type\":\"implementer\""));
2354 assert!(json.contains("\"mode\":\"read_write\""));
2355 assert!(json.contains("\"isolation\":\"worktree\""));
2356 assert!(json.contains("\"on_failure\":\"abort\""));
2357
2358 let parsed: WorkflowConfig = serde_json::from_str(&json).expect("parse workflow");
2359 assert_eq!(parsed, workflow);
2360 }
2361
2362 #[test]
2363 fn workflow_ir_roundtrip() {
2364 let discover_leaf = LeafSpec {
2365 id: "scan-readme".to_string(),
2366 prompt: "Inspect README setup gaps".to_string(),
2367 agent_type: AgentType::Explore,
2368 profile: Some("scout".to_string()),
2369 mode: TaskMode::ReadOnly,
2370 isolation: IsolationMode::Shared,
2371 file_scope: vec!["README.md".to_string()],
2372 depends_on_results: Vec::new(),
2373 budget: BudgetSpec {
2374 max_steps: Some(8),
2375 timeout_secs: Some(300),
2376 max_parallel: None,
2377 max_tokens: None,
2378 },
2379 permissions: PermissionSpec::default(),
2380 model_policy: ModelPolicy {
2381 provider: Some("openai".to_string()),
2382 model: Some("gpt-5.4".to_string()),
2383 fallback_models: Vec::new(),
2384 },
2385 };
2386 let workflow = WorkflowSpec {
2387 id: Some("v090-readme-check".to_string()),
2388 goal: "tighten setup docs".to_string(),
2389 description: Some("metadata-only typed Workflow IR".to_string()),
2390 budget: BudgetSpec {
2391 max_steps: Some(30),
2392 timeout_secs: Some(1_800),
2393 max_parallel: Some(2),
2394 max_tokens: None,
2395 },
2396 permissions: PermissionSpec {
2397 allow_write: false,
2398 allow_network: false,
2399 allowed_tools: vec!["rg".to_string()],
2400 file_scope: vec!["README.md".to_string()],
2401 },
2402 model_policy: ModelPolicy {
2403 provider: Some("openai".to_string()),
2404 model: Some("gpt-5.4".to_string()),
2405 fallback_models: vec!["gpt-5.4-mini".to_string()],
2406 },
2407 promotion_policy: PromotionPolicy {
2408 strategy: PromotionStrategy::TeacherSelected,
2409 require_teacher_review: true,
2410 min_successful_branches: Some(1),
2411 promotion_gate: PromotionGate::default(),
2412 },
2413 nodes: vec![
2414 WorkflowNode::BranchSet(BranchSpec {
2415 id: "discover".to_string(),
2416 description: Some("parallel doc inspection".to_string()),
2417 parallel: true,
2418 budget: BudgetSpec {
2419 max_steps: Some(12),
2420 timeout_secs: Some(600),
2421 max_parallel: Some(2),
2422 max_tokens: None,
2423 },
2424 permissions: PermissionSpec::default(),
2425 model_policy: ModelPolicy::default(),
2426 children: vec![WorkflowNode::Leaf(discover_leaf)],
2427 }),
2428 WorkflowNode::Sequence(SequenceSpec {
2429 id: "review-and-reduce".to_string(),
2430 children: vec![
2431 WorkflowNode::TeacherReview(TeacherReviewSpec {
2432 id: "select-best".to_string(),
2433 candidates: vec!["scan-readme".to_string()],
2434 promotion_policy: PromotionPolicy {
2435 strategy: PromotionStrategy::BestScore,
2436 require_teacher_review: true,
2437 min_successful_branches: Some(1),
2438 promotion_gate: PromotionGate::default(),
2439 },
2440 }),
2441 WorkflowNode::Reduce(ReduceSpec {
2442 id: "summarize".to_string(),
2443 inputs: vec!["scan-readme".to_string()],
2444 prompt: "Summarize the smallest safe patch".to_string(),
2445 model_policy: ModelPolicy::default(),
2446 }),
2447 ],
2448 }),
2449 WorkflowNode::Cond(CondSpec {
2450 id: "maybe-expand".to_string(),
2451 condition: "summary identifies multiple independent gaps".to_string(),
2452 then_nodes: vec![WorkflowNode::Expand(ExpandSpec {
2453 id: "split-followups".to_string(),
2454 source: "summarize".to_string(),
2455 max_children: None,
2456 template: Some(Box::new(WorkflowNode::Leaf(LeafSpec {
2457 id: "followup-template".to_string(),
2458 prompt: "Patch one independent gap".to_string(),
2459 agent_type: AgentType::Implementer,
2460 profile: None,
2461 mode: TaskMode::ReadWrite,
2462 isolation: IsolationMode::Worktree,
2463 file_scope: vec!["README.md".to_string()],
2464 depends_on_results: Vec::new(),
2465 budget: BudgetSpec::default(),
2466 permissions: PermissionSpec {
2467 allow_write: true,
2468 allow_network: false,
2469 allowed_tools: Vec::new(),
2470 file_scope: vec!["README.md".to_string()],
2471 },
2472 model_policy: ModelPolicy::default(),
2473 }))),
2474 })],
2475 else_nodes: vec![WorkflowNode::LoopUntil(LoopUntilSpec {
2476 id: "verify-once".to_string(),
2477 condition: "local verification passes".to_string(),
2478 max_iterations: Some(1),
2479 children: Vec::new(),
2480 })],
2481 }),
2482 ],
2483 };
2484
2485 let json = serde_json::to_string_pretty(&workflow).expect("serialize workflow ir");
2486
2487 assert!(json.contains("\"kind\": \"branch_set\""));
2488 assert!(json.contains("\"strategy\": \"teacher_selected\""));
2489 assert!(json.contains("\"profile\": \"scout\""));
2490 let parsed: WorkflowSpec = serde_json::from_str(&json).expect("parse workflow ir");
2491 assert_eq!(parsed, workflow);
2492
2493 let minimal: WorkflowSpec = serde_json::from_str(r#"{"goal":"ship v0.9","nodes":[]}"#)
2494 .expect("parse minimal workflow ir");
2495 assert_eq!(minimal.budget, BudgetSpec::default());
2496 assert_eq!(minimal.permissions, PermissionSpec::default());
2497 assert_eq!(minimal.model_policy, ModelPolicy::default());
2498
2499 let legacy_leaf: LeafSpec = serde_json::from_str(r#"{"id":"scan","prompt":"scan safely"}"#)
2501 .expect("parse pre-profile leaf ir");
2502 assert_eq!(legacy_leaf.profile, None);
2503 let legacy_json = serde_json::to_string(&legacy_leaf).expect("serialize legacy leaf");
2504 assert!(!legacy_json.contains("profile"));
2505 }
2506
2507 #[test]
2508 fn fleet_validation_accepts_one_hundred_agents_and_variable_models() {
2509 let nodes = (0..DEFAULT_FLEET_WORKFLOW_MAX_AGENTS)
2510 .map(|index| {
2511 let mut leaf = match leaf_node(&format!("agent-{index}")) {
2512 WorkflowNode::Leaf(leaf) => leaf,
2513 _ => unreachable!("leaf helper returns a leaf"),
2514 };
2515 leaf.model_policy = if index == 0 {
2516 ModelPolicy {
2517 provider: Some("deepseek".to_string()),
2518 model: Some("deepseek-v4-pro".to_string()),
2519 fallback_models: Vec::new(),
2520 }
2521 } else {
2522 ModelPolicy {
2523 provider: Some("deepseek".to_string()),
2524 model: Some("deepseek-v4-flash".to_string()),
2525 fallback_models: Vec::new(),
2526 }
2527 };
2528 WorkflowNode::Leaf(leaf)
2529 })
2530 .collect();
2531 let workflow = workflow_spec(nodes);
2532
2533 let shape = workflow
2534 .validate_for_fleet()
2535 .expect("one hundred agents should fit the Fleet Workflow limit");
2536
2537 assert_eq!(shape.total_agents, DEFAULT_FLEET_WORKFLOW_MAX_AGENTS);
2538 assert_eq!(shape.max_depth, 1);
2539 }
2540
2541 #[test]
2542 fn fleet_validation_rejects_more_than_one_hundred_agents() {
2543 let nodes = (0..=DEFAULT_FLEET_WORKFLOW_MAX_AGENTS)
2544 .map(|index| leaf_node(&format!("agent-{index}")))
2545 .collect();
2546 let workflow = workflow_spec(nodes);
2547
2548 let err = workflow
2549 .validate_for_fleet()
2550 .expect_err("agent population should be bounded before Fleet launch");
2551
2552 assert_eq!(
2553 err,
2554 WorkflowFleetLimitError::TooManyAgents {
2555 total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS + 1,
2556 max_total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS,
2557 }
2558 );
2559 }
2560
2561 #[test]
2562 fn fleet_validation_rejects_depth_beyond_five() {
2563 let mut node = leaf_node("deep-leaf");
2564 for depth in (0..DEFAULT_FLEET_WORKFLOW_MAX_DEPTH).rev() {
2565 node = WorkflowNode::BranchSet(BranchSpec {
2566 id: format!("ring-{depth}"),
2567 description: None,
2568 parallel: true,
2569 budget: BudgetSpec::default(),
2570 permissions: PermissionSpec::default(),
2571 model_policy: ModelPolicy::default(),
2572 children: vec![node],
2573 });
2574 }
2575 let workflow = workflow_spec(vec![node]);
2576
2577 let err = workflow
2578 .validate_for_fleet()
2579 .expect_err("sixth agent ring should be rejected");
2580
2581 assert_eq!(
2582 err,
2583 WorkflowFleetLimitError::RecursionTooDeep {
2584 depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH + 1,
2585 max_depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH,
2586 }
2587 );
2588 }
2589
2590 #[test]
2591 fn fleet_validation_counts_loop_and_expand_fanout_conservatively() {
2592 let workflow = workflow_spec(vec![
2593 WorkflowNode::LoopUntil(LoopUntilSpec {
2594 id: "retry-ring".to_string(),
2595 condition: "verifier passes".to_string(),
2596 max_iterations: Some(3),
2597 children: vec![leaf_node("retry-worker")],
2598 }),
2599 WorkflowNode::Expand(ExpandSpec {
2600 id: "split".to_string(),
2601 source: "retry-ring".to_string(),
2602 max_children: Some(4),
2603 template: Some(Box::new(leaf_node("split-template"))),
2604 }),
2605 ]);
2606
2607 let shape = workflow
2608 .validate_for_fleet()
2609 .expect("bounded loop and expand should validate");
2610
2611 assert_eq!(shape.total_agents, 7);
2612 assert_eq!(shape.max_depth, 1);
2613 }
2614
2615 #[test]
2616 fn fleet_validation_rejects_unbounded_loop_or_expand_before_launch() {
2617 let workflow = workflow_spec(vec![
2618 WorkflowNode::LoopUntil(LoopUntilSpec {
2619 id: "retry-ring".to_string(),
2620 condition: "verifier passes".to_string(),
2621 max_iterations: None,
2622 children: vec![leaf_node("retry-worker")],
2623 }),
2624 WorkflowNode::Expand(ExpandSpec {
2625 id: "split".to_string(),
2626 source: "retry-ring".to_string(),
2627 max_children: Some(4),
2628 template: Some(Box::new(leaf_node("split-template"))),
2629 }),
2630 ]);
2631
2632 assert!(matches!(
2633 workflow.validate_for_fleet(),
2634 Err(WorkflowFleetLimitError::UnboundedLoop { node }) if node == "retry-ring"
2635 ));
2636
2637 let workflow = workflow_spec(vec![WorkflowNode::Expand(ExpandSpec {
2638 id: "split".to_string(),
2639 source: "retry-ring".to_string(),
2640 max_children: None,
2641 template: Some(Box::new(leaf_node("split-template"))),
2642 })]);
2643
2644 assert!(matches!(
2645 workflow.validate_for_fleet(),
2646 Err(WorkflowFleetLimitError::UnboundedExpand { node }) if node == "split"
2647 ));
2648 }
2649
2650 #[test]
2651 fn branch_result_serialization() {
2652 let result = BranchResult {
2653 branch_id: "discover".to_string(),
2654 task_id: "scan".to_string(),
2655 status: WorkflowRunStatus::Succeeded,
2656 usage: WorkflowUsage {
2657 input_tokens: 100,
2658 output_tokens: 25,
2659 cost_microusd: 42,
2660 },
2661 memo_usage: WorkflowMemoUsage::default(),
2662 artifacts: vec!["trace://branches/discover".to_string()],
2663 notes: Some("validated prompt surfaces".to_string()),
2664 };
2665
2666 let json = serde_json::to_string(&result).expect("serialize branch result");
2667
2668 assert!(json.contains("\"status\":\"succeeded\""));
2669 assert!(json.contains("\"cost_microusd\":42"));
2670 let parsed: BranchResult = serde_json::from_str(&json).expect("parse branch result");
2671 assert_eq!(parsed, result);
2672
2673 let minimal: BranchResult =
2674 serde_json::from_str(r#"{"branch_id":"discover","task_id":"scan","status":"pending"}"#)
2675 .expect("parse minimal branch result");
2676 assert_eq!(minimal.usage, WorkflowUsage::default());
2677 assert_eq!(minimal.memo_usage, WorkflowMemoUsage::default());
2678 assert!(minimal.artifacts.is_empty());
2679 assert_eq!(minimal.notes, None);
2680 }
2681
2682 #[test]
2683 fn leaf_result_serialization() {
2684 let result = LeafResult {
2685 leaf_id: "scan-readme".to_string(),
2686 task_id: "scan".to_string(),
2687 profile: Some("reviewer".to_string()),
2688 status: WorkflowRunStatus::Failed,
2689 usage: WorkflowUsage {
2690 input_tokens: 11,
2691 output_tokens: 7,
2692 cost_microusd: 3,
2693 },
2694 memo_usage: WorkflowMemoUsage {
2695 armh_hits: 1,
2696 armh_misses: 0,
2697 armh_saved_estimated_tokens: 128,
2698 provider_prompt_cache_hits: 2,
2699 provider_prompt_cache_misses: 1,
2700 },
2701 output: Some("README needs clearer setup steps".to_string()),
2702 artifacts: vec!["trace://leaves/scan-readme".to_string()],
2703 schema_error: None,
2704 };
2705
2706 let json = serde_json::to_string(&result).expect("serialize leaf result");
2707
2708 assert!(json.contains("\"status\":\"failed\""));
2709 assert!(json.contains("\"input_tokens\":11"));
2710 assert!(json.contains("\"armh_saved_estimated_tokens\":128"));
2711 assert!(json.contains("\"profile\":\"reviewer\""));
2712 let parsed: LeafResult = serde_json::from_str(&json).expect("parse leaf result");
2713 assert_eq!(parsed, result);
2714
2715 let minimal: LeafResult = serde_json::from_str(
2716 r#"{"leaf_id":"scan-readme","task_id":"scan","status":"pending"}"#,
2717 )
2718 .expect("parse minimal leaf result");
2719 assert_eq!(minimal.profile, None);
2720 assert_eq!(minimal.usage, WorkflowUsage::default());
2721 assert_eq!(minimal.memo_usage, WorkflowMemoUsage::default());
2722 assert_eq!(minimal.output, None);
2723 assert!(minimal.artifacts.is_empty());
2724 }
2725
2726 #[test]
2727 fn control_node_result_serialization() {
2728 let result = ControlNodeResult {
2729 node_id: "select-fix".to_string(),
2730 kind: ControlNodeKind::TeacherReview,
2731 status: WorkflowRunStatus::Running,
2732 selected_children: vec!["branch-a".to_string(), "branch-c".to_string()],
2733 summary: Some("teacher review is waiting on verifier evidence".to_string()),
2734 };
2735
2736 let json = serde_json::to_string(&result).expect("serialize control node result");
2737
2738 assert!(json.contains("\"kind\":\"teacher_review\""));
2739 assert!(json.contains("\"status\":\"running\""));
2740 let parsed: ControlNodeResult =
2741 serde_json::from_str(&json).expect("parse control node result");
2742 assert_eq!(parsed, result);
2743
2744 let minimal: ControlNodeResult = serde_json::from_str(
2745 r#"{"node_id":"select-fix","kind":"branch_set","status":"pending"}"#,
2746 )
2747 .expect("parse minimal control node result");
2748 assert!(minimal.selected_children.is_empty());
2749 assert_eq!(minimal.summary, None);
2750 }
2751
2752 #[test]
2753 fn run_mock_three_branch_workflow() {
2754 let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
2755 id: "discover".to_string(),
2756 description: None,
2757 parallel: true,
2758 budget: BudgetSpec::default(),
2759 permissions: PermissionSpec::default(),
2760 model_policy: ModelPolicy::default(),
2761 children: vec![
2762 leaf_node("scan-readme"),
2763 leaf_node("scan-config"),
2764 leaf_node("scan-tests"),
2765 ],
2766 })]);
2767
2768 let mut executor = MockWorkflowExecutor::new();
2769 let execution = executor.run(&workflow).expect("mock workflow should run");
2770
2771 assert_eq!(execution.status, WorkflowRunStatus::Succeeded);
2772 assert_eq!(
2773 execution
2774 .leaf_results
2775 .iter()
2776 .map(|result| result.leaf_id.as_str())
2777 .collect::<Vec<_>>(),
2778 vec!["scan-readme", "scan-config", "scan-tests"]
2779 );
2780 assert_eq!(execution.branch_results.len(), 1);
2781 assert_eq!(execution.branch_results[0].branch_id, "discover");
2782 assert_eq!(
2783 control_result(&execution, "discover").selected_children,
2784 vec!["scan-readme", "scan-config", "scan-tests"]
2785 );
2786 }
2787
2788 #[test]
2789 fn mock_executor_surfaces_leaf_profile() {
2790 let mut profiled_leaf = match leaf_node("review-change") {
2791 WorkflowNode::Leaf(leaf) => leaf,
2792 _ => unreachable!("leaf helper returns a leaf"),
2793 };
2794 profiled_leaf.profile = Some("reviewer".to_string());
2795 let workflow = workflow_spec(vec![
2796 WorkflowNode::Leaf(profiled_leaf),
2797 leaf_node("scan-readme"),
2798 ]);
2799
2800 let execution = MockWorkflowExecutor::new()
2801 .run(&workflow)
2802 .expect("mock workflow should run");
2803
2804 assert_eq!(execution.status, WorkflowRunStatus::Succeeded);
2805 assert_eq!(
2806 execution.leaf_results[0].profile.as_deref(),
2807 Some("reviewer")
2808 );
2809 assert_eq!(execution.leaf_results[1].profile, None);
2810 }
2811
2812 #[test]
2813 fn leaf_profile_token_rule_rejects_invalid_names() {
2814 for bad in ["", "has space", "quote\"y", "role=reviewer", "back`tick"] {
2815 let mut leaf = match leaf_node("scan") {
2816 WorkflowNode::Leaf(leaf) => leaf,
2817 _ => unreachable!("leaf helper returns a leaf"),
2818 };
2819 leaf.profile = Some(bad.to_string());
2820 let workflow = workflow_spec(vec![WorkflowNode::Leaf(leaf)]);
2821
2822 let err = MockWorkflowExecutor::new()
2823 .run(&workflow)
2824 .expect_err("invalid profile token should fail validation");
2825
2826 assert!(
2827 matches!(&err, WorkflowExecutionError::InvalidLeafProfile { profile, .. } if profile == bad),
2828 "profile `{bad}` should be rejected, got {err:?}"
2829 );
2830 }
2831
2832 let mut leaf = match leaf_node("scan") {
2833 WorkflowNode::Leaf(leaf) => leaf,
2834 _ => unreachable!("leaf helper returns a leaf"),
2835 };
2836 leaf.profile = Some("reviewer".to_string());
2837 let workflow = workflow_spec(vec![WorkflowNode::Leaf(leaf)]);
2838 MockWorkflowExecutor::new()
2839 .run(&workflow)
2840 .expect("valid profile token should pass validation");
2841 }
2842
2843 #[test]
2844 fn mock_executor_aggregates_leaf_usage() {
2845 let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
2846 id: "discover".to_string(),
2847 description: None,
2848 parallel: true,
2849 budget: BudgetSpec::default(),
2850 permissions: PermissionSpec::default(),
2851 model_policy: ModelPolicy::default(),
2852 children: vec![leaf_node("scan-readme"), leaf_node("scan-tests")],
2853 })]);
2854
2855 let mut executor = MockWorkflowExecutor::new()
2856 .with_leaf_outcome(
2857 "scan-readme",
2858 MockLeafOutcome::succeeded("readme ok").with_usage(WorkflowUsage {
2859 input_tokens: 100,
2860 output_tokens: 25,
2861 cost_microusd: 500,
2862 }),
2863 )
2864 .with_leaf_outcome(
2865 "scan-tests",
2866 MockLeafOutcome::succeeded("tests ok").with_usage(WorkflowUsage {
2867 input_tokens: 50,
2868 output_tokens: 10,
2869 cost_microusd: 250,
2870 }),
2871 );
2872
2873 let execution = executor.run(&workflow).expect("mock workflow should run");
2874
2875 assert_eq!(
2876 execution.usage,
2877 WorkflowUsage {
2878 input_tokens: 150,
2879 output_tokens: 35,
2880 cost_microusd: 750,
2881 }
2882 );
2883 assert_eq!(execution.usage.total_tokens(), 185);
2884 assert_eq!(execution.branch_results[0].usage, execution.usage);
2885 assert_eq!(
2886 execution
2887 .leaf_results
2888 .iter()
2889 .map(|result| result.usage.cost_microusd)
2890 .collect::<Vec<_>>(),
2891 vec![500, 250]
2892 );
2893 }
2894
2895 #[test]
2896 fn mock_executor_aggregates_memo_usage() {
2897 let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
2898 id: "cache-branches".to_string(),
2899 description: None,
2900 parallel: true,
2901 budget: BudgetSpec::default(),
2902 permissions: PermissionSpec::default(),
2903 model_policy: ModelPolicy::default(),
2904 children: vec![leaf_node("rlm-hit"), leaf_node("rlm-miss")],
2905 })]);
2906
2907 let mut executor = MockWorkflowExecutor::new()
2908 .with_leaf_outcome(
2909 "rlm-hit",
2910 MockLeafOutcome::succeeded("memo hit").with_memo_usage(WorkflowMemoUsage {
2911 armh_hits: 1,
2912 armh_misses: 0,
2913 armh_saved_estimated_tokens: 4096,
2914 provider_prompt_cache_hits: 1,
2915 provider_prompt_cache_misses: 0,
2916 }),
2917 )
2918 .with_leaf_outcome(
2919 "rlm-miss",
2920 MockLeafOutcome::succeeded("memo miss").with_memo_usage(WorkflowMemoUsage {
2921 armh_hits: 0,
2922 armh_misses: 1,
2923 armh_saved_estimated_tokens: 0,
2924 provider_prompt_cache_hits: 0,
2925 provider_prompt_cache_misses: 1,
2926 }),
2927 );
2928
2929 let execution = executor.run(&workflow).expect("mock workflow should run");
2930
2931 assert_eq!(
2932 execution.memo_usage,
2933 WorkflowMemoUsage {
2934 armh_hits: 1,
2935 armh_misses: 1,
2936 armh_saved_estimated_tokens: 4096,
2937 provider_prompt_cache_hits: 1,
2938 provider_prompt_cache_misses: 1,
2939 }
2940 );
2941 assert_eq!(execution.branch_results[0].memo_usage, execution.memo_usage);
2942 assert_eq!(
2943 execution
2944 .leaf_results
2945 .iter()
2946 .map(|result| (result.memo_usage.armh_hits, result.memo_usage.armh_misses))
2947 .collect::<Vec<_>>(),
2948 vec![(1, 0), (0, 1)]
2949 );
2950 }
2951
2952 #[test]
2953 fn mock_executor_marks_cancelled_before_leaf() {
2954 let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
2955 id: "discover".to_string(),
2956 description: None,
2957 parallel: true,
2958 budget: BudgetSpec::default(),
2959 permissions: PermissionSpec::default(),
2960 model_policy: ModelPolicy::default(),
2961 children: vec![leaf_node("scan-readme"), leaf_node("scan-tests")],
2962 })]);
2963
2964 let mut executor = MockWorkflowExecutor::new().with_cancelled();
2965 let execution = executor.run(&workflow).expect("mock workflow should run");
2966
2967 assert_eq!(execution.status, WorkflowRunStatus::Cancelled);
2968 assert_eq!(execution.leaf_results.len(), 1);
2969 assert_eq!(
2970 execution.leaf_results[0].status,
2971 WorkflowRunStatus::Cancelled
2972 );
2973 assert_eq!(
2974 execution.branch_results[0].status,
2975 WorkflowRunStatus::Cancelled
2976 );
2977 assert_eq!(
2978 control_result(&execution, "discover").status,
2979 WorkflowRunStatus::Cancelled
2980 );
2981 }
2982
2983 #[test]
2984 fn mock_executor_stops_when_global_leaf_budget_is_exhausted() {
2985 let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
2986 id: "discover".to_string(),
2987 description: None,
2988 parallel: true,
2989 budget: BudgetSpec::default(),
2990 permissions: PermissionSpec::default(),
2991 model_policy: ModelPolicy::default(),
2992 children: vec![
2993 leaf_node("scan-readme"),
2994 leaf_node("scan-config"),
2995 leaf_node("scan-tests"),
2996 ],
2997 })]);
2998
2999 let mut executor = MockWorkflowExecutor::new().with_max_leaf_steps(1);
3000 let execution = executor.run(&workflow).expect("mock workflow should run");
3001
3002 assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded);
3003 assert_eq!(
3004 execution
3005 .leaf_results
3006 .iter()
3007 .map(|result| (result.leaf_id.as_str(), result.status))
3008 .collect::<Vec<_>>(),
3009 vec![
3010 ("scan-readme", WorkflowRunStatus::Succeeded),
3011 ("scan-config", WorkflowRunStatus::BudgetExceeded)
3012 ]
3013 );
3014 assert_eq!(
3015 execution.branch_results[0].status,
3016 WorkflowRunStatus::BudgetExceeded
3017 );
3018 }
3019
3020 #[test]
3021 fn mock_executor_honors_zero_step_leaf_budget() {
3022 let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
3023 id: "verify".to_string(),
3024 description: None,
3025 parallel: false,
3026 budget: BudgetSpec::default(),
3027 permissions: PermissionSpec::default(),
3028 model_policy: ModelPolicy::default(),
3029 children: vec![
3030 leaf_node_with_budget(
3031 "run-tests",
3032 BudgetSpec {
3033 max_steps: Some(0),
3034 timeout_secs: None,
3035 max_parallel: None,
3036 max_tokens: None,
3037 },
3038 ),
3039 leaf_node("summarize"),
3040 ],
3041 })]);
3042
3043 let mut executor = MockWorkflowExecutor::new();
3044 let execution = executor.run(&workflow).expect("mock workflow should run");
3045
3046 assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded);
3047 assert_eq!(execution.leaf_results.len(), 1);
3048 assert_eq!(
3049 execution.leaf_results[0].status,
3050 WorkflowRunStatus::BudgetExceeded
3051 );
3052 assert!(
3053 execution.leaf_results[0]
3054 .output
3055 .as_deref()
3056 .unwrap_or_default()
3057 .contains("budget exhausted")
3058 );
3059 }
3060
3061 #[test]
3062 fn mock_executor_stops_when_global_token_budget_is_exhausted() {
3063 let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
3064 id: "discover".to_string(),
3065 description: None,
3066 parallel: true,
3067 budget: BudgetSpec::default(),
3068 permissions: PermissionSpec::default(),
3069 model_policy: ModelPolicy::default(),
3070 children: vec![
3071 leaf_node("scan-readme"),
3072 leaf_node("scan-config"),
3073 leaf_node("scan-tests"),
3074 ],
3075 })]);
3076
3077 let mut executor = MockWorkflowExecutor::new()
3082 .with_max_leaf_tokens(1000)
3083 .with_leaf_outcome(
3084 "scan-readme",
3085 MockLeafOutcome::succeeded("readme done").with_usage(WorkflowUsage {
3086 input_tokens: 300,
3087 output_tokens: 300,
3088 cost_microusd: 0,
3089 }),
3090 )
3091 .with_leaf_outcome(
3092 "scan-config",
3093 MockLeafOutcome::succeeded("config done").with_usage(WorkflowUsage {
3094 input_tokens: 250,
3095 output_tokens: 250,
3096 cost_microusd: 0,
3097 }),
3098 );
3099 let execution = executor.run(&workflow).expect("mock workflow should run");
3100
3101 assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded);
3102 assert_eq!(execution.leaf_results.len(), 3);
3108 assert_eq!(
3109 execution.leaf_results[0].status,
3110 WorkflowRunStatus::Succeeded
3111 );
3112 assert_eq!(
3113 execution.leaf_results[1].status,
3114 WorkflowRunStatus::Succeeded
3115 );
3116 assert_eq!(
3117 execution.leaf_results[2].status,
3118 WorkflowRunStatus::BudgetExceeded
3119 );
3120 assert_eq!(execution.usage.total_tokens(), 1100);
3121 }
3122
3123 #[test]
3124 fn mock_executor_honors_zero_token_leaf_budget() {
3125 let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
3126 id: "verify".to_string(),
3127 description: None,
3128 parallel: false,
3129 budget: BudgetSpec::default(),
3130 permissions: PermissionSpec::default(),
3131 model_policy: ModelPolicy::default(),
3132 children: vec![
3133 leaf_node_with_budget(
3134 "run-tests",
3135 BudgetSpec {
3136 max_steps: None,
3137 timeout_secs: None,
3138 max_parallel: None,
3139 max_tokens: Some(0),
3140 },
3141 ),
3142 leaf_node("summarize"),
3143 ],
3144 })]);
3145
3146 let mut executor = MockWorkflowExecutor::new();
3147 let execution = executor.run(&workflow).expect("mock workflow should run");
3148
3149 assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded);
3150 assert_eq!(execution.leaf_results.len(), 1);
3151 assert_eq!(
3152 execution.leaf_results[0].status,
3153 WorkflowRunStatus::BudgetExceeded
3154 );
3155 assert!(
3156 execution.leaf_results[0]
3157 .output
3158 .as_deref()
3159 .unwrap_or_default()
3160 .contains("token budget exhausted")
3161 );
3162 }
3163
3164 #[test]
3165 fn mock_executor_honors_per_leaf_token_cap() {
3166 let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
3167 id: "review".to_string(),
3168 description: None,
3169 parallel: false,
3170 budget: BudgetSpec::default(),
3171 permissions: PermissionSpec::default(),
3172 model_policy: ModelPolicy::default(),
3173 children: vec![
3174 leaf_node_with_budget(
3175 "expensive-scan",
3176 BudgetSpec {
3177 max_steps: None,
3178 timeout_secs: None,
3179 max_parallel: None,
3180 max_tokens: Some(500),
3181 },
3182 ),
3183 leaf_node("summarize"),
3184 ],
3185 })]);
3186
3187 let mut executor = MockWorkflowExecutor::new().with_leaf_outcome(
3189 "expensive-scan",
3190 MockLeafOutcome::succeeded("scan done").with_usage(WorkflowUsage {
3191 input_tokens: 500,
3192 output_tokens: 300,
3193 cost_microusd: 0,
3194 }),
3195 );
3196 let execution = executor.run(&workflow).expect("mock workflow should run");
3197
3198 assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded);
3199 assert_eq!(execution.leaf_results.len(), 1);
3200 assert_eq!(
3201 execution.leaf_results[0].status,
3202 WorkflowRunStatus::BudgetExceeded
3203 );
3204 assert!(
3205 execution.leaf_results[0]
3206 .output
3207 .as_deref()
3208 .unwrap_or_default()
3209 .contains("token budget exhausted")
3210 );
3211 }
3212
3213 #[test]
3214 fn budget_spec_serializes_max_tokens() {
3215 let budget = BudgetSpec {
3216 max_steps: Some(10),
3217 timeout_secs: Some(600),
3218 max_parallel: Some(4),
3219 max_tokens: Some(50_000),
3220 };
3221 let json = serde_json::to_string(&budget).expect("serialize budget");
3222 let parsed: BudgetSpec = serde_json::from_str(&json).expect("parse budget");
3223 assert_eq!(parsed, budget);
3224 assert!(json.contains("\"max_tokens\":50000"));
3225
3226 let default_json =
3228 serde_json::to_string(&BudgetSpec::default()).expect("serialize default");
3229 let parsed_default: BudgetSpec =
3230 serde_json::from_str(&default_json).expect("parse default budget");
3231 assert_eq!(parsed_default, BudgetSpec::default());
3232 assert!(parsed_default.max_tokens.is_none());
3233 }
3234
3235 #[test]
3236 fn loop_until_stops_on_pass() {
3237 let workflow = workflow_spec(vec![WorkflowNode::LoopUntil(LoopUntilSpec {
3238 id: "verify".to_string(),
3239 condition: "verification passed".to_string(),
3240 max_iterations: Some(5),
3241 children: vec![leaf_node("run-check")],
3242 })]);
3243
3244 let mut executor =
3245 MockWorkflowExecutor::new().with_predicate_results("verify", vec![false, false, true]);
3246 let execution = executor.run(&workflow).expect("loop should run");
3247
3248 assert_eq!(execution.status, WorkflowRunStatus::Succeeded);
3249 assert_eq!(execution.leaf_results.len(), 3);
3250 assert_eq!(
3251 control_result(&execution, "verify").summary.as_deref(),
3252 Some("loop_until iterations=3")
3253 );
3254 }
3255
3256 #[test]
3257 fn loop_until_honors_max_iters() {
3258 let workflow = workflow_spec(vec![WorkflowNode::LoopUntil(LoopUntilSpec {
3259 id: "verify".to_string(),
3260 condition: "verification passed".to_string(),
3261 max_iterations: Some(2),
3262 children: vec![leaf_node("run-check")],
3263 })]);
3264
3265 let mut executor =
3266 MockWorkflowExecutor::new().with_predicate_results("verify", vec![false, false, true]);
3267 let execution = executor.run(&workflow).expect("loop should run");
3268
3269 assert_eq!(execution.status, WorkflowRunStatus::Failed);
3270 assert_eq!(execution.leaf_results.len(), 2);
3271 assert_eq!(
3272 control_result(&execution, "verify").summary.as_deref(),
3273 Some("loop_until iterations=2")
3274 );
3275 }
3276
3277 #[test]
3278 fn cond_uses_logged_predicate_result() {
3279 let workflow = workflow_spec(vec![WorkflowNode::Cond(CondSpec {
3280 id: "should-fix".to_string(),
3281 condition: "finding requires a patch".to_string(),
3282 then_nodes: vec![leaf_node("patch")],
3283 else_nodes: vec![leaf_node("report-only")],
3284 })]);
3285
3286 let mut executor =
3287 MockWorkflowExecutor::new().with_predicate_results("should-fix", vec![true]);
3288 let execution = executor.run(&workflow).expect("cond should run");
3289
3290 assert_eq!(
3291 execution
3292 .leaf_results
3293 .iter()
3294 .map(|result| result.leaf_id.as_str())
3295 .collect::<Vec<_>>(),
3296 vec!["patch"]
3297 );
3298 assert_eq!(
3299 control_result(&execution, "should-fix").summary.as_deref(),
3300 Some("predicate_result=true")
3301 );
3302 }
3303
3304 #[test]
3305 fn expand_respects_max_children() {
3306 let workflow = workflow_spec(vec![WorkflowNode::Expand(ExpandSpec {
3307 id: "split".to_string(),
3308 source: "plan".to_string(),
3309 max_children: Some(2),
3310 template: None,
3311 })]);
3312
3313 let generated = vec![leaf_node("first"), leaf_node("second"), leaf_node("third")];
3314 let mut executor = MockWorkflowExecutor::new().with_generated_nodes("split", generated);
3315 let execution = executor.run(&workflow).expect("expand should run");
3316
3317 assert_eq!(
3318 execution
3319 .leaf_results
3320 .iter()
3321 .map(|result| result.leaf_id.as_str())
3322 .collect::<Vec<_>>(),
3323 vec!["first", "second"]
3324 );
3325 assert_eq!(
3326 control_result(&execution, "split").selected_children,
3327 vec!["first", "second"]
3328 );
3329 }
3330
3331 #[test]
3332 fn expand_generated_nodes_validate_before_run() {
3333 let workflow = workflow_spec(vec![WorkflowNode::Expand(ExpandSpec {
3334 id: "split".to_string(),
3335 source: "plan".to_string(),
3336 max_children: None,
3337 template: None,
3338 })]);
3339
3340 let mut executor = MockWorkflowExecutor::new()
3341 .with_generated_nodes("split", vec![invalid_leaf_node("bad")]);
3342 let err = executor
3343 .run(&workflow)
3344 .expect_err("invalid generated leaf should fail before execution");
3345
3346 assert_eq!(
3347 err,
3348 WorkflowExecutionError::EmptyLeafPrompt {
3349 leaf: "bad".to_string()
3350 }
3351 );
3352 }
3353
3354 #[test]
3355 fn workflow_spec_rejects_unknown_leaf_dependency() {
3356 let mut summarize = leaf_node("summarize");
3357 let WorkflowNode::Leaf(spec) = &mut summarize else {
3358 panic!("expected leaf");
3359 };
3360 spec.depends_on_results = vec!["missing-scan".to_string()];
3361 let workflow = workflow_spec(vec![summarize]);
3362
3363 let mut executor = MockWorkflowExecutor::new();
3364 let err = executor
3365 .run(&workflow)
3366 .expect_err("unknown leaf dependency should fail before execution");
3367
3368 assert_eq!(
3369 err,
3370 WorkflowExecutionError::UnknownNodeReference {
3371 node: "summarize".to_string(),
3372 field: "depends_on_results",
3373 reference: "missing-scan".to_string(),
3374 }
3375 );
3376 }
3377
3378 #[test]
3379 fn workflow_spec_rejects_unknown_reduce_input() {
3380 let workflow = workflow_spec(vec![
3381 leaf_node("scan"),
3382 WorkflowNode::Reduce(ReduceSpec {
3383 id: "summarize".to_string(),
3384 inputs: vec!["scan".to_string(), "missing-review".to_string()],
3385 prompt: "Summarize safe fixes".to_string(),
3386 model_policy: ModelPolicy::default(),
3387 }),
3388 ]);
3389
3390 let mut executor = MockWorkflowExecutor::new();
3391 let err = executor
3392 .run(&workflow)
3393 .expect_err("unknown reduce input should fail before execution");
3394
3395 assert_eq!(
3396 err,
3397 WorkflowExecutionError::UnknownNodeReference {
3398 node: "summarize".to_string(),
3399 field: "inputs",
3400 reference: "missing-review".to_string(),
3401 }
3402 );
3403 }
3404
3405 #[test]
3406 fn workflow_spec_rejects_unknown_teacher_candidate() {
3407 let workflow = workflow_spec(vec![
3408 leaf_node("candidate-a"),
3409 WorkflowNode::TeacherReview(TeacherReviewSpec {
3410 id: "teacher-review".to_string(),
3411 candidates: vec!["candidate-a".to_string(), "candidate-b".to_string()],
3412 promotion_policy: PromotionPolicy::default(),
3413 }),
3414 ]);
3415
3416 let mut executor = MockWorkflowExecutor::new();
3417 let err = executor
3418 .run(&workflow)
3419 .expect_err("unknown teacher candidate should fail before execution");
3420
3421 assert_eq!(
3422 err,
3423 WorkflowExecutionError::UnknownNodeReference {
3424 node: "teacher-review".to_string(),
3425 field: "candidates",
3426 reference: "candidate-b".to_string(),
3427 }
3428 );
3429 }
3430
3431 #[test]
3432 fn teacher_candidate_serialization() {
3433 let candidate = TeacherCandidate {
3434 candidate_id: "teacher-review:branch-a".to_string(),
3435 kind: TeacherCandidateKind::WorkflowRecipe,
3436 status: TeacherCandidateStatus::Proposed,
3437 source_node_id: "branch-a".to_string(),
3438 source_branch_id: Some("branch-a".to_string()),
3439 summary: "Winning branch found a reusable workflow recipe.".to_string(),
3440 evidence: vec![
3441 "status=Succeeded".to_string(),
3442 "tokens=42, cost_microusd=7".to_string(),
3443 ],
3444 replay_results: vec![StudentReplayResult {
3445 trace_id: "trace-a".to_string(),
3446 candidate_id: "teacher-review:branch-a".to_string(),
3447 baseline: StudentReplayMetrics {
3448 score: 70,
3449 cost_microusd: 10,
3450 },
3451 candidate: StudentReplayMetrics {
3452 score: 74,
3453 cost_microusd: 12,
3454 },
3455 required_tests: vec![StudentReplayTestResult {
3456 name: "cargo test -p codewhale-workflow".to_string(),
3457 passed: true,
3458 }],
3459 policy_violations: Vec::new(),
3460 stale: false,
3461 notes: Some("offline replay improved the constrained student".to_string()),
3462 }],
3463 };
3464
3465 let json = serde_json::to_string(&candidate).expect("serialize teacher candidate");
3466
3467 assert!(json.contains("\"kind\":\"workflow_recipe\""));
3468 assert!(json.contains("\"status\":\"proposed\""));
3469 assert!(json.contains("\"replay_results\""));
3470 let parsed: TeacherCandidate =
3471 serde_json::from_str(&json).expect("parse teacher candidate");
3472 assert_eq!(parsed, candidate);
3473 }
3474
3475 #[test]
3476 fn teacher_review_produces_candidate_from_trace() {
3477 let review = TeacherReviewSpec {
3478 id: "teacher-review".to_string(),
3479 candidates: vec!["winning-branch".to_string()],
3480 promotion_policy: PromotionPolicy::default(),
3481 };
3482 let execution = WorkflowExecution {
3483 branch_results: vec![BranchResult {
3484 branch_id: "winning-branch".to_string(),
3485 task_id: "winning-branch".to_string(),
3486 status: WorkflowRunStatus::Succeeded,
3487 usage: WorkflowUsage {
3488 input_tokens: 30,
3489 output_tokens: 12,
3490 cost_microusd: 7,
3491 },
3492 memo_usage: WorkflowMemoUsage::default(),
3493 artifacts: vec!["trace://branches/winning-branch".to_string()],
3494 notes: Some("branch produced a minimal verified patch".to_string()),
3495 }],
3496 ..WorkflowExecution::default()
3497 };
3498
3499 let report = TeacherReviewReport::from_execution(&review, &execution);
3500
3501 assert_eq!(report.review_node_id, "teacher-review");
3502 assert_eq!(report.candidates.len(), 1);
3503 assert_eq!(
3504 report.candidates[0].kind,
3505 TeacherCandidateKind::WorkflowRecipe
3506 );
3507 assert_eq!(
3508 report.candidates[0].status,
3509 TeacherCandidateStatus::Proposed
3510 );
3511 assert!(
3512 report.candidates[0]
3513 .evidence
3514 .iter()
3515 .any(|line| line.contains("tokens=42"))
3516 );
3517 }
3518
3519 #[test]
3520 fn failed_leaf_becomes_regression_test_candidate() {
3521 let review = TeacherReviewSpec {
3522 id: "teacher-review".to_string(),
3523 candidates: vec!["verify-failure".to_string()],
3524 promotion_policy: PromotionPolicy::default(),
3525 };
3526 let execution = WorkflowExecution {
3527 leaf_results: vec![LeafResult {
3528 leaf_id: "verify-failure".to_string(),
3529 task_id: "verify-failure".to_string(),
3530 profile: None,
3531 status: WorkflowRunStatus::Failed,
3532 usage: WorkflowUsage::default(),
3533 memo_usage: WorkflowMemoUsage::default(),
3534 output: Some("cargo test failed with a replay mismatch".to_string()),
3535 artifacts: Vec::new(),
3536 schema_error: None,
3537 }],
3538 ..WorkflowExecution::default()
3539 };
3540
3541 let candidates = teacher_candidates_from_execution(&review, &execution);
3542
3543 assert_eq!(candidates.len(), 1);
3544 assert_eq!(candidates[0].kind, TeacherCandidateKind::RegressionTest);
3545 assert_eq!(candidates[0].status, TeacherCandidateStatus::Proposed);
3546 assert!(
3547 candidates[0]
3548 .evidence
3549 .iter()
3550 .any(|line| { line.contains("cargo test failed with a replay mismatch") })
3551 );
3552 }
3553
3554 #[test]
3555 fn student_replay_promotes_only_on_delta() {
3556 let gate = PromotionGate {
3557 min_score_delta: 3,
3558 max_cost_delta_microusd: Some(25),
3559 ..PromotionGate::default()
3560 };
3561 let replay = StudentReplayResult {
3562 trace_id: "trace-a".to_string(),
3563 candidate_id: "teacher-review:branch-a".to_string(),
3564 baseline: StudentReplayMetrics {
3565 score: 80,
3566 cost_microusd: 100,
3567 },
3568 candidate: StudentReplayMetrics {
3569 score: 84,
3570 cost_microusd: 120,
3571 },
3572 required_tests: vec![StudentReplayTestResult {
3573 name: "workflow replay".to_string(),
3574 passed: true,
3575 }],
3576 policy_violations: Vec::new(),
3577 stale: false,
3578 notes: None,
3579 };
3580
3581 let promoted = gate.evaluate_replay("teacher-review:branch-a", &replay);
3582 assert!(promoted.promoted());
3583 assert_eq!(promoted.status, TeacherCandidateStatus::Promoted);
3584 assert_eq!(promoted.score_delta, 4);
3585
3586 let weak_replay = StudentReplayResult {
3587 candidate: StudentReplayMetrics {
3588 score: 82,
3589 cost_microusd: 120,
3590 },
3591 ..replay
3592 };
3593 let rejected = gate.evaluate_replay("teacher-review:branch-a", &weak_replay);
3594 assert!(!rejected.promoted());
3595 assert_eq!(rejected.status, TeacherCandidateStatus::Rejected);
3596 assert!(
3597 rejected
3598 .reasons
3599 .iter()
3600 .any(|reason| reason.contains("below required 3"))
3601 );
3602 }
3603
3604 #[test]
3605 fn promotion_gate_rejects_stale_policy_cost_and_failed_tests() {
3606 let gate = PromotionGate {
3607 min_score_delta: 1,
3608 max_cost_delta_microusd: Some(10),
3609 ..PromotionGate::default()
3610 };
3611 let replay = StudentReplayResult {
3612 trace_id: "trace-a".to_string(),
3613 candidate_id: "teacher-review:branch-a".to_string(),
3614 baseline: StudentReplayMetrics {
3615 score: 70,
3616 cost_microusd: 10,
3617 },
3618 candidate: StudentReplayMetrics {
3619 score: 90,
3620 cost_microusd: 30,
3621 },
3622 required_tests: vec![StudentReplayTestResult {
3623 name: "required regression".to_string(),
3624 passed: false,
3625 }],
3626 policy_violations: vec!["writes outside file scope".to_string()],
3627 stale: true,
3628 notes: None,
3629 };
3630
3631 let decision = gate.evaluate_replay("teacher-review:branch-a", &replay);
3632
3633 assert_eq!(decision.status, TeacherCandidateStatus::Rejected);
3634 assert!(
3635 decision
3636 .reasons
3637 .iter()
3638 .any(|reason| { reason.contains("cost delta 20 exceeds allowed 10") })
3639 );
3640 assert!(
3641 decision
3642 .reasons
3643 .iter()
3644 .any(|reason| { reason.contains("required test `required regression` failed") })
3645 );
3646 assert!(
3647 decision
3648 .reasons
3649 .iter()
3650 .any(|reason| { reason.contains("policy violation: writes outside file scope") })
3651 );
3652 assert!(
3653 decision
3654 .reasons
3655 .iter()
3656 .any(|reason| { reason.contains("student replay result is stale") })
3657 );
3658 }
3659
3660 #[test]
3661 fn promotion_gate_requires_recorded_replay_before_candidate_promotion() {
3662 let candidate = TeacherCandidate {
3663 candidate_id: "teacher-review:branch-a".to_string(),
3664 kind: TeacherCandidateKind::WorkflowRecipe,
3665 status: TeacherCandidateStatus::Proposed,
3666 source_node_id: "branch-a".to_string(),
3667 source_branch_id: Some("branch-a".to_string()),
3668 summary: "candidate waits for replay".to_string(),
3669 evidence: Vec::new(),
3670 replay_results: Vec::new(),
3671 };
3672
3673 let decision = PromotionGate::default().evaluate_candidate(&candidate);
3674
3675 assert_eq!(decision.status, TeacherCandidateStatus::Rejected);
3676 assert_eq!(
3677 decision.reasons,
3678 vec!["no student replay result recorded".to_string()]
3679 );
3680 }
3681
3682 #[test]
3683 fn tournament_selects_passing_minimal_branch() {
3684 let tournament = BranchTournament { min_score: 60 };
3685 let candidates = vec![
3686 candidate(
3687 "expensive-pass",
3688 WorkflowRunStatus::Succeeded,
3689 90,
3690 90,
3691 "quality",
3692 ),
3693 candidate("failed-cheap", WorkflowRunStatus::Failed, 100, 1, "broken"),
3694 candidate(
3695 "cheap-pass",
3696 WorkflowRunStatus::Succeeded,
3697 70,
3698 10,
3699 "minimal",
3700 ),
3701 candidate("too-low", WorkflowRunStatus::Succeeded, 40, 2, "weak"),
3702 ];
3703
3704 let selected = tournament
3705 .select(&candidates)
3706 .expect("one passing branch should be selected");
3707
3708 assert_eq!(selected.branch_id, "cheap-pass");
3709 }
3710
3711 #[test]
3712 fn pareto_frontier_keeps_diverse_candidates() {
3713 let frontier = ParetoFrontier { max_items: 4 };
3714 let candidates = vec![
3715 candidate("quality", WorkflowRunStatus::Succeeded, 95, 100, "quality"),
3716 candidate("minimal", WorkflowRunStatus::Succeeded, 70, 10, "small"),
3717 candidate("dominated", WorkflowRunStatus::Succeeded, 60, 40, "middle"),
3718 candidate("failed", WorkflowRunStatus::Failed, 100, 1, "broken"),
3719 ];
3720
3721 let selected = frontier.select(&candidates);
3722
3723 assert_eq!(
3724 selected
3725 .iter()
3726 .map(|candidate| candidate.branch_id.as_str())
3727 .collect::<Vec<_>>(),
3728 vec!["quality", "minimal"]
3729 );
3730 assert_eq!(
3731 selected
3732 .iter()
3733 .filter_map(|candidate| candidate.diversity_key.as_deref())
3734 .collect::<Vec<_>>(),
3735 vec!["quality", "small"]
3736 );
3737 }
3738}