Skip to main content

codewhale_workflow/
lib.rs

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