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