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/// Whether `transition()` should zero
52/// [`crate::state::State::consecutive_failures`] when moving from `from` to
53/// `to`.
54///
55/// `consecutive_failures` is meant to count repeated Code↔Validate CYCLES —
56/// each cycle is a full loop through Code, then Validate, then (on failure)
57/// back to Code again. But the Code→Validate hop is crossed on *every
58/// single cycle*, including the ones that are about to fail. Resetting the
59/// counter on that specific hop means it can never accumulate past 1, so
60/// [`MAX_CONSECUTIVE_FAILURES`] — the ceiling that exists specifically to
61/// bound this loop — is unreachable (18d). Every other transition is
62/// genuine forward progress out of the Code↔Validate loop (or the initial
63/// Define→Plan→Code entry into it) and correctly clears the counter.
64///
65/// This rule deliberately does NOT apply to
66/// [`crate::state::State::infra_failures`], whose unconditional reset in
67/// `transition()` is correct for its own semantics: infra faults accumulate
68/// within a single stage's repeated failures and are routed through
69/// `handle_infra_outcome` → `gate_or_abort_infra` → `handle_stage_failure`,
70/// whose retry arms call `launch_stage` directly and never cross
71/// `transition()` at all. Widening this predicate's shape onto
72/// `infra_failures` would silently convert [`MAX_INFRA_FAILURES`] from a
73/// stuck-loop bound into a phase-lifetime bound — the exact regression
74/// 17-06 was written to prevent.
75pub fn transition_resets_consecutive_failures(from: Stage, to: Stage) -> bool {
76    !matches!((from, to), (Stage::Code, Stage::Validate))
77}
78
79/// How DevFlow drives the pipeline for a session.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum Mode {
83    /// Run the pipeline without human gates until Ship (or repeated failure).
84    Auto,
85    /// Fire a Validate gate to Hermes → Human before Ship.
86    Supervise,
87}
88
89impl Mode {
90    /// Whether `stage` should fire a gate, given how many consecutive Validate
91    /// failures have already occurred this session.
92    ///
93    /// - Ship always gates (both modes).
94    /// - Supervise gates at every Validate.
95    /// - Auto gates at Validate only after [`MAX_CONSECUTIVE_FAILURES`] failures.
96    pub fn should_gate(self, stage: Stage, consecutive_failures: u32) -> bool {
97        match stage {
98            Stage::Ship => true,
99            Stage::Validate => match self {
100                Mode::Supervise => true,
101                Mode::Auto => consecutive_failures >= MAX_CONSECUTIVE_FAILURES,
102            },
103            _ => false,
104        }
105    }
106
107    /// Whether a failed Validate at `stage` may auto-loop back to Code without a
108    /// human gate. Auto loops Code↔Validate; Supervise requires human approval.
109    pub fn should_auto_loop(self, stage: Stage) -> bool {
110        matches!(stage, Stage::Validate) && matches!(self, Mode::Auto)
111    }
112}
113
114impl fmt::Display for Mode {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        let name = match self {
117            Mode::Auto => "auto",
118            Mode::Supervise => "supervise",
119        };
120        f.write_str(name)
121    }
122}
123
124impl FromStr for Mode {
125    type Err = ModeParseError;
126
127    fn from_str(value: &str) -> Result<Self, Self::Err> {
128        match value.to_ascii_lowercase().as_str() {
129            "auto" => Ok(Mode::Auto),
130            "supervise" | "supervised" => Ok(Mode::Supervise),
131            other => Err(ModeParseError(other.to_string())),
132        }
133    }
134}
135
136/// Error returned when parsing an unsupported mode name.
137#[derive(Debug, Clone, thiserror::Error)]
138#[error("unsupported mode `{0}`; expected auto or supervise")]
139pub struct ModeParseError(String);
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn from_str_accepts_canonical_and_alias() {
147        assert_eq!("auto".parse::<Mode>().unwrap(), Mode::Auto);
148        assert_eq!("AUTO".parse::<Mode>().unwrap(), Mode::Auto);
149        assert_eq!("supervise".parse::<Mode>().unwrap(), Mode::Supervise);
150        assert_eq!("supervised".parse::<Mode>().unwrap(), Mode::Supervise);
151    }
152
153    #[test]
154    fn from_str_rejects_unknown() {
155        let err = "yolo".parse::<Mode>().unwrap_err();
156        assert!(err.to_string().contains("yolo"));
157    }
158
159    #[test]
160    fn auto_does_not_gate_validate_until_failure_threshold() {
161        assert!(!Mode::Auto.should_gate(Stage::Validate, 0));
162        assert!(!Mode::Auto.should_gate(Stage::Validate, 2));
163        assert!(Mode::Auto.should_gate(Stage::Validate, MAX_CONSECUTIVE_FAILURES));
164        assert!(Mode::Auto.should_gate(Stage::Validate, 9));
165    }
166
167    #[test]
168    fn supervise_always_gates_validate() {
169        assert!(Mode::Supervise.should_gate(Stage::Validate, 0));
170        assert!(Mode::Supervise.should_gate(Stage::Validate, 5));
171    }
172
173    #[test]
174    fn ship_always_gates_in_both_modes() {
175        assert!(Mode::Auto.should_gate(Stage::Ship, 0));
176        assert!(Mode::Supervise.should_gate(Stage::Ship, 0));
177    }
178
179    #[test]
180    fn non_gate_stages_never_gate() {
181        for stage in [Stage::Define, Stage::Plan, Stage::Code] {
182            assert!(!Mode::Auto.should_gate(stage, 99));
183            assert!(!Mode::Supervise.should_gate(stage, 99));
184        }
185    }
186
187    #[test]
188    fn auto_loops_validate_supervise_does_not() {
189        assert!(Mode::Auto.should_auto_loop(Stage::Validate));
190        assert!(!Mode::Supervise.should_auto_loop(Stage::Validate));
191        assert!(!Mode::Auto.should_auto_loop(Stage::Code));
192    }
193
194    #[test]
195    fn display_round_trips_through_from_str() {
196        for mode in [Mode::Auto, Mode::Supervise] {
197            assert_eq!(mode.to_string().parse::<Mode>().unwrap(), mode);
198        }
199    }
200
201    #[test]
202    fn consecutive_reset_skips_the_code_to_validate_hop() {
203        assert!(!transition_resets_consecutive_failures(
204            Stage::Code,
205            Stage::Validate
206        ));
207    }
208
209    #[test]
210    fn consecutive_reset_fires_on_every_other_transition() {
211        // Enumerated explicitly (not a negation of the skip case above) so a
212        // future Stage variant added to the linear chain doesn't silently
213        // fall through un-asserted.
214        assert!(transition_resets_consecutive_failures(
215            Stage::Define,
216            Stage::Plan
217        ));
218        assert!(transition_resets_consecutive_failures(
219            Stage::Plan,
220            Stage::Code
221        ));
222        assert!(transition_resets_consecutive_failures(
223            Stage::Validate,
224            Stage::Ship
225        ));
226    }
227}