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::infra_failures`] before an
21/// infrastructure-class fault chain (OOM/`ResourceKilled`, missing agent
22/// binary/`AgentUnavailable`) forces a terminal gate (D-08, 17-01).
23///
24/// Deliberately more lenient than [`MAX_CONSECUTIVE_FAILURES`] (3): infra
25/// faults are not the agent's fault, so a higher ceiling tolerates transient
26/// cloud outages/OOM blips that a 3-ceiling would abort prematurely, while
27/// still bounding a stuck loop to at most 5 unobserved cycles before a
28/// terminal abort. Any increment of `infra_failures` must use
29/// `saturating_add` so a long-running stuck loop cannot overflow `u32`. The
30/// CLI's `transition()` resets `infra_failures` to 0 unconditionally on
31/// every successful stage transition (CR-01, 17-06 gap closure) — this
32/// reset is what makes the "5 unobserved cycles" ceiling bound a stuck loop
33/// rather than a phase's entire lifetime. Unlike `infra_failures`,
34/// `consecutive_failures`' reset is conditional — see
35/// [`transition_resets_consecutive_failures`] — the two counters no longer
36/// share a single reset condition (18d, WR-11).
37pub const MAX_INFRA_FAILURES: u32 = 5;
38
39/// Ceiling for [`crate::state::State::preflight_retries`] before a
40/// preflight gate's `GateAction::LoopBack` recursion aborts rather than
41/// polling another 7-day gate timeout (18f, D-18f backstop). A failing
42/// preflight is a readiness problem the operator is actively being asked
43/// about right now, not a transient infrastructure blip, so this takes the
44/// tighter [`MAX_CONSECUTIVE_FAILURES`]-style ceiling rather than the more
45/// lenient [`MAX_INFRA_FAILURES`]. Unlike those two counters, this one is
46/// NOT reset by `transition()` — it is reset by preflight success and by
47/// human approval (`GateAction::Advance`), both inside `run_preflight`
48/// (`devflow-cli/src/main.rs`).
49pub const MAX_PREFLIGHT_RETRIES: u32 = 3;
50
51/// Ceiling for [`crate::state::State::checkpoint_resumes`] before a
52/// checkpoint auto-decide relaunch (D-03/D-04, 28-03) stops resuming and
53/// falls through to the never-silent gate instead, its context naming the
54/// exhaustion. Bounds consecutive `claude --resume` relaunches for one
55/// stage's agent run against a checkpoint that keeps re-firing.
56///
57/// Takes the tighter [`MAX_CONSECUTIVE_FAILURES`]-style ceiling rather than
58/// the more lenient [`MAX_INFRA_FAILURES`]: a re-firing checkpoint is a
59/// decision the agent is failing to close on its own, not a transient
60/// infrastructure blip, so it does not deserve the same tolerance an OOM
61/// blip or a missing binary gets. An unbounded resume loop here would be
62/// structurally the same "gates hang forever" failure class D-09
63/// (`28-CONTEXT.md`) documents — this ceiling is what keeps it from becoming
64/// that.
65///
66/// Any increment of `checkpoint_resumes` must use `saturating_add`, exactly
67/// like [`Self::infra_failures`] and [`Self::preflight_retries`], so a stuck
68/// loop cannot overflow `u32`. Reset to 0 by every ORDINARY fresh stage
69/// launch (`pipeline_launch::launch_stage_inner`) — never by `transition()`
70/// — so the ceiling bounds one stage's resume budget, not a phase's entire
71/// lifetime, the same distinction [`MAX_INFRA_FAILURES`]'s doc comment draws
72/// for `infra_failures`. On exhaustion: fall through to the never-silent
73/// gate with a reason naming the exhaustion — never a silent stop, never an
74/// unbounded loop.
75pub const MAX_CHECKPOINT_RESUMES: u32 = 3;
76
77/// 28-03 (Task 1): the ceiling must be a small, positive, bounded number —
78/// greater than zero (or a checkpoint could never resume even once) and no
79/// larger than the more lenient infra ceiling (a re-firing checkpoint gets
80/// LESS tolerance than a transient infra blip, not more). A compile-time
81/// assertion rather than a runtime `#[test]` because both operands are
82/// `const` — clippy's `assertions_on_constants` correctly flags a runtime
83/// test here as unable to ever fail at runtime; this const block still
84/// fails the BUILD if a future edit violates the invariant.
85const _: () = assert!(MAX_CHECKPOINT_RESUMES > 0 && MAX_CHECKPOINT_RESUMES <= MAX_INFRA_FAILURES);
86
87/// Whether `transition()` should zero
88/// [`crate::state::State::consecutive_failures`] when moving from `from` to
89/// `to`.
90///
91/// `consecutive_failures` is meant to count repeated Code↔Validate CYCLES —
92/// each cycle is a full loop through Code, then Validate, then (on failure)
93/// back to Code again. But the Code→Validate hop is crossed on *every
94/// single cycle*, including the ones that are about to fail. Resetting the
95/// counter on that specific hop means it can never accumulate past 1, so
96/// [`MAX_CONSECUTIVE_FAILURES`] — the ceiling that exists specifically to
97/// bound this loop — is unreachable (18d). Every other transition is
98/// genuine forward progress out of the Code↔Validate loop (or the initial
99/// Define→Plan→Code entry into it) and correctly clears the counter.
100///
101/// This rule deliberately does NOT apply to
102/// [`crate::state::State::infra_failures`], whose unconditional reset in
103/// `transition()` is correct for its own semantics: infra faults accumulate
104/// within a single stage's repeated failures and are routed through
105/// `handle_infra_outcome` → `gate_or_abort_infra` → `handle_stage_failure`,
106/// whose retry arms call `launch_stage` directly and never cross
107/// `transition()` at all. Widening this predicate's shape onto
108/// `infra_failures` would silently convert [`MAX_INFRA_FAILURES`] from a
109/// stuck-loop bound into a phase-lifetime bound — the exact regression
110/// 17-06 was written to prevent.
111pub fn transition_resets_consecutive_failures(from: Stage, to: Stage) -> bool {
112    !matches!((from, to), (Stage::Code, Stage::Validate))
113}
114
115/// How DevFlow drives the pipeline for a session.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "lowercase")]
118pub enum Mode {
119    /// Run the pipeline without human gates until Ship (or repeated failure).
120    Auto,
121    /// Fire a Validate gate to Hermes → Human before Ship.
122    Supervise,
123}
124
125impl Mode {
126    /// Whether `stage` should fire a gate, given how many consecutive Validate
127    /// failures have already occurred this session.
128    ///
129    /// - Ship always gates (both modes).
130    /// - Supervise gates at every Validate.
131    /// - Auto gates at Validate only after [`MAX_CONSECUTIVE_FAILURES`] failures.
132    pub fn should_gate(self, stage: Stage, consecutive_failures: u32) -> bool {
133        match stage {
134            Stage::Ship => true,
135            Stage::Validate => match self {
136                Mode::Supervise => true,
137                Mode::Auto => consecutive_failures >= MAX_CONSECUTIVE_FAILURES,
138            },
139            _ => false,
140        }
141    }
142
143    /// Whether a failed Validate at `stage` may auto-loop back to Code without a
144    /// human gate. Auto loops Code↔Validate; Supervise requires human approval.
145    pub fn should_auto_loop(self, stage: Stage) -> bool {
146        matches!(stage, Stage::Validate) && matches!(self, Mode::Auto)
147    }
148}
149
150impl fmt::Display for Mode {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        let name = match self {
153            Mode::Auto => "auto",
154            Mode::Supervise => "supervise",
155        };
156        f.write_str(name)
157    }
158}
159
160impl FromStr for Mode {
161    type Err = ModeParseError;
162
163    fn from_str(value: &str) -> Result<Self, Self::Err> {
164        match value.to_ascii_lowercase().as_str() {
165            "auto" => Ok(Mode::Auto),
166            "supervise" | "supervised" => Ok(Mode::Supervise),
167            other => Err(ModeParseError(other.to_string())),
168        }
169    }
170}
171
172/// Error returned when parsing an unsupported mode name.
173#[derive(Debug, Clone, thiserror::Error)]
174#[error("unsupported mode `{0}`; expected auto or supervise")]
175pub struct ModeParseError(String);
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn from_str_accepts_canonical_and_alias() {
183        assert_eq!("auto".parse::<Mode>().unwrap(), Mode::Auto);
184        assert_eq!("AUTO".parse::<Mode>().unwrap(), Mode::Auto);
185        assert_eq!("supervise".parse::<Mode>().unwrap(), Mode::Supervise);
186        assert_eq!("supervised".parse::<Mode>().unwrap(), Mode::Supervise);
187    }
188
189    #[test]
190    fn from_str_rejects_unknown() {
191        let err = "yolo".parse::<Mode>().unwrap_err();
192        assert!(err.to_string().contains("yolo"));
193    }
194
195    #[test]
196    fn auto_does_not_gate_validate_until_failure_threshold() {
197        assert!(!Mode::Auto.should_gate(Stage::Validate, 0));
198        assert!(!Mode::Auto.should_gate(Stage::Validate, 2));
199        assert!(Mode::Auto.should_gate(Stage::Validate, MAX_CONSECUTIVE_FAILURES));
200        assert!(Mode::Auto.should_gate(Stage::Validate, 9));
201    }
202
203    #[test]
204    fn supervise_always_gates_validate() {
205        assert!(Mode::Supervise.should_gate(Stage::Validate, 0));
206        assert!(Mode::Supervise.should_gate(Stage::Validate, 5));
207    }
208
209    #[test]
210    fn ship_always_gates_in_both_modes() {
211        assert!(Mode::Auto.should_gate(Stage::Ship, 0));
212        assert!(Mode::Supervise.should_gate(Stage::Ship, 0));
213    }
214
215    #[test]
216    fn non_gate_stages_never_gate() {
217        for stage in [Stage::Define, Stage::Plan, Stage::Code] {
218            assert!(!Mode::Auto.should_gate(stage, 99));
219            assert!(!Mode::Supervise.should_gate(stage, 99));
220        }
221    }
222
223    #[test]
224    fn auto_loops_validate_supervise_does_not() {
225        assert!(Mode::Auto.should_auto_loop(Stage::Validate));
226        assert!(!Mode::Supervise.should_auto_loop(Stage::Validate));
227        assert!(!Mode::Auto.should_auto_loop(Stage::Code));
228    }
229
230    #[test]
231    fn display_round_trips_through_from_str() {
232        for mode in [Mode::Auto, Mode::Supervise] {
233            assert_eq!(mode.to_string().parse::<Mode>().unwrap(), mode);
234        }
235    }
236
237    #[test]
238    fn consecutive_reset_skips_the_code_to_validate_hop() {
239        assert!(!transition_resets_consecutive_failures(
240            Stage::Code,
241            Stage::Validate
242        ));
243    }
244
245    #[test]
246    fn consecutive_reset_fires_on_every_other_transition() {
247        // Enumerated explicitly (not a negation of the skip case above) so a
248        // future Stage variant added to the linear chain doesn't silently
249        // fall through un-asserted.
250        assert!(transition_resets_consecutive_failures(
251            Stage::Define,
252            Stage::Plan
253        ));
254        assert!(transition_resets_consecutive_failures(
255            Stage::Plan,
256            Stage::Code
257        ));
258        assert!(transition_resets_consecutive_failures(
259            Stage::Validate,
260            Stage::Ship
261        ));
262    }
263}