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    /// What this run's delivery canary established (D-13/D-15, 31-03),
144    /// recorded by the first stage launch that routes through the Claude
145    /// `stream-json` transport. `None` means EITHER "no canary has run for
146    /// this run yet" OR "the state was written by a binary predating this
147    /// field" — both cases behave identically: the canary runs.
148    ///
149    /// Persisted rather than held in memory for the same reason
150    /// [`Self::yes_ship`] is: each stage launch happens in a SEPARATE
151    /// `devflow` process (the monitor's own `advance` tail), so an
152    /// in-process flag would reset to "not yet run" at every stage
153    /// transition and re-spend a real throwaway agent invocation each time —
154    /// which is exactly the symptom 31-RESEARCH Pitfall 5 names for a canary
155    /// that landed in the per-stage `preflight` hook.
156    ///
157    /// A recorded `Absent`/`Unverified` keeps refusing on every later launch
158    /// in the run; it is not consumed by the first refusal.
159    #[serde(default)]
160    pub canary: Option<crate::canary::CanaryOutcome>,
161    /// D-11's opt-out: force the pre-31 single-document Claude launch
162    /// (positional prompt, `--output-format json`, the `sh` monitor) for this
163    /// run, off by default.
164    ///
165    /// `false` means EITHER "the operator did not ask for the legacy path" OR
166    /// "the state was written by a binary predating this field" — both cases
167    /// behave identically: the D-09/D-10 rollout decides the transport, which
168    /// is the pre-existing behaviour.
169    ///
170    /// Persisted rather than passed through the call stack for the reason
171    /// [`Self::yes_ship`] gives: each stage launch happens in a SEPARATE
172    /// `devflow` process (the detached monitor's own `advance` tail), so a
173    /// CLI-scoped value would be gone by the time the second stage launches
174    /// and the run would silently revert to the stream transport mid-flight.
175    ///
176    /// Only ever OR-ed, never cleared, once set — see
177    /// `pipeline_launch::apply_legacy_launch_opt_out`. Clearing it on a plain
178    /// `devflow resume` would be the same silent-drop class as `stop_until`'s
179    /// old unconditional clear (999.60). To turn it back off, edit
180    /// `.devflow/state-NN.json` or start a new run.
181    #[serde(default)]
182    pub legacy_claude_launch: bool,
183}
184
185/// Supported coding agents.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "lowercase")]
188pub enum AgentKind {
189    /// Anthropic Claude Code CLI.
190    Claude,
191    /// OpenAI Codex CLI.
192    Codex,
193    /// OpenCode CLI.
194    OpenCode,
195}
196
197impl fmt::Display for AgentKind {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        let name = match self {
200            AgentKind::Claude => "claude",
201            AgentKind::Codex => "codex",
202            AgentKind::OpenCode => "opencode",
203        };
204        f.write_str(name)
205    }
206}
207
208impl FromStr for AgentKind {
209    type Err = AgentParseError;
210
211    fn from_str(value: &str) -> Result<Self, Self::Err> {
212        match value.to_ascii_lowercase().as_str() {
213            "claude" => Ok(AgentKind::Claude),
214            "codex" => Ok(AgentKind::Codex),
215            "opencode" | "open-code" => Ok(AgentKind::OpenCode),
216            other => Err(AgentParseError(other.to_string())),
217        }
218    }
219}
220
221/// Error returned when parsing an unsupported agent name.
222#[derive(Debug, Clone, thiserror::Error)]
223#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
224pub struct AgentParseError(String);
225
226impl State {
227    /// Create a new state for starting a phase at the [`Stage::Define`] stage.
228    pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
229        State {
230            stage: Stage::Define,
231            phase,
232            agent,
233            mode,
234            gate_pending: false,
235            consecutive_failures: 0,
236            infra_failures: 0,
237            preflight_retries: 0,
238            started_at: timestamp_now(),
239            project_root,
240            worktree_path: None,
241            monitor_pid: None,
242            session_id: None,
243            checkpoint_resumes: 0,
244            stop_until: None,
245            stopped: false,
246            stop_reason: None,
247            yes_ship: false,
248            canary: None,
249            legacy_claude_launch: false,
250        }
251    }
252}
253
254fn timestamp_now() -> String {
255    match SystemTime::now().duration_since(UNIX_EPOCH) {
256        Ok(duration) => format!("{}", duration.as_secs()),
257        Err(_) => String::from("0"),
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::path::PathBuf;
265
266    #[test]
267    fn agent_name_and_display() {
268        use crate::agents::adapter_for;
269        assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
270        assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
271        assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
272
273        assert_eq!(AgentKind::Claude.to_string(), "claude");
274        assert_eq!(AgentKind::Codex.to_string(), "codex");
275        assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
276    }
277
278    #[test]
279    fn agent_from_str_accepts_canonical_and_aliases() {
280        assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
281        assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
282        assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
283        assert_eq!(
284            "opencode".parse::<AgentKind>().unwrap(),
285            AgentKind::OpenCode
286        );
287        assert_eq!(
288            "open-code".parse::<AgentKind>().unwrap(),
289            AgentKind::OpenCode
290        );
291    }
292
293    #[test]
294    fn agent_from_str_rejects_unknown() {
295        let err = "aider".parse::<AgentKind>().unwrap_err();
296        assert!(err.to_string().contains("aider"));
297    }
298
299    #[test]
300    fn new_state_starts_at_define() {
301        let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
302        assert_eq!(state.stage, Stage::Define);
303        assert_eq!(state.phase, 2);
304        assert_eq!(state.agent, AgentKind::Claude);
305        assert_eq!(state.mode, Mode::Auto);
306        assert!(!state.gate_pending);
307        assert_eq!(state.consecutive_failures, 0);
308        assert_eq!(state.infra_failures, 0);
309        assert_eq!(state.preflight_retries, 0);
310        assert!(!state.started_at.is_empty());
311        assert_eq!(state.monitor_pid, None);
312        assert_eq!(state.stop_until, None);
313        assert!(!state.stopped);
314        assert_eq!(state.stop_reason, None);
315        assert!(!state.yes_ship);
316    }
317
318    #[test]
319    fn state_serde_round_trips() {
320        let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
321        let json = serde_json::to_string(&state).unwrap();
322        let back: State = serde_json::from_str(&json).unwrap();
323        assert_eq!(back.phase, 9);
324        assert_eq!(back.agent, AgentKind::Codex);
325        assert_eq!(back.stage, Stage::Define);
326        assert_eq!(back.mode, Mode::Supervise);
327    }
328
329    #[test]
330    fn consecutive_failures_persists_across_advance_calls() {
331        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
332        state.consecutive_failures = 3;
333        let json = serde_json::to_string(&state).unwrap();
334        assert!(
335            json.contains("consecutive_failures"),
336            "consecutive_failures must appear in persisted JSON"
337        );
338        let loaded: State = serde_json::from_str(&json).unwrap();
339        assert_eq!(
340            loaded.consecutive_failures, 3,
341            "consecutive_failures must round-trip through serde"
342        );
343    }
344
345    /// D-08 (17-01): a distinct infra-failure counter round-trips through
346    /// serde and its own key appears in the persisted JSON.
347    #[test]
348    fn infra_failures_round_trips_through_serde() {
349        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
350        state.infra_failures = 4;
351        let json = serde_json::to_string(&state).unwrap();
352        assert!(
353            json.contains("infra_failures"),
354            "infra_failures must appear in persisted JSON"
355        );
356        let loaded: State = serde_json::from_str(&json).unwrap();
357        assert_eq!(
358            loaded.infra_failures, 4,
359            "infra_failures must round-trip through serde"
360        );
361    }
362
363    /// A serde-absent `infra_failures` (older persisted state.json without
364    /// the field) must default to 0, not fail to deserialize.
365    #[test]
366    fn infra_failures_absent_from_json_defaults_to_zero() {
367        let json = r#"{
368            "stage": "code",
369            "phase": 1,
370            "agent": "claude",
371            "mode": "auto",
372            "started_at": "0",
373            "project_root": "/repo"
374        }"#;
375        let loaded: State = serde_json::from_str(json).unwrap();
376        assert_eq!(loaded.infra_failures, 0);
377    }
378
379    /// D-18f: `preflight_retries` round-trips through serde (its own key
380    /// appears in the persisted JSON) — the wedge this counter bounds spans
381    /// separate `devflow` invocations, so it must survive a save/load
382    /// cycle, not just live in memory — and a serde-absent value (state
383    /// written by a pre-18f binary) deserializes to 0, not a hard error.
384    #[test]
385    fn preflight_retries_round_trips_through_serde() {
386        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
387        state.preflight_retries = 2;
388        let json = serde_json::to_string(&state).unwrap();
389        assert!(
390            json.contains("preflight_retries"),
391            "preflight_retries must appear in persisted JSON"
392        );
393        let loaded: State = serde_json::from_str(&json).unwrap();
394        assert_eq!(
395            loaded.preflight_retries, 2,
396            "preflight_retries must round-trip through serde"
397        );
398
399        let absent_json = r#"{
400            "stage": "code",
401            "phase": 1,
402            "agent": "claude",
403            "mode": "auto",
404            "started_at": "0",
405            "project_root": "/repo"
406        }"#;
407        let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
408        assert_eq!(loaded_absent.preflight_retries, 0);
409    }
410
411    /// `monitor_pid` round-trips through serde as an exact `u32` (18b).
412    #[test]
413    fn monitor_pid_round_trips_through_serde() {
414        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
415        state.monitor_pid = Some(4242);
416        let json = serde_json::to_string(&state).unwrap();
417        assert!(
418            json.contains("monitor_pid"),
419            "monitor_pid must appear in persisted JSON"
420        );
421        let loaded: State = serde_json::from_str(&json).unwrap();
422        assert_eq!(
423            loaded.monitor_pid,
424            Some(4242),
425            "monitor_pid must round-trip through serde"
426        );
427    }
428
429    /// A serde-absent `monitor_pid` (state written by a pre-18b binary) must
430    /// deserialize to `None`, not `Some(0)` — a `Some(0)` default would let a
431    /// pre-18b state file render as a monitor at pid 0.
432    #[test]
433    fn monitor_pid_absent_from_json_defaults_to_none() {
434        let json = r#"{
435            "stage": "code",
436            "phase": 1,
437            "agent": "claude",
438            "mode": "auto",
439            "started_at": "0",
440            "project_root": "/repo"
441        }"#;
442        let loaded: State = serde_json::from_str(json).unwrap();
443        assert_eq!(loaded.monitor_pid, None);
444    }
445
446    /// `session_id` round-trips through serde as an exact `Option<String>`
447    /// (D-04, 28-02) — mirrors the `monitor_pid` pair above.
448    #[test]
449    fn session_id_round_trips_through_serde() {
450        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
451        state.session_id = Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e".to_string());
452        let json = serde_json::to_string(&state).unwrap();
453        assert!(
454            json.contains("session_id"),
455            "session_id must appear in persisted JSON"
456        );
457        let loaded: State = serde_json::from_str(&json).unwrap();
458        assert_eq!(
459            loaded.session_id.as_deref(),
460            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e"),
461            "session_id must round-trip through serde"
462        );
463    }
464
465    /// A serde-absent `session_id` (state written by a pre-28-02 binary) must
466    /// deserialize to `None`, not fail to deserialize.
467    #[test]
468    fn session_id_absent_from_json_defaults_to_none() {
469        let json = r#"{
470            "stage": "code",
471            "phase": 1,
472            "agent": "claude",
473            "mode": "auto",
474            "started_at": "0",
475            "project_root": "/repo"
476        }"#;
477        let loaded: State = serde_json::from_str(json).unwrap();
478        assert_eq!(loaded.session_id, None);
479    }
480
481    /// `checkpoint_resumes` round-trips through serde as an exact `u32`
482    /// (D-04, 28-02).
483    #[test]
484    fn checkpoint_resumes_round_trips_through_serde() {
485        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
486        state.checkpoint_resumes = 2;
487        let json = serde_json::to_string(&state).unwrap();
488        assert!(
489            json.contains("checkpoint_resumes"),
490            "checkpoint_resumes must appear in persisted JSON"
491        );
492        let loaded: State = serde_json::from_str(&json).unwrap();
493        assert_eq!(
494            loaded.checkpoint_resumes, 2,
495            "checkpoint_resumes must round-trip through serde"
496        );
497    }
498
499    /// A serde-absent `checkpoint_resumes` (state written by a pre-28-02
500    /// binary) must deserialize to `0`, not fail to deserialize.
501    #[test]
502    fn checkpoint_resumes_absent_from_json_defaults_to_zero() {
503        let json = r#"{
504            "stage": "code",
505            "phase": 1,
506            "agent": "claude",
507            "mode": "auto",
508            "started_at": "0",
509            "project_root": "/repo"
510        }"#;
511        let loaded: State = serde_json::from_str(json).unwrap();
512        assert_eq!(loaded.checkpoint_resumes, 0);
513    }
514
515    /// 23-09 Task 1: `yes_ship` round-trips through serde as an exact `bool`
516    /// — its own key appears in the persisted JSON, and a fresh deserialize
517    /// recovers the value set, mirroring the `monitor_pid` pair above.
518    #[test]
519    fn yes_ship_round_trips_through_serde() {
520        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
521        state.yes_ship = true;
522        let json = serde_json::to_string(&state).unwrap();
523        assert!(
524            json.contains("yes_ship"),
525            "yes_ship must appear in persisted JSON"
526        );
527        let loaded: State = serde_json::from_str(&json).unwrap();
528        assert!(loaded.yes_ship, "yes_ship must round-trip through serde");
529    }
530
531    /// A serde-absent `yes_ship` (state written by a pre-23-09 binary) must
532    /// deserialize to `false`, not fail to deserialize — the same
533    /// backward-compat pattern as every other `#[serde(default)]` field
534    /// added since 17-01.
535    #[test]
536    fn yes_ship_absent_from_json_defaults_to_false() {
537        let json = r#"{
538            "stage": "code",
539            "phase": 1,
540            "agent": "claude",
541            "mode": "auto",
542            "started_at": "0",
543            "project_root": "/repo"
544        }"#;
545        let loaded: State = serde_json::from_str(json).unwrap();
546        assert!(!loaded.yes_ship);
547    }
548
549    /// 20c: `stop_until`/`stopped`/`stop_reason` all round-trip through
550    /// serde — each field's own key appears in the persisted JSON, and a
551    /// fresh deserialize recovers the exact values set.
552    #[test]
553    fn stop_fields_round_trip_through_serde() {
554        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
555        state.stop_until = Some(Stage::Plan);
556        state.stopped = true;
557        state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
558        let json = serde_json::to_string(&state).unwrap();
559        assert!(
560            json.contains("stop_until") && json.contains("stopped") && json.contains("stop_reason"),
561            "all three stop fields must appear in persisted JSON: {json}"
562        );
563        let loaded: State = serde_json::from_str(&json).unwrap();
564        assert_eq!(
565            loaded.stop_until,
566            Some(Stage::Plan),
567            "stop_until must round-trip through serde"
568        );
569        assert!(loaded.stopped, "stopped must round-trip through serde");
570        assert_eq!(
571            loaded.stop_reason.as_deref(),
572            Some("stopped after plan completed (--until plan)"),
573            "stop_reason must round-trip through serde"
574        );
575    }
576
577    /// A serde-absent `stop_until`/`stopped`/`stop_reason` (state written by
578    /// a pre-20c binary) must default to `None`/`false`/`None`, not fail to
579    /// deserialize — the same backward-compat pattern as every other
580    /// `#[serde(default)]` field added since 17-01.
581    #[test]
582    fn stop_fields_absent_from_json_default() {
583        let json = r#"{
584            "stage": "code",
585            "phase": 1,
586            "agent": "claude",
587            "mode": "auto",
588            "started_at": "0",
589            "project_root": "/repo"
590        }"#;
591        let loaded: State = serde_json::from_str(json).unwrap();
592        assert_eq!(loaded.stop_until, None);
593        assert!(!loaded.stopped);
594        assert_eq!(loaded.stop_reason, None);
595    }
596}