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///
17/// # Construction
18///
19/// Marked `#[non_exhaustive]`: downstream crates must build this through
20/// [`State::new`] and then assign the fields they care about, rather than by
21/// struct literal. Deserialization is unaffected — the `Deserialize` derive
22/// and every `#[serde(default)]` field keep working exactly as before, so
23/// state files written by older binaries still load.
24///
25/// This exists because `State` accumulates a field roughly every phase that
26/// adds a run-scoped concept (`worktree_path`, `monitor_pid`, `stop_until`,
27/// `yes_ship`, and — in phase 28 — `session_id` and `checkpoint_resumes`).
28/// Without `non_exhaustive`, each of those additions is a semver-breaking
29/// change for any consumer that used a struct literal, which would force a
30/// major bump for what is really an internal bookkeeping change. Paying that
31/// cost once here makes every future field additive.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[non_exhaustive]
34pub struct State {
35    /// Current workflow stage.
36    pub stage: Stage,
37    /// Phase number being worked on.
38    pub phase: u32,
39    /// Which coding agent was launched.
40    pub agent: AgentKind,
41    /// How the pipeline is driven (auto vs. supervise).
42    pub mode: Mode,
43    /// Whether a gate has been written and is awaiting a human response.
44    #[serde(default)]
45    pub gate_pending: bool,
46    /// Consecutive Validate failures — drives the Auto-mode forced gate after
47    /// [`crate::mode::MAX_CONSECUTIVE_FAILURES`] failures. Persisted across
48    /// `devflow advance` invocations so the counter survives monitor restarts.
49    #[serde(default)]
50    pub consecutive_failures: u32,
51    /// Consecutive infrastructure-class faults (`ResourceKilled`,
52    /// `AgentUnavailable`) — distinct from [`Self::consecutive_failures`]
53    /// (D-08, 17-01). Gates at [`crate::mode::MAX_INFRA_FAILURES`]. Any
54    /// increment (wired in Plan 04) must use `saturating_add` so a
55    /// long-running stuck loop cannot overflow `u32`. A serde-absent value
56    /// (older persisted state) defaults to 0. Reset to 0 on every successful
57    /// stage transition, alongside `consecutive_failures` (CR-01, 17-06 gap
58    /// closure), so the ceiling bounds a stuck loop, not a phase's lifetime.
59    #[serde(default)]
60    pub infra_failures: u32,
61    /// How many times a preflight gate has been resolved and retried for
62    /// this phase (18f). Bounded by [`crate::mode::MAX_PREFLIGHT_RETRIES`].
63    /// Persisted rather than recursion-scoped because the documented wedge
64    /// spanned separate `devflow` invocations after a monitor death — an
65    /// in-process recursion-depth counter would reset to zero on every new
66    /// process and fail to bound the exact incident it exists to prevent.
67    /// Reset to 0 whenever preflight passes and whenever a human explicitly
68    /// approves (`GateAction::Advance`), both inside `run_preflight`. Unlike
69    /// [`Self::consecutive_failures`] and [`Self::infra_failures`], this
70    /// counter is NOT touched by `transition()`.
71    #[serde(default)]
72    pub preflight_retries: u32,
73    /// When the phase started (Unix seconds).
74    pub started_at: String,
75    /// Path to the project root.
76    pub project_root: PathBuf,
77    /// Working directory for the agent when running in a git worktree.
78    ///
79    /// `None` means the agent runs in `project_root`. State and capture files
80    /// always live under the main `project_root`; only the agent's cwd changes.
81    #[serde(default)]
82    pub worktree_path: Option<PathBuf>,
83    /// PID of the detached monitor process that owns the agent for the
84    /// current stage, recorded by `launch_stage` at spawn time. `None` means
85    /// no monitor has been spawned for this state yet, OR the state was
86    /// written by a binary predating this field — in both cases the
87    /// liveness probe reports Unknown, never Stuck.
88    #[serde(default)]
89    pub monitor_pid: Option<u32>,
90    /// The Claude session id captured from the most recent captured stdout
91    /// envelope for this phase's current stage (D-04, 28-02), read via
92    /// [`crate::agent_result::session_id_from_capture`]. `None` means EITHER
93    /// "no session has been captured for this state yet" OR "the state was
94    /// written by a binary predating this field" — both cases behave
95    /// identically (no relaunch target to address). Recorded so a checkpoint
96    /// auto-decide relaunch (plan 28-03) can `--resume` the exact session
97    /// that hit the checkpoint rather than spawning a fresh one, which would
98    /// lose the original session's conversation context and permission mode.
99    #[serde(default)]
100    pub session_id: Option<String>,
101    /// How many times the current stage's agent has been relaunched via a
102    /// checkpoint auto-decide resume (D-04, 28-03). Bounds a stuck
103    /// checkpoint loop against `mode::MAX_CHECKPOINT_RESUMES` (added in plan
104    /// 28-03) the same way [`Self::infra_failures`] bounds an infra-fault
105    /// loop against `mode::MAX_INFRA_FAILURES`. Reset to 0 by every ordinary fresh stage
106    /// launch, so the ceiling bounds one stage's resume budget, not a
107    /// phase's lifetime (the same distinction `MAX_INFRA_FAILURES`' doc
108    /// comment draws for `infra_failures`). Any increment must use
109    /// `saturating_add` so a stuck loop cannot overflow `u32`. A
110    /// serde-absent value (state written by a binary predating this field)
111    /// defaults to 0.
112    #[serde(default)]
113    pub checkpoint_resumes: u32,
114    /// The stage `devflow start --until <stage>` requests as the last stage
115    /// to run before halting (20c). `None` means no stop point was
116    /// requested (the pipeline runs to Ship), OR the state was written by a
117    /// binary predating this field — both cases behave identically (no
118    /// interception in `transition()`).
119    #[serde(default)]
120    pub stop_until: Option<Stage>,
121    /// Set by `transition()` when `stop_until` names the stage just
122    /// completed — a terminal-but-not-failed halt short of Ship (20c).
123    /// `false` for a normal in-flight or completed-to-Ship phase, and for
124    /// any state written by a binary predating this field.
125    #[serde(default)]
126    pub stopped: bool,
127    /// Human-readable reason recorded alongside `stopped` (20c). `None`
128    /// when `stopped` is `false`, or when the state predates this field.
129    #[serde(default)]
130    pub stop_reason: Option<String>,
131    /// Pre-authorization for the Ship gate (D-04/D-05/D-06, 23-09),
132    /// set only from the `--yes-ship` CLI flag typed on `devflow start`.
133    ///
134    /// Persisted rather than passed through the call stack: the Ship gate
135    /// fires inside a detached monitor's `advance` process, minutes to
136    /// hours after the launching `devflow start` process has already
137    /// exited, so a CLI-scoped value would be gone by the time it matters —
138    /// only a value written to `state.json` at start time survives to be
139    /// read back by that later, separate process. `false` for any state
140    /// written by a binary predating this field.
141    #[serde(default)]
142    pub yes_ship: bool,
143}
144
145/// Supported coding agents.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "lowercase")]
148pub enum AgentKind {
149    /// Anthropic Claude Code CLI.
150    Claude,
151    /// OpenAI Codex CLI.
152    Codex,
153    /// OpenCode CLI.
154    OpenCode,
155}
156
157impl fmt::Display for AgentKind {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        let name = match self {
160            AgentKind::Claude => "claude",
161            AgentKind::Codex => "codex",
162            AgentKind::OpenCode => "opencode",
163        };
164        f.write_str(name)
165    }
166}
167
168impl FromStr for AgentKind {
169    type Err = AgentParseError;
170
171    fn from_str(value: &str) -> Result<Self, Self::Err> {
172        match value.to_ascii_lowercase().as_str() {
173            "claude" => Ok(AgentKind::Claude),
174            "codex" => Ok(AgentKind::Codex),
175            "opencode" | "open-code" => Ok(AgentKind::OpenCode),
176            other => Err(AgentParseError(other.to_string())),
177        }
178    }
179}
180
181/// Error returned when parsing an unsupported agent name.
182#[derive(Debug, Clone, thiserror::Error)]
183#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
184pub struct AgentParseError(String);
185
186impl State {
187    /// Create a new state for starting a phase at the [`Stage::Define`] stage.
188    pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
189        State {
190            stage: Stage::Define,
191            phase,
192            agent,
193            mode,
194            gate_pending: false,
195            consecutive_failures: 0,
196            infra_failures: 0,
197            preflight_retries: 0,
198            started_at: timestamp_now(),
199            project_root,
200            worktree_path: None,
201            monitor_pid: None,
202            session_id: None,
203            checkpoint_resumes: 0,
204            stop_until: None,
205            stopped: false,
206            stop_reason: None,
207            yes_ship: false,
208        }
209    }
210}
211
212fn timestamp_now() -> String {
213    match SystemTime::now().duration_since(UNIX_EPOCH) {
214        Ok(duration) => format!("{}", duration.as_secs()),
215        Err(_) => String::from("0"),
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::path::PathBuf;
223
224    #[test]
225    fn agent_name_and_display() {
226        use crate::agents::adapter_for;
227        assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
228        assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
229        assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
230
231        assert_eq!(AgentKind::Claude.to_string(), "claude");
232        assert_eq!(AgentKind::Codex.to_string(), "codex");
233        assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
234    }
235
236    #[test]
237    fn agent_from_str_accepts_canonical_and_aliases() {
238        assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
239        assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
240        assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
241        assert_eq!(
242            "opencode".parse::<AgentKind>().unwrap(),
243            AgentKind::OpenCode
244        );
245        assert_eq!(
246            "open-code".parse::<AgentKind>().unwrap(),
247            AgentKind::OpenCode
248        );
249    }
250
251    #[test]
252    fn agent_from_str_rejects_unknown() {
253        let err = "aider".parse::<AgentKind>().unwrap_err();
254        assert!(err.to_string().contains("aider"));
255    }
256
257    #[test]
258    fn new_state_starts_at_define() {
259        let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
260        assert_eq!(state.stage, Stage::Define);
261        assert_eq!(state.phase, 2);
262        assert_eq!(state.agent, AgentKind::Claude);
263        assert_eq!(state.mode, Mode::Auto);
264        assert!(!state.gate_pending);
265        assert_eq!(state.consecutive_failures, 0);
266        assert_eq!(state.infra_failures, 0);
267        assert_eq!(state.preflight_retries, 0);
268        assert!(!state.started_at.is_empty());
269        assert_eq!(state.monitor_pid, None);
270        assert_eq!(state.stop_until, None);
271        assert!(!state.stopped);
272        assert_eq!(state.stop_reason, None);
273        assert!(!state.yes_ship);
274    }
275
276    #[test]
277    fn state_serde_round_trips() {
278        let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
279        let json = serde_json::to_string(&state).unwrap();
280        let back: State = serde_json::from_str(&json).unwrap();
281        assert_eq!(back.phase, 9);
282        assert_eq!(back.agent, AgentKind::Codex);
283        assert_eq!(back.stage, Stage::Define);
284        assert_eq!(back.mode, Mode::Supervise);
285    }
286
287    #[test]
288    fn consecutive_failures_persists_across_advance_calls() {
289        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
290        state.consecutive_failures = 3;
291        let json = serde_json::to_string(&state).unwrap();
292        assert!(
293            json.contains("consecutive_failures"),
294            "consecutive_failures must appear in persisted JSON"
295        );
296        let loaded: State = serde_json::from_str(&json).unwrap();
297        assert_eq!(
298            loaded.consecutive_failures, 3,
299            "consecutive_failures must round-trip through serde"
300        );
301    }
302
303    /// D-08 (17-01): a distinct infra-failure counter round-trips through
304    /// serde and its own key appears in the persisted JSON.
305    #[test]
306    fn infra_failures_round_trips_through_serde() {
307        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
308        state.infra_failures = 4;
309        let json = serde_json::to_string(&state).unwrap();
310        assert!(
311            json.contains("infra_failures"),
312            "infra_failures must appear in persisted JSON"
313        );
314        let loaded: State = serde_json::from_str(&json).unwrap();
315        assert_eq!(
316            loaded.infra_failures, 4,
317            "infra_failures must round-trip through serde"
318        );
319    }
320
321    /// A serde-absent `infra_failures` (older persisted state.json without
322    /// the field) must default to 0, not fail to deserialize.
323    #[test]
324    fn infra_failures_absent_from_json_defaults_to_zero() {
325        let json = r#"{
326            "stage": "code",
327            "phase": 1,
328            "agent": "claude",
329            "mode": "auto",
330            "started_at": "0",
331            "project_root": "/repo"
332        }"#;
333        let loaded: State = serde_json::from_str(json).unwrap();
334        assert_eq!(loaded.infra_failures, 0);
335    }
336
337    /// D-18f: `preflight_retries` round-trips through serde (its own key
338    /// appears in the persisted JSON) — the wedge this counter bounds spans
339    /// separate `devflow` invocations, so it must survive a save/load
340    /// cycle, not just live in memory — and a serde-absent value (state
341    /// written by a pre-18f binary) deserializes to 0, not a hard error.
342    #[test]
343    fn preflight_retries_round_trips_through_serde() {
344        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
345        state.preflight_retries = 2;
346        let json = serde_json::to_string(&state).unwrap();
347        assert!(
348            json.contains("preflight_retries"),
349            "preflight_retries must appear in persisted JSON"
350        );
351        let loaded: State = serde_json::from_str(&json).unwrap();
352        assert_eq!(
353            loaded.preflight_retries, 2,
354            "preflight_retries must round-trip through serde"
355        );
356
357        let absent_json = r#"{
358            "stage": "code",
359            "phase": 1,
360            "agent": "claude",
361            "mode": "auto",
362            "started_at": "0",
363            "project_root": "/repo"
364        }"#;
365        let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
366        assert_eq!(loaded_absent.preflight_retries, 0);
367    }
368
369    /// `monitor_pid` round-trips through serde as an exact `u32` (18b).
370    #[test]
371    fn monitor_pid_round_trips_through_serde() {
372        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
373        state.monitor_pid = Some(4242);
374        let json = serde_json::to_string(&state).unwrap();
375        assert!(
376            json.contains("monitor_pid"),
377            "monitor_pid must appear in persisted JSON"
378        );
379        let loaded: State = serde_json::from_str(&json).unwrap();
380        assert_eq!(
381            loaded.monitor_pid,
382            Some(4242),
383            "monitor_pid must round-trip through serde"
384        );
385    }
386
387    /// A serde-absent `monitor_pid` (state written by a pre-18b binary) must
388    /// deserialize to `None`, not `Some(0)` — a `Some(0)` default would let a
389    /// pre-18b state file render as a monitor at pid 0.
390    #[test]
391    fn monitor_pid_absent_from_json_defaults_to_none() {
392        let json = r#"{
393            "stage": "code",
394            "phase": 1,
395            "agent": "claude",
396            "mode": "auto",
397            "started_at": "0",
398            "project_root": "/repo"
399        }"#;
400        let loaded: State = serde_json::from_str(json).unwrap();
401        assert_eq!(loaded.monitor_pid, None);
402    }
403
404    /// `session_id` round-trips through serde as an exact `Option<String>`
405    /// (D-04, 28-02) — mirrors the `monitor_pid` pair above.
406    #[test]
407    fn session_id_round_trips_through_serde() {
408        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
409        state.session_id = Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e".to_string());
410        let json = serde_json::to_string(&state).unwrap();
411        assert!(
412            json.contains("session_id"),
413            "session_id must appear in persisted JSON"
414        );
415        let loaded: State = serde_json::from_str(&json).unwrap();
416        assert_eq!(
417            loaded.session_id.as_deref(),
418            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e"),
419            "session_id must round-trip through serde"
420        );
421    }
422
423    /// A serde-absent `session_id` (state written by a pre-28-02 binary) must
424    /// deserialize to `None`, not fail to deserialize.
425    #[test]
426    fn session_id_absent_from_json_defaults_to_none() {
427        let json = r#"{
428            "stage": "code",
429            "phase": 1,
430            "agent": "claude",
431            "mode": "auto",
432            "started_at": "0",
433            "project_root": "/repo"
434        }"#;
435        let loaded: State = serde_json::from_str(json).unwrap();
436        assert_eq!(loaded.session_id, None);
437    }
438
439    /// `checkpoint_resumes` round-trips through serde as an exact `u32`
440    /// (D-04, 28-02).
441    #[test]
442    fn checkpoint_resumes_round_trips_through_serde() {
443        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
444        state.checkpoint_resumes = 2;
445        let json = serde_json::to_string(&state).unwrap();
446        assert!(
447            json.contains("checkpoint_resumes"),
448            "checkpoint_resumes must appear in persisted JSON"
449        );
450        let loaded: State = serde_json::from_str(&json).unwrap();
451        assert_eq!(
452            loaded.checkpoint_resumes, 2,
453            "checkpoint_resumes must round-trip through serde"
454        );
455    }
456
457    /// A serde-absent `checkpoint_resumes` (state written by a pre-28-02
458    /// binary) must deserialize to `0`, not fail to deserialize.
459    #[test]
460    fn checkpoint_resumes_absent_from_json_defaults_to_zero() {
461        let json = r#"{
462            "stage": "code",
463            "phase": 1,
464            "agent": "claude",
465            "mode": "auto",
466            "started_at": "0",
467            "project_root": "/repo"
468        }"#;
469        let loaded: State = serde_json::from_str(json).unwrap();
470        assert_eq!(loaded.checkpoint_resumes, 0);
471    }
472
473    /// 23-09 Task 1: `yes_ship` round-trips through serde as an exact `bool`
474    /// — its own key appears in the persisted JSON, and a fresh deserialize
475    /// recovers the value set, mirroring the `monitor_pid` pair above.
476    #[test]
477    fn yes_ship_round_trips_through_serde() {
478        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
479        state.yes_ship = true;
480        let json = serde_json::to_string(&state).unwrap();
481        assert!(
482            json.contains("yes_ship"),
483            "yes_ship must appear in persisted JSON"
484        );
485        let loaded: State = serde_json::from_str(&json).unwrap();
486        assert!(loaded.yes_ship, "yes_ship must round-trip through serde");
487    }
488
489    /// A serde-absent `yes_ship` (state written by a pre-23-09 binary) must
490    /// deserialize to `false`, not fail to deserialize — the same
491    /// backward-compat pattern as every other `#[serde(default)]` field
492    /// added since 17-01.
493    #[test]
494    fn yes_ship_absent_from_json_defaults_to_false() {
495        let json = r#"{
496            "stage": "code",
497            "phase": 1,
498            "agent": "claude",
499            "mode": "auto",
500            "started_at": "0",
501            "project_root": "/repo"
502        }"#;
503        let loaded: State = serde_json::from_str(json).unwrap();
504        assert!(!loaded.yes_ship);
505    }
506
507    /// 20c: `stop_until`/`stopped`/`stop_reason` all round-trip through
508    /// serde — each field's own key appears in the persisted JSON, and a
509    /// fresh deserialize recovers the exact values set.
510    #[test]
511    fn stop_fields_round_trip_through_serde() {
512        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
513        state.stop_until = Some(Stage::Plan);
514        state.stopped = true;
515        state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
516        let json = serde_json::to_string(&state).unwrap();
517        assert!(
518            json.contains("stop_until") && json.contains("stopped") && json.contains("stop_reason"),
519            "all three stop fields must appear in persisted JSON: {json}"
520        );
521        let loaded: State = serde_json::from_str(&json).unwrap();
522        assert_eq!(
523            loaded.stop_until,
524            Some(Stage::Plan),
525            "stop_until must round-trip through serde"
526        );
527        assert!(loaded.stopped, "stopped must round-trip through serde");
528        assert_eq!(
529            loaded.stop_reason.as_deref(),
530            Some("stopped after plan completed (--until plan)"),
531            "stop_reason must round-trip through serde"
532        );
533    }
534
535    /// A serde-absent `stop_until`/`stopped`/`stop_reason` (state written by
536    /// a pre-20c binary) must default to `None`/`false`/`None`, not fail to
537    /// deserialize — the same backward-compat pattern as every other
538    /// `#[serde(default)]` field added since 17-01.
539    #[test]
540    fn stop_fields_absent_from_json_default() {
541        let json = r#"{
542            "stage": "code",
543            "phase": 1,
544            "agent": "claude",
545            "mode": "auto",
546            "started_at": "0",
547            "project_root": "/repo"
548        }"#;
549        let loaded: State = serde_json::from_str(json).unwrap();
550        assert_eq!(loaded.stop_until, None);
551        assert!(!loaded.stopped);
552        assert_eq!(loaded.stop_reason, None);
553    }
554}