Skip to main content

devflow_core/
mode.rs

1//! Execution mode and the mode-driven gate decision.
2//!
3//! Mode is a per-session CLI flag on `devflow start` — there is no config file
4//! and no per-phase toggling.
5//!
6//! - **Auto:** Define and Plan run once. Code ↔ Validate auto-loop until clean.
7//!   Then Ship. The only human gate is at Ship — unless Validate fails
8//!   [`MAX_CONSECUTIVE_FAILURES`] times in a row, which forces a gate.
9//! - **Supervise:** Same pipeline, but Validate always fires a gate to Hermes →
10//!   Human before advancing to Ship.
11
12use crate::stage::Stage;
13use serde::{Deserialize, Serialize};
14use std::fmt;
15use std::str::FromStr;
16
17/// Number of consecutive Validate failures in Auto mode before a gate is forced.
18pub const MAX_CONSECUTIVE_FAILURES: u32 = 3;
19
20/// Ceiling for [`crate::state::State::phase_validate_failures`] — the total
21/// number of Validate failures recorded for one PHASE, accumulated without
22/// regard to forward progress (999.78/WR-01, D-07).
23///
24/// **Why it sits meaningfully above [`MAX_CONSECUTIVE_FAILURES`] (3).** This
25/// is a backstop for the case where the streak keeps resetting, not a
26/// competing primary bound. `consecutive_failures` is cleared whenever
27/// [`consecutive_failures_made_progress`] reports new commits, and the Code
28/// stage's fix command is a GSD command that routinely commits `.planning/`
29/// artifacts even when no source changed — so "commits something trivial
30/// every cycle" is the ORDINARY behaviour of the thing in that slot. A phase
31/// in that state never reaches the streak ceiling. Setting this one low
32/// enough to compete would make the coarser signal primary and change when
33/// ordinary, genuinely-converging runs gate; ten leaves the streak ceiling
34/// the first thing a stuck loop meets, and catches only the loops the streak
35/// ceiling structurally cannot.
36///
37/// Exhausting it fires a human gate and the run stays alive (D-07). It must
38/// never introduce an abort path: aborting is destructive and irreversible
39/// relative to gating, and a phase one cycle from converging would be killed
40/// by a bound whose only purpose is to summon a human.
41pub const MAX_PHASE_VALIDATE_FAILURES: u32 = 10;
42
43/// 35-04: the phase ceiling must sit strictly above the streak ceiling, or it
44/// stops being a backstop and becomes a competing primary bound that changes
45/// when ordinary runs gate. A compile-time assertion rather than a `#[test]`
46/// for the reason [`MAX_CHECKPOINT_RESUMES`]' own const block gives: both
47/// operands are `const`, so a runtime test could never fail at runtime, and
48/// clippy's `assertions_on_constants` correctly says so.
49const _: () = assert!(MAX_PHASE_VALIDATE_FAILURES > MAX_CONSECUTIVE_FAILURES);
50
51/// Ceiling for [`crate::state::State::infra_failures`] before an
52/// infrastructure-class fault chain (OOM/`ResourceKilled`, missing agent
53/// binary/`AgentUnavailable`) forces a terminal gate (D-08, 17-01).
54///
55/// Deliberately more lenient than [`MAX_CONSECUTIVE_FAILURES`] (3): infra
56/// faults are not the agent's fault, so a higher ceiling tolerates transient
57/// cloud outages/OOM blips that a 3-ceiling would abort prematurely, while
58/// still bounding a stuck loop to at most 5 unobserved cycles before a
59/// terminal abort. Any increment of `infra_failures` must use
60/// `saturating_add` so a long-running stuck loop cannot overflow `u32`. The
61/// CLI's `transition()` resets `infra_failures` to 0 unconditionally on
62/// every successful stage transition (CR-01, 17-06 gap closure) — this
63/// reset is what makes the "5 unobserved cycles" ceiling bound a stuck loop
64/// rather than a phase's entire lifetime. Unlike `infra_failures`,
65/// `consecutive_failures`' reset is conditional — see
66/// [`transition_resets_consecutive_failures`] — the two counters no longer
67/// share a single reset condition (18d, WR-11).
68pub const MAX_INFRA_FAILURES: u32 = 5;
69
70/// Ceiling for [`crate::state::State::preflight_retries`] before a
71/// preflight gate's `GateAction::LoopBack` recursion aborts rather than
72/// polling another 7-day gate timeout (18f, D-18f backstop). A failing
73/// preflight is a readiness problem the operator is actively being asked
74/// about right now, not a transient infrastructure blip, so this takes the
75/// tighter [`MAX_CONSECUTIVE_FAILURES`]-style ceiling rather than the more
76/// lenient [`MAX_INFRA_FAILURES`]. Unlike those two counters, this one is
77/// NOT reset by `transition()` — it is reset by preflight success and by
78/// human approval (`GateAction::Advance`), both inside `run_preflight`
79/// (`devflow-cli/src/main.rs`).
80pub const MAX_PREFLIGHT_RETRIES: u32 = 3;
81
82/// Ceiling for [`crate::state::State::checkpoint_resumes`] before a
83/// checkpoint auto-decide relaunch (D-03/D-04, 28-03) stops resuming and
84/// falls through to the never-silent gate instead, its context naming the
85/// exhaustion. Bounds consecutive `claude --resume` relaunches for one
86/// stage's agent run against a checkpoint that keeps re-firing.
87///
88/// Takes the tighter [`MAX_CONSECUTIVE_FAILURES`]-style ceiling rather than
89/// the more lenient [`MAX_INFRA_FAILURES`]: a re-firing checkpoint is a
90/// decision the agent is failing to close on its own, not a transient
91/// infrastructure blip, so it does not deserve the same tolerance an OOM
92/// blip or a missing binary gets. An unbounded resume loop here would be
93/// structurally the same "gates hang forever" failure class D-09
94/// (`28-CONTEXT.md`) documents — this ceiling is what keeps it from becoming
95/// that.
96///
97/// Any increment of `checkpoint_resumes` must use `saturating_add`, exactly
98/// like [`crate::state::State::infra_failures`] and [`crate::state::State::preflight_retries`], so a stuck
99/// loop cannot overflow `u32`. Reset to 0 by every ORDINARY fresh stage
100/// launch (`pipeline_launch::launch_stage_inner`) — never by `transition()`
101/// — so the ceiling bounds one stage's resume budget, not a phase's entire
102/// lifetime, the same distinction [`MAX_INFRA_FAILURES`]'s doc comment draws
103/// for `infra_failures`. On exhaustion: fall through to the never-silent
104/// gate with a reason naming the exhaustion — never a silent stop, never an
105/// unbounded loop.
106pub const MAX_CHECKPOINT_RESUMES: u32 = 3;
107
108/// 28-03 (Task 1): the ceiling must be a small, positive, bounded number —
109/// greater than zero (or a checkpoint could never resume even once) and no
110/// larger than the more lenient infra ceiling (a re-firing checkpoint gets
111/// LESS tolerance than a transient infra blip, not more). A compile-time
112/// assertion rather than a runtime `#[test]` because both operands are
113/// `const` — clippy's `assertions_on_constants` correctly flags a runtime
114/// test here as unable to ever fail at runtime; this const block still
115/// fails the BUILD if a future edit violates the invariant.
116const _: () = assert!(MAX_CHECKPOINT_RESUMES > 0 && MAX_CHECKPOINT_RESUMES <= MAX_INFRA_FAILURES);
117
118/// Whether `transition()` should zero
119/// [`crate::state::State::consecutive_failures`] when moving from `from` to
120/// `to`.
121///
122/// `consecutive_failures` is meant to count repeated Code↔Validate CYCLES —
123/// each cycle is a full loop through Code, then Validate, then (on failure)
124/// back to Code again. But the Code→Validate hop is crossed on *every
125/// single cycle*, including the ones that are about to fail. Resetting the
126/// counter on that specific hop means it can never accumulate past 1, so
127/// [`MAX_CONSECUTIVE_FAILURES`] — the ceiling that exists specifically to
128/// bound this loop — is unreachable (18d). Every other transition is
129/// genuine forward progress out of the Code↔Validate loop (or the initial
130/// Define→Plan→Code entry into it) and correctly clears the counter.
131///
132/// This rule deliberately does NOT apply to
133/// [`crate::state::State::infra_failures`], whose unconditional reset in
134/// `transition()` is correct for its own semantics: infra faults accumulate
135/// within a single stage's repeated failures and are routed through
136/// `handle_infra_outcome` → `gate_or_abort_infra` → `handle_stage_failure`,
137/// whose retry arms call `launch_stage` directly and never cross
138/// `transition()` at all. Widening this predicate's shape onto
139/// `infra_failures` would silently convert [`MAX_INFRA_FAILURES`] from a
140/// stuck-loop bound into a phase-lifetime bound — the exact regression
141/// 17-06 was written to prevent.
142pub fn transition_resets_consecutive_failures(from: Stage, to: Stage) -> bool {
143    !matches!((from, to), (Stage::Code, Stage::Validate))
144}
145
146/// Whether a Validate failure represents forward progress since the last
147/// recorded failure (999.66, D-03) — i.e. whether Code produced new commits
148/// on the phase's feature branch since
149/// [`crate::state::State::last_validate_failure_commit_count`] was last
150/// observed.
151///
152/// `previous` is the baseline recorded at the prior failure;
153/// `current` is the commit count observed at THIS failure.
154///
155/// `None` for `previous` reports progress: it means no prior failure has
156/// been recorded, so there is no streak to continue — the first failure of
157/// a phase, and the first failure observed after resuming state written
158/// before this baseline field existed, must both begin a fresh streak
159/// rather than extend a nonexistent one.
160///
161/// The comparison is strictly greater, not merely not-equal: a count that
162/// went DOWN means the branch was rewound or rebuilt, which is not evidence
163/// that the problem Validate reported was addressed. Treating a decrease as
164/// progress would hand a free counter reset to exactly the situation least
165/// likely to deserve one.
166///
167/// **What this predicate does not establish.** A `true` result means new
168/// commits exist, not that those commits addressed anything. An agent that
169/// commits something trivial on every cycle resets the streak every cycle
170/// and never reaches [`MAX_CONSECUTIVE_FAILURES`]. This is the accepted,
171/// documented weakness of the commit-count signal recorded in
172/// `33-RESEARCH.md`'s D-03 Recommendation and Assumptions Log A1 — the same
173/// weakness `evaluate_layer2`'s own "no work done" gate already carries,
174/// which a single trivial commit also already defeats today. It is a real
175/// narrowing of the guarantee that `MAX_CONSECUTIVE_FAILURES` bounds a
176/// genuinely stuck loop, and it is deliberately NOT strengthened here with a
177/// lines-changed or files-touched threshold — that is a follow-up if the
178/// assumption proves wrong, not a speculative heuristic to add to the
179/// safety gate's path now.
180pub fn consecutive_failures_made_progress(previous: Option<u32>, current: u32) -> bool {
181    previous.is_none_or(|p| current > p)
182}
183
184/// Whether the per-phase Validate-failure total has reached
185/// [`MAX_PHASE_VALIDATE_FAILURES`] (999.78, F-6).
186///
187/// **Why this exists as a named predicate rather than an inline comparison.**
188/// [`Mode::should_gate`]'s `Stage::Validate` arm returns `true`
189/// unconditionally in [`Mode::Supervise`], so in that mode the ceiling
190/// condition and the ordinary-gate condition overlap completely and "a gate
191/// fired" carries no information about WHY. Two sites need that distinction
192/// and cannot get it from `should_gate`'s boolean:
193///
194/// - the Validate gate message, whose ceiling clause must appear only at the
195///   ceiling — keyed on gating instead, it would appear on every Supervise
196///   message and mean nothing;
197/// - the reset of [`crate::state::State::phase_validate_failures`] on
198///   operator approval, which keyed on gating would clear the total at every
199///   Supervise failure so it could never accumulate at all — an unbounded
200///   loop wearing a gate on every cycle.
201///
202/// This is the SINGLE implementation of the comparison. No caller may
203/// re-derive it: a second copy is exactly the drift hazard that made
204/// `should_gate` take the total as a parameter instead of checking it at the
205/// call site, and it must not reappear in a new form here.
206pub fn phase_failure_ceiling_reached(phase_validate_failures: u32) -> bool {
207    phase_validate_failures >= MAX_PHASE_VALIDATE_FAILURES
208}
209
210/// How DevFlow drives the pipeline for a session.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "lowercase")]
213pub enum Mode {
214    /// Run the pipeline without human gates until Ship (or repeated failure).
215    Auto,
216    /// Fire a Validate gate to Hermes → Human before Ship.
217    Supervise,
218}
219
220impl Mode {
221    /// Whether `stage` should fire a gate, given how many consecutive Validate
222    /// failures have already occurred this session and how many Validate
223    /// failures have been recorded for this PHASE in total.
224    ///
225    /// - Ship always gates (both modes).
226    /// - Validate gates in EITHER mode once the per-phase total reaches
227    ///   [`MAX_PHASE_VALIDATE_FAILURES`] (999.78/D-07) — evaluated ahead of
228    ///   the per-mode match, so it gates in Auto and is a harmless no-op in
229    ///   Supervise, which already gates.
230    /// - Supervise gates at every Validate.
231    /// - Auto gates at Validate only after [`MAX_CONSECUTIVE_FAILURES`]
232    ///   consecutive failures.
233    ///
234    /// **Why `phase_validate_failures` is a parameter rather than an extra
235    /// disjunct at the call site.** Several tests re-derive this expression to
236    /// mirror the production gating decision. A disjunct added at the call
237    /// site would leave every one of those mirrors compiling untouched while
238    /// silently no longer mirroring anything — a hand-audited equality that
239    /// 34/D-06 already rejects in favour of structural guards. Taking the
240    /// number here makes the compiler enumerate every mirror instead.
241    /// Mirroring tests must pass the state's own value, never a literal zero,
242    /// or they restore the same silent drift by a shorter route.
243    pub fn should_gate(
244        self,
245        stage: Stage,
246        consecutive_failures: u32,
247        phase_validate_failures: u32,
248    ) -> bool {
249        match stage {
250            Stage::Ship => true,
251            Stage::Validate => {
252                phase_failure_ceiling_reached(phase_validate_failures)
253                    || match self {
254                        Mode::Supervise => true,
255                        Mode::Auto => consecutive_failures >= MAX_CONSECUTIVE_FAILURES,
256                    }
257            }
258            _ => false,
259        }
260    }
261
262    /// Whether a failed Validate at `stage` may auto-loop back to Code without a
263    /// human gate. Auto loops Code↔Validate; Supervise requires human approval.
264    pub fn should_auto_loop(self, stage: Stage) -> bool {
265        matches!(stage, Stage::Validate) && matches!(self, Mode::Auto)
266    }
267}
268
269impl fmt::Display for Mode {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        let name = match self {
272            Mode::Auto => "auto",
273            Mode::Supervise => "supervise",
274        };
275        f.write_str(name)
276    }
277}
278
279impl FromStr for Mode {
280    type Err = ModeParseError;
281
282    fn from_str(value: &str) -> Result<Self, Self::Err> {
283        match value.to_ascii_lowercase().as_str() {
284            "auto" => Ok(Mode::Auto),
285            "supervise" | "supervised" => Ok(Mode::Supervise),
286            other => Err(ModeParseError(other.to_string())),
287        }
288    }
289}
290
291/// Error returned when parsing an unsupported mode name.
292#[derive(Debug, Clone, thiserror::Error)]
293#[error("unsupported mode `{0}`; expected auto or supervise")]
294pub struct ModeParseError(String);
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn from_str_accepts_canonical_and_alias() {
302        assert_eq!("auto".parse::<Mode>().unwrap(), Mode::Auto);
303        assert_eq!("AUTO".parse::<Mode>().unwrap(), Mode::Auto);
304        assert_eq!("supervise".parse::<Mode>().unwrap(), Mode::Supervise);
305        assert_eq!("supervised".parse::<Mode>().unwrap(), Mode::Supervise);
306    }
307
308    #[test]
309    fn from_str_rejects_unknown() {
310        let err = "yolo".parse::<Mode>().unwrap_err();
311        assert!(err.to_string().contains("yolo"));
312    }
313
314    #[test]
315    fn auto_does_not_gate_validate_until_failure_threshold() {
316        assert!(!Mode::Auto.should_gate(Stage::Validate, 0, 0));
317        assert!(!Mode::Auto.should_gate(Stage::Validate, 2, 0));
318        assert!(Mode::Auto.should_gate(Stage::Validate, MAX_CONSECUTIVE_FAILURES, 0));
319        assert!(Mode::Auto.should_gate(Stage::Validate, 9, 0));
320    }
321
322    #[test]
323    fn supervise_always_gates_validate() {
324        assert!(Mode::Supervise.should_gate(Stage::Validate, 0, 0));
325        assert!(Mode::Supervise.should_gate(Stage::Validate, 5, 0));
326    }
327
328    #[test]
329    fn ship_always_gates_in_both_modes() {
330        assert!(Mode::Auto.should_gate(Stage::Ship, 0, 0));
331        assert!(Mode::Supervise.should_gate(Stage::Ship, 0, 0));
332    }
333
334    #[test]
335    fn non_gate_stages_never_gate() {
336        for stage in [Stage::Define, Stage::Plan, Stage::Code] {
337            assert!(!Mode::Auto.should_gate(stage, 99, 99));
338            assert!(!Mode::Supervise.should_gate(stage, 99, 99));
339        }
340    }
341
342    /// 999.78/D-07 boundary: the per-phase total gates in its OWN right. The
343    /// streak is held at zero throughout — below `MAX_CONSECUTIVE_FAILURES`,
344    /// so it cannot be what makes any of these gate — and Auto is the mode to
345    /// test in, because Supervise gates on every Validate and would report
346    /// `true` at all three points regardless of the new bound.
347    #[test]
348    fn phase_failure_ceiling_gates_at_the_ceiling_not_below_it() {
349        assert!(!Mode::Auto.should_gate(Stage::Validate, 0, MAX_PHASE_VALIDATE_FAILURES - 1));
350        assert!(Mode::Auto.should_gate(Stage::Validate, 0, MAX_PHASE_VALIDATE_FAILURES));
351        assert!(Mode::Auto.should_gate(Stage::Validate, 0, MAX_PHASE_VALIDATE_FAILURES + 1));
352    }
353
354    /// F-6: the predicate has the same boundary shape as the gate it feeds.
355    #[test]
356    fn phase_failure_ceiling_reached_has_the_same_boundary() {
357        assert!(!phase_failure_ceiling_reached(0));
358        assert!(!phase_failure_ceiling_reached(
359            MAX_PHASE_VALIDATE_FAILURES - 1
360        ));
361        assert!(phase_failure_ceiling_reached(MAX_PHASE_VALIDATE_FAILURES));
362        assert!(phase_failure_ceiling_reached(
363            MAX_PHASE_VALIDATE_FAILURES + 1
364        ));
365    }
366
367    /// F-6's agreement test — what stops the predicate and the gating decision
368    /// drifting apart later. The message's ceiling clause and the total's reset
369    /// both read the predicate directly rather than inferring from
370    /// `should_gate`, so the two must answer the same question at the boundary.
371    ///
372    /// Auto mode with a zero streak is the only setting where the two CAN
373    /// disagree observably: in Supervise, `should_gate` is `true` at every
374    /// point and the comparison would pass however the predicate behaved.
375    #[test]
376    fn phase_failure_ceiling_predicate_agrees_with_should_gate() {
377        for total in [
378            MAX_PHASE_VALIDATE_FAILURES - 1,
379            MAX_PHASE_VALIDATE_FAILURES,
380            MAX_PHASE_VALIDATE_FAILURES + 1,
381        ] {
382            assert_eq!(
383                phase_failure_ceiling_reached(total),
384                Mode::Auto.should_gate(Stage::Validate, 0, total),
385                "the ceiling predicate and the Auto-mode Validate gate must agree at {total}"
386            );
387        }
388    }
389
390    #[test]
391    fn auto_loops_validate_supervise_does_not() {
392        assert!(Mode::Auto.should_auto_loop(Stage::Validate));
393        assert!(!Mode::Supervise.should_auto_loop(Stage::Validate));
394        assert!(!Mode::Auto.should_auto_loop(Stage::Code));
395    }
396
397    #[test]
398    fn display_round_trips_through_from_str() {
399        for mode in [Mode::Auto, Mode::Supervise] {
400            assert_eq!(mode.to_string().parse::<Mode>().unwrap(), mode);
401        }
402    }
403
404    #[test]
405    fn consecutive_reset_skips_the_code_to_validate_hop() {
406        assert!(!transition_resets_consecutive_failures(
407            Stage::Code,
408            Stage::Validate
409        ));
410    }
411
412    #[test]
413    fn consecutive_reset_fires_on_every_other_transition() {
414        // Enumerated explicitly (not a negation of the skip case above) so a
415        // future Stage variant added to the linear chain doesn't silently
416        // fall through un-asserted.
417        assert!(transition_resets_consecutive_failures(
418            Stage::Define,
419            Stage::Plan
420        ));
421        assert!(transition_resets_consecutive_failures(
422            Stage::Plan,
423            Stage::Code
424        ));
425        assert!(transition_resets_consecutive_failures(
426            Stage::Validate,
427            Stage::Ship
428        ));
429    }
430
431    #[test]
432    fn made_progress_treats_no_prior_record_as_progress() {
433        // No prior record with a zero current count: the state of a
434        // brand-new phase whose feature branch does not exist yet. This is
435        // the case that matters most — it must report progress so the very
436        // first failure of a phase never mis-accumulates.
437        assert!(consecutive_failures_made_progress(None, 0));
438        // No prior record with a non-zero current count.
439        assert!(consecutive_failures_made_progress(None, 5));
440    }
441
442    #[test]
443    fn made_progress_requires_a_strictly_higher_count() {
444        // Strictly greater: progress.
445        assert!(consecutive_failures_made_progress(Some(2), 3));
446        // Equal, both non-zero: no progress.
447        assert!(!consecutive_failures_made_progress(Some(2), 2));
448        // Equal, both zero: no progress. This is the case
449        // `consecutive_failures_reaches_ceiling_across_cycles` (devflow-cli)
450        // actually exercises — a repo with no feature branch, counting zero
451        // commits every cycle — and it is the single case
452        // MAX_CONSECUTIVE_FAILURES most depends on remaining reachable.
453        assert!(!consecutive_failures_made_progress(Some(0), 0));
454        // Lower: no progress. A count that went down means the branch was
455        // rewound or rebuilt, not that the reported problem was addressed.
456        assert!(!consecutive_failures_made_progress(Some(3), 2));
457    }
458}