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}