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#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TaskExecutionStep {
21 pub id: String,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub required_capability: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub template: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub execution_profile: Option<String>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub builtin: Option<String>,
35 #[serde(default = "default_true")]
37 pub enabled: bool,
38 #[serde(default = "default_true")]
40 pub repeatable: bool,
41 #[serde(default)]
43 pub is_guard: bool,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub cost_preference: Option<CostPreference>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub prehook: Option<StepPrehookConfig>,
50 #[serde(default)]
52 pub tty: bool,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub outputs: Vec<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub pipe_to: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub command: Option<String>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub chain_steps: Vec<TaskExecutionStep>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub scope: Option<StepScope>,
68 #[serde(default)]
70 pub behavior: StepBehavior,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub max_parallel: Option<usize>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub stagger_delay_ms: Option<u64>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub timeout_secs: Option<u64>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub stall_timeout_secs: Option<u64>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub item_select_config: Option<ItemSelectConfig>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub store_inputs: Vec<StoreInputConfig>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub store_outputs: Vec<StoreOutputConfig>,
92}
93
94impl TaskExecutionStep {
95 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct TaskExecutionPlan {
187 pub steps: Vec<TaskExecutionStep>,
189 #[serde(rename = "loop")]
190 pub loop_policy: WorkflowLoopConfig,
192 #[serde(default)]
194 pub finalize: WorkflowFinalizeConfig,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub max_parallel: Option<usize>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub stagger_delay_ms: Option<u64>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub item_isolation: Option<ItemIsolationConfig>,
204}
205
206impl TaskExecutionPlan {
207 pub fn step_by_id(&self, id: &str) -> Option<&TaskExecutionStep> {
209 self.steps.iter().find(|step| step.id == id)
210 }
211}
212
213#[derive(Debug, Clone)]
215pub struct TaskRuntimeContext {
216 pub workspace_id: String,
218 pub workspace_root: std::path::PathBuf,
220 pub ticket_dir: String,
222 pub execution_plan: Arc<TaskExecutionPlan>,
224 pub execution: WorkflowExecutionConfig,
226 pub current_cycle: u32,
228 pub init_done: bool,
230 pub dynamic_steps: Arc<Vec<crate::dynamic_step::DynamicStepConfig>>,
232 pub adaptive: Arc<Option<crate::adaptive::AdaptivePlannerConfig>>,
234 pub pipeline_vars: PipelineVariables,
236 pub safety: Arc<SafetyConfig>,
238 pub self_referential: bool,
240 pub consecutive_failures: u32,
242 pub project_id: String,
244 pub pinned_invariants: Arc<Vec<InvariantConfig>>,
246 pub workflow_id: String,
248 pub spawn_depth: i64,
250 pub item_step_failures: HashMap<(String, String), u32>,
252 pub item_retry_after: HashMap<String, Instant>,
254 pub restart_completed_steps: HashSet<String>,
257}
258
259impl TaskRuntimeContext {
260 pub fn adaptive_config(&self) -> Option<&crate::adaptive::AdaptivePlannerConfig> {
262 self.adaptive.as_ref().as_ref()
263 }
264
265 pub fn dynamic_step_configs(&self) -> &[crate::dynamic_step::DynamicStepConfig] {
267 self.dynamic_steps.as_ref().as_slice()
268 }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, Default)]
273pub struct StepPrehookContext {
274 pub task_id: String,
276 pub task_item_id: String,
278 pub cycle: u32,
280 pub step: String,
282 pub qa_file_path: String,
284 pub item_status: String,
286 pub task_status: String,
288 pub qa_exit_code: Option<i64>,
290 pub fix_exit_code: Option<i64>,
292 pub retest_exit_code: Option<i64>,
294 pub active_ticket_count: i64,
296 pub new_ticket_count: i64,
298 pub qa_failed: bool,
300 pub fix_required: bool,
302 pub qa_confidence: Option<f32>,
304 pub qa_quality_score: Option<f32>,
306 pub fix_has_changes: Option<bool>,
308 #[serde(default)]
310 pub upstream_artifacts: Vec<ArtifactSummary>,
311 #[serde(default)]
313 pub build_error_count: i64,
314 #[serde(default)]
316 pub test_failure_count: i64,
317 pub build_exit_code: Option<i64>,
319 pub test_exit_code: Option<i64>,
321 #[serde(default)]
323 pub self_test_exit_code: Option<i64>,
324 #[serde(default)]
326 pub self_test_passed: bool,
327 #[serde(default)]
329 pub max_cycles: u32,
330 #[serde(default)]
332 pub is_last_cycle: bool,
333 #[serde(default)]
335 pub last_sandbox_denied: bool,
336 #[serde(default)]
338 pub sandbox_denied_count: u32,
339 #[serde(default)]
341 pub last_sandbox_denial_reason: Option<String>,
342 #[serde(default = "default_true")]
344 pub self_referential_safe: bool,
345 #[serde(default)]
348 pub self_referential_safe_scenarios: Vec<String>,
349 #[serde(default)]
352 pub vars: std::collections::HashMap<String, String>,
353}
354
355#[derive(Debug, Clone, Default)]
357pub struct ConvergenceContext {
358 pub cycle: u32,
360 pub active_ticket_count: i64,
362 pub self_test_passed: bool,
364 pub max_cycles: u32,
366 pub vars: std::collections::HashMap<String, String>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, Default)]
372pub struct ArtifactSummary {
373 pub phase: String,
375 pub kind: String,
377 pub path: Option<String>,
379}
380
381#[derive(Debug, Clone, Serialize)]
383pub struct ItemFinalizeContext {
384 pub task_id: String,
386 pub task_item_id: String,
388 pub cycle: u32,
390 pub qa_file_path: String,
392 pub item_status: String,
394 pub task_status: String,
396 pub qa_exit_code: Option<i64>,
398 pub fix_exit_code: Option<i64>,
400 pub retest_exit_code: Option<i64>,
402 pub active_ticket_count: i64,
404 pub new_ticket_count: i64,
406 pub retest_new_ticket_count: i64,
408 pub qa_failed: bool,
410 pub fix_required: bool,
412 pub qa_configured: bool,
414 pub qa_observed: bool,
416 pub qa_enabled: bool,
418 pub qa_ran: bool,
420 pub qa_skipped: bool,
422 pub fix_configured: bool,
424 pub fix_enabled: bool,
426 pub fix_ran: bool,
428 pub fix_skipped: bool,
430 pub fix_success: bool,
432 pub retest_enabled: bool,
434 pub retest_ran: bool,
436 pub retest_success: bool,
438 pub qa_confidence: Option<f32>,
440 pub qa_quality_score: Option<f32>,
442 pub fix_confidence: Option<f32>,
444 pub fix_quality_score: Option<f32>,
446 pub total_artifacts: i64,
448 pub has_ticket_artifacts: bool,
450 pub has_code_change_artifacts: bool,
452 pub is_last_cycle: bool,
454 pub last_sandbox_denied: bool,
456 pub sandbox_denied_count: u32,
458 pub last_sandbox_denial_reason: Option<String>,
460}
461
462#[derive(Debug, Clone)]
464pub struct WorkflowFinalizeOutcome {
465 pub rule_id: String,
467 pub status: String,
469 pub reason: String,
471}
472
473#[derive(Debug, Clone)]
475pub struct ResolvedWorkspace {
476 pub root_path: std::path::PathBuf,
478 pub qa_targets: Vec<String>,
480 pub ticket_dir: String,
482}
483
484#[derive(Debug, Clone)]
486pub struct ResolvedProject {
487 pub workspaces: HashMap<String, ResolvedWorkspace>,
489 pub agents: HashMap<String, AgentConfig>,
491 pub workflows: HashMap<String, WorkflowConfig>,
493 pub step_templates: HashMap<String, crate::config::StepTemplateConfig>,
495 pub env_stores: HashMap<String, crate::config::EnvStoreConfig>,
497 pub execution_profiles: HashMap<String, ExecutionProfileConfig>,
499}
500
501#[derive(Debug, Clone)]
503pub struct ActiveConfig {
504 pub config: OrchestratorConfig,
506 pub workspaces: HashMap<String, ResolvedWorkspace>,
508 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(), 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), 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 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 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 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}