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    /// Pre-authorization for the Ship gate (D-04/D-05/D-06, 23-09),
91    /// set only from the `--yes-ship` CLI flag typed on `devflow start`.
92    ///
93    /// Persisted rather than passed through the call stack: the Ship gate
94    /// fires inside a detached monitor's `advance` process, minutes to
95    /// hours after the launching `devflow start` process has already
96    /// exited, so a CLI-scoped value would be gone by the time it matters —
97    /// only a value written to `state.json` at start time survives to be
98    /// read back by that later, separate process. `false` for any state
99    /// written by a binary predating this field.
100    #[serde(default)]
101    pub yes_ship: bool,
102}
103
104/// Supported coding agents.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum AgentKind {
108    /// Anthropic Claude Code CLI.
109    Claude,
110    /// OpenAI Codex CLI.
111    Codex,
112    /// OpenCode CLI.
113    OpenCode,
114}
115
116impl fmt::Display for AgentKind {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        let name = match self {
119            AgentKind::Claude => "claude",
120            AgentKind::Codex => "codex",
121            AgentKind::OpenCode => "opencode",
122        };
123        f.write_str(name)
124    }
125}
126
127impl FromStr for AgentKind {
128    type Err = AgentParseError;
129
130    fn from_str(value: &str) -> Result<Self, Self::Err> {
131        match value.to_ascii_lowercase().as_str() {
132            "claude" => Ok(AgentKind::Claude),
133            "codex" => Ok(AgentKind::Codex),
134            "opencode" | "open-code" => Ok(AgentKind::OpenCode),
135            other => Err(AgentParseError(other.to_string())),
136        }
137    }
138}
139
140/// Error returned when parsing an unsupported agent name.
141#[derive(Debug, Clone, thiserror::Error)]
142#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
143pub struct AgentParseError(String);
144
145impl State {
146    /// Create a new state for starting a phase at the [`Stage::Define`] stage.
147    pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
148        State {
149            stage: Stage::Define,
150            phase,
151            agent,
152            mode,
153            gate_pending: false,
154            consecutive_failures: 0,
155            infra_failures: 0,
156            preflight_retries: 0,
157            started_at: timestamp_now(),
158            project_root,
159            worktree_path: None,
160            monitor_pid: None,
161            stop_until: None,
162            stopped: false,
163            stop_reason: None,
164            yes_ship: false,
165        }
166    }
167}
168
169fn timestamp_now() -> String {
170    match SystemTime::now().duration_since(UNIX_EPOCH) {
171        Ok(duration) => format!("{}", duration.as_secs()),
172        Err(_) => String::from("0"),
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::path::PathBuf;
180
181    #[test]
182    fn agent_name_and_display() {
183        use crate::agents::adapter_for;
184        assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
185        assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
186        assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
187
188        assert_eq!(AgentKind::Claude.to_string(), "claude");
189        assert_eq!(AgentKind::Codex.to_string(), "codex");
190        assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
191    }
192
193    #[test]
194    fn agent_from_str_accepts_canonical_and_aliases() {
195        assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
196        assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
197        assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
198        assert_eq!(
199            "opencode".parse::<AgentKind>().unwrap(),
200            AgentKind::OpenCode
201        );
202        assert_eq!(
203            "open-code".parse::<AgentKind>().unwrap(),
204            AgentKind::OpenCode
205        );
206    }
207
208    #[test]
209    fn agent_from_str_rejects_unknown() {
210        let err = "aider".parse::<AgentKind>().unwrap_err();
211        assert!(err.to_string().contains("aider"));
212    }
213
214    #[test]
215    fn new_state_starts_at_define() {
216        let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
217        assert_eq!(state.stage, Stage::Define);
218        assert_eq!(state.phase, 2);
219        assert_eq!(state.agent, AgentKind::Claude);
220        assert_eq!(state.mode, Mode::Auto);
221        assert!(!state.gate_pending);
222        assert_eq!(state.consecutive_failures, 0);
223        assert_eq!(state.infra_failures, 0);
224        assert_eq!(state.preflight_retries, 0);
225        assert!(!state.started_at.is_empty());
226        assert_eq!(state.monitor_pid, None);
227        assert_eq!(state.stop_until, None);
228        assert!(!state.stopped);
229        assert_eq!(state.stop_reason, None);
230        assert!(!state.yes_ship);
231    }
232
233    #[test]
234    fn state_serde_round_trips() {
235        let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
236        let json = serde_json::to_string(&state).unwrap();
237        let back: State = serde_json::from_str(&json).unwrap();
238        assert_eq!(back.phase, 9);
239        assert_eq!(back.agent, AgentKind::Codex);
240        assert_eq!(back.stage, Stage::Define);
241        assert_eq!(back.mode, Mode::Supervise);
242    }
243
244    #[test]
245    fn consecutive_failures_persists_across_advance_calls() {
246        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
247        state.consecutive_failures = 3;
248        let json = serde_json::to_string(&state).unwrap();
249        assert!(
250            json.contains("consecutive_failures"),
251            "consecutive_failures must appear in persisted JSON"
252        );
253        let loaded: State = serde_json::from_str(&json).unwrap();
254        assert_eq!(
255            loaded.consecutive_failures, 3,
256            "consecutive_failures must round-trip through serde"
257        );
258    }
259
260    /// D-08 (17-01): a distinct infra-failure counter round-trips through
261    /// serde and its own key appears in the persisted JSON.
262    #[test]
263    fn infra_failures_round_trips_through_serde() {
264        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
265        state.infra_failures = 4;
266        let json = serde_json::to_string(&state).unwrap();
267        assert!(
268            json.contains("infra_failures"),
269            "infra_failures must appear in persisted JSON"
270        );
271        let loaded: State = serde_json::from_str(&json).unwrap();
272        assert_eq!(
273            loaded.infra_failures, 4,
274            "infra_failures must round-trip through serde"
275        );
276    }
277
278    /// A serde-absent `infra_failures` (older persisted state.json without
279    /// the field) must default to 0, not fail to deserialize.
280    #[test]
281    fn infra_failures_absent_from_json_defaults_to_zero() {
282        let json = r#"{
283            "stage": "code",
284            "phase": 1,
285            "agent": "claude",
286            "mode": "auto",
287            "started_at": "0",
288            "project_root": "/repo"
289        }"#;
290        let loaded: State = serde_json::from_str(json).unwrap();
291        assert_eq!(loaded.infra_failures, 0);
292    }
293
294    /// D-18f: `preflight_retries` round-trips through serde (its own key
295    /// appears in the persisted JSON) — the wedge this counter bounds spans
296    /// separate `devflow` invocations, so it must survive a save/load
297    /// cycle, not just live in memory — and a serde-absent value (state
298    /// written by a pre-18f binary) deserializes to 0, not a hard error.
299    #[test]
300    fn preflight_retries_round_trips_through_serde() {
301        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
302        state.preflight_retries = 2;
303        let json = serde_json::to_string(&state).unwrap();
304        assert!(
305            json.contains("preflight_retries"),
306            "preflight_retries must appear in persisted JSON"
307        );
308        let loaded: State = serde_json::from_str(&json).unwrap();
309        assert_eq!(
310            loaded.preflight_retries, 2,
311            "preflight_retries must round-trip through serde"
312        );
313
314        let absent_json = r#"{
315            "stage": "code",
316            "phase": 1,
317            "agent": "claude",
318            "mode": "auto",
319            "started_at": "0",
320            "project_root": "/repo"
321        }"#;
322        let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
323        assert_eq!(loaded_absent.preflight_retries, 0);
324    }
325
326    /// `monitor_pid` round-trips through serde as an exact `u32` (18b).
327    #[test]
328    fn monitor_pid_round_trips_through_serde() {
329        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
330        state.monitor_pid = Some(4242);
331        let json = serde_json::to_string(&state).unwrap();
332        assert!(
333            json.contains("monitor_pid"),
334            "monitor_pid must appear in persisted JSON"
335        );
336        let loaded: State = serde_json::from_str(&json).unwrap();
337        assert_eq!(
338            loaded.monitor_pid,
339            Some(4242),
340            "monitor_pid must round-trip through serde"
341        );
342    }
343
344    /// A serde-absent `monitor_pid` (state written by a pre-18b binary) must
345    /// deserialize to `None`, not `Some(0)` — a `Some(0)` default would let a
346    /// pre-18b state file render as a monitor at pid 0.
347    #[test]
348    fn monitor_pid_absent_from_json_defaults_to_none() {
349        let json = r#"{
350            "stage": "code",
351            "phase": 1,
352            "agent": "claude",
353            "mode": "auto",
354            "started_at": "0",
355            "project_root": "/repo"
356        }"#;
357        let loaded: State = serde_json::from_str(json).unwrap();
358        assert_eq!(loaded.monitor_pid, None);
359    }
360
361    /// 23-09 Task 1: `yes_ship` round-trips through serde as an exact `bool`
362    /// — its own key appears in the persisted JSON, and a fresh deserialize
363    /// recovers the value set, mirroring the `monitor_pid` pair above.
364    #[test]
365    fn yes_ship_round_trips_through_serde() {
366        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
367        state.yes_ship = true;
368        let json = serde_json::to_string(&state).unwrap();
369        assert!(
370            json.contains("yes_ship"),
371            "yes_ship must appear in persisted JSON"
372        );
373        let loaded: State = serde_json::from_str(&json).unwrap();
374        assert!(loaded.yes_ship, "yes_ship must round-trip through serde");
375    }
376
377    /// A serde-absent `yes_ship` (state written by a pre-23-09 binary) must
378    /// deserialize to `false`, not fail to deserialize — the same
379    /// backward-compat pattern as every other `#[serde(default)]` field
380    /// added since 17-01.
381    #[test]
382    fn yes_ship_absent_from_json_defaults_to_false() {
383        let json = r#"{
384            "stage": "code",
385            "phase": 1,
386            "agent": "claude",
387            "mode": "auto",
388            "started_at": "0",
389            "project_root": "/repo"
390        }"#;
391        let loaded: State = serde_json::from_str(json).unwrap();
392        assert!(!loaded.yes_ship);
393    }
394
395    /// 20c: `stop_until`/`stopped`/`stop_reason` all round-trip through
396    /// serde — each field's own key appears in the persisted JSON, and a
397    /// fresh deserialize recovers the exact values set.
398    #[test]
399    fn stop_fields_round_trip_through_serde() {
400        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
401        state.stop_until = Some(Stage::Plan);
402        state.stopped = true;
403        state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
404        let json = serde_json::to_string(&state).unwrap();
405        assert!(
406            json.contains("stop_until") && json.contains("stopped") && json.contains("stop_reason"),
407            "all three stop fields must appear in persisted JSON: {json}"
408        );
409        let loaded: State = serde_json::from_str(&json).unwrap();
410        assert_eq!(
411            loaded.stop_until,
412            Some(Stage::Plan),
413            "stop_until must round-trip through serde"
414        );
415        assert!(loaded.stopped, "stopped must round-trip through serde");
416        assert_eq!(
417            loaded.stop_reason.as_deref(),
418            Some("stopped after plan completed (--until plan)"),
419            "stop_reason must round-trip through serde"
420        );
421    }
422
423    /// A serde-absent `stop_until`/`stopped`/`stop_reason` (state written by
424    /// a pre-20c binary) must default to `None`/`false`/`None`, not fail to
425    /// deserialize — the same backward-compat pattern as every other
426    /// `#[serde(default)]` field added since 17-01.
427    #[test]
428    fn stop_fields_absent_from_json_default() {
429        let json = r#"{
430            "stage": "code",
431            "phase": 1,
432            "agent": "claude",
433            "mode": "auto",
434            "started_at": "0",
435            "project_root": "/repo"
436        }"#;
437        let loaded: State = serde_json::from_str(json).unwrap();
438        assert_eq!(loaded.stop_until, None);
439        assert!(!loaded.stopped);
440        assert_eq!(loaded.stop_reason, None);
441    }
442}