Skip to main content

devflow_core/
outcome_policy.rs

1//! Pure outcome -> action policy table (D-08/D-11/D-12, 17-01).
2//!
3//! [`decide_action`] is the single exhaustive policy surface `advance()`
4//! (Plan 04) dispatches on. It has no I/O, no `CliError`, no filesystem, and
5//! no process spawn — deterministic pure function of `(Stage, AgentStatus)`.
6//! The `match` has NO wildcard arm: adding a future [`crate::agent_result::AgentStatus`]
7//! variant without extending this match is a compile error, which is the
8//! mechanism that prevents the D-01 regression class (a new/unhandled
9//! outcome silently advancing).
10
11use crate::agent_result::AgentStatus;
12use crate::stage::Stage;
13
14/// The action to take in response to an agent outcome at a given stage.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Action {
17    /// Advance to the next stage.
18    Advance,
19    /// Automatically resume/retry (e.g. rate limit — wait and retry).
20    AutoResume,
21    /// Gate for a human due to an infrastructure-class fault (OOM, agent
22    /// binary unavailable) — not the agent's fault.
23    GateInfra,
24    /// Gate for a human due to a review-worthy outcome (agent-reported
25    /// failure, or an indeterminate/unknown result that must never
26    /// silently advance).
27    GateReview,
28}
29
30/// Decide what to do given the outcome of a stage's agent run.
31///
32/// `stage` is part of the signature for Plan 04's dispatch even though the
33/// current mapping is stage-independent — kept for forward compatibility,
34/// not used in the match itself.
35///
36/// The match is exhaustive over every [`AgentStatus`] variant with NO
37/// wildcard arm — see the module doc comment.
38pub fn decide_action(_stage: Stage, outcome: AgentStatus) -> Action {
39    match outcome {
40        AgentStatus::Success => Action::Advance,
41        AgentStatus::RateLimited => Action::AutoResume,
42        AgentStatus::ResourceKilled => Action::GateInfra,
43        AgentStatus::AgentUnavailable => Action::GateInfra,
44        // DEFERRED (Plan 01 MEDIUM, OpenCode): Failed and Unknown map
45        // identically to GateReview. Intentional — both are non-advance
46        // outcomes today and the current phase needs no behavioral
47        // distinction between them. The distinction is NOT lost:
48        // AgentResult.decided_by_layer plus the underlying AgentStatus
49        // variant both survive into events.jsonl, so Phase 18's 18d
50        // reconciliation can differentiate a reported failure from a
51        // vanished process without a new Action variant. Revisit if 18d
52        // requires divergent routing.
53        AgentStatus::Failed => Action::GateReview,
54        AgentStatus::Unknown => Action::GateReview,
55        // 31-02 (D-06/D-08). GateReview, not GateInfra: nothing
56        // infrastructural failed — DevFlow chose to stop waiting, and the
57        // operator has real commits from a partly-done run to look at, which
58        // is a review question, not an infra one.
59        //
60        // Emphatically NOT AutoResume. D-08 makes an idle timeout terminal:
61        // the run's extent is unknown (the agent went quiet rather than
62        // reporting), so a retry would restart on top of a dirty tree nobody
63        // has surveyed. `idle_timeout_is_never_auto_resumed` pins this across
64        // every stage.
65        AgentStatus::IdleTimeout => Action::GateReview,
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn success_advances() {
75        assert_eq!(
76            decide_action(Stage::Code, AgentStatus::Success),
77            Action::Advance
78        );
79    }
80
81    #[test]
82    fn rate_limited_auto_resumes() {
83        assert_eq!(
84            decide_action(Stage::Code, AgentStatus::RateLimited),
85            Action::AutoResume
86        );
87    }
88
89    #[test]
90    fn resource_killed_gates_infra() {
91        assert_eq!(
92            decide_action(Stage::Code, AgentStatus::ResourceKilled),
93            Action::GateInfra
94        );
95    }
96
97    #[test]
98    fn agent_unavailable_gates_infra() {
99        assert_eq!(
100            decide_action(Stage::Code, AgentStatus::AgentUnavailable),
101            Action::GateInfra
102        );
103    }
104
105    #[test]
106    fn failed_gates_review() {
107        assert_eq!(
108            decide_action(Stage::Code, AgentStatus::Failed),
109            Action::GateReview
110        );
111    }
112
113    /// D-01: Unknown must NEVER map to Advance.
114    #[test]
115    fn unknown_gates_review_never_advances() {
116        assert_eq!(
117            decide_action(Stage::Code, AgentStatus::Unknown),
118            Action::GateReview
119        );
120    }
121
122    /// 31-02 D-06/D-08: an idle timeout is a review-worthy outcome about an
123    /// indeterminate run the operator has real commits to look at — never an
124    /// advance, and never an infra gate (nothing infrastructural failed).
125    #[test]
126    fn idle_timeout_gates_review() {
127        assert_eq!(
128            decide_action(Stage::Code, AgentStatus::IdleTimeout),
129            Action::GateReview
130        );
131    }
132
133    /// Every stage in the chain, walked from `Define` via `Stage::next` rather
134    /// than hardcoded, so a stage inserted into the chain is covered without
135    /// editing this test. (Limit: a stage added OUTSIDE the linear chain would
136    /// still be missed — `Stage` exposes no exhaustive iterator to key off.)
137    fn every_stage() -> Vec<Stage> {
138        let mut stages = vec![Stage::Define];
139        while let Some(next) = stages.last().and_then(|s| s.next()) {
140            stages.push(next);
141        }
142        stages
143    }
144
145    /// 31-02 D-08: an idle timeout is TERMINAL. Auto-resuming would restart
146    /// from a dirty, partly-done state whose extent nobody has established —
147    /// the run went quiet, it did not report. Asserted for every stage, not
148    /// just `Code`, because `decide_action`'s mapping is stage-independent
149    /// today and a future stage-sensitive arm must not quietly reintroduce a
150    /// retry here.
151    #[test]
152    fn idle_timeout_is_never_auto_resumed() {
153        let stages = every_stage();
154        assert_eq!(stages.len(), 5, "stage chain changed; review this test");
155        for stage in stages {
156            let action = decide_action(stage, AgentStatus::IdleTimeout);
157            assert_ne!(
158                action,
159                Action::AutoResume,
160                "IdleTimeout must never auto-resume at {stage:?}"
161            );
162            assert_ne!(
163                action,
164                Action::Advance,
165                "IdleTimeout must never advance at {stage:?}"
166            );
167        }
168    }
169
170    /// Negative control for the test above: the assertion loop has teeth only
171    /// if it can actually fail. `RateLimited` is the one status that DOES
172    /// auto-resume, so running the same loop over it must produce the opposite
173    /// result at every stage. If this ever stops holding, the loop above is
174    /// vacuous and its green is meaningless.
175    #[test]
176    fn the_never_auto_resume_loop_can_actually_fail() {
177        for stage in every_stage() {
178            assert_eq!(
179                decide_action(stage, AgentStatus::RateLimited),
180                Action::AutoResume,
181                "negative control: RateLimited must auto-resume at {stage:?}"
182            );
183        }
184    }
185}