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