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}