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