#[non_exhaustive]pub struct State {Show 26 fields
pub stage: Stage,
pub phase: PhaseId,
pub agent: AgentKind,
pub mode: Mode,
pub gate_pending: bool,
pub consecutive_failures: u32,
pub infra_failures: u32,
pub preflight_retries: u32,
pub last_validate_failure_commit_count: Option<u32>,
pub phase_validate_failures: u32,
pub last_verification_fingerprint: Option<u64>,
pub verification_baseline_captured: bool,
pub last_verification_mtime_nanos: Option<u64>,
pub verification_run_nonce: Option<u64>,
pub started_at: String,
pub project_root: PathBuf,
pub worktree_path: Option<PathBuf>,
pub monitor_pid: Option<u32>,
pub session_id: Option<String>,
pub checkpoint_resumes: u32,
pub stop_until: Option<Stage>,
pub stopped: bool,
pub stop_reason: Option<String>,
pub yes_ship: bool,
pub canary: Option<CanaryOutcome>,
pub legacy_claude_launch: bool,
}Expand description
Full workflow state persisted to .devflow/state.json.
§Construction
Marked #[non_exhaustive]: downstream crates must build this through
State::new and then assign the fields they care about, rather than by
struct literal. Deserialization is unaffected — the Deserialize derive
and every #[serde(default)] field keep working exactly as before, so
state files written by older binaries still load.
This exists because State accumulates a field roughly every phase that
adds a run-scoped concept (worktree_path, monitor_pid, stop_until,
yes_ship, and — in phase 28 — session_id and checkpoint_resumes).
Without non_exhaustive, each of those additions is a semver-breaking
change for any consumer that used a struct literal, which would force a
major bump for what is really an internal bookkeeping change. Paying that
cost once here makes every future field additive.
Fields (Non-exhaustive)§
This struct is marked as non-exhaustive
Struct { .. } syntax; cannot be matched against without a wildcard ..; and struct update syntax will not work.stage: StageCurrent workflow stage.
phase: PhaseIdPhase number being worked on.
agent: AgentKindWhich coding agent was launched.
mode: ModeHow the pipeline is driven (auto vs. supervise).
gate_pending: boolWhether a gate has been written and is awaiting a human response.
consecutive_failures: u32Consecutive Validate failures — drives the Auto-mode forced gate after
crate::mode::MAX_CONSECUTIVE_FAILURES failures. Persisted across
devflow advance invocations so the counter survives monitor restarts.
infra_failures: u32Consecutive infrastructure-class faults (ResourceKilled,
AgentUnavailable) — distinct from Self::consecutive_failures
(D-08, 17-01). Gates at crate::mode::MAX_INFRA_FAILURES. Any
increment (wired in Plan 04) must use saturating_add so a
long-running stuck loop cannot overflow u32. A serde-absent value
(older persisted state) defaults to 0. Reset to 0 on every successful
stage transition, alongside consecutive_failures (CR-01, 17-06 gap
closure), so the ceiling bounds a stuck loop, not a phase’s lifetime.
preflight_retries: u32How many times a preflight gate has been resolved and retried for
this phase (18f). Bounded by crate::mode::MAX_PREFLIGHT_RETRIES.
Persisted rather than recursion-scoped because the documented wedge
spanned separate devflow invocations after a monitor death — an
in-process recursion-depth counter would reset to zero on every new
process and fail to bound the exact incident it exists to prevent.
Reset to 0 whenever preflight passes and whenever a human explicitly
approves (GateAction::Advance), both inside run_preflight. Unlike
Self::consecutive_failures and Self::infra_failures, this
counter is NOT touched by transition().
last_validate_failure_commit_count: Option<u32>The commit count observed on the phase’s feature branch at the most
recent Validate failure (999.66, D-03) — the forward-progress
baseline crate::mode::consecutive_failures_made_progress compares
against to decide whether a new failure begins a fresh streak or
continues the existing one.
None means no prior failure has been recorded — either the first
failure of a phase, or the first failure observed after resuming
state written by a binary predating this field — and is deliberately
distinct from Some(0), which means a failure WAS recorded and the
branch genuinely carried zero commits at that moment; a later failure
that again counts zero commits must accumulate against that Some(0)
baseline rather than being treated as a fresh streak.
A serde-absent value (state written by a binary predating this field)
deserializes to None, which is exactly the “no prior record”
meaning above — the same backward-compat pattern as every other
#[serde(default)] field added since 17-01.
Unlike Self::consecutive_failures and Self::infra_failures,
this field is NOT touched by transition() — it is a baseline
observation rather than a counter, matching how
Self::preflight_retries and Self::checkpoint_resumes are
handled. It is replaced wholesale at each failure rather than
incremented, so it needs no saturating_add treatment, unlike every
other numeric field on this struct.
phase_validate_failures: u32Every Validate failure recorded for this PHASE, accumulated without
regard to forward progress (999.78/WR-01, D-07) — the backstop bound
crate::mode::MAX_PHASE_VALIDATE_FAILURES compares against, and the
leading number in the Supervise gate message (WR-04).
A serde-absent value (state written by a binary predating this field)
deserializes to 0, which is exactly its “no failures recorded for this
phase” meaning — the same backward-compat pattern as every other
#[serde(default)] field added since 17-01. Unlike
Self::last_validate_failure_commit_count, zero is not ambiguous
here: an upgraded binary and a genuine first failure both start the
budget at its full width, and that widening is what IN-02’s distinct
loop-back reason exists to announce.
Why it exists next to Self::consecutive_failures rather than
replacing it: consecutive_failures is reset whenever
crate::mode::consecutive_failures_made_progress reports that new
commits landed, and the Code stage’s fix command is a GSD command
which routinely commits .planning/ artifacts even when no source
changed. A loop that commits something trivial every cycle therefore
resets the streak every cycle and never reaches
crate::mode::MAX_CONSECUTIVE_FAILURES. This total cannot be reset
by a commit count.
Lifetime — deliberately unlike every other counter on this struct.
It is NOT touched by the stage transition (transition_resets_* has no
say over it), matching how Self::preflight_retries and
Self::checkpoint_resumes are handled, because it is a per-phase
total rather than a per-streak counter. It is also carried across a
forced restart: commands::start() reads any persisted state for the
same phase and copies this one field into the fresh State, because a
bound a devflow start --force resets does not bound the unattended
case D-07 exists for. Exactly two events reset it to zero:
- Phase completion —
finish_workflow_with_gate_timeoutcallsworkflow::clear_state, deleting.devflow/state-{NN}.json, so the next start for that phase finds nothing to carry. - Operator approval at the ceiling gate — the Validate gate
handling zeroes it when a human advances or loops back AND
crate::mode::phase_failure_ceiling_reachedis true. Keyed on that predicate and never on “a gate fired”: Supervise gates on every Validate, so a gate-keyed reset would clear the total at every failure and it would never accumulate in the one mode where an operator watches every occurrence.
Any increment must use saturating_add, like Self::infra_failures
and Self::checkpoint_resumes, so an exhausted budget can never wrap
back to zero and silently restore itself.
last_verification_fingerprint: Option<u64>The content fingerprint of this phase’s {N}-VERIFICATION.md as it
stood at the START of this run (999.79), read via
crate::agent_result::phase_verification_fingerprint once the
evidence root for the run is known.
None means no artifact was observed at the start of this run — the
ordinary case for a phase being executed for the first time. It is
deliberately distinct from Some(h): an artifact that EXISTS now where
the baseline recorded none was authored during this run, whereas an
artifact whose fingerprint still equals the baseline was inherited from
a previous run and its verdict must not be reused.
State written by a binary predating this field also deserializes to
None, and that is NOT the same reading (WR-05, 35-REVIEW). This doc
comment used to claim it was. For a phase started under an older binary
and continued by this one, the previous run’s committed
{N}-VERIFICATION.md is already on disk while the baseline reads
None — so the (Some, None) row would classify an inherited artifact
as authored-this-run and dispatch --gaps-only against zero matching
plans, gating unresolvably. That is verbatim the DOGFOOD-01-class stall
999.79 exists to close, reproduced for every in-flight phase across the
upgrade.
Self::verification_baseline_captured is the discriminator: only a
run that actually performed the observation sets it, so a None from an
old state file is distinguishable from a None that means “looked, and
there was nothing there”.
Why this exists at all: nothing deletes or dates {N}-VERIFICATION.md,
so a devflow start --force re-run checks out a branch still carrying
the previous run’s committed copy. Without this baseline the first
Validate failure of that re-run reads the inherited artifact as a
verdict and dispatches a --gaps-only pass against zero matching plans,
which gates unresolvably — the same unattended-stall class as
DOGFOOD-01, reached from a different direction.
Lifetime. Like Self::last_validate_failure_commit_count, and
unlike Self::consecutive_failures and Self::infra_failures, this
field is NOT touched by transition() — it is a run-scoped observation
rather than a counter, so it is replaced wholesale rather than
incremented and needs no saturating_add treatment. It is also NOT
carried across a forced restart the way
Self::phase_validate_failures is: a new run must re-observe the
artifact, because the whole point is to compare against what THIS run
started with.
verification_baseline_captured: boolWhether Self::last_verification_fingerprint was actually observed by
this run, as opposed to merely absent (WR-05, 35-REVIEW).
Option<u64> cannot carry this on its own: None means both “the run
looked and found no artifact” and “this state file predates the field,
so nobody ever looked”, and those two demand OPPOSITE dispatches. The
first is the ordinary first-verification case and --gaps-only is
right; the second may be sitting on an inherited artifact, where
--gaps-only matches zero plans and stalls.
false is therefore the correct serde default in both directions: a
state file written before this field existed genuinely did not capture a
baseline, and the conservative reading of an artifact whose provenance
is unknown is “inherited” — a full execute is wasteful, an unresolvable
gate is not recoverable.
Set exactly once per run, at the same site that captures the baseline,
after state.worktree_path holds its final value.
last_verification_mtime_nanos: Option<u64>The mtime of the same artifact Self::last_verification_fingerprint
hashes, in nanoseconds since the Unix epoch, as of the same observation.
WR-06 (35-REVIEW): a content fingerprint cannot see an IDEMPOTENT rewrite. A Validate agent that re-authors byte-identical content on a later failing cycle produces the same hash as an artifact nobody touched, so a hash-only rule reads its own agent’s work as inherited and dispatches a full execute — re-running every plan in the phase on every subsequent cycle instead of the gaps-only pass Phase 33 built. That is the “too strict” direction the freshness rule’s own comment claims to guard against and did not.
Moves in lockstep with the fingerprint: written at the same capture site, replaced at the same update site, and never read on its own — the pair is the observation, and either one differing means the artifact was written during this run.
35.2 D-05: mtime was considered as the provenance signal and REJECTED.
A branch checkout or worktree merge-back updates mtime exactly as a
real write does — it fails on the identical scenario
Self::verification_run_nonce exists to catch, which is why 999.89
survived 35-05’s WR-06 fix. mtime is still what detects a byte-identical
rewrite INSIDE the Validate dispatch window whose bounds the nonce
establishes — provenance and freshness are different questions.
verification_run_nonce: Option<u64>A run-owned marker stamped per Validate dispatch proving DevFlow itself launched the agent whose output this state describes (35.2, 999.89 / HARDEN-03, D-01).
None means DevFlow never stamped a Validate dispatch for this state,
which is both the pre-35.2-state-file case and the never-dispatched
case. Both demand the conservative reading: the artifact’s provenance is
unknown and verification_authored_this_run returns false.
Lifetime — replaced wholesale on every Validate dispatch, not
incremented across runs. Unlike Self::consecutive_failures and
Self::phase_validate_failures, this field is NOT touched by
transition(), and State::new resets it, so a --force restart
cannot inherit a previous run’s stamp. The value is a monotonically
increasing counter; the predicate consults Option::is_some, never
the magnitude, so saturation cannot degrade the signal.
The write site is launch_stage_inner in pipeline_launch.rs, gated
on Stage::Validate, co-located with a fresh fingerprint/mtime
re-observation — the stamp and the baseline are one mechanism, and
splitting them silently restores the run-wide observation window.
An actor who can write .devflow/state-{N}.json can set stage or
consecutive_failures directly; this field adds no attack surface
beyond what already exists (P-03).
started_at: StringWhen the phase started (Unix seconds).
project_root: PathBufPath to the project root.
worktree_path: Option<PathBuf>Working directory for the agent when running in a git worktree.
None means the agent runs in project_root. State and capture files
always live under the main project_root; only the agent’s cwd changes.
monitor_pid: Option<u32>PID of the detached monitor process that owns the agent for the
current stage, recorded by launch_stage at spawn time. None means
no monitor has been spawned for this state yet, OR the state was
written by a binary predating this field — in both cases the
liveness probe reports Unknown, never Stuck.
session_id: Option<String>The Claude session id captured from the most recent captured stdout
envelope for this phase’s current stage (D-04, 28-02), read via
crate::agent_result::session_id_from_capture. None means EITHER
“no session has been captured for this state yet” OR “the state was
written by a binary predating this field” — both cases behave
identically (no relaunch target to address). Recorded so a checkpoint
auto-decide relaunch (plan 28-03) can --resume the exact session
that hit the checkpoint rather than spawning a fresh one, which would
lose the original session’s conversation context and permission mode.
checkpoint_resumes: u32How many times the current stage’s agent has been relaunched via a
checkpoint auto-decide resume (D-04, 28-03). Bounds a stuck
checkpoint loop against mode::MAX_CHECKPOINT_RESUMES (added in plan
28-03) the same way Self::infra_failures bounds an infra-fault
loop against mode::MAX_INFRA_FAILURES. Reset to 0 by every ordinary fresh stage
launch, so the ceiling bounds one stage’s resume budget, not a
phase’s lifetime (the same distinction MAX_INFRA_FAILURES’ doc
comment draws for infra_failures). Any increment must use
saturating_add so a stuck loop cannot overflow u32. A
serde-absent value (state written by a binary predating this field)
defaults to 0.
stop_until: Option<Stage>The stage devflow start --until <stage> requests as the last stage
to run before halting (20c). None means no stop point was
requested (the pipeline runs to Ship), OR the state was written by a
binary predating this field — both cases behave identically (no
interception in transition()).
stopped: boolSet by transition() when stop_until names the stage just
completed — a terminal-but-not-failed halt short of Ship (20c).
false for a normal in-flight or completed-to-Ship phase, and for
any state written by a binary predating this field.
stop_reason: Option<String>Human-readable reason recorded alongside stopped (20c). None
when stopped is false, or when the state predates this field.
yes_ship: boolPre-authorization for the Ship gate (D-04/D-05/D-06, 23-09),
set only from the --yes-ship CLI flag typed on devflow start.
Persisted rather than passed through the call stack: the Ship gate
fires inside a detached monitor’s advance process, minutes to
hours after the launching devflow start process has already
exited, so a CLI-scoped value would be gone by the time it matters —
only a value written to state.json at start time survives to be
read back by that later, separate process. false for any state
written by a binary predating this field.
canary: Option<CanaryOutcome>What this run’s delivery canary established (D-13/D-15, 31-03),
recorded by the first stage launch that routes through the Claude
stream-json transport. None means EITHER “no canary has run for
this run yet” OR “the state was written by a binary predating this
field” — both cases behave identically: the canary runs.
Persisted rather than held in memory for the same reason
Self::yes_ship is: each stage launch happens in a SEPARATE
devflow process (the monitor’s own advance tail), so an
in-process flag would reset to “not yet run” at every stage
transition and re-spend a real throwaway agent invocation each time —
which is exactly the symptom 31-RESEARCH Pitfall 5 names for a canary
that landed in the per-stage preflight hook.
A recorded Absent/Unverified keeps refusing on every later launch
in the run; it is not consumed by the first refusal.
legacy_claude_launch: boolD-11’s opt-out: force the pre-31 single-document Claude launch
(positional prompt, --output-format json, the sh monitor) for this
run, off by default.
false means EITHER “the operator did not ask for the legacy path” OR
“the state was written by a binary predating this field” — both cases
behave identically: the D-09/D-10 rollout decides the transport, which
is the pre-existing behaviour.
Persisted rather than passed through the call stack for the reason
Self::yes_ship gives: each stage launch happens in a SEPARATE
devflow process (the detached monitor’s own advance tail), so a
CLI-scoped value would be gone by the time the second stage launches
and the run would silently revert to the stream transport mid-flight.
Only ever OR-ed, never cleared, once set — see
pipeline_launch::apply_legacy_launch_opt_out. Clearing it on a plain
devflow resume would be the same silent-drop class as stop_until’s
old unconditional clear (999.60). To turn it back off, edit
.devflow/state-NN.json or start a new run.