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}