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 on every successful
31/// stage transition (CR-01, 17-06 gap closure) — this reset is what makes
32/// the "5 unobserved cycles" ceiling bound a stuck loop rather than a
33/// phase's entire lifetime.
34pub const MAX_INFRA_FAILURES: u32 = 5;
35
36/// How DevFlow drives the pipeline for a session.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum Mode {
40    /// Run the pipeline without human gates until Ship (or repeated failure).
41    Auto,
42    /// Fire a Validate gate to Hermes → Human before Ship.
43    Supervise,
44}
45
46impl Mode {
47    /// Whether `stage` should fire a gate, given how many consecutive Validate
48    /// failures have already occurred this session.
49    ///
50    /// - Ship always gates (both modes).
51    /// - Supervise gates at every Validate.
52    /// - Auto gates at Validate only after [`MAX_CONSECUTIVE_FAILURES`] failures.
53    pub fn should_gate(self, stage: Stage, consecutive_failures: u32) -> bool {
54        match stage {
55            Stage::Ship => true,
56            Stage::Validate => match self {
57                Mode::Supervise => true,
58                Mode::Auto => consecutive_failures >= MAX_CONSECUTIVE_FAILURES,
59            },
60            _ => false,
61        }
62    }
63
64    /// Whether a failed Validate at `stage` may auto-loop back to Code without a
65    /// human gate. Auto loops Code↔Validate; Supervise requires human approval.
66    pub fn should_auto_loop(self, stage: Stage) -> bool {
67        matches!(stage, Stage::Validate) && matches!(self, Mode::Auto)
68    }
69}
70
71impl fmt::Display for Mode {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        let name = match self {
74            Mode::Auto => "auto",
75            Mode::Supervise => "supervise",
76        };
77        f.write_str(name)
78    }
79}
80
81impl FromStr for Mode {
82    type Err = ModeParseError;
83
84    fn from_str(value: &str) -> Result<Self, Self::Err> {
85        match value.to_ascii_lowercase().as_str() {
86            "auto" => Ok(Mode::Auto),
87            "supervise" | "supervised" => Ok(Mode::Supervise),
88            other => Err(ModeParseError(other.to_string())),
89        }
90    }
91}
92
93/// Error returned when parsing an unsupported mode name.
94#[derive(Debug, Clone, thiserror::Error)]
95#[error("unsupported mode `{0}`; expected auto or supervise")]
96pub struct ModeParseError(String);
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn from_str_accepts_canonical_and_alias() {
104        assert_eq!("auto".parse::<Mode>().unwrap(), Mode::Auto);
105        assert_eq!("AUTO".parse::<Mode>().unwrap(), Mode::Auto);
106        assert_eq!("supervise".parse::<Mode>().unwrap(), Mode::Supervise);
107        assert_eq!("supervised".parse::<Mode>().unwrap(), Mode::Supervise);
108    }
109
110    #[test]
111    fn from_str_rejects_unknown() {
112        let err = "yolo".parse::<Mode>().unwrap_err();
113        assert!(err.to_string().contains("yolo"));
114    }
115
116    #[test]
117    fn auto_does_not_gate_validate_until_failure_threshold() {
118        assert!(!Mode::Auto.should_gate(Stage::Validate, 0));
119        assert!(!Mode::Auto.should_gate(Stage::Validate, 2));
120        assert!(Mode::Auto.should_gate(Stage::Validate, MAX_CONSECUTIVE_FAILURES));
121        assert!(Mode::Auto.should_gate(Stage::Validate, 9));
122    }
123
124    #[test]
125    fn supervise_always_gates_validate() {
126        assert!(Mode::Supervise.should_gate(Stage::Validate, 0));
127        assert!(Mode::Supervise.should_gate(Stage::Validate, 5));
128    }
129
130    #[test]
131    fn ship_always_gates_in_both_modes() {
132        assert!(Mode::Auto.should_gate(Stage::Ship, 0));
133        assert!(Mode::Supervise.should_gate(Stage::Ship, 0));
134    }
135
136    #[test]
137    fn non_gate_stages_never_gate() {
138        for stage in [Stage::Define, Stage::Plan, Stage::Code] {
139            assert!(!Mode::Auto.should_gate(stage, 99));
140            assert!(!Mode::Supervise.should_gate(stage, 99));
141        }
142    }
143
144    #[test]
145    fn auto_loops_validate_supervise_does_not() {
146        assert!(Mode::Auto.should_auto_loop(Stage::Validate));
147        assert!(!Mode::Supervise.should_auto_loop(Stage::Validate));
148        assert!(!Mode::Auto.should_auto_loop(Stage::Code));
149    }
150
151    #[test]
152    fn display_round_trips_through_from_str() {
153        for mode in [Mode::Auto, Mode::Supervise] {
154            assert_eq!(mode.to_string().parse::<Mode>().unwrap(), mode);
155        }
156    }
157}