Skip to main content

devflow_core/
state.rs

1//! DevFlow state machine.
2//!
3//! Drives the development workflow through a single linear chain of five stages:
4//! Define → Plan → Code → Validate → Ship. See [`crate::stage::Stage`].
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::path::PathBuf;
9use std::str::FromStr;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::mode::Mode;
13use crate::stage::Stage;
14
15/// Full workflow state persisted to `.devflow/state.json`.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct State {
18    /// Current workflow stage.
19    pub stage: Stage,
20    /// Phase number being worked on.
21    pub phase: u32,
22    /// Which coding agent was launched.
23    pub agent: AgentKind,
24    /// How the pipeline is driven (auto vs. supervise).
25    pub mode: Mode,
26    /// Whether a gate has been written and is awaiting a human response.
27    #[serde(default)]
28    pub gate_pending: bool,
29    /// Consecutive Validate failures — drives the Auto-mode forced gate after
30    /// [`crate::mode::MAX_CONSECUTIVE_FAILURES`] failures. Persisted across
31    /// `devflow advance` invocations so the counter survives monitor restarts.
32    #[serde(default)]
33    pub consecutive_failures: u32,
34    /// Consecutive infrastructure-class faults (`ResourceKilled`,
35    /// `AgentUnavailable`) — distinct from [`Self::consecutive_failures`]
36    /// (D-08, 17-01). Gates at [`crate::mode::MAX_INFRA_FAILURES`]. Any
37    /// increment (wired in Plan 04) must use `saturating_add` so a
38    /// long-running stuck loop cannot overflow `u32`. A serde-absent value
39    /// (older persisted state) defaults to 0. Reset to 0 on every successful
40    /// stage transition, alongside `consecutive_failures` (CR-01, 17-06 gap
41    /// closure), so the ceiling bounds a stuck loop, not a phase's lifetime.
42    #[serde(default)]
43    pub infra_failures: u32,
44    /// How many times a preflight gate has been resolved and retried for
45    /// this phase (18f). Bounded by [`crate::mode::MAX_PREFLIGHT_RETRIES`].
46    /// Persisted rather than recursion-scoped because the documented wedge
47    /// spanned separate `devflow` invocations after a monitor death — an
48    /// in-process recursion-depth counter would reset to zero on every new
49    /// process and fail to bound the exact incident it exists to prevent.
50    /// Reset to 0 whenever preflight passes and whenever a human explicitly
51    /// approves (`GateAction::Advance`), both inside `run_preflight`. Unlike
52    /// [`Self::consecutive_failures`] and [`Self::infra_failures`], this
53    /// counter is NOT touched by `transition()`.
54    #[serde(default)]
55    pub preflight_retries: u32,
56    /// When the phase started (Unix seconds).
57    pub started_at: String,
58    /// Path to the project root.
59    pub project_root: PathBuf,
60    /// Working directory for the agent when running in a git worktree.
61    ///
62    /// `None` means the agent runs in `project_root`. State and capture files
63    /// always live under the main `project_root`; only the agent's cwd changes.
64    #[serde(default)]
65    pub worktree_path: Option<PathBuf>,
66    /// PID of the detached monitor process that owns the agent for the
67    /// current stage, recorded by `launch_stage` at spawn time. `None` means
68    /// no monitor has been spawned for this state yet, OR the state was
69    /// written by a binary predating this field — in both cases the
70    /// liveness probe reports Unknown, never Stuck.
71    #[serde(default)]
72    pub monitor_pid: Option<u32>,
73    /// The stage `devflow start --until <stage>` requests as the last stage
74    /// to run before halting (20c). `None` means no stop point was
75    /// requested (the pipeline runs to Ship), OR the state was written by a
76    /// binary predating this field — both cases behave identically (no
77    /// interception in `transition()`).
78    #[serde(default)]
79    pub stop_until: Option<Stage>,
80    /// Set by `transition()` when `stop_until` names the stage just
81    /// completed — a terminal-but-not-failed halt short of Ship (20c).
82    /// `false` for a normal in-flight or completed-to-Ship phase, and for
83    /// any state written by a binary predating this field.
84    #[serde(default)]
85    pub stopped: bool,
86    /// Human-readable reason recorded alongside `stopped` (20c). `None`
87    /// when `stopped` is `false`, or when the state predates this field.
88    #[serde(default)]
89    pub stop_reason: Option<String>,
90}
91
92/// Supported coding agents.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "lowercase")]
95pub enum AgentKind {
96    /// Anthropic Claude Code CLI.
97    Claude,
98    /// OpenAI Codex CLI.
99    Codex,
100    /// OpenCode CLI.
101    OpenCode,
102}
103
104impl fmt::Display for AgentKind {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        let name = match self {
107            AgentKind::Claude => "claude",
108            AgentKind::Codex => "codex",
109            AgentKind::OpenCode => "opencode",
110        };
111        f.write_str(name)
112    }
113}
114
115impl FromStr for AgentKind {
116    type Err = AgentParseError;
117
118    fn from_str(value: &str) -> Result<Self, Self::Err> {
119        match value.to_ascii_lowercase().as_str() {
120            "claude" => Ok(AgentKind::Claude),
121            "codex" => Ok(AgentKind::Codex),
122            "opencode" | "open-code" => Ok(AgentKind::OpenCode),
123            other => Err(AgentParseError(other.to_string())),
124        }
125    }
126}
127
128/// Error returned when parsing an unsupported agent name.
129#[derive(Debug, Clone, thiserror::Error)]
130#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
131pub struct AgentParseError(String);
132
133impl State {
134    /// Create a new state for starting a phase at the [`Stage::Define`] stage.
135    pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
136        State {
137            stage: Stage::Define,
138            phase,
139            agent,
140            mode,
141            gate_pending: false,
142            consecutive_failures: 0,
143            infra_failures: 0,
144            preflight_retries: 0,
145            started_at: timestamp_now(),
146            project_root,
147            worktree_path: None,
148            monitor_pid: None,
149            stop_until: None,
150            stopped: false,
151            stop_reason: None,
152        }
153    }
154}
155
156fn timestamp_now() -> String {
157    match SystemTime::now().duration_since(UNIX_EPOCH) {
158        Ok(duration) => format!("{}", duration.as_secs()),
159        Err(_) => String::from("0"),
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use std::path::PathBuf;
167
168    #[test]
169    fn agent_name_and_display() {
170        use crate::agents::adapter_for;
171        assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
172        assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
173        assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
174
175        assert_eq!(AgentKind::Claude.to_string(), "claude");
176        assert_eq!(AgentKind::Codex.to_string(), "codex");
177        assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
178    }
179
180    #[test]
181    fn agent_from_str_accepts_canonical_and_aliases() {
182        assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
183        assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
184        assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
185        assert_eq!(
186            "opencode".parse::<AgentKind>().unwrap(),
187            AgentKind::OpenCode
188        );
189        assert_eq!(
190            "open-code".parse::<AgentKind>().unwrap(),
191            AgentKind::OpenCode
192        );
193    }
194
195    #[test]
196    fn agent_from_str_rejects_unknown() {
197        let err = "aider".parse::<AgentKind>().unwrap_err();
198        assert!(err.to_string().contains("aider"));
199    }
200
201    #[test]
202    fn new_state_starts_at_define() {
203        let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
204        assert_eq!(state.stage, Stage::Define);
205        assert_eq!(state.phase, 2);
206        assert_eq!(state.agent, AgentKind::Claude);
207        assert_eq!(state.mode, Mode::Auto);
208        assert!(!state.gate_pending);
209        assert_eq!(state.consecutive_failures, 0);
210        assert_eq!(state.infra_failures, 0);
211        assert_eq!(state.preflight_retries, 0);
212        assert!(!state.started_at.is_empty());
213        assert_eq!(state.monitor_pid, None);
214        assert_eq!(state.stop_until, None);
215        assert!(!state.stopped);
216        assert_eq!(state.stop_reason, None);
217    }
218
219    #[test]
220    fn state_serde_round_trips() {
221        let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
222        let json = serde_json::to_string(&state).unwrap();
223        let back: State = serde_json::from_str(&json).unwrap();
224        assert_eq!(back.phase, 9);
225        assert_eq!(back.agent, AgentKind::Codex);
226        assert_eq!(back.stage, Stage::Define);
227        assert_eq!(back.mode, Mode::Supervise);
228    }
229
230    #[test]
231    fn consecutive_failures_persists_across_advance_calls() {
232        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
233        state.consecutive_failures = 3;
234        let json = serde_json::to_string(&state).unwrap();
235        assert!(
236            json.contains("consecutive_failures"),
237            "consecutive_failures must appear in persisted JSON"
238        );
239        let loaded: State = serde_json::from_str(&json).unwrap();
240        assert_eq!(
241            loaded.consecutive_failures, 3,
242            "consecutive_failures must round-trip through serde"
243        );
244    }
245
246    /// D-08 (17-01): a distinct infra-failure counter round-trips through
247    /// serde and its own key appears in the persisted JSON.
248    #[test]
249    fn infra_failures_round_trips_through_serde() {
250        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
251        state.infra_failures = 4;
252        let json = serde_json::to_string(&state).unwrap();
253        assert!(
254            json.contains("infra_failures"),
255            "infra_failures must appear in persisted JSON"
256        );
257        let loaded: State = serde_json::from_str(&json).unwrap();
258        assert_eq!(
259            loaded.infra_failures, 4,
260            "infra_failures must round-trip through serde"
261        );
262    }
263
264    /// A serde-absent `infra_failures` (older persisted state.json without
265    /// the field) must default to 0, not fail to deserialize.
266    #[test]
267    fn infra_failures_absent_from_json_defaults_to_zero() {
268        let json = r#"{
269            "stage": "code",
270            "phase": 1,
271            "agent": "claude",
272            "mode": "auto",
273            "started_at": "0",
274            "project_root": "/repo"
275        }"#;
276        let loaded: State = serde_json::from_str(json).unwrap();
277        assert_eq!(loaded.infra_failures, 0);
278    }
279
280    /// D-18f: `preflight_retries` round-trips through serde (its own key
281    /// appears in the persisted JSON) — the wedge this counter bounds spans
282    /// separate `devflow` invocations, so it must survive a save/load
283    /// cycle, not just live in memory — and a serde-absent value (state
284    /// written by a pre-18f binary) deserializes to 0, not a hard error.
285    #[test]
286    fn preflight_retries_round_trips_through_serde() {
287        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
288        state.preflight_retries = 2;
289        let json = serde_json::to_string(&state).unwrap();
290        assert!(
291            json.contains("preflight_retries"),
292            "preflight_retries must appear in persisted JSON"
293        );
294        let loaded: State = serde_json::from_str(&json).unwrap();
295        assert_eq!(
296            loaded.preflight_retries, 2,
297            "preflight_retries must round-trip through serde"
298        );
299
300        let absent_json = r#"{
301            "stage": "code",
302            "phase": 1,
303            "agent": "claude",
304            "mode": "auto",
305            "started_at": "0",
306            "project_root": "/repo"
307        }"#;
308        let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
309        assert_eq!(loaded_absent.preflight_retries, 0);
310    }
311
312    /// `monitor_pid` round-trips through serde as an exact `u32` (18b).
313    #[test]
314    fn monitor_pid_round_trips_through_serde() {
315        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
316        state.monitor_pid = Some(4242);
317        let json = serde_json::to_string(&state).unwrap();
318        assert!(
319            json.contains("monitor_pid"),
320            "monitor_pid must appear in persisted JSON"
321        );
322        let loaded: State = serde_json::from_str(&json).unwrap();
323        assert_eq!(
324            loaded.monitor_pid,
325            Some(4242),
326            "monitor_pid must round-trip through serde"
327        );
328    }
329
330    /// A serde-absent `monitor_pid` (state written by a pre-18b binary) must
331    /// deserialize to `None`, not `Some(0)` — a `Some(0)` default would let a
332    /// pre-18b state file render as a monitor at pid 0.
333    #[test]
334    fn monitor_pid_absent_from_json_defaults_to_none() {
335        let json = r#"{
336            "stage": "code",
337            "phase": 1,
338            "agent": "claude",
339            "mode": "auto",
340            "started_at": "0",
341            "project_root": "/repo"
342        }"#;
343        let loaded: State = serde_json::from_str(json).unwrap();
344        assert_eq!(loaded.monitor_pid, None);
345    }
346
347    /// 20c: `stop_until`/`stopped`/`stop_reason` all round-trip through
348    /// serde — each field's own key appears in the persisted JSON, and a
349    /// fresh deserialize recovers the exact values set.
350    #[test]
351    fn stop_fields_round_trip_through_serde() {
352        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
353        state.stop_until = Some(Stage::Plan);
354        state.stopped = true;
355        state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
356        let json = serde_json::to_string(&state).unwrap();
357        assert!(
358            json.contains("stop_until") && json.contains("stopped") && json.contains("stop_reason"),
359            "all three stop fields must appear in persisted JSON: {json}"
360        );
361        let loaded: State = serde_json::from_str(&json).unwrap();
362        assert_eq!(
363            loaded.stop_until,
364            Some(Stage::Plan),
365            "stop_until must round-trip through serde"
366        );
367        assert!(loaded.stopped, "stopped must round-trip through serde");
368        assert_eq!(
369            loaded.stop_reason.as_deref(),
370            Some("stopped after plan completed (--until plan)"),
371            "stop_reason must round-trip through serde"
372        );
373    }
374
375    /// A serde-absent `stop_until`/`stopped`/`stop_reason` (state written by
376    /// a pre-20c binary) must default to `None`/`false`/`None`, not fail to
377    /// deserialize — the same backward-compat pattern as every other
378    /// `#[serde(default)]` field added since 17-01.
379    #[test]
380    fn stop_fields_absent_from_json_default() {
381        let json = r#"{
382            "stage": "code",
383            "phase": 1,
384            "agent": "claude",
385            "mode": "auto",
386            "started_at": "0",
387            "project_root": "/repo"
388        }"#;
389        let loaded: State = serde_json::from_str(json).unwrap();
390        assert_eq!(loaded.stop_until, None);
391        assert!(!loaded.stopped);
392        assert_eq!(loaded.stop_reason, None);
393    }
394}