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    /// The commit count observed on the phase's feature branch at the most
74    /// recent Validate failure (999.66, D-03) — the forward-progress
75    /// baseline [`crate::mode::consecutive_failures_made_progress`] compares
76    /// against to decide whether a new failure begins a fresh streak or
77    /// continues the existing one.
78    ///
79    /// `None` means no prior failure has been recorded — either the first
80    /// failure of a phase, or the first failure observed after resuming
81    /// state written by a binary predating this field — and is deliberately
82    /// distinct from `Some(0)`, which means a failure WAS recorded and the
83    /// branch genuinely carried zero commits at that moment; a later failure
84    /// that again counts zero commits must accumulate against that `Some(0)`
85    /// baseline rather than being treated as a fresh streak.
86    ///
87    /// A serde-absent value (state written by a binary predating this field)
88    /// deserializes to `None`, which is exactly the "no prior record"
89    /// meaning above — the same backward-compat pattern as every other
90    /// `#[serde(default)]` field added since 17-01.
91    ///
92    /// Unlike [`Self::consecutive_failures`] and [`Self::infra_failures`],
93    /// this field is NOT touched by `transition()` — it is a baseline
94    /// observation rather than a counter, matching how
95    /// [`Self::preflight_retries`] and [`Self::checkpoint_resumes`] are
96    /// handled. It is replaced wholesale at each failure rather than
97    /// incremented, so it needs no `saturating_add` treatment, unlike every
98    /// other numeric field on this struct.
99    #[serde(default)]
100    pub last_validate_failure_commit_count: Option<u32>,
101    /// When the phase started (Unix seconds).
102    pub started_at: String,
103    /// Path to the project root.
104    pub project_root: PathBuf,
105    /// Working directory for the agent when running in a git worktree.
106    ///
107    /// `None` means the agent runs in `project_root`. State and capture files
108    /// always live under the main `project_root`; only the agent's cwd changes.
109    #[serde(default)]
110    pub worktree_path: Option<PathBuf>,
111    /// PID of the detached monitor process that owns the agent for the
112    /// current stage, recorded by `launch_stage` at spawn time. `None` means
113    /// no monitor has been spawned for this state yet, OR the state was
114    /// written by a binary predating this field — in both cases the
115    /// liveness probe reports Unknown, never Stuck.
116    #[serde(default)]
117    pub monitor_pid: Option<u32>,
118    /// The Claude session id captured from the most recent captured stdout
119    /// envelope for this phase's current stage (D-04, 28-02), read via
120    /// [`crate::agent_result::session_id_from_capture`]. `None` means EITHER
121    /// "no session has been captured for this state yet" OR "the state was
122    /// written by a binary predating this field" — both cases behave
123    /// identically (no relaunch target to address). Recorded so a checkpoint
124    /// auto-decide relaunch (plan 28-03) can `--resume` the exact session
125    /// that hit the checkpoint rather than spawning a fresh one, which would
126    /// lose the original session's conversation context and permission mode.
127    #[serde(default)]
128    pub session_id: Option<String>,
129    /// How many times the current stage's agent has been relaunched via a
130    /// checkpoint auto-decide resume (D-04, 28-03). Bounds a stuck
131    /// checkpoint loop against `mode::MAX_CHECKPOINT_RESUMES` (added in plan
132    /// 28-03) the same way [`Self::infra_failures`] bounds an infra-fault
133    /// loop against `mode::MAX_INFRA_FAILURES`. Reset to 0 by every ordinary fresh stage
134    /// launch, so the ceiling bounds one stage's resume budget, not a
135    /// phase's lifetime (the same distinction `MAX_INFRA_FAILURES`' doc
136    /// comment draws for `infra_failures`). Any increment must use
137    /// `saturating_add` so a stuck loop cannot overflow `u32`. A
138    /// serde-absent value (state written by a binary predating this field)
139    /// defaults to 0.
140    #[serde(default)]
141    pub checkpoint_resumes: u32,
142    /// The stage `devflow start --until <stage>` requests as the last stage
143    /// to run before halting (20c). `None` means no stop point was
144    /// requested (the pipeline runs to Ship), OR the state was written by a
145    /// binary predating this field — both cases behave identically (no
146    /// interception in `transition()`).
147    #[serde(default)]
148    pub stop_until: Option<Stage>,
149    /// Set by `transition()` when `stop_until` names the stage just
150    /// completed — a terminal-but-not-failed halt short of Ship (20c).
151    /// `false` for a normal in-flight or completed-to-Ship phase, and for
152    /// any state written by a binary predating this field.
153    #[serde(default)]
154    pub stopped: bool,
155    /// Human-readable reason recorded alongside `stopped` (20c). `None`
156    /// when `stopped` is `false`, or when the state predates this field.
157    #[serde(default)]
158    pub stop_reason: Option<String>,
159    /// Pre-authorization for the Ship gate (D-04/D-05/D-06, 23-09),
160    /// set only from the `--yes-ship` CLI flag typed on `devflow start`.
161    ///
162    /// Persisted rather than passed through the call stack: the Ship gate
163    /// fires inside a detached monitor's `advance` process, minutes to
164    /// hours after the launching `devflow start` process has already
165    /// exited, so a CLI-scoped value would be gone by the time it matters —
166    /// only a value written to `state.json` at start time survives to be
167    /// read back by that later, separate process. `false` for any state
168    /// written by a binary predating this field.
169    #[serde(default)]
170    pub yes_ship: bool,
171    /// What this run's delivery canary established (D-13/D-15, 31-03),
172    /// recorded by the first stage launch that routes through the Claude
173    /// `stream-json` transport. `None` means EITHER "no canary has run for
174    /// this run yet" OR "the state was written by a binary predating this
175    /// field" — both cases behave identically: the canary runs.
176    ///
177    /// Persisted rather than held in memory for the same reason
178    /// [`Self::yes_ship`] is: each stage launch happens in a SEPARATE
179    /// `devflow` process (the monitor's own `advance` tail), so an
180    /// in-process flag would reset to "not yet run" at every stage
181    /// transition and re-spend a real throwaway agent invocation each time —
182    /// which is exactly the symptom 31-RESEARCH Pitfall 5 names for a canary
183    /// that landed in the per-stage `preflight` hook.
184    ///
185    /// A recorded `Absent`/`Unverified` keeps refusing on every later launch
186    /// in the run; it is not consumed by the first refusal.
187    #[serde(default)]
188    pub canary: Option<crate::canary::CanaryOutcome>,
189    /// D-11's opt-out: force the pre-31 single-document Claude launch
190    /// (positional prompt, `--output-format json`, the `sh` monitor) for this
191    /// run, off by default.
192    ///
193    /// `false` means EITHER "the operator did not ask for the legacy path" OR
194    /// "the state was written by a binary predating this field" — both cases
195    /// behave identically: the D-09/D-10 rollout decides the transport, which
196    /// is the pre-existing behaviour.
197    ///
198    /// Persisted rather than passed through the call stack for the reason
199    /// [`Self::yes_ship`] gives: each stage launch happens in a SEPARATE
200    /// `devflow` process (the detached monitor's own `advance` tail), so a
201    /// CLI-scoped value would be gone by the time the second stage launches
202    /// and the run would silently revert to the stream transport mid-flight.
203    ///
204    /// Only ever OR-ed, never cleared, once set — see
205    /// `pipeline_launch::apply_legacy_launch_opt_out`. Clearing it on a plain
206    /// `devflow resume` would be the same silent-drop class as `stop_until`'s
207    /// old unconditional clear (999.60). To turn it back off, edit
208    /// `.devflow/state-NN.json` or start a new run.
209    #[serde(default)]
210    pub legacy_claude_launch: bool,
211}
212
213/// Supported coding agents.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "lowercase")]
216pub enum AgentKind {
217    /// Anthropic Claude Code CLI.
218    Claude,
219    /// OpenAI Codex CLI.
220    Codex,
221    /// OpenCode CLI.
222    OpenCode,
223}
224
225impl fmt::Display for AgentKind {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        let name = match self {
228            AgentKind::Claude => "claude",
229            AgentKind::Codex => "codex",
230            AgentKind::OpenCode => "opencode",
231        };
232        f.write_str(name)
233    }
234}
235
236impl FromStr for AgentKind {
237    type Err = AgentParseError;
238
239    fn from_str(value: &str) -> Result<Self, Self::Err> {
240        match value.to_ascii_lowercase().as_str() {
241            "claude" => Ok(AgentKind::Claude),
242            "codex" => Ok(AgentKind::Codex),
243            "opencode" | "open-code" => Ok(AgentKind::OpenCode),
244            other => Err(AgentParseError(other.to_string())),
245        }
246    }
247}
248
249/// Error returned when parsing an unsupported agent name.
250#[derive(Debug, Clone, thiserror::Error)]
251#[error("unsupported agent `{0}`; expected claude, codex, or opencode")]
252pub struct AgentParseError(String);
253
254impl State {
255    /// Create a new state for starting a phase at the [`Stage::Define`] stage.
256    pub fn new(phase: u32, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
257        State {
258            stage: Stage::Define,
259            phase,
260            agent,
261            mode,
262            gate_pending: false,
263            consecutive_failures: 0,
264            infra_failures: 0,
265            preflight_retries: 0,
266            last_validate_failure_commit_count: None,
267            started_at: timestamp_now(),
268            project_root,
269            worktree_path: None,
270            monitor_pid: None,
271            session_id: None,
272            checkpoint_resumes: 0,
273            stop_until: None,
274            stopped: false,
275            stop_reason: None,
276            yes_ship: false,
277            canary: None,
278            legacy_claude_launch: false,
279        }
280    }
281}
282
283fn timestamp_now() -> String {
284    match SystemTime::now().duration_since(UNIX_EPOCH) {
285        Ok(duration) => format!("{}", duration.as_secs()),
286        Err(_) => String::from("0"),
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use std::path::PathBuf;
294
295    #[test]
296    fn agent_name_and_display() {
297        use crate::agents::adapter_for;
298        assert_eq!(adapter_for(AgentKind::Claude).name(), "Claude Code");
299        assert_eq!(adapter_for(AgentKind::Codex).name(), "OpenAI Codex");
300        assert_eq!(adapter_for(AgentKind::OpenCode).name(), "OpenCode");
301
302        assert_eq!(AgentKind::Claude.to_string(), "claude");
303        assert_eq!(AgentKind::Codex.to_string(), "codex");
304        assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
305    }
306
307    #[test]
308    fn agent_from_str_accepts_canonical_and_aliases() {
309        assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
310        assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
311        assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
312        assert_eq!(
313            "opencode".parse::<AgentKind>().unwrap(),
314            AgentKind::OpenCode
315        );
316        assert_eq!(
317            "open-code".parse::<AgentKind>().unwrap(),
318            AgentKind::OpenCode
319        );
320    }
321
322    #[test]
323    fn agent_from_str_rejects_unknown() {
324        let err = "aider".parse::<AgentKind>().unwrap_err();
325        assert!(err.to_string().contains("aider"));
326    }
327
328    #[test]
329    fn new_state_starts_at_define() {
330        let state = State::new(2, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
331        assert_eq!(state.stage, Stage::Define);
332        assert_eq!(state.phase, 2);
333        assert_eq!(state.agent, AgentKind::Claude);
334        assert_eq!(state.mode, Mode::Auto);
335        assert!(!state.gate_pending);
336        assert_eq!(state.consecutive_failures, 0);
337        assert_eq!(state.infra_failures, 0);
338        assert_eq!(state.preflight_retries, 0);
339        assert!(!state.started_at.is_empty());
340        assert_eq!(state.monitor_pid, None);
341        assert_eq!(state.stop_until, None);
342        assert!(!state.stopped);
343        assert_eq!(state.stop_reason, None);
344        assert!(!state.yes_ship);
345    }
346
347    #[test]
348    fn state_serde_round_trips() {
349        let state = State::new(9, AgentKind::Codex, Mode::Supervise, PathBuf::from("/repo"));
350        let json = serde_json::to_string(&state).unwrap();
351        let back: State = serde_json::from_str(&json).unwrap();
352        assert_eq!(back.phase, 9);
353        assert_eq!(back.agent, AgentKind::Codex);
354        assert_eq!(back.stage, Stage::Define);
355        assert_eq!(back.mode, Mode::Supervise);
356    }
357
358    #[test]
359    fn consecutive_failures_persists_across_advance_calls() {
360        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
361        state.consecutive_failures = 3;
362        let json = serde_json::to_string(&state).unwrap();
363        assert!(
364            json.contains("consecutive_failures"),
365            "consecutive_failures must appear in persisted JSON"
366        );
367        let loaded: State = serde_json::from_str(&json).unwrap();
368        assert_eq!(
369            loaded.consecutive_failures, 3,
370            "consecutive_failures must round-trip through serde"
371        );
372    }
373
374    /// D-08 (17-01): a distinct infra-failure counter round-trips through
375    /// serde and its own key appears in the persisted JSON.
376    #[test]
377    fn infra_failures_round_trips_through_serde() {
378        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
379        state.infra_failures = 4;
380        let json = serde_json::to_string(&state).unwrap();
381        assert!(
382            json.contains("infra_failures"),
383            "infra_failures must appear in persisted JSON"
384        );
385        let loaded: State = serde_json::from_str(&json).unwrap();
386        assert_eq!(
387            loaded.infra_failures, 4,
388            "infra_failures must round-trip through serde"
389        );
390    }
391
392    /// A serde-absent `infra_failures` (older persisted state.json without
393    /// the field) must default to 0, not fail to deserialize.
394    #[test]
395    fn infra_failures_absent_from_json_defaults_to_zero() {
396        let json = r#"{
397            "stage": "code",
398            "phase": 1,
399            "agent": "claude",
400            "mode": "auto",
401            "started_at": "0",
402            "project_root": "/repo"
403        }"#;
404        let loaded: State = serde_json::from_str(json).unwrap();
405        assert_eq!(loaded.infra_failures, 0);
406    }
407
408    /// `last_validate_failure_commit_count` round-trips through serde as an
409    /// exact `Option<u32>` (999.66, D-03) — its own key appears in the
410    /// persisted JSON before the value round-trip is asserted, so a field
411    /// accidentally attributed `skip_serializing_if` (which would still pass
412    /// a naive in-memory round-trip while never persisting anything) is
413    /// caught.
414    #[test]
415    fn last_validate_failure_commit_count_round_trips_through_serde() {
416        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
417        state.last_validate_failure_commit_count = Some(3);
418        let json = serde_json::to_string(&state).unwrap();
419        assert!(
420            json.contains("last_validate_failure_commit_count"),
421            "last_validate_failure_commit_count must appear in persisted JSON"
422        );
423        let loaded: State = serde_json::from_str(&json).unwrap();
424        assert_eq!(
425            loaded.last_validate_failure_commit_count,
426            Some(3),
427            "last_validate_failure_commit_count must round-trip through serde"
428        );
429    }
430
431    /// A serde-absent `last_validate_failure_commit_count` (state written by
432    /// a binary predating this field) must deserialize to `None` — the
433    /// "no prior failure recorded" meaning — not to `Some(0)`, which would
434    /// misrepresent a never-observed baseline as an observed zero.
435    #[test]
436    fn last_validate_failure_commit_count_absent_from_json_defaults_to_none() {
437        let json = r#"{
438            "stage": "code",
439            "phase": 1,
440            "agent": "claude",
441            "mode": "auto",
442            "started_at": "0",
443            "project_root": "/repo"
444        }"#;
445        let loaded: State = serde_json::from_str(json).unwrap();
446        assert_eq!(loaded.last_validate_failure_commit_count, None);
447    }
448
449    /// D-18f: `preflight_retries` round-trips through serde (its own key
450    /// appears in the persisted JSON) — the wedge this counter bounds spans
451    /// separate `devflow` invocations, so it must survive a save/load
452    /// cycle, not just live in memory — and a serde-absent value (state
453    /// written by a pre-18f binary) deserializes to 0, not a hard error.
454    #[test]
455    fn preflight_retries_round_trips_through_serde() {
456        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
457        state.preflight_retries = 2;
458        let json = serde_json::to_string(&state).unwrap();
459        assert!(
460            json.contains("preflight_retries"),
461            "preflight_retries must appear in persisted JSON"
462        );
463        let loaded: State = serde_json::from_str(&json).unwrap();
464        assert_eq!(
465            loaded.preflight_retries, 2,
466            "preflight_retries must round-trip through serde"
467        );
468
469        let absent_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_absent: State = serde_json::from_str(absent_json).unwrap();
478        assert_eq!(loaded_absent.preflight_retries, 0);
479    }
480
481    /// `monitor_pid` round-trips through serde as an exact `u32` (18b).
482    #[test]
483    fn monitor_pid_round_trips_through_serde() {
484        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
485        state.monitor_pid = Some(4242);
486        let json = serde_json::to_string(&state).unwrap();
487        assert!(
488            json.contains("monitor_pid"),
489            "monitor_pid must appear in persisted JSON"
490        );
491        let loaded: State = serde_json::from_str(&json).unwrap();
492        assert_eq!(
493            loaded.monitor_pid,
494            Some(4242),
495            "monitor_pid must round-trip through serde"
496        );
497    }
498
499    /// A serde-absent `monitor_pid` (state written by a pre-18b binary) must
500    /// deserialize to `None`, not `Some(0)` — a `Some(0)` default would let a
501    /// pre-18b state file render as a monitor at pid 0.
502    #[test]
503    fn monitor_pid_absent_from_json_defaults_to_none() {
504        let json = r#"{
505            "stage": "code",
506            "phase": 1,
507            "agent": "claude",
508            "mode": "auto",
509            "started_at": "0",
510            "project_root": "/repo"
511        }"#;
512        let loaded: State = serde_json::from_str(json).unwrap();
513        assert_eq!(loaded.monitor_pid, None);
514    }
515
516    /// `session_id` round-trips through serde as an exact `Option<String>`
517    /// (D-04, 28-02) — mirrors the `monitor_pid` pair above.
518    #[test]
519    fn session_id_round_trips_through_serde() {
520        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
521        state.session_id = Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e".to_string());
522        let json = serde_json::to_string(&state).unwrap();
523        assert!(
524            json.contains("session_id"),
525            "session_id must appear in persisted JSON"
526        );
527        let loaded: State = serde_json::from_str(&json).unwrap();
528        assert_eq!(
529            loaded.session_id.as_deref(),
530            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e"),
531            "session_id must round-trip through serde"
532        );
533    }
534
535    /// A serde-absent `session_id` (state written by a pre-28-02 binary) must
536    /// deserialize to `None`, not fail to deserialize.
537    #[test]
538    fn session_id_absent_from_json_defaults_to_none() {
539        let json = r#"{
540            "stage": "code",
541            "phase": 1,
542            "agent": "claude",
543            "mode": "auto",
544            "started_at": "0",
545            "project_root": "/repo"
546        }"#;
547        let loaded: State = serde_json::from_str(json).unwrap();
548        assert_eq!(loaded.session_id, None);
549    }
550
551    /// `checkpoint_resumes` round-trips through serde as an exact `u32`
552    /// (D-04, 28-02).
553    #[test]
554    fn checkpoint_resumes_round_trips_through_serde() {
555        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
556        state.checkpoint_resumes = 2;
557        let json = serde_json::to_string(&state).unwrap();
558        assert!(
559            json.contains("checkpoint_resumes"),
560            "checkpoint_resumes must appear in persisted JSON"
561        );
562        let loaded: State = serde_json::from_str(&json).unwrap();
563        assert_eq!(
564            loaded.checkpoint_resumes, 2,
565            "checkpoint_resumes must round-trip through serde"
566        );
567    }
568
569    /// A serde-absent `checkpoint_resumes` (state written by a pre-28-02
570    /// binary) must deserialize to `0`, not fail to deserialize.
571    #[test]
572    fn checkpoint_resumes_absent_from_json_defaults_to_zero() {
573        let json = r#"{
574            "stage": "code",
575            "phase": 1,
576            "agent": "claude",
577            "mode": "auto",
578            "started_at": "0",
579            "project_root": "/repo"
580        }"#;
581        let loaded: State = serde_json::from_str(json).unwrap();
582        assert_eq!(loaded.checkpoint_resumes, 0);
583    }
584
585    /// 23-09 Task 1: `yes_ship` round-trips through serde as an exact `bool`
586    /// — its own key appears in the persisted JSON, and a fresh deserialize
587    /// recovers the value set, mirroring the `monitor_pid` pair above.
588    #[test]
589    fn yes_ship_round_trips_through_serde() {
590        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
591        state.yes_ship = true;
592        let json = serde_json::to_string(&state).unwrap();
593        assert!(
594            json.contains("yes_ship"),
595            "yes_ship must appear in persisted JSON"
596        );
597        let loaded: State = serde_json::from_str(&json).unwrap();
598        assert!(loaded.yes_ship, "yes_ship must round-trip through serde");
599    }
600
601    /// A serde-absent `yes_ship` (state written by a pre-23-09 binary) must
602    /// deserialize to `false`, not fail to deserialize — the same
603    /// backward-compat pattern as every other `#[serde(default)]` field
604    /// added since 17-01.
605    #[test]
606    fn yes_ship_absent_from_json_defaults_to_false() {
607        let json = r#"{
608            "stage": "code",
609            "phase": 1,
610            "agent": "claude",
611            "mode": "auto",
612            "started_at": "0",
613            "project_root": "/repo"
614        }"#;
615        let loaded: State = serde_json::from_str(json).unwrap();
616        assert!(!loaded.yes_ship);
617    }
618
619    /// 20c: `stop_until`/`stopped`/`stop_reason` all round-trip through
620    /// serde — each field's own key appears in the persisted JSON, and a
621    /// fresh deserialize recovers the exact values set.
622    #[test]
623    fn stop_fields_round_trip_through_serde() {
624        let mut state = State::new(1, AgentKind::Claude, Mode::Auto, PathBuf::from("/repo"));
625        state.stop_until = Some(Stage::Plan);
626        state.stopped = true;
627        state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
628        let json = serde_json::to_string(&state).unwrap();
629        assert!(
630            json.contains("stop_until") && json.contains("stopped") && json.contains("stop_reason"),
631            "all three stop fields must appear in persisted JSON: {json}"
632        );
633        let loaded: State = serde_json::from_str(&json).unwrap();
634        assert_eq!(
635            loaded.stop_until,
636            Some(Stage::Plan),
637            "stop_until must round-trip through serde"
638        );
639        assert!(loaded.stopped, "stopped must round-trip through serde");
640        assert_eq!(
641            loaded.stop_reason.as_deref(),
642            Some("stopped after plan completed (--until plan)"),
643            "stop_reason must round-trip through serde"
644        );
645    }
646
647    /// A serde-absent `stop_until`/`stopped`/`stop_reason` (state written by
648    /// a pre-20c binary) must default to `None`/`false`/`None`, not fail to
649    /// deserialize — the same backward-compat pattern as every other
650    /// `#[serde(default)]` field added since 17-01.
651    #[test]
652    fn stop_fields_absent_from_json_default() {
653        let json = r#"{
654            "stage": "code",
655            "phase": 1,
656            "agent": "claude",
657            "mode": "auto",
658            "started_at": "0",
659            "project_root": "/repo"
660        }"#;
661        let loaded: State = serde_json::from_str(json).unwrap();
662        assert_eq!(loaded.stop_until, None);
663        assert!(!loaded.stopped);
664        assert_eq!(loaded.stop_reason, None);
665    }
666}