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}
74
75/// Supported coding agents.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum AgentKind {
79    /// Anthropic Claude Code CLI.
80    Claude,
81    /// OpenAI Codex CLI.
82    Codex,
83    /// OpenCode CLI.
84    OpenCode,
85}
86
87impl fmt::Display for AgentKind {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        let name = match self {
90            AgentKind::Claude => "claude",
91            AgentKind::Codex => "codex",
92            AgentKind::OpenCode => "opencode",
93        };
94        f.write_str(name)
95    }
96}
97
98impl FromStr for AgentKind {
99    type Err = AgentParseError;
100
101    fn from_str(value: &str) -> Result<Self, Self::Err> {
102        match value.to_ascii_lowercase().as_str() {
103            "claude" => Ok(AgentKind::Claude),
104            "codex" => Ok(AgentKind::Codex),
105            "opencode" | "open-code" => Ok(AgentKind::OpenCode),
106            other => Err(AgentParseError(other.to_string())),
107        }
108    }
109}
110
111/// Error returned when parsing an unsupported agent name.
112#[derive(Debug, Clone, thiserror::Error)]
113#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
114pub struct AgentParseError(String);
115
116impl State {
117    /// Create a new state for starting a phase at the [`Stage::Define`] stage.
118    pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
119        State {
120            stage: Stage::Define,
121            phase,
122            agent,
123            mode,
124            gate_pending: false,
125            consecutive_failures: 0,
126            infra_failures: 0,
127            preflight_retries: 0,
128            started_at: timestamp_now(),
129            project_root,
130            worktree_path: None,
131            monitor_pid: None,
132        }
133    }
134}
135
136fn timestamp_now() -> String {
137    match SystemTime::now().duration_since(UNIX_EPOCH) {
138        Ok(duration) => format!("{}", duration.as_secs()),
139        Err(_) => String::from("0"),
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use std::path::PathBuf;
147
148    #[test]
149    fn agent_name_and_display() {
150        use crate::agents::adapter_for;
151        assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
152        assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
153        assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
154
155        assert_eq!(AgentKind::Claude.to_string(), "claude");
156        assert_eq!(AgentKind::Codex.to_string(), "codex");
157        assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
158    }
159
160    #[test]
161    fn agent_from_str_accepts_canonical_and_aliases() {
162        assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
163        assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
164        assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
165        assert_eq!(
166            "opencode".parse::<AgentKind>().unwrap(),
167            AgentKind::OpenCode
168        );
169        assert_eq!(
170            "open-code".parse::<AgentKind>().unwrap(),
171            AgentKind::OpenCode
172        );
173    }
174
175    #[test]
176    fn agent_from_str_rejects_unknown() {
177        let err = "aider".parse::<AgentKind>().unwrap_err();
178        assert!(err.to_string().contains("aider"));
179    }
180
181    #[test]
182    fn new_state_starts_at_define() {
183        let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
184        assert_eq!(state.stage, Stage::Define);
185        assert_eq!(state.phase, 2);
186        assert_eq!(state.agent, AgentKind::Claude);
187        assert_eq!(state.mode, Mode::Auto);
188        assert!(!state.gate_pending);
189        assert_eq!(state.consecutive_failures, 0);
190        assert_eq!(state.infra_failures, 0);
191        assert_eq!(state.preflight_retries, 0);
192        assert!(!state.started_at.is_empty());
193        assert_eq!(state.monitor_pid, None);
194    }
195
196    #[test]
197    fn state_serde_round_trips() {
198        let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
199        let json = serde_json::to_string(&state).unwrap();
200        let back: State = serde_json::from_str(&json).unwrap();
201        assert_eq!(back.phase, 9);
202        assert_eq!(back.agent, AgentKind::Codex);
203        assert_eq!(back.stage, Stage::Define);
204        assert_eq!(back.mode, Mode::Supervise);
205    }
206
207    #[test]
208    fn consecutive_failures_persists_across_advance_calls() {
209        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
210        state.consecutive_failures = 3;
211        let json = serde_json::to_string(&state).unwrap();
212        assert!(
213            json.contains("consecutive_failures"),
214            "consecutive_failures must appear in persisted JSON"
215        );
216        let loaded: State = serde_json::from_str(&json).unwrap();
217        assert_eq!(
218            loaded.consecutive_failures, 3,
219            "consecutive_failures must round-trip through serde"
220        );
221    }
222
223    /// D-08 (17-01): a distinct infra-failure counter round-trips through
224    /// serde and its own key appears in the persisted JSON.
225    #[test]
226    fn infra_failures_round_trips_through_serde() {
227        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
228        state.infra_failures = 4;
229        let json = serde_json::to_string(&state).unwrap();
230        assert!(
231            json.contains("infra_failures"),
232            "infra_failures must appear in persisted JSON"
233        );
234        let loaded: State = serde_json::from_str(&json).unwrap();
235        assert_eq!(
236            loaded.infra_failures, 4,
237            "infra_failures must round-trip through serde"
238        );
239    }
240
241    /// A serde-absent `infra_failures` (older persisted state.json without
242    /// the field) must default to 0, not fail to deserialize.
243    #[test]
244    fn infra_failures_absent_from_json_defaults_to_zero() {
245        let json = r#"{
246            "stage": "code",
247            "phase": 1,
248            "agent": "claude",
249            "mode": "auto",
250            "started_at": "0",
251            "project_root": "/repo"
252        }"#;
253        let loaded: State = serde_json::from_str(json).unwrap();
254        assert_eq!(loaded.infra_failures, 0);
255    }
256
257    /// D-18f: `preflight_retries` round-trips through serde (its own key
258    /// appears in the persisted JSON) — the wedge this counter bounds spans
259    /// separate `devflow` invocations, so it must survive a save/load
260    /// cycle, not just live in memory — and a serde-absent value (state
261    /// written by a pre-18f binary) deserializes to 0, not a hard error.
262    #[test]
263    fn preflight_retries_round_trips_through_serde() {
264        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
265        state.preflight_retries = 2;
266        let json = serde_json::to_string(&state).unwrap();
267        assert!(
268            json.contains("preflight_retries"),
269            "preflight_retries must appear in persisted JSON"
270        );
271        let loaded: State = serde_json::from_str(&json).unwrap();
272        assert_eq!(
273            loaded.preflight_retries, 2,
274            "preflight_retries must round-trip through serde"
275        );
276
277        let absent_json = r#"{
278            "stage": "code",
279            "phase": 1,
280            "agent": "claude",
281            "mode": "auto",
282            "started_at": "0",
283            "project_root": "/repo"
284        }"#;
285        let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
286        assert_eq!(loaded_absent.preflight_retries, 0);
287    }
288
289    /// `monitor_pid` round-trips through serde as an exact `u32` (18b).
290    #[test]
291    fn monitor_pid_round_trips_through_serde() {
292        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
293        state.monitor_pid = Some(4242);
294        let json = serde_json::to_string(&state).unwrap();
295        assert!(
296            json.contains("monitor_pid"),
297            "monitor_pid must appear in persisted JSON"
298        );
299        let loaded: State = serde_json::from_str(&json).unwrap();
300        assert_eq!(
301            loaded.monitor_pid,
302            Some(4242),
303            "monitor_pid must round-trip through serde"
304        );
305    }
306
307    /// A serde-absent `monitor_pid` (state written by a pre-18b binary) must
308    /// deserialize to `None`, not `Some(0)` — a `Some(0)` default would let a
309    /// pre-18b state file render as a monitor at pid 0.
310    #[test]
311    fn monitor_pid_absent_from_json_defaults_to_none() {
312        let json = r#"{
313            "stage": "code",
314            "phase": 1,
315            "agent": "claude",
316            "mode": "auto",
317            "started_at": "0",
318            "project_root": "/repo"
319        }"#;
320        let loaded: State = serde_json::from_str(json).unwrap();
321        assert_eq!(loaded.monitor_pid, None);
322    }
323}