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