#[non_exhaustive]pub struct State {Show 21 fields
pub stage: Stage,
pub phase: u32,
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 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: u32Phase 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.
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.