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