Skip to main content

orchestrator_config/config/
execution.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::sync::Arc;
4use std::time::Instant;
5
6use super::{
7    AgentConfig, CostPreference, ExecutionMode, ExecutionProfileConfig, InvariantConfig,
8    ItemIsolationConfig, ItemSelectConfig, OrchestratorConfig, PipelineVariables, SafetyConfig,
9    StepBehavior, StepPrehookConfig, StepScope, StoreInputConfig, StoreOutputConfig,
10    WorkflowConfig, WorkflowExecutionConfig, WorkflowFinalizeConfig, WorkflowLoopConfig,
11    default_scope_for_step_id, is_known_builtin_step_name,
12};
13
14fn default_true() -> bool {
15    true
16}
17
18/// Task execution step (runtime representation)
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TaskExecutionStep {
21    /// Stable step identifier used in plans, logs, and references.
22    pub id: String,
23    /// Required agent capability when this is an agent-dispatched step.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub required_capability: Option<String>,
26    /// Reference to a StepTemplate resource name
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub template: Option<String>,
29    /// Named execution profile applied to this step.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub execution_profile: Option<String>,
32    /// Builtin step implementation to invoke instead of agent dispatch.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub builtin: Option<String>,
35    /// Enables or disables the step without removing it from the plan.
36    #[serde(default = "default_true")]
37    pub enabled: bool,
38    /// Allows the step to run again in later workflow cycles.
39    #[serde(default = "default_true")]
40    pub repeatable: bool,
41    /// Marks the step as a loop guard that can terminate execution.
42    #[serde(default)]
43    pub is_guard: bool,
44    /// Optional agent-cost preference used during selection.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub cost_preference: Option<CostPreference>,
47    /// Runtime prehook controlling whether and how the step runs.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub prehook: Option<StepPrehookConfig>,
50    /// Requests a TTY when the step launches a command.
51    #[serde(default)]
52    pub tty: bool,
53    /// Named outputs this step produces (for pipeline variable passing)
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub outputs: Vec<String>,
56    /// Pipe this step's output to the named step as input
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub pipe_to: Option<String>,
59    /// Build command for builtin build/test/lint steps
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub command: Option<String>,
62    /// Sub-steps to execute in sequence for smoke_chain step
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub chain_steps: Vec<TaskExecutionStep>,
65    /// Execution scope override (defaults based on step type)
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub scope: Option<StepScope>,
68    /// Declarative step behavior (on_failure, captures, post_actions, etc.)
69    #[serde(default)]
70    pub behavior: StepBehavior,
71    /// Maximum parallel items for item-scoped steps (per-step override)
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub max_parallel: Option<usize>,
74    /// Stagger delay in ms between parallel agent spawns (per-step override)
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub stagger_delay_ms: Option<u64>,
77    /// Per-step timeout in seconds (overrides global safety.step_timeout_secs)
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub timeout_secs: Option<u64>,
80    /// Per-step stall auto-kill threshold in seconds (overrides global safety.stall_timeout_secs)
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub stall_timeout_secs: Option<u64>,
83    /// WP03: Configuration for item_select builtin step
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub item_select_config: Option<ItemSelectConfig>,
86    /// Store inputs: read values from workflow stores before step execution
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub store_inputs: Vec<StoreInputConfig>,
89    /// Store outputs: write pipeline vars to workflow stores after step execution
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub store_outputs: Vec<StoreOutputConfig>,
92}
93
94impl TaskExecutionStep {
95    /// Returns the resolved scope: explicit override or default based on step id,
96    /// falling back to required_capability when the id is not a known step type.
97    pub fn resolved_scope(&self) -> StepScope {
98        self.scope.unwrap_or_else(|| {
99            let scope = default_scope_for_step_id(&self.id);
100            if scope == StepScope::Task {
101                if let Some(ref cap) = self.required_capability {
102                    let cap_scope = default_scope_for_step_id(cap);
103                    if cap_scope == StepScope::Item {
104                        return cap_scope;
105                    }
106                }
107            }
108            scope
109        })
110    }
111
112    /// Returns the authoritative execution mode for this step.
113    ///
114    /// If the step shape implies a specific mode, this always returns it
115    /// regardless of what `behavior.execution` says:
116    ///
117    /// - `chain_steps` => `Chain`
118    /// - known `builtin` => `Builtin { name }`
119    /// - `command` => `Builtin { name: self.id }`
120    ///
121    /// This is the single consolidated entry point for dispatch decisions.
122    ///
123    /// Unlike `normalize_step_execution_mode` in `config::step`, which mutates stored state,
124    /// this method is read-only and is always authoritative at dispatch time,
125    /// even if renormalization hasn't run yet.
126    pub fn effective_execution_mode(&self) -> std::borrow::Cow<'_, ExecutionMode> {
127        if !self.chain_steps.is_empty() {
128            return std::borrow::Cow::Owned(ExecutionMode::Chain);
129        }
130        if let Some(ref bname) = self.builtin {
131            if is_known_builtin_step_name(bname) {
132                return std::borrow::Cow::Owned(ExecutionMode::Builtin {
133                    name: bname.clone(),
134                });
135            }
136        }
137        if self.command.is_some() {
138            return std::borrow::Cow::Owned(ExecutionMode::Builtin {
139                name: self.id.clone(),
140            });
141        }
142        std::borrow::Cow::Borrowed(&self.behavior.execution)
143    }
144
145    /// Corrects `behavior.execution` when stored state drifts from the step shape.
146    ///
147    /// After deserializing from SQLite the `behavior.execution` field may carry
148    /// the serde `#[default]` value (`ExecutionMode::Agent`) even though
149    /// `self.builtin` names a known builtin step.  This method is the single
150    /// source of truth for healing that mismatch:
151    ///
152    /// - If the step contains `chain_steps`, force `behavior.execution` to `Chain`.
153    /// - If `self.builtin` names a known builtin, force `behavior.execution`
154    ///   to `Builtin { name }` and clear `required_capability`.
155    /// - If the step is a command step, force `behavior.execution` to
156    ///   `Builtin { name: self.id }` so dispatch uses the command path.
157    /// - Recurse into child chain steps.
158    pub fn renormalize_execution_mode(&mut self) {
159        for chain_step in &mut self.chain_steps {
160            chain_step.renormalize_execution_mode();
161        }
162
163        if !self.chain_steps.is_empty() {
164            self.behavior.execution = ExecutionMode::Chain;
165            return;
166        }
167
168        if let Some(ref name) = self.builtin.clone() {
169            if is_known_builtin_step_name(name) {
170                self.behavior.execution = ExecutionMode::Builtin { name: name.clone() };
171                self.required_capability = None;
172                return;
173            }
174        }
175
176        if self.command.is_some() {
177            self.behavior.execution = ExecutionMode::Builtin {
178                name: self.id.clone(),
179            };
180        }
181    }
182}
183
184/// Task execution plan
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct TaskExecutionPlan {
187    /// Ordered steps that make up the workflow execution plan.
188    pub steps: Vec<TaskExecutionStep>,
189    #[serde(rename = "loop")]
190    /// Loop policy governing cycle repetition and stop conditions.
191    pub loop_policy: WorkflowLoopConfig,
192    /// Finalization rules evaluated after each item or workflow completes.
193    #[serde(default)]
194    pub finalize: WorkflowFinalizeConfig,
195    /// Default max parallelism for item-scoped segments (1 = sequential)
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub max_parallel: Option<usize>,
198    /// Default stagger delay in ms between parallel agent spawns
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub stagger_delay_ms: Option<u64>,
201    /// Workflow-level item isolation for item-scoped execution.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub item_isolation: Option<ItemIsolationConfig>,
204}
205
206impl TaskExecutionPlan {
207    /// Find step by string id
208    pub fn step_by_id(&self, id: &str) -> Option<&TaskExecutionStep> {
209        self.steps.iter().find(|step| step.id == id)
210    }
211}
212
213/// Task runtime context
214#[derive(Debug, Clone)]
215pub struct TaskRuntimeContext {
216    /// Workspace identifier selected for this task run.
217    pub workspace_id: String,
218    /// Absolute filesystem root for the active workspace.
219    pub workspace_root: std::path::PathBuf,
220    /// Directory where QA tickets are written.
221    pub ticket_dir: String,
222    /// Immutable execution plan snapshot pinned to the task.
223    pub execution_plan: Arc<TaskExecutionPlan>,
224    /// Workflow execution settings resolved for this task.
225    pub execution: WorkflowExecutionConfig,
226    /// One-based cycle counter for the current loop iteration.
227    pub current_cycle: u32,
228    /// Whether the one-time init step has already completed.
229    pub init_done: bool,
230    /// Dynamic step definitions available to the planner.
231    pub dynamic_steps: Arc<Vec<crate::dynamic_step::DynamicStepConfig>>,
232    /// Optional adaptive planning configuration.
233    pub adaptive: Arc<Option<crate::adaptive::AdaptivePlannerConfig>>,
234    /// Pipeline variables accumulated across steps in the current cycle
235    pub pipeline_vars: PipelineVariables,
236    /// Safety configuration
237    pub safety: Arc<SafetyConfig>,
238    /// Whether the workspace is self-referential
239    pub self_referential: bool,
240    /// Consecutive failure counter for auto-rollback
241    pub consecutive_failures: u32,
242    /// Project ID for project-scoped agent selection.
243    pub project_id: String,
244    /// WP04: Immutable snapshot of invariants, pinned at task start
245    pub pinned_invariants: Arc<Vec<InvariantConfig>>,
246    /// WP02: Workflow ID for spawn inheritance
247    pub workflow_id: String,
248    /// WP02: Current spawn depth for depth limiting
249    pub spawn_depth: i64,
250    /// FR-035: Per-item per-step consecutive failure counter (item_id, step_id) -> count
251    pub item_step_failures: HashMap<(String, String), u32>,
252    /// FR-035: Per-item retry-after timestamp for exponential backoff
253    pub item_retry_after: HashMap<String, Instant>,
254    /// Steps that already completed in this cycle before a self_restart.
255    /// Populated when resuming from restart_pending to avoid re-running steps.
256    pub restart_completed_steps: HashSet<String>,
257}
258
259impl TaskRuntimeContext {
260    /// Returns the adaptive planner configuration when adaptive orchestration is enabled.
261    pub fn adaptive_config(&self) -> Option<&crate::adaptive::AdaptivePlannerConfig> {
262        self.adaptive.as_ref().as_ref()
263    }
264
265    /// Returns the currently resolved dynamic step definitions.
266    pub fn dynamic_step_configs(&self) -> &[crate::dynamic_step::DynamicStepConfig] {
267        self.dynamic_steps.as_ref().as_slice()
268    }
269}
270
271/// Step prehook context for evaluation
272#[derive(Debug, Clone, Serialize, Deserialize, Default)]
273pub struct StepPrehookContext {
274    /// Parent task identifier.
275    pub task_id: String,
276    /// Current task item identifier.
277    pub task_item_id: String,
278    /// One-based workflow cycle currently being evaluated.
279    pub cycle: u32,
280    /// Step identifier whose prehook is running.
281    pub step: String,
282    /// QA document associated with the current item.
283    pub qa_file_path: String,
284    /// Current task item status.
285    pub item_status: String,
286    /// Current top-level task status.
287    pub task_status: String,
288    /// Exit code observed from the last QA step.
289    pub qa_exit_code: Option<i64>,
290    /// Exit code observed from the last fix step.
291    pub fix_exit_code: Option<i64>,
292    /// Exit code observed from the last retest step.
293    pub retest_exit_code: Option<i64>,
294    /// Number of open tickets for the task.
295    pub active_ticket_count: i64,
296    /// Number of tickets created in the latest QA pass.
297    pub new_ticket_count: i64,
298    /// Whether the last QA pass failed.
299    pub qa_failed: bool,
300    /// Whether the workflow believes a fix pass is required.
301    pub fix_required: bool,
302    /// Confidence score emitted by QA tooling.
303    pub qa_confidence: Option<f32>,
304    /// Quality score emitted by QA tooling.
305    pub qa_quality_score: Option<f32>,
306    /// Whether the last fix step changed the workspace.
307    pub fix_has_changes: Option<bool>,
308    /// Summaries of artifacts produced by upstream steps in the same cycle.
309    #[serde(default)]
310    pub upstream_artifacts: Vec<ArtifactSummary>,
311    /// Number of build errors from the last build step
312    #[serde(default)]
313    pub build_error_count: i64,
314    /// Number of test failures from the last test step
315    #[serde(default)]
316    pub test_failure_count: i64,
317    /// Exit code of the last build step
318    pub build_exit_code: Option<i64>,
319    /// Exit code of the last test step
320    pub test_exit_code: Option<i64>,
321    /// Exit code of the last self_test step
322    #[serde(default)]
323    pub self_test_exit_code: Option<i64>,
324    /// Whether the last self_test step passed
325    #[serde(default)]
326    pub self_test_passed: bool,
327    /// Maximum number of cycles configured for this workflow
328    #[serde(default)]
329    pub max_cycles: u32,
330    /// Whether this is the last cycle (cycle == max_cycles)
331    #[serde(default)]
332    pub is_last_cycle: bool,
333    /// Whether the latest command was denied by sandbox policy.
334    #[serde(default)]
335    pub last_sandbox_denied: bool,
336    /// Number of sandbox denials observed for the item.
337    #[serde(default)]
338    pub sandbox_denied_count: u32,
339    /// Human-readable reason for the latest sandbox denial.
340    #[serde(default)]
341    pub last_sandbox_denial_reason: Option<String>,
342    /// Whether this QA doc is safe to run in a self-referential workspace
343    #[serde(default = "default_true")]
344    pub self_referential_safe: bool,
345    /// Scenario IDs that are safe to run in self-referential mode.
346    /// Non-empty only when the doc is marked unsafe but has safe scenarios.
347    #[serde(default)]
348    pub self_referential_safe_scenarios: Vec<String>,
349    /// User-defined pipeline variables (from step captures).
350    /// Available in prehook CEL expressions with automatic type inference.
351    #[serde(default)]
352    pub vars: std::collections::HashMap<String, String>,
353}
354
355/// Context provided to convergence expression CEL evaluation.
356#[derive(Debug, Clone, Default)]
357pub struct ConvergenceContext {
358    /// One-based cycle counter.
359    pub cycle: u32,
360    /// Number of open tickets for the task.
361    pub active_ticket_count: i64,
362    /// Whether the last self_test step passed.
363    pub self_test_passed: bool,
364    /// Max cycles configured for the workflow.
365    pub max_cycles: u32,
366    /// User-defined pipeline variables (from step captures).
367    pub vars: std::collections::HashMap<String, String>,
368}
369
370/// Artifact summary
371#[derive(Debug, Clone, Serialize, Deserialize, Default)]
372pub struct ArtifactSummary {
373    /// Workflow phase that produced the artifact.
374    pub phase: String,
375    /// Artifact category, such as ticket or code_change.
376    pub kind: String,
377    /// Optional on-disk path for the artifact.
378    pub path: Option<String>,
379}
380
381/// Item finalize context
382#[derive(Debug, Clone, Serialize)]
383pub struct ItemFinalizeContext {
384    /// Parent task identifier.
385    pub task_id: String,
386    /// Current task item identifier.
387    pub task_item_id: String,
388    /// One-based workflow cycle currently being finalized.
389    pub cycle: u32,
390    /// QA document associated with the current item.
391    pub qa_file_path: String,
392    /// Current item status.
393    pub item_status: String,
394    /// Current task status.
395    pub task_status: String,
396    /// Exit code observed from the last QA step.
397    pub qa_exit_code: Option<i64>,
398    /// Exit code observed from the last fix step.
399    pub fix_exit_code: Option<i64>,
400    /// Exit code observed from the last retest step.
401    pub retest_exit_code: Option<i64>,
402    /// Number of open tickets for the task.
403    pub active_ticket_count: i64,
404    /// Number of tickets created by the latest QA step.
405    pub new_ticket_count: i64,
406    /// Number of tickets created during retest.
407    pub retest_new_ticket_count: i64,
408    /// Whether the latest QA pass failed.
409    pub qa_failed: bool,
410    /// Whether a fix pass is required.
411    pub fix_required: bool,
412    /// Whether a QA step exists in the plan.
413    pub qa_configured: bool,
414    /// Whether QA telemetry was observed.
415    pub qa_observed: bool,
416    /// Whether QA was enabled when the item ran.
417    pub qa_enabled: bool,
418    /// Whether QA actually executed.
419    pub qa_ran: bool,
420    /// Whether QA was skipped.
421    pub qa_skipped: bool,
422    /// Whether a fix step exists in the plan.
423    pub fix_configured: bool,
424    /// Whether fix execution was enabled.
425    pub fix_enabled: bool,
426    /// Whether fix execution actually ran.
427    pub fix_ran: bool,
428    /// Whether fix execution was skipped.
429    pub fix_skipped: bool,
430    /// Whether the latest fix step succeeded.
431    pub fix_success: bool,
432    /// Whether retest execution was enabled.
433    pub retest_enabled: bool,
434    /// Whether retest actually ran.
435    pub retest_ran: bool,
436    /// Whether the latest retest succeeded.
437    pub retest_success: bool,
438    /// Confidence score emitted by QA tooling.
439    pub qa_confidence: Option<f32>,
440    /// Quality score emitted by QA tooling.
441    pub qa_quality_score: Option<f32>,
442    /// Confidence score emitted by fix tooling.
443    pub fix_confidence: Option<f32>,
444    /// Quality score emitted by fix tooling.
445    pub fix_quality_score: Option<f32>,
446    /// Total number of artifacts recorded for the item.
447    pub total_artifacts: i64,
448    /// Whether any ticket artifact exists.
449    pub has_ticket_artifacts: bool,
450    /// Whether any code-change artifact exists.
451    pub has_code_change_artifacts: bool,
452    /// Whether the current cycle is the final allowed cycle.
453    pub is_last_cycle: bool,
454    /// Whether the latest command was denied by sandbox policy.
455    pub last_sandbox_denied: bool,
456    /// Number of sandbox denials observed for the item.
457    pub sandbox_denied_count: u32,
458    /// Human-readable reason for the latest sandbox denial.
459    pub last_sandbox_denial_reason: Option<String>,
460}
461
462/// Workflow finalize outcome
463#[derive(Debug, Clone)]
464pub struct WorkflowFinalizeOutcome {
465    /// Finalize rule identifier that produced the outcome.
466    pub rule_id: String,
467    /// Machine-readable status string.
468    pub status: String,
469    /// Human-readable explanation for the selected outcome.
470    pub reason: String,
471}
472
473/// Resolved workspace (with absolute paths)
474#[derive(Debug, Clone)]
475pub struct ResolvedWorkspace {
476    /// Absolute root path of the workspace.
477    pub root_path: std::path::PathBuf,
478    /// QA targets derived from workspace configuration.
479    pub qa_targets: Vec<String>,
480    /// Workspace-local ticket directory.
481    pub ticket_dir: String,
482}
483
484/// Resolved project
485#[derive(Debug, Clone)]
486pub struct ResolvedProject {
487    /// Workspaces available inside the project.
488    pub workspaces: HashMap<String, ResolvedWorkspace>,
489    /// Agent configurations available to the project.
490    pub agents: HashMap<String, AgentConfig>,
491    /// Workflow definitions available to the project.
492    pub workflows: HashMap<String, WorkflowConfig>,
493    /// Reusable step templates indexed by name.
494    pub step_templates: HashMap<String, crate::config::StepTemplateConfig>,
495    /// Environment or secret stores available to the project.
496    pub env_stores: HashMap<String, crate::config::EnvStoreConfig>,
497    /// Named execution profiles available to the project.
498    pub execution_profiles: HashMap<String, ExecutionProfileConfig>,
499}
500
501/// Active configuration (runtime state)
502#[derive(Debug, Clone)]
503pub struct ActiveConfig {
504    /// Fully materialized orchestrator configuration.
505    pub config: OrchestratorConfig,
506    /// Globally resolved workspaces.
507    pub workspaces: HashMap<String, ResolvedWorkspace>,
508    /// Project-scoped resolved configuration views.
509    pub projects: HashMap<String, ResolvedProject>,
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn make_agent_step(
517        id: &str,
518        builtin: Option<&str>,
519        capability: Option<&str>,
520    ) -> TaskExecutionStep {
521        TaskExecutionStep {
522            id: id.to_string(),
523            required_capability: capability.map(|s| s.to_string()),
524            template: None,
525            execution_profile: None,
526            builtin: builtin.map(|s| s.to_string()),
527            enabled: true,
528            repeatable: true,
529            is_guard: false,
530            cost_preference: None,
531            prehook: None,
532            tty: false,
533            outputs: vec![],
534            pipe_to: None,
535            command: None,
536            chain_steps: vec![],
537            scope: None,
538            behavior: StepBehavior::default(),
539            max_parallel: None,
540            stagger_delay_ms: None,
541            timeout_secs: None,
542            stall_timeout_secs: None,
543            item_select_config: None,
544            store_inputs: vec![],
545            store_outputs: vec![],
546        }
547    }
548
549    #[test]
550    fn test_resolved_scope_explicit_override() {
551        let step = TaskExecutionStep {
552            id: "qa".to_string(), // default would be Item
553            required_capability: None,
554            template: None,
555            execution_profile: None,
556            builtin: None,
557            enabled: true,
558            repeatable: true,
559            is_guard: false,
560            cost_preference: None,
561            prehook: None,
562            tty: false,
563            outputs: vec![],
564            pipe_to: None,
565            command: None,
566            chain_steps: vec![],
567            scope: Some(StepScope::Task), // explicit override
568            behavior: StepBehavior::default(),
569            max_parallel: None,
570            stagger_delay_ms: None,
571            timeout_secs: None,
572            stall_timeout_secs: None,
573            item_select_config: None,
574            store_inputs: vec![],
575            store_outputs: vec![],
576        };
577        assert_eq!(step.resolved_scope(), StepScope::Task);
578    }
579
580    #[test]
581    fn test_resolved_scope_from_step_id() {
582        let step = TaskExecutionStep {
583            id: "plan".to_string(),
584            required_capability: None,
585            template: None,
586            execution_profile: None,
587            builtin: None,
588            enabled: true,
589            repeatable: true,
590            is_guard: false,
591            cost_preference: None,
592            prehook: None,
593            tty: false,
594            outputs: vec![],
595            pipe_to: None,
596            command: None,
597            chain_steps: vec![],
598            scope: None,
599            behavior: StepBehavior::default(),
600            max_parallel: None,
601            stagger_delay_ms: None,
602            timeout_secs: None,
603            stall_timeout_secs: None,
604            item_select_config: None,
605            store_inputs: vec![],
606            store_outputs: vec![],
607        };
608        assert_eq!(step.resolved_scope(), StepScope::Task);
609    }
610
611    #[test]
612    fn test_resolved_scope_unknown_id_defaults_to_task() {
613        let step = TaskExecutionStep {
614            id: "my_custom_step".to_string(),
615            required_capability: None,
616            template: None,
617            execution_profile: None,
618            builtin: None,
619            enabled: true,
620            repeatable: true,
621            is_guard: false,
622            cost_preference: None,
623            prehook: None,
624            tty: false,
625            outputs: vec![],
626            pipe_to: None,
627            command: None,
628            chain_steps: vec![],
629            scope: None,
630            behavior: StepBehavior::default(),
631            max_parallel: None,
632            stagger_delay_ms: None,
633            timeout_secs: None,
634            stall_timeout_secs: None,
635            item_select_config: None,
636            store_inputs: vec![],
637            store_outputs: vec![],
638        };
639        assert_eq!(step.resolved_scope(), StepScope::Task);
640    }
641
642    #[test]
643    fn test_task_execution_plan_step_by_id_found() {
644        let plan = TaskExecutionPlan {
645            steps: vec![
646                TaskExecutionStep {
647                    id: "plan".to_string(),
648                    required_capability: None,
649                    template: None,
650                    execution_profile: None,
651                    builtin: None,
652                    enabled: true,
653                    repeatable: false,
654                    is_guard: false,
655                    cost_preference: None,
656                    prehook: None,
657                    tty: false,
658                    outputs: vec![],
659                    pipe_to: None,
660                    command: None,
661                    chain_steps: vec![],
662                    scope: None,
663                    behavior: StepBehavior::default(),
664                    max_parallel: None,
665                    stagger_delay_ms: None,
666                    timeout_secs: None,
667                    stall_timeout_secs: None,
668                    item_select_config: None,
669                    store_inputs: vec![],
670                    store_outputs: vec![],
671                },
672                TaskExecutionStep {
673                    id: "qa".to_string(),
674                    required_capability: None,
675                    template: None,
676                    execution_profile: None,
677                    builtin: None,
678                    enabled: true,
679                    repeatable: true,
680                    is_guard: false,
681                    cost_preference: None,
682                    prehook: None,
683                    tty: false,
684                    outputs: vec![],
685                    pipe_to: None,
686                    command: None,
687                    chain_steps: vec![],
688                    scope: None,
689                    behavior: StepBehavior::default(),
690                    max_parallel: None,
691                    stagger_delay_ms: None,
692                    timeout_secs: None,
693                    stall_timeout_secs: None,
694                    item_select_config: None,
695                    store_inputs: vec![],
696                    store_outputs: vec![],
697                },
698            ],
699            loop_policy: WorkflowLoopConfig::default(),
700            finalize: WorkflowFinalizeConfig::default(),
701            max_parallel: None,
702            stagger_delay_ms: None,
703            item_isolation: None,
704        };
705
706        let found = plan.step_by_id("qa");
707        let found = found.expect("qa step should be found");
708        assert_eq!(found.id, "qa");
709
710        let found_plan = plan.step_by_id("plan");
711        let found_plan = found_plan.expect("plan step should be found");
712        assert_eq!(found_plan.id, "plan");
713    }
714
715    #[test]
716    fn test_task_execution_plan_step_by_id_not_found() {
717        let plan = TaskExecutionPlan {
718            steps: vec![],
719            loop_policy: WorkflowLoopConfig::default(),
720            finalize: WorkflowFinalizeConfig::default(),
721            max_parallel: None,
722            stagger_delay_ms: None,
723            item_isolation: None,
724        };
725        assert!(plan.step_by_id("fix").is_none());
726    }
727
728    #[test]
729    fn renormalize_corrects_stale_agent_to_builtin() {
730        let mut step = make_agent_step("self_test", Some("self_test"), None);
731        // Precondition: execution defaults to Agent (serde default)
732        assert_eq!(step.behavior.execution, ExecutionMode::Agent);
733        step.renormalize_execution_mode();
734        assert_eq!(
735            step.behavior.execution,
736            ExecutionMode::Builtin {
737                name: "self_test".to_string()
738            }
739        );
740    }
741
742    #[test]
743    fn renormalize_clears_stale_required_capability() {
744        let mut step = make_agent_step("self_test", Some("self_test"), Some("self_test"));
745        step.renormalize_execution_mode();
746        assert!(step.required_capability.is_none());
747    }
748
749    #[test]
750    fn renormalize_noop_for_correct_builtin() {
751        let mut step = make_agent_step("self_test", Some("self_test"), None);
752        step.behavior.execution = ExecutionMode::Builtin {
753            name: "self_test".to_string(),
754        };
755        step.renormalize_execution_mode();
756        assert_eq!(
757            step.behavior.execution,
758            ExecutionMode::Builtin {
759                name: "self_test".to_string()
760            }
761        );
762    }
763
764    #[test]
765    fn renormalize_noop_for_agent_step() {
766        let mut step = make_agent_step("plan", None, Some("plan"));
767        step.renormalize_execution_mode();
768        // stays Agent, capability unchanged
769        assert_eq!(step.behavior.execution, ExecutionMode::Agent);
770        assert_eq!(step.required_capability, Some("plan".to_string()));
771    }
772
773    #[test]
774    fn renormalize_restores_chain_execution_recursively() {
775        let mut step = make_agent_step("smoke_chain", None, Some("smoke_chain"));
776        step.chain_steps = vec![TaskExecutionStep {
777            id: "chain_plan".to_string(),
778            command: Some("printf 'CHAIN_PLAN'".to_string()),
779            ..make_agent_step("chain_plan", None, None)
780        }];
781
782        step.renormalize_execution_mode();
783
784        assert_eq!(step.behavior.execution, ExecutionMode::Chain);
785        assert_eq!(
786            step.chain_steps[0].behavior.execution,
787            ExecutionMode::Builtin {
788                name: "chain_plan".to_string()
789            }
790        );
791    }
792
793    #[test]
794    fn renormalize_handles_all_known_builtins() {
795        for name in &["init_once", "loop_guard", "ticket_scan", "self_test"] {
796            let mut step = make_agent_step(name, Some(name), None);
797            // Starts as Agent (default)
798            assert_eq!(
799                step.behavior.execution,
800                ExecutionMode::Agent,
801                "name={}",
802                name
803            );
804            step.renormalize_execution_mode();
805            assert_eq!(
806                step.behavior.execution,
807                ExecutionMode::Builtin {
808                    name: name.to_string()
809                },
810                "name={}",
811                name
812            );
813        }
814    }
815
816    #[test]
817    fn step_prehook_context_serde_defaults_round_trip() {
818        let json = serde_json::json!({
819            "task_id": "task-1",
820            "task_item_id": "item-1",
821            "cycle": 1,
822            "step": "qa_testing",
823            "qa_file_path": "docs/qa/test.md",
824            "item_status": "pending",
825            "task_status": "running",
826            "qa_exit_code": 1,
827            "fix_exit_code": null,
828            "retest_exit_code": null,
829            "active_ticket_count": 2,
830            "new_ticket_count": 1,
831            "qa_failed": true,
832            "fix_required": true,
833            "qa_confidence": 0.9,
834            "qa_quality_score": 0.7,
835            "fix_has_changes": null
836        });
837
838        let context: StepPrehookContext =
839            serde_json::from_value(json).expect("context should deserialize");
840        assert!(context.upstream_artifacts.is_empty());
841        assert_eq!(context.build_error_count, 0);
842        assert_eq!(context.test_failure_count, 0);
843        assert_eq!(context.self_test_exit_code, None);
844        assert!(!context.self_test_passed);
845        assert_eq!(context.max_cycles, 0);
846        assert!(!context.is_last_cycle);
847        assert!(context.self_referential_safe);
848
849        let artifact = ArtifactSummary {
850            phase: "qa".to_string(),
851            kind: "report".to_string(),
852            path: Some("artifacts/report.json".to_string()),
853        };
854        let round_trip = StepPrehookContext {
855            upstream_artifacts: vec![artifact],
856            build_error_count: 3,
857            test_failure_count: 4,
858            self_test_exit_code: Some(2),
859            self_test_passed: true,
860            max_cycles: 5,
861            is_last_cycle: false,
862            self_referential_safe: false,
863            ..context
864        };
865        let serialized = serde_json::to_value(&round_trip).expect("context should serialize");
866        let reparsed: StepPrehookContext =
867            serde_json::from_value(serialized).expect("context should round-trip");
868        assert_eq!(reparsed.upstream_artifacts.len(), 1);
869        assert_eq!(reparsed.build_error_count, 3);
870        assert_eq!(reparsed.test_failure_count, 4);
871        assert_eq!(reparsed.self_test_exit_code, Some(2));
872        assert!(reparsed.self_test_passed);
873        assert_eq!(reparsed.max_cycles, 5);
874        assert!(!reparsed.is_last_cycle);
875        assert!(!reparsed.self_referential_safe);
876    }
877}