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}