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::phase_id::PhaseId;
14use crate::stage::Stage;
15
16/// Full workflow state persisted to `.devflow/state.json`.
17///
18/// # Construction
19///
20/// Marked `#[non_exhaustive]`: downstream crates must build this through
21/// [`State::new`] and then assign the fields they care about, rather than by
22/// struct literal. Deserialization is unaffected — the `Deserialize` derive
23/// and every `#[serde(default)]` field keep working exactly as before, so
24/// state files written by older binaries still load.
25///
26/// This exists because `State` accumulates a field roughly every phase that
27/// adds a run-scoped concept (`worktree_path`, `monitor_pid`, `stop_until`,
28/// `yes_ship`, and — in phase 28 — `session_id` and `checkpoint_resumes`).
29/// Without `non_exhaustive`, each of those additions is a semver-breaking
30/// change for any consumer that used a struct literal, which would force a
31/// major bump for what is really an internal bookkeeping change. Paying that
32/// cost once here makes every future field additive.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[non_exhaustive]
35pub struct State {
36 /// Current workflow stage.
37 pub stage: Stage,
38 /// Phase number being worked on.
39 pub phase: PhaseId,
40 /// Which coding agent was launched.
41 pub agent: AgentKind,
42 /// How the pipeline is driven (auto vs. supervise).
43 pub mode: Mode,
44 /// Whether a gate has been written and is awaiting a human response.
45 #[serde(default)]
46 pub gate_pending: bool,
47 /// Consecutive Validate failures — drives the Auto-mode forced gate after
48 /// [`crate::mode::MAX_CONSECUTIVE_FAILURES`] failures. Persisted across
49 /// `devflow advance` invocations so the counter survives monitor restarts.
50 #[serde(default)]
51 pub consecutive_failures: u32,
52 /// Consecutive infrastructure-class faults (`ResourceKilled`,
53 /// `AgentUnavailable`) — distinct from [`Self::consecutive_failures`]
54 /// (D-08, 17-01). Gates at [`crate::mode::MAX_INFRA_FAILURES`]. Any
55 /// increment (wired in Plan 04) must use `saturating_add` so a
56 /// long-running stuck loop cannot overflow `u32`. A serde-absent value
57 /// (older persisted state) defaults to 0. Reset to 0 on every successful
58 /// stage transition, alongside `consecutive_failures` (CR-01, 17-06 gap
59 /// closure), so the ceiling bounds a stuck loop, not a phase's lifetime.
60 #[serde(default)]
61 pub infra_failures: u32,
62 /// How many times a preflight gate has been resolved and retried for
63 /// this phase (18f). Bounded by [`crate::mode::MAX_PREFLIGHT_RETRIES`].
64 /// Persisted rather than recursion-scoped because the documented wedge
65 /// spanned separate `devflow` invocations after a monitor death — an
66 /// in-process recursion-depth counter would reset to zero on every new
67 /// process and fail to bound the exact incident it exists to prevent.
68 /// Reset to 0 whenever preflight passes and whenever a human explicitly
69 /// approves (`GateAction::Advance`), both inside `run_preflight`. Unlike
70 /// [`Self::consecutive_failures`] and [`Self::infra_failures`], this
71 /// counter is NOT touched by `transition()`.
72 #[serde(default)]
73 pub preflight_retries: u32,
74 /// The commit count observed on the phase's feature branch at the most
75 /// recent Validate failure (999.66, D-03) — the forward-progress
76 /// baseline [`crate::mode::consecutive_failures_made_progress`] compares
77 /// against to decide whether a new failure begins a fresh streak or
78 /// continues the existing one.
79 ///
80 /// `None` means no prior failure has been recorded — either the first
81 /// failure of a phase, or the first failure observed after resuming
82 /// state written by a binary predating this field — and is deliberately
83 /// distinct from `Some(0)`, which means a failure WAS recorded and the
84 /// branch genuinely carried zero commits at that moment; a later failure
85 /// that again counts zero commits must accumulate against that `Some(0)`
86 /// baseline rather than being treated as a fresh streak.
87 ///
88 /// A serde-absent value (state written by a binary predating this field)
89 /// deserializes to `None`, which is exactly the "no prior record"
90 /// meaning above — the same backward-compat pattern as every other
91 /// `#[serde(default)]` field added since 17-01.
92 ///
93 /// Unlike [`Self::consecutive_failures`] and [`Self::infra_failures`],
94 /// this field is NOT touched by `transition()` — it is a baseline
95 /// observation rather than a counter, matching how
96 /// [`Self::preflight_retries`] and [`Self::checkpoint_resumes`] are
97 /// handled. It is replaced wholesale at each failure rather than
98 /// incremented, so it needs no `saturating_add` treatment, unlike every
99 /// other numeric field on this struct.
100 #[serde(default)]
101 pub last_validate_failure_commit_count: Option<u32>,
102 /// Every Validate failure recorded for this PHASE, accumulated without
103 /// regard to forward progress (999.78/WR-01, D-07) — the backstop bound
104 /// [`crate::mode::MAX_PHASE_VALIDATE_FAILURES`] compares against, and the
105 /// leading number in the Supervise gate message (WR-04).
106 ///
107 /// A serde-absent value (state written by a binary predating this field)
108 /// deserializes to 0, which is exactly its "no failures recorded for this
109 /// phase" meaning — the same backward-compat pattern as every other
110 /// `#[serde(default)]` field added since 17-01. Unlike
111 /// [`Self::last_validate_failure_commit_count`], zero is not ambiguous
112 /// here: an upgraded binary and a genuine first failure both start the
113 /// budget at its full width, and that widening is what IN-02's distinct
114 /// loop-back reason exists to announce.
115 ///
116 /// Why it exists next to [`Self::consecutive_failures`] rather than
117 /// replacing it: `consecutive_failures` is reset whenever
118 /// [`crate::mode::consecutive_failures_made_progress`] reports that new
119 /// commits landed, and the Code stage's fix command is a GSD command
120 /// which routinely commits `.planning/` artifacts even when no source
121 /// changed. A loop that commits something trivial every cycle therefore
122 /// resets the streak every cycle and never reaches
123 /// [`crate::mode::MAX_CONSECUTIVE_FAILURES`]. This total cannot be reset
124 /// by a commit count.
125 ///
126 /// **Lifetime — deliberately unlike every other counter on this struct.**
127 /// It is NOT touched by the stage transition (`transition_resets_*` has no
128 /// say over it), matching how [`Self::preflight_retries`] and
129 /// [`Self::checkpoint_resumes`] are handled, because it is a per-phase
130 /// total rather than a per-streak counter. It is also carried across a
131 /// forced restart: `commands::start()` reads any persisted state for the
132 /// same phase and copies this one field into the fresh `State`, because a
133 /// bound a `devflow start --force` resets does not bound the unattended
134 /// case D-07 exists for. Exactly two events reset it to zero:
135 ///
136 /// 1. **Phase completion** — `finish_workflow_with_gate_timeout` calls
137 /// `workflow::clear_state`, deleting `.devflow/state-{NN}.json`, so the
138 /// next start for that phase finds nothing to carry.
139 /// 2. **Operator approval at the ceiling gate** — the Validate gate
140 /// handling zeroes it when a human advances or loops back AND
141 /// [`crate::mode::phase_failure_ceiling_reached`] is true. Keyed on that
142 /// predicate and never on "a gate fired": Supervise gates on every
143 /// Validate, so a gate-keyed reset would clear the total at every
144 /// failure and it would never accumulate in the one mode where an
145 /// operator watches every occurrence.
146 ///
147 /// Any increment must use `saturating_add`, like [`Self::infra_failures`]
148 /// and [`Self::checkpoint_resumes`], so an exhausted budget can never wrap
149 /// back to zero and silently restore itself.
150 #[serde(default)]
151 pub phase_validate_failures: u32,
152 /// The content fingerprint of this phase's `{N}-VERIFICATION.md` as it
153 /// stood at the START of this run (999.79), read via
154 /// [`crate::agent_result::phase_verification_fingerprint`] once the
155 /// evidence root for the run is known.
156 ///
157 /// `None` means no artifact was observed at the start of this run — the
158 /// ordinary case for a phase being executed for the first time. It is
159 /// deliberately distinct from `Some(h)`: an artifact that EXISTS now where
160 /// the baseline recorded none was authored during this run, whereas an
161 /// artifact whose fingerprint still equals the baseline was inherited from
162 /// a previous run and its verdict must not be reused.
163 ///
164 /// **State written by a binary predating this field also deserializes to
165 /// `None`, and that is NOT the same reading** (WR-05, 35-REVIEW). This doc
166 /// comment used to claim it was. For a phase started under an older binary
167 /// and continued by this one, the previous run's committed
168 /// `{N}-VERIFICATION.md` is already on disk while the baseline reads
169 /// `None` — so the `(Some, None)` row would classify an inherited artifact
170 /// as authored-this-run and dispatch `--gaps-only` against zero matching
171 /// plans, gating unresolvably. That is verbatim the DOGFOOD-01-class stall
172 /// 999.79 exists to close, reproduced for every in-flight phase across the
173 /// upgrade.
174 ///
175 /// [`Self::verification_baseline_captured`] is the discriminator: only a
176 /// run that actually performed the observation sets it, so a `None` from an
177 /// old state file is distinguishable from a `None` that means "looked, and
178 /// there was nothing there".
179 ///
180 /// Why this exists at all: nothing deletes or dates `{N}-VERIFICATION.md`,
181 /// so a `devflow start --force` re-run checks out a branch still carrying
182 /// the previous run's committed copy. Without this baseline the first
183 /// Validate failure of that re-run reads the inherited artifact as a
184 /// verdict and dispatches a `--gaps-only` pass against zero matching plans,
185 /// which gates unresolvably — the same unattended-stall class as
186 /// DOGFOOD-01, reached from a different direction.
187 ///
188 /// **Lifetime.** Like [`Self::last_validate_failure_commit_count`], and
189 /// unlike [`Self::consecutive_failures`] and [`Self::infra_failures`], this
190 /// field is NOT touched by `transition()` — it is a run-scoped observation
191 /// rather than a counter, so it is replaced wholesale rather than
192 /// incremented and needs no `saturating_add` treatment. It is also NOT
193 /// carried across a forced restart the way
194 /// [`Self::phase_validate_failures`] is: a new run must re-observe the
195 /// artifact, because the whole point is to compare against what THIS run
196 /// started with.
197 #[serde(default)]
198 pub last_verification_fingerprint: Option<u64>,
199 /// Whether [`Self::last_verification_fingerprint`] was actually observed by
200 /// this run, as opposed to merely absent (WR-05, 35-REVIEW).
201 ///
202 /// `Option<u64>` cannot carry this on its own: `None` means both "the run
203 /// looked and found no artifact" and "this state file predates the field,
204 /// so nobody ever looked", and those two demand OPPOSITE dispatches. The
205 /// first is the ordinary first-verification case and `--gaps-only` is
206 /// right; the second may be sitting on an inherited artifact, where
207 /// `--gaps-only` matches zero plans and stalls.
208 ///
209 /// `false` is therefore the correct serde default in both directions: a
210 /// state file written before this field existed genuinely did not capture a
211 /// baseline, and the conservative reading of an artifact whose provenance
212 /// is unknown is "inherited" — a full execute is wasteful, an unresolvable
213 /// gate is not recoverable.
214 ///
215 /// Set exactly once per run, at the same site that captures the baseline,
216 /// after `state.worktree_path` holds its final value.
217 #[serde(default)]
218 pub verification_baseline_captured: bool,
219 /// The mtime of the same artifact [`Self::last_verification_fingerprint`]
220 /// hashes, in nanoseconds since the Unix epoch, as of the same observation.
221 ///
222 /// WR-06 (35-REVIEW): a content fingerprint cannot see an IDEMPOTENT
223 /// rewrite. A Validate agent that re-authors byte-identical content on a
224 /// later failing cycle produces the same hash as an artifact nobody
225 /// touched, so a hash-only rule reads its own agent's work as inherited and
226 /// dispatches a full execute — re-running every plan in the phase on every
227 /// subsequent cycle instead of the gaps-only pass Phase 33 built. That is
228 /// the "too strict" direction the freshness rule's own comment claims to
229 /// guard against and did not.
230 ///
231 /// Moves in lockstep with the fingerprint: written at the same capture
232 /// site, replaced at the same update site, and never read on its own — the
233 /// pair is the observation, and either one differing means the artifact was
234 /// written during this run.
235 ///
236 /// 35.2 D-05: mtime was considered as the provenance signal and REJECTED.
237 /// A branch checkout or worktree merge-back updates mtime exactly as a
238 /// real write does — it fails on the identical scenario
239 /// [`Self::verification_run_nonce`] exists to catch, which is why 999.89
240 /// survived 35-05's WR-06 fix. mtime is still what detects a byte-identical
241 /// rewrite INSIDE the Validate dispatch window whose bounds the nonce
242 /// establishes — provenance and freshness are different questions.
243 #[serde(default)]
244 pub last_verification_mtime_nanos: Option<u64>,
245 /// A run-owned marker stamped per Validate dispatch proving DevFlow itself
246 /// launched the agent whose output this state describes (35.2, 999.89 /
247 /// HARDEN-03, D-01).
248 ///
249 /// `None` means DevFlow never stamped a Validate dispatch for this state,
250 /// which is both the pre-35.2-state-file case and the never-dispatched
251 /// case. Both demand the conservative reading: the artifact's provenance is
252 /// unknown and `verification_authored_this_run` returns `false`.
253 ///
254 /// **Lifetime — replaced wholesale on every Validate dispatch, not
255 /// incremented across runs.** Unlike [`Self::consecutive_failures`] and
256 /// [`Self::phase_validate_failures`], this field is NOT touched by
257 /// `transition()`, and [`State::new`] resets it, so a `--force` restart
258 /// cannot inherit a previous run's stamp. The value is a monotonically
259 /// increasing counter; the predicate consults [`Option::is_some`], never
260 /// the magnitude, so saturation cannot degrade the signal.
261 ///
262 /// The write site is `launch_stage_inner` in `pipeline_launch.rs`, gated
263 /// on `Stage::Validate`, co-located with a fresh fingerprint/mtime
264 /// re-observation — the stamp and the baseline are one mechanism, and
265 /// splitting them silently restores the run-wide observation window.
266 ///
267 /// An actor who can write `.devflow/state-{N}.json` can set `stage` or
268 /// `consecutive_failures` directly; this field adds no attack surface
269 /// beyond what already exists (P-03).
270 #[serde(default)]
271 pub verification_run_nonce: Option<u64>,
272 /// When the phase started (Unix seconds).
273 pub started_at: String,
274 /// Path to the project root.
275 pub project_root: PathBuf,
276 /// Working directory for the agent when running in a git worktree.
277 ///
278 /// `None` means the agent runs in `project_root`. State and capture files
279 /// always live under the main `project_root`; only the agent's cwd changes.
280 #[serde(default)]
281 pub worktree_path: Option<PathBuf>,
282 /// PID of the detached monitor process that owns the agent for the
283 /// current stage, recorded by `launch_stage` at spawn time. `None` means
284 /// no monitor has been spawned for this state yet, OR the state was
285 /// written by a binary predating this field — in both cases the
286 /// liveness probe reports Unknown, never Stuck.
287 #[serde(default)]
288 pub monitor_pid: Option<u32>,
289 /// The Claude session id captured from the most recent captured stdout
290 /// envelope for this phase's current stage (D-04, 28-02), read via
291 /// [`crate::agent_result::session_id_from_capture`]. `None` means EITHER
292 /// "no session has been captured for this state yet" OR "the state was
293 /// written by a binary predating this field" — both cases behave
294 /// identically (no relaunch target to address). Recorded so a checkpoint
295 /// auto-decide relaunch (plan 28-03) can `--resume` the exact session
296 /// that hit the checkpoint rather than spawning a fresh one, which would
297 /// lose the original session's conversation context and permission mode.
298 #[serde(default)]
299 pub session_id: Option<String>,
300 /// How many times the current stage's agent has been relaunched via a
301 /// checkpoint auto-decide resume (D-04, 28-03). Bounds a stuck
302 /// checkpoint loop against `mode::MAX_CHECKPOINT_RESUMES` (added in plan
303 /// 28-03) the same way [`Self::infra_failures`] bounds an infra-fault
304 /// loop against `mode::MAX_INFRA_FAILURES`. Reset to 0 by every ordinary fresh stage
305 /// launch, so the ceiling bounds one stage's resume budget, not a
306 /// phase's lifetime (the same distinction `MAX_INFRA_FAILURES`' doc
307 /// comment draws for `infra_failures`). Any increment must use
308 /// `saturating_add` so a stuck loop cannot overflow `u32`. A
309 /// serde-absent value (state written by a binary predating this field)
310 /// defaults to 0.
311 #[serde(default)]
312 pub checkpoint_resumes: u32,
313 /// The stage `devflow start --until <stage>` requests as the last stage
314 /// to run before halting (20c). `None` means no stop point was
315 /// requested (the pipeline runs to Ship), OR the state was written by a
316 /// binary predating this field — both cases behave identically (no
317 /// interception in `transition()`).
318 #[serde(default)]
319 pub stop_until: Option<Stage>,
320 /// Set by `transition()` when `stop_until` names the stage just
321 /// completed — a terminal-but-not-failed halt short of Ship (20c).
322 /// `false` for a normal in-flight or completed-to-Ship phase, and for
323 /// any state written by a binary predating this field.
324 #[serde(default)]
325 pub stopped: bool,
326 /// Human-readable reason recorded alongside `stopped` (20c). `None`
327 /// when `stopped` is `false`, or when the state predates this field.
328 #[serde(default)]
329 pub stop_reason: Option<String>,
330 /// Pre-authorization for the Ship gate (D-04/D-05/D-06, 23-09),
331 /// set only from the `--yes-ship` CLI flag typed on `devflow start`.
332 ///
333 /// Persisted rather than passed through the call stack: the Ship gate
334 /// fires inside a detached monitor's `advance` process, minutes to
335 /// hours after the launching `devflow start` process has already
336 /// exited, so a CLI-scoped value would be gone by the time it matters —
337 /// only a value written to `state.json` at start time survives to be
338 /// read back by that later, separate process. `false` for any state
339 /// written by a binary predating this field.
340 #[serde(default)]
341 pub yes_ship: bool,
342 /// What this run's delivery canary established (D-13/D-15, 31-03),
343 /// recorded by the first stage launch that routes through the Claude
344 /// `stream-json` transport. `None` means EITHER "no canary has run for
345 /// this run yet" OR "the state was written by a binary predating this
346 /// field" — both cases behave identically: the canary runs.
347 ///
348 /// Persisted rather than held in memory for the same reason
349 /// [`Self::yes_ship`] is: each stage launch happens in a SEPARATE
350 /// `devflow` process (the monitor's own `advance` tail), so an
351 /// in-process flag would reset to "not yet run" at every stage
352 /// transition and re-spend a real throwaway agent invocation each time —
353 /// which is exactly the symptom 31-RESEARCH Pitfall 5 names for a canary
354 /// that landed in the per-stage `preflight` hook.
355 ///
356 /// A recorded `Absent`/`Unverified` keeps refusing on every later launch
357 /// in the run; it is not consumed by the first refusal.
358 #[serde(default)]
359 pub canary: Option<crate::canary::CanaryOutcome>,
360 /// D-11's opt-out: force the pre-31 single-document Claude launch
361 /// (positional prompt, `--output-format json`, the `sh` monitor) for this
362 /// run, off by default.
363 ///
364 /// `false` means EITHER "the operator did not ask for the legacy path" OR
365 /// "the state was written by a binary predating this field" — both cases
366 /// behave identically: the D-09/D-10 rollout decides the transport, which
367 /// is the pre-existing behaviour.
368 ///
369 /// Persisted rather than passed through the call stack for the reason
370 /// [`Self::yes_ship`] gives: each stage launch happens in a SEPARATE
371 /// `devflow` process (the detached monitor's own `advance` tail), so a
372 /// CLI-scoped value would be gone by the time the second stage launches
373 /// and the run would silently revert to the stream transport mid-flight.
374 ///
375 /// Only ever OR-ed, never cleared, once set — see
376 /// `pipeline_launch::apply_legacy_launch_opt_out`. Clearing it on a plain
377 /// `devflow resume` would be the same silent-drop class as `stop_until`'s
378 /// old unconditional clear (999.60). To turn it back off, edit
379 /// `.devflow/state-NN.json` or start a new run.
380 #[serde(default)]
381 pub legacy_claude_launch: bool,
382}
383
384/// Supported coding agents.
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(rename_all = "lowercase")]
387pub enum AgentKind {
388 /// Anthropic Claude Code CLI.
389 Claude,
390 /// OpenAI Codex CLI.
391 Codex,
392 /// OpenCode CLI.
393 OpenCode,
394 /// Pi coding-agent harness.
395 Pi,
396 /// Antigravity CLI (`agy`), stream-json transport (phase 41).
397 Antigravity,
398}
399
400impl fmt::Display for AgentKind {
401 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
402 let name = match self {
403 AgentKind::Claude => "claude",
404 AgentKind::Codex => "codex",
405 AgentKind::OpenCode => "opencode",
406 AgentKind::Pi => "pi",
407 AgentKind::Antigravity => "antigravity",
408 };
409 f.write_str(name)
410 }
411}
412
413impl FromStr for AgentKind {
414 type Err = AgentParseError;
415
416 fn from_str(value: &str) -> Result<Self, Self::Err> {
417 match value.to_ascii_lowercase().as_str() {
418 "claude" => Ok(AgentKind::Claude),
419 "codex" => Ok(AgentKind::Codex),
420 "opencode" | "open-code" => Ok(AgentKind::OpenCode),
421 "pi" => Ok(AgentKind::Pi),
422 "antigravity" => Ok(AgentKind::Antigravity),
423 other => Err(AgentParseError(other.to_string())),
424 }
425 }
426}
427
428/// Error returned when parsing an unsupported agent name.
429#[derive(Debug, Clone, thiserror::Error)]
430#[error("unsupported agent `{0}`; expected claude, codex, opencode, pi, or antigravity")]
431pub struct AgentParseError(String);
432
433impl State {
434 /// Create a new state for starting a phase at the [`Stage::Define`] stage.
435 pub fn new(phase: PhaseId, agent: AgentKind, mode: Mode, project_root: PathBuf) -> Self {
436 State {
437 stage: Stage::Define,
438 phase,
439 agent,
440 mode,
441 gate_pending: false,
442 consecutive_failures: 0,
443 infra_failures: 0,
444 preflight_retries: 0,
445 last_validate_failure_commit_count: None,
446 phase_validate_failures: 0,
447 last_verification_fingerprint: None,
448 verification_baseline_captured: false,
449 last_verification_mtime_nanos: None,
450 verification_run_nonce: None,
451 started_at: timestamp_now(),
452 project_root,
453 worktree_path: None,
454 monitor_pid: None,
455 session_id: None,
456 checkpoint_resumes: 0,
457 stop_until: None,
458 stopped: false,
459 stop_reason: None,
460 yes_ship: false,
461 canary: None,
462 legacy_claude_launch: false,
463 }
464 }
465}
466
467fn timestamp_now() -> String {
468 match SystemTime::now().duration_since(UNIX_EPOCH) {
469 Ok(duration) => format!("{}", duration.as_secs()),
470 Err(_) => String::from("0"),
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use std::path::PathBuf;
478
479 #[test]
480 fn agent_name_and_display() {
481 use crate::agents::driver_for;
482 assert_eq!(driver_for(AgentKind::Claude).name(), "Claude Code");
483 assert_eq!(driver_for(AgentKind::Codex).name(), "OpenAI Codex");
484 assert_eq!(driver_for(AgentKind::OpenCode).name(), "OpenCode");
485
486 assert_eq!(AgentKind::Claude.to_string(), "claude");
487 assert_eq!(AgentKind::Codex.to_string(), "codex");
488 assert_eq!(AgentKind::OpenCode.to_string(), "opencode");
489 }
490
491 #[test]
492 fn agent_from_str_accepts_canonical_and_aliases() {
493 assert_eq!("claude".parse::<AgentKind>().unwrap(), AgentKind::Claude);
494 assert_eq!("CLAUDE".parse::<AgentKind>().unwrap(), AgentKind::Claude);
495 assert_eq!("codex".parse::<AgentKind>().unwrap(), AgentKind::Codex);
496 assert_eq!(
497 "opencode".parse::<AgentKind>().unwrap(),
498 AgentKind::OpenCode
499 );
500 assert_eq!(
501 "open-code".parse::<AgentKind>().unwrap(),
502 AgentKind::OpenCode
503 );
504 }
505
506 #[test]
507 fn agent_from_str_rejects_unknown() {
508 let err = "aider".parse::<AgentKind>().unwrap_err();
509 assert!(err.to_string().contains("aider"));
510 }
511
512 #[test]
513 fn new_state_starts_at_define() {
514 let state = State::new(
515 PhaseId::new(2),
516 AgentKind::Claude,
517 Mode::Auto,
518 PathBuf::from("/repo"),
519 );
520 assert_eq!(state.stage, Stage::Define);
521 assert_eq!(state.phase, PhaseId::new(2));
522 assert_eq!(state.agent, AgentKind::Claude);
523 assert_eq!(state.mode, Mode::Auto);
524 assert!(!state.gate_pending);
525 assert_eq!(state.consecutive_failures, 0);
526 assert_eq!(state.infra_failures, 0);
527 assert_eq!(state.preflight_retries, 0);
528 assert_eq!(state.phase_validate_failures, 0);
529 assert!(!state.started_at.is_empty());
530 assert_eq!(state.monitor_pid, None);
531 assert_eq!(state.stop_until, None);
532 assert!(!state.stopped);
533 assert_eq!(state.stop_reason, None);
534 assert!(!state.yes_ship);
535 }
536
537 #[test]
538 fn state_serde_round_trips() {
539 let state = State::new(
540 PhaseId::new(9),
541 AgentKind::Codex,
542 Mode::Supervise,
543 PathBuf::from("/repo"),
544 );
545 let json = serde_json::to_string(&state).unwrap();
546 let back: State = serde_json::from_str(&json).unwrap();
547 assert_eq!(back.phase, PhaseId::new(9));
548 assert_eq!(back.agent, AgentKind::Codex);
549 assert_eq!(back.stage, Stage::Define);
550 assert_eq!(back.mode, Mode::Supervise);
551 }
552
553 #[test]
554 fn consecutive_failures_persists_across_advance_calls() {
555 let mut state = State::new(
556 PhaseId::new(1),
557 AgentKind::Claude,
558 Mode::Auto,
559 PathBuf::from("/repo"),
560 );
561 state.consecutive_failures = 3;
562 let json = serde_json::to_string(&state).unwrap();
563 assert!(
564 json.contains("consecutive_failures"),
565 "consecutive_failures must appear in persisted JSON"
566 );
567 let loaded: State = serde_json::from_str(&json).unwrap();
568 assert_eq!(
569 loaded.consecutive_failures, 3,
570 "consecutive_failures must round-trip through serde"
571 );
572 }
573
574 /// D-08 (17-01): a distinct infra-failure counter round-trips through
575 /// serde and its own key appears in the persisted JSON.
576 #[test]
577 fn infra_failures_round_trips_through_serde() {
578 let mut state = State::new(
579 PhaseId::new(1),
580 AgentKind::Claude,
581 Mode::Auto,
582 PathBuf::from("/repo"),
583 );
584 state.infra_failures = 4;
585 let json = serde_json::to_string(&state).unwrap();
586 assert!(
587 json.contains("infra_failures"),
588 "infra_failures must appear in persisted JSON"
589 );
590 let loaded: State = serde_json::from_str(&json).unwrap();
591 assert_eq!(
592 loaded.infra_failures, 4,
593 "infra_failures must round-trip through serde"
594 );
595 }
596
597 /// A serde-absent `infra_failures` (older persisted state.json without
598 /// the field) must default to 0, not fail to deserialize.
599 #[test]
600 fn infra_failures_absent_from_json_defaults_to_zero() {
601 let json = r#"{
602 "stage": "code",
603 "phase": 1,
604 "agent": "claude",
605 "mode": "auto",
606 "started_at": "0",
607 "project_root": "/repo"
608 }"#;
609 let loaded: State = serde_json::from_str(json).unwrap();
610 assert_eq!(loaded.infra_failures, 0);
611 }
612
613 /// `last_validate_failure_commit_count` round-trips through serde as an
614 /// exact `Option<u32>` (999.66, D-03) — its own key appears in the
615 /// persisted JSON before the value round-trip is asserted, so a field
616 /// accidentally attributed `skip_serializing_if` (which would still pass
617 /// a naive in-memory round-trip while never persisting anything) is
618 /// caught.
619 #[test]
620 fn last_validate_failure_commit_count_round_trips_through_serde() {
621 let mut state = State::new(
622 PhaseId::new(1),
623 AgentKind::Claude,
624 Mode::Auto,
625 PathBuf::from("/repo"),
626 );
627 state.last_validate_failure_commit_count = Some(3);
628 let json = serde_json::to_string(&state).unwrap();
629 assert!(
630 json.contains("last_validate_failure_commit_count"),
631 "last_validate_failure_commit_count must appear in persisted JSON"
632 );
633 let loaded: State = serde_json::from_str(&json).unwrap();
634 assert_eq!(
635 loaded.last_validate_failure_commit_count,
636 Some(3),
637 "last_validate_failure_commit_count must round-trip through serde"
638 );
639 }
640
641 /// A serde-absent `last_validate_failure_commit_count` (state written by
642 /// a binary predating this field) must deserialize to `None` — the
643 /// "no prior failure recorded" meaning — not to `Some(0)`, which would
644 /// misrepresent a never-observed baseline as an observed zero.
645 #[test]
646 fn last_validate_failure_commit_count_absent_from_json_defaults_to_none() {
647 let json = r#"{
648 "stage": "code",
649 "phase": 1,
650 "agent": "claude",
651 "mode": "auto",
652 "started_at": "0",
653 "project_root": "/repo"
654 }"#;
655 let loaded: State = serde_json::from_str(json).unwrap();
656 assert_eq!(loaded.last_validate_failure_commit_count, None);
657 }
658
659 /// 999.78/D-07: `phase_validate_failures` round-trips through serde. The
660 /// key-presence assertion comes BEFORE the value round-trip deliberately —
661 /// a field that never actually persists still passes a naive in-memory
662 /// round trip, and a bound that lives only in memory does not bound a
663 /// phase whose whole failure mode spans separate `devflow` processes.
664 #[test]
665 fn phase_validate_failures_round_trips_through_serde() {
666 let mut state = State::new(
667 PhaseId::new(1),
668 AgentKind::Claude,
669 Mode::Auto,
670 PathBuf::from("/repo"),
671 );
672 state.phase_validate_failures = 7;
673 let json = serde_json::to_string(&state).unwrap();
674 assert!(
675 json.contains("phase_validate_failures"),
676 "phase_validate_failures must appear in persisted JSON"
677 );
678 let loaded: State = serde_json::from_str(&json).unwrap();
679 assert_eq!(
680 loaded.phase_validate_failures, 7,
681 "phase_validate_failures must round-trip through serde"
682 );
683 }
684
685 /// A serde-absent `phase_validate_failures` (state written by a binary
686 /// predating this field) deserializes to 0 — "no failures recorded for
687 /// this phase" — rather than failing the load outright, which would make
688 /// an upgrade mid-phase unrecoverable.
689 #[test]
690 fn phase_validate_failures_absent_from_json_defaults_to_zero() {
691 let json = r#"{
692 "stage": "code",
693 "phase": 1,
694 "agent": "claude",
695 "mode": "auto",
696 "started_at": "0",
697 "project_root": "/repo"
698 }"#;
699 let loaded: State = serde_json::from_str(json).unwrap();
700 assert_eq!(loaded.phase_validate_failures, 0);
701 }
702
703 /// 999.79 (35-05): `last_verification_fingerprint` round-trips through
704 /// serde. The key-presence assertion comes BEFORE the value round-trip for
705 /// the same reason the two fields above give — this baseline is written by
706 /// `devflow start` and compared by a later `devflow advance`, which is a
707 /// different process, so a field that never reaches disk would leave every
708 /// comparison reading `None` and defeat the whole rule.
709 #[test]
710 fn last_verification_fingerprint_round_trips_through_serde() {
711 let mut state = State::new(
712 PhaseId::new(1),
713 AgentKind::Claude,
714 Mode::Auto,
715 PathBuf::from("/repo"),
716 );
717 state.last_verification_fingerprint = Some(0x0123_4567_89ab_cdef);
718 let json = serde_json::to_string(&state).unwrap();
719 assert!(
720 json.contains("last_verification_fingerprint"),
721 "last_verification_fingerprint must appear in persisted JSON"
722 );
723 let loaded: State = serde_json::from_str(&json).unwrap();
724 assert_eq!(
725 loaded.last_verification_fingerprint,
726 Some(0x0123_4567_89ab_cdef),
727 "last_verification_fingerprint must round-trip through serde"
728 );
729 }
730
731 /// A serde-absent `last_verification_fingerprint` (state written by a
732 /// binary predating this field) deserializes to `None` — "no artifact was
733 /// observed at the start of this run" — rather than failing the load, which
734 /// would make an upgrade mid-phase unrecoverable.
735 #[test]
736 fn last_verification_fingerprint_absent_from_json_defaults_to_none() {
737 let json = r#"{
738 "stage": "code",
739 "phase": 1,
740 "agent": "claude",
741 "mode": "auto",
742 "started_at": "0",
743 "project_root": "/repo"
744 }"#;
745 let loaded: State = serde_json::from_str(json).unwrap();
746 assert_eq!(loaded.last_verification_fingerprint, None);
747 // WR-05 (35-REVIEW): the SAME absent JSON must also report that nobody
748 // captured a baseline. `None` alone cannot carry that — it means both
749 // "looked, found nothing" and "never looked" — and the two demand
750 // opposite dispatches downstream.
751 assert!(
752 !loaded.verification_baseline_captured,
753 "state predating the baseline field never captured one, and must not claim to"
754 );
755 }
756
757 /// The other half of the pair above: a state file written by THIS binary
758 /// carries the flag, so the two cases really are distinguishable after a
759 /// round trip. Without this, `verification_baseline_captured` could be
760 /// hardcoded `false` and the absent-JSON assertion above would still pass.
761 #[test]
762 fn verification_baseline_captured_round_trips_through_serde() {
763 let mut state = State::new(
764 PhaseId::new(1),
765 AgentKind::Claude,
766 Mode::Auto,
767 PathBuf::from("/repo"),
768 );
769 state.verification_baseline_captured = true;
770 let json = serde_json::to_string(&state).unwrap();
771 assert!(
772 json.contains("verification_baseline_captured"),
773 "verification_baseline_captured must appear in persisted JSON"
774 );
775 let loaded: State = serde_json::from_str(&json).unwrap();
776 assert!(
777 loaded.verification_baseline_captured,
778 "a captured baseline must survive the save/load the real pipeline performs"
779 );
780 }
781
782 /// 35.2 D-01: verification_run_nonce must survive the save/load cycle
783 /// `handle_validate_outcome` → `select_loop_back_fix` performs.
784 #[test]
785 fn verification_run_nonce_round_trips_through_serde() {
786 let mut state = State::new(
787 PhaseId::new(1),
788 AgentKind::Claude,
789 Mode::Auto,
790 PathBuf::from("/repo"),
791 );
792 state.verification_run_nonce = Some(42);
793 let json = serde_json::to_string(&state).unwrap();
794 assert!(
795 json.contains("verification_run_nonce"),
796 "verification_run_nonce must appear in persisted JSON"
797 );
798 let loaded: State = serde_json::from_str(&json).unwrap();
799 assert_eq!(
800 loaded.verification_run_nonce,
801 Some(42),
802 "verification_run_nonce must round-trip through serde"
803 );
804 }
805
806 /// 35.2 D-01: a serde-absent verification_run_nonce (state written by
807 /// a pre-35.2 binary) deserializes to None — the conservative direction.
808 #[test]
809 fn verification_run_nonce_absent_from_json_defaults_to_none() {
810 let json = r#"{
811 "stage": "code",
812 "phase": 1,
813 "agent": "claude",
814 "mode": "auto",
815 "started_at": "0",
816 "project_root": "/repo"
817 }"#;
818 let loaded: State = serde_json::from_str(json).unwrap();
819 assert_eq!(
820 loaded.verification_run_nonce, None,
821 "pre-35.2 state must default to None — the conservative provenance reading"
822 );
823 }
824
825 /// D-18f: `preflight_retries` round-trips through serde (its own key
826 /// appears in the persisted JSON) — the wedge this counter bounds spans
827 /// separate `devflow` invocations, so it must survive a save/load
828 /// cycle, not just live in memory — and a serde-absent value (state
829 /// written by a pre-18f binary) deserializes to 0, not a hard error.
830 #[test]
831 fn preflight_retries_round_trips_through_serde() {
832 let mut state = State::new(
833 PhaseId::new(1),
834 AgentKind::Claude,
835 Mode::Auto,
836 PathBuf::from("/repo"),
837 );
838 state.preflight_retries = 2;
839 let json = serde_json::to_string(&state).unwrap();
840 assert!(
841 json.contains("preflight_retries"),
842 "preflight_retries must appear in persisted JSON"
843 );
844 let loaded: State = serde_json::from_str(&json).unwrap();
845 assert_eq!(
846 loaded.preflight_retries, 2,
847 "preflight_retries must round-trip through serde"
848 );
849
850 let absent_json = r#"{
851 "stage": "code",
852 "phase": 1,
853 "agent": "claude",
854 "mode": "auto",
855 "started_at": "0",
856 "project_root": "/repo"
857 }"#;
858 let loaded_absent: State = serde_json::from_str(absent_json).unwrap();
859 assert_eq!(loaded_absent.preflight_retries, 0);
860 }
861
862 /// `monitor_pid` round-trips through serde as an exact `u32` (18b).
863 #[test]
864 fn monitor_pid_round_trips_through_serde() {
865 let mut state = State::new(
866 PhaseId::new(1),
867 AgentKind::Claude,
868 Mode::Auto,
869 PathBuf::from("/repo"),
870 );
871 state.monitor_pid = Some(4242);
872 let json = serde_json::to_string(&state).unwrap();
873 assert!(
874 json.contains("monitor_pid"),
875 "monitor_pid must appear in persisted JSON"
876 );
877 let loaded: State = serde_json::from_str(&json).unwrap();
878 assert_eq!(
879 loaded.monitor_pid,
880 Some(4242),
881 "monitor_pid must round-trip through serde"
882 );
883 }
884
885 /// A serde-absent `monitor_pid` (state written by a pre-18b binary) must
886 /// deserialize to `None`, not `Some(0)` — a `Some(0)` default would let a
887 /// pre-18b state file render as a monitor at pid 0.
888 #[test]
889 fn monitor_pid_absent_from_json_defaults_to_none() {
890 let json = r#"{
891 "stage": "code",
892 "phase": 1,
893 "agent": "claude",
894 "mode": "auto",
895 "started_at": "0",
896 "project_root": "/repo"
897 }"#;
898 let loaded: State = serde_json::from_str(json).unwrap();
899 assert_eq!(loaded.monitor_pid, None);
900 }
901
902 /// `session_id` round-trips through serde as an exact `Option<String>`
903 /// (D-04, 28-02) — mirrors the `monitor_pid` pair above.
904 #[test]
905 fn session_id_round_trips_through_serde() {
906 let mut state = State::new(
907 PhaseId::new(1),
908 AgentKind::Claude,
909 Mode::Auto,
910 PathBuf::from("/repo"),
911 );
912 state.session_id = Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e".to_string());
913 let json = serde_json::to_string(&state).unwrap();
914 assert!(
915 json.contains("session_id"),
916 "session_id must appear in persisted JSON"
917 );
918 let loaded: State = serde_json::from_str(&json).unwrap();
919 assert_eq!(
920 loaded.session_id.as_deref(),
921 Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e"),
922 "session_id must round-trip through serde"
923 );
924 }
925
926 /// A serde-absent `session_id` (state written by a pre-28-02 binary) must
927 /// deserialize to `None`, not fail to deserialize.
928 #[test]
929 fn session_id_absent_from_json_defaults_to_none() {
930 let json = r#"{
931 "stage": "code",
932 "phase": 1,
933 "agent": "claude",
934 "mode": "auto",
935 "started_at": "0",
936 "project_root": "/repo"
937 }"#;
938 let loaded: State = serde_json::from_str(json).unwrap();
939 assert_eq!(loaded.session_id, None);
940 }
941
942 /// `checkpoint_resumes` round-trips through serde as an exact `u32`
943 /// (D-04, 28-02).
944 #[test]
945 fn checkpoint_resumes_round_trips_through_serde() {
946 let mut state = State::new(
947 PhaseId::new(1),
948 AgentKind::Claude,
949 Mode::Auto,
950 PathBuf::from("/repo"),
951 );
952 state.checkpoint_resumes = 2;
953 let json = serde_json::to_string(&state).unwrap();
954 assert!(
955 json.contains("checkpoint_resumes"),
956 "checkpoint_resumes must appear in persisted JSON"
957 );
958 let loaded: State = serde_json::from_str(&json).unwrap();
959 assert_eq!(
960 loaded.checkpoint_resumes, 2,
961 "checkpoint_resumes must round-trip through serde"
962 );
963 }
964
965 /// A serde-absent `checkpoint_resumes` (state written by a pre-28-02
966 /// binary) must deserialize to `0`, not fail to deserialize.
967 #[test]
968 fn checkpoint_resumes_absent_from_json_defaults_to_zero() {
969 let json = r#"{
970 "stage": "code",
971 "phase": 1,
972 "agent": "claude",
973 "mode": "auto",
974 "started_at": "0",
975 "project_root": "/repo"
976 }"#;
977 let loaded: State = serde_json::from_str(json).unwrap();
978 assert_eq!(loaded.checkpoint_resumes, 0);
979 }
980
981 /// 23-09 Task 1: `yes_ship` round-trips through serde as an exact `bool`
982 /// — its own key appears in the persisted JSON, and a fresh deserialize
983 /// recovers the value set, mirroring the `monitor_pid` pair above.
984 #[test]
985 fn yes_ship_round_trips_through_serde() {
986 let mut state = State::new(
987 PhaseId::new(1),
988 AgentKind::Claude,
989 Mode::Auto,
990 PathBuf::from("/repo"),
991 );
992 state.yes_ship = true;
993 let json = serde_json::to_string(&state).unwrap();
994 assert!(
995 json.contains("yes_ship"),
996 "yes_ship must appear in persisted JSON"
997 );
998 let loaded: State = serde_json::from_str(&json).unwrap();
999 assert!(loaded.yes_ship, "yes_ship must round-trip through serde");
1000 }
1001
1002 /// A serde-absent `yes_ship` (state written by a pre-23-09 binary) must
1003 /// deserialize to `false`, not fail to deserialize — the same
1004 /// backward-compat pattern as every other `#[serde(default)]` field
1005 /// added since 17-01.
1006 #[test]
1007 fn yes_ship_absent_from_json_defaults_to_false() {
1008 let json = r#"{
1009 "stage": "code",
1010 "phase": 1,
1011 "agent": "claude",
1012 "mode": "auto",
1013 "started_at": "0",
1014 "project_root": "/repo"
1015 }"#;
1016 let loaded: State = serde_json::from_str(json).unwrap();
1017 assert!(!loaded.yes_ship);
1018 }
1019
1020 /// 20c: `stop_until`/`stopped`/`stop_reason` all round-trip through
1021 /// serde — each field's own key appears in the persisted JSON, and a
1022 /// fresh deserialize recovers the exact values set.
1023 #[test]
1024 fn stop_fields_round_trip_through_serde() {
1025 let mut state = State::new(
1026 PhaseId::new(1),
1027 AgentKind::Claude,
1028 Mode::Auto,
1029 PathBuf::from("/repo"),
1030 );
1031 state.stop_until = Some(Stage::Plan);
1032 state.stopped = true;
1033 state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
1034 let json = serde_json::to_string(&state).unwrap();
1035 assert!(
1036 json.contains("stop_until") && json.contains("stopped") && json.contains("stop_reason"),
1037 "all three stop fields must appear in persisted JSON: {json}"
1038 );
1039 let loaded: State = serde_json::from_str(&json).unwrap();
1040 assert_eq!(
1041 loaded.stop_until,
1042 Some(Stage::Plan),
1043 "stop_until must round-trip through serde"
1044 );
1045 assert!(loaded.stopped, "stopped must round-trip through serde");
1046 assert_eq!(
1047 loaded.stop_reason.as_deref(),
1048 Some("stopped after plan completed (--until plan)"),
1049 "stop_reason must round-trip through serde"
1050 );
1051 }
1052
1053 /// A serde-absent `stop_until`/`stopped`/`stop_reason` (state written by
1054 /// a pre-20c binary) must default to `None`/`false`/`None`, not fail to
1055 /// deserialize — the same backward-compat pattern as every other
1056 /// `#[serde(default)]` field added since 17-01.
1057 #[test]
1058 fn stop_fields_absent_from_json_default() {
1059 let json = r#"{
1060 "stage": "code",
1061 "phase": 1,
1062 "agent": "claude",
1063 "mode": "auto",
1064 "started_at": "0",
1065 "project_root": "/repo"
1066 }"#;
1067 let loaded: State = serde_json::from_str(json).unwrap();
1068 assert_eq!(loaded.stop_until, None);
1069 assert!(!loaded.stopped);
1070 assert_eq!(loaded.stop_reason, None);
1071 }
1072
1073 // ------------------------------------------------------------------
1074 // Phase 41 Task 6: AgentKind::Antigravity (unique test prefix so the
1075 // verify filter matches ONLY the new work — F6).
1076 // ------------------------------------------------------------------
1077
1078 #[test]
1079 fn agent_kind_antigravity_from_str_is_case_insensitive() {
1080 assert_eq!(
1081 "antigravity".parse::<AgentKind>().unwrap(),
1082 AgentKind::Antigravity
1083 );
1084 assert_eq!(
1085 "ANTIGRAVITY".parse::<AgentKind>().unwrap(),
1086 AgentKind::Antigravity
1087 );
1088 assert_eq!(
1089 "Antigravity".parse::<AgentKind>().unwrap(),
1090 AgentKind::Antigravity
1091 );
1092 }
1093
1094 #[test]
1095 fn agent_kind_antigravity_display_is_lowercase() {
1096 assert_eq!(AgentKind::Antigravity.to_string(), "antigravity");
1097 }
1098
1099 #[test]
1100 fn agent_kind_antigravity_serde_round_trips_lowercase() {
1101 let wire = serde_json::to_string(&AgentKind::Antigravity).unwrap();
1102 assert_eq!(wire, "\"antigravity\"");
1103 let back: AgentKind = serde_json::from_str(&wire).unwrap();
1104 assert_eq!(back, AgentKind::Antigravity);
1105 }
1106
1107 #[test]
1108 fn agent_kind_antigravity_error_message_lists_it() {
1109 let err = "aider".parse::<AgentKind>().unwrap_err();
1110 assert!(
1111 err.to_string().contains("antigravity"),
1112 "the parse error must advertise the new agent: {err}"
1113 );
1114 }
1115
1116 #[test]
1117 fn agent_kind_antigravity_driver_for_resolves() {
1118 assert_eq!(
1119 crate::agents::driver_for(AgentKind::Antigravity).name(),
1120 "Antigravity"
1121 );
1122 }
1123}