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