Skip to main content

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