Skip to main content

edda_conductor/state/
machine.rs

1use anyhow::{bail, Result};
2use serde::{Deserialize, Serialize};
3
4use crate::plan::schema::Plan;
5
6// ── Status enums ──
7
8#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10pub enum PhaseStatus {
11    Pending,
12    Running,
13    Checking,
14    Passed,
15    Failed,
16    Skipped,
17    Stale,
18}
19
20#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(rename_all = "snake_case")]
22pub enum PlanStatus {
23    Pending,
24    Running,
25    Blocked,
26    Completed,
27    Aborted,
28}
29
30// ── State types ──
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PlanState {
34    pub plan_name: String,
35    pub plan_file: String,
36    pub plan_status: PlanStatus,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub started_at: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub completed_at: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub aborted_at: Option<String>,
43    #[serde(default)]
44    pub total_cost_usd: f64,
45    pub phases: Vec<PhaseState>,
46    #[serde(default)]
47    pub version: u32,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct PhaseState {
52    pub id: String,
53    pub status: PhaseStatus,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub started_at: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub completed_at: Option<String>,
58    #[serde(default)]
59    pub attempts: u32,
60    #[serde(default)]
61    pub checks: Vec<CheckResult>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub error: Option<ErrorInfo>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub skip_reason: Option<String>,
66    /// Error context from previous attempt, injected into retry prompt.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub retry_context: Option<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ErrorInfo {
73    pub error_type: ErrorType,
74    pub message: String,
75    pub retryable: bool,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub check_index: Option<usize>,
78    pub timestamp: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83pub enum ErrorType {
84    AgentCrash,
85    CheckFailed,
86    Timeout,
87    BudgetExceeded,
88    UserAbort,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct CheckResult {
93    pub check_type: String,
94    pub status: CheckStatus,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub detail: Option<String>,
97    pub duration_ms: u64,
98}
99
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "snake_case")]
102pub enum CheckStatus {
103    Waiting,
104    Running,
105    Passed,
106    Failed,
107}
108
109// ── Valid transitions ──
110
111const VALID_TRANSITIONS: &[(PhaseStatus, &[PhaseStatus])] = &[
112    (
113        PhaseStatus::Pending,
114        &[PhaseStatus::Running, PhaseStatus::Skipped],
115    ),
116    (
117        PhaseStatus::Running,
118        &[
119            PhaseStatus::Checking,
120            PhaseStatus::Failed,
121            PhaseStatus::Stale,
122        ],
123    ),
124    (
125        PhaseStatus::Checking,
126        &[PhaseStatus::Passed, PhaseStatus::Failed],
127    ),
128    (PhaseStatus::Failed, &[PhaseStatus::Pending]), // retry
129    (PhaseStatus::Stale, &[PhaseStatus::Pending]),  // retry
130                                                    // Passed and Skipped are terminal
131];
132
133fn is_valid_transition(from: PhaseStatus, to: PhaseStatus) -> bool {
134    VALID_TRANSITIONS
135        .iter()
136        .any(|(f, targets)| *f == from && targets.contains(&to))
137}
138
139// ── Side effects ──
140
141/// Optional side-effect data applied during a transition.
142#[derive(Debug, Clone, Default)]
143pub struct PhaseUpdate {
144    pub started_at: Option<String>,
145    pub completed_at: Option<String>,
146    pub attempts: Option<u32>,
147    pub checks: Option<Vec<CheckResult>>,
148    pub error: Option<ErrorInfo>,
149    pub skip_reason: Option<String>,
150    pub retry_context: Option<Option<String>>,
151}
152
153impl PhaseUpdate {
154    pub fn apply(self, phase: &mut PhaseState) {
155        if let Some(v) = self.started_at {
156            phase.started_at = Some(v);
157        }
158        if let Some(v) = self.completed_at {
159            phase.completed_at = Some(v);
160        }
161        if let Some(v) = self.attempts {
162            phase.attempts = v;
163        }
164        if let Some(v) = self.checks {
165            phase.checks = v;
166        }
167        if self.error.is_some() {
168            phase.error = self.error;
169        }
170        if let Some(v) = self.skip_reason {
171            phase.skip_reason = Some(v);
172        }
173        if let Some(v) = self.retry_context {
174            phase.retry_context = v;
175        }
176    }
177}
178
179// ── CAS-guarded transition ──
180
181/// Transition a phase from `from` to `to`, applying side effects.
182/// Returns Ok(true) on success, Ok(false) on CAS miss (current != from).
183pub fn transition(
184    state: &mut PlanState,
185    phase_id: &str,
186    from: PhaseStatus,
187    to: PhaseStatus,
188    side_effect: Option<PhaseUpdate>,
189) -> Result<bool> {
190    let phase = state.get_phase_mut(phase_id)?;
191    if phase.status != from {
192        return Ok(false); // CAS miss
193    }
194    if !is_valid_transition(from, to) {
195        bail!("invalid transition: {phase_id} {from:?} → {to:?}");
196    }
197    phase.status = to;
198    if let Some(update) = side_effect {
199        update.apply(phase);
200    }
201    state.version += 1;
202    Ok(true)
203}
204
205// ── PlanState methods ──
206
207impl PlanState {
208    /// Create initial state from a plan.
209    pub fn from_plan(plan: &Plan, plan_file: &str) -> Self {
210        let phases = plan
211            .phases
212            .iter()
213            .map(|p| PhaseState {
214                id: p.id.clone(),
215                status: PhaseStatus::Pending,
216                started_at: None,
217                completed_at: None,
218                attempts: 0,
219                checks: Vec::new(),
220                error: None,
221                skip_reason: None,
222                retry_context: None,
223            })
224            .collect();
225
226        PlanState {
227            plan_name: plan.name.clone(),
228            plan_file: plan_file.to_string(),
229            plan_status: PlanStatus::Pending,
230            started_at: None,
231            completed_at: None,
232            aborted_at: None,
233            total_cost_usd: 0.0,
234            phases,
235            version: 0,
236        }
237    }
238
239    pub fn get_phase(&self, id: &str) -> Result<&PhaseState> {
240        self.phases
241            .iter()
242            .find(|p| p.id == id)
243            .ok_or_else(|| anyhow::anyhow!("phase not found: \"{id}\""))
244    }
245
246    pub fn get_phase_mut(&mut self, id: &str) -> Result<&mut PhaseState> {
247        self.phases
248            .iter_mut()
249            .find(|p| p.id == id)
250            .ok_or_else(|| anyhow::anyhow!("phase not found: \"{id}\""))
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::plan::parser::parse_plan;
258
259    fn test_plan() -> Plan {
260        parse_plan(
261            r#"
262name: test
263phases:
264  - id: a
265    prompt: "x"
266  - id: b
267    prompt: "x"
268    depends_on: [a]
269"#,
270        )
271        .unwrap()
272    }
273
274    #[test]
275    fn from_plan_initializes_pending() {
276        let plan = test_plan();
277        let state = PlanState::from_plan(&plan, "plan.yaml");
278        assert_eq!(state.plan_status, PlanStatus::Pending);
279        assert_eq!(state.phases.len(), 2);
280        assert!(state
281            .phases
282            .iter()
283            .all(|p| p.status == PhaseStatus::Pending));
284        assert_eq!(state.version, 0);
285    }
286
287    #[test]
288    fn valid_transition_pending_to_running() {
289        let plan = test_plan();
290        let mut state = PlanState::from_plan(&plan, "plan.yaml");
291        let ok = transition(
292            &mut state,
293            "a",
294            PhaseStatus::Pending,
295            PhaseStatus::Running,
296            None,
297        )
298        .unwrap();
299        assert!(ok);
300        assert_eq!(state.get_phase("a").unwrap().status, PhaseStatus::Running);
301        assert_eq!(state.version, 1);
302    }
303
304    #[test]
305    fn cas_miss_returns_false() {
306        let plan = test_plan();
307        let mut state = PlanState::from_plan(&plan, "plan.yaml");
308        // Try to transition from Running, but it's Pending
309        let ok = transition(
310            &mut state,
311            "a",
312            PhaseStatus::Running,
313            PhaseStatus::Checking,
314            None,
315        )
316        .unwrap();
317        assert!(!ok);
318        assert_eq!(state.get_phase("a").unwrap().status, PhaseStatus::Pending);
319    }
320
321    #[test]
322    fn invalid_transition_errors() {
323        let plan = test_plan();
324        let mut state = PlanState::from_plan(&plan, "plan.yaml");
325        // Pending → Passed is not valid
326        let err = transition(
327            &mut state,
328            "a",
329            PhaseStatus::Pending,
330            PhaseStatus::Passed,
331            None,
332        );
333        assert!(err.is_err());
334    }
335
336    #[test]
337    fn side_effects_applied() {
338        let plan = test_plan();
339        let mut state = PlanState::from_plan(&plan, "plan.yaml");
340        transition(
341            &mut state,
342            "a",
343            PhaseStatus::Pending,
344            PhaseStatus::Running,
345            Some(PhaseUpdate {
346                started_at: Some("2026-01-01T00:00:00Z".into()),
347                attempts: Some(1),
348                ..Default::default()
349            }),
350        )
351        .unwrap();
352
353        let phase = state.get_phase("a").unwrap();
354        assert_eq!(phase.started_at.as_deref(), Some("2026-01-01T00:00:00Z"));
355        assert_eq!(phase.attempts, 1);
356    }
357
358    #[test]
359    fn retry_transition_failed_to_pending() {
360        let plan = test_plan();
361        let mut state = PlanState::from_plan(&plan, "plan.yaml");
362
363        // pending → running → failed → pending (retry)
364        transition(
365            &mut state,
366            "a",
367            PhaseStatus::Pending,
368            PhaseStatus::Running,
369            None,
370        )
371        .unwrap();
372        transition(
373            &mut state,
374            "a",
375            PhaseStatus::Running,
376            PhaseStatus::Failed,
377            None,
378        )
379        .unwrap();
380        let ok = transition(
381            &mut state,
382            "a",
383            PhaseStatus::Failed,
384            PhaseStatus::Pending,
385            None,
386        )
387        .unwrap();
388        assert!(ok);
389        assert_eq!(state.get_phase("a").unwrap().status, PhaseStatus::Pending);
390    }
391
392    #[test]
393    fn terminal_states_have_no_transitions() {
394        let plan = test_plan();
395        let mut state = PlanState::from_plan(&plan, "plan.yaml");
396
397        // Get to Passed
398        transition(
399            &mut state,
400            "a",
401            PhaseStatus::Pending,
402            PhaseStatus::Running,
403            None,
404        )
405        .unwrap();
406        transition(
407            &mut state,
408            "a",
409            PhaseStatus::Running,
410            PhaseStatus::Checking,
411            None,
412        )
413        .unwrap();
414        transition(
415            &mut state,
416            "a",
417            PhaseStatus::Checking,
418            PhaseStatus::Passed,
419            None,
420        )
421        .unwrap();
422
423        // Passed → anything should fail
424        let err = transition(
425            &mut state,
426            "a",
427            PhaseStatus::Passed,
428            PhaseStatus::Pending,
429            None,
430        );
431        assert!(err.is_err());
432    }
433
434    #[test]
435    fn unknown_phase_errors() {
436        let plan = test_plan();
437        let mut state = PlanState::from_plan(&plan, "plan.yaml");
438        let err = transition(
439            &mut state,
440            "nonexistent",
441            PhaseStatus::Pending,
442            PhaseStatus::Running,
443            None,
444        );
445        assert!(err.is_err());
446    }
447
448    #[test]
449    fn state_roundtrip_json() {
450        let plan = test_plan();
451        let state = PlanState::from_plan(&plan, "plan.yaml");
452        let json = serde_json::to_string_pretty(&state).unwrap();
453        let restored: PlanState = serde_json::from_str(&json).unwrap();
454        assert_eq!(restored.plan_name, "test");
455        assert_eq!(restored.phases.len(), 2);
456    }
457}