Skip to main content

State

Struct State 

Source
#[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
Non-exhaustive structs could have additional fields added in future. Therefore, non-exhaustive structs cannot be constructed in external crates using the traditional Struct { .. } syntax; cannot be matched against without a wildcard ..; and struct update syntax will not work.
§stage: Stage

Current workflow stage.

§phase: PhaseId

Phase number being worked on.

§agent: AgentKind

Which coding agent was launched.

§mode: Mode

How the pipeline is driven (auto vs. supervise).

§gate_pending: bool

Whether a gate has been written and is awaiting a human response.

§consecutive_failures: u32

Consecutive 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: u32

Consecutive 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: u32

How 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: u32

Every 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:

  1. Phase completionfinish_workflow_with_gate_timeout calls workflow::clear_state, deleting .devflow/state-{NN}.json, so the next start for that phase finds nothing to carry.
  2. 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_reached is 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: bool

Whether 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: String

When the phase started (Unix seconds).

§project_root: PathBuf

Path 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: u32

How 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: bool

Set 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: bool

Pre-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: bool

D-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.

Implementations§

Source§

impl State

Source

pub fn new( phase: PhaseId, agent: AgentKind, mode: Mode, project_root: PathBuf, ) -> Self

Create a new state for starting a phase at the Stage::Define stage.

Trait Implementations§

Source§

impl Clone for State

Source§

fn clone(&self) -> State

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for State

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for State

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl Serialize for State

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

§

impl Freeze for State

§

impl RefUnwindSafe for State

§

impl Send for State

§

impl Sync for State

§

impl Unpin for State

§

impl UnsafeUnpin for State

§

impl UnwindSafe for State

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> DeserializeOwned for T
where T: for<'de> Deserialize<'de>,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more