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        // A2 (41-antigravity UAT): an ambiguous transport-cancel — the agent's
43        // own final message self-reported success but the CLI's envelope was
44        // torn down before finalization — is retried, never advanced and never
45        // gated. Bounded by the same shared infra ceiling as RateLimited (see
46        // `handle_ambiguous_outcome` in the CLI).
47        AgentStatus::Ambiguous => Action::AutoResume,
48        AgentStatus::ResourceKilled => Action::GateInfra,
49        AgentStatus::AgentUnavailable => Action::GateInfra,
50        // DEFERRED (Plan 01 MEDIUM, OpenCode): Failed and Unknown map
51        // identically to GateReview. Intentional — both are non-advance
52        // outcomes today and the current phase needs no behavioral
53        // distinction between them. The distinction is NOT lost:
54        // AgentResult.decided_by_layer plus the underlying AgentStatus
55        // variant both survive into events.jsonl, so Phase 18's 18d
56        // reconciliation can differentiate a reported failure from a
57        // vanished process without a new Action variant. Revisit if 18d
58        // requires divergent routing.
59        AgentStatus::Failed => Action::GateReview,
60        AgentStatus::Unknown => Action::GateReview,
61        // 31-02 (D-06/D-08). GateReview, not GateInfra: nothing
62        // infrastructural failed — DevFlow chose to stop waiting, and the
63        // operator has real commits from a partly-done run to look at, which
64        // is a review question, not an infra one.
65        //
66        // Emphatically NOT AutoResume. D-08 makes an idle timeout terminal:
67        // the run's extent is unknown (the agent went quiet rather than
68        // reporting), so a retry would restart on top of a dirty tree nobody
69        // has surveyed. `idle_timeout_is_never_auto_resumed` pins this across
70        // every stage.
71        AgentStatus::IdleTimeout => Action::GateReview,
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn success_advances() {
81        assert_eq!(
82            decide_action(Stage::Code, AgentStatus::Success),
83            Action::Advance
84        );
85    }
86
87    #[test]
88    fn rate_limited_auto_resumes() {
89        assert_eq!(
90            decide_action(Stage::Code, AgentStatus::RateLimited),
91            Action::AutoResume
92        );
93    }
94
95    /// A2 (41-antigravity UAT): an ambiguous transport-cancel resolves to
96    /// auto-resume, never Advance and never a gate — the agent self-reported
97    /// success, so the stage is re-driven rather than reviewed.
98    #[test]
99    fn ambiguous_auto_resumes_never_advances() {
100        assert_eq!(
101            decide_action(Stage::Plan, AgentStatus::Ambiguous),
102            Action::AutoResume
103        );
104        assert_ne!(
105            decide_action(Stage::Plan, AgentStatus::Ambiguous),
106            Action::Advance,
107            "Ambiguous must never advance"
108        );
109        assert_ne!(
110            decide_action(Stage::Plan, AgentStatus::Ambiguous),
111            Action::GateReview,
112            "Ambiguous must never gate for review"
113        );
114    }
115
116    #[test]
117    fn resource_killed_gates_infra() {
118        assert_eq!(
119            decide_action(Stage::Code, AgentStatus::ResourceKilled),
120            Action::GateInfra
121        );
122    }
123
124    #[test]
125    fn agent_unavailable_gates_infra() {
126        assert_eq!(
127            decide_action(Stage::Code, AgentStatus::AgentUnavailable),
128            Action::GateInfra
129        );
130    }
131
132    #[test]
133    fn failed_gates_review() {
134        assert_eq!(
135            decide_action(Stage::Code, AgentStatus::Failed),
136            Action::GateReview
137        );
138    }
139
140    /// D-01: Unknown must NEVER map to Advance.
141    #[test]
142    fn unknown_gates_review_never_advances() {
143        assert_eq!(
144            decide_action(Stage::Code, AgentStatus::Unknown),
145            Action::GateReview
146        );
147    }
148
149    /// 31-02 D-06/D-08: an idle timeout is a review-worthy outcome about an
150    /// indeterminate run the operator has real commits to look at — never an
151    /// advance, and never an infra gate (nothing infrastructural failed).
152    #[test]
153    fn idle_timeout_gates_review() {
154        assert_eq!(
155            decide_action(Stage::Code, AgentStatus::IdleTimeout),
156            Action::GateReview
157        );
158    }
159
160    /// Every stage in the chain, walked from `Define` via `Stage::next` rather
161    /// than hardcoded, so a stage inserted into the chain is covered without
162    /// editing this test. (Limit: a stage added OUTSIDE the linear chain would
163    /// still be missed — `Stage` exposes no exhaustive iterator to key off.)
164    fn every_stage() -> Vec<Stage> {
165        let mut stages = vec![Stage::Define];
166        while let Some(next) = stages.last().and_then(|s| s.next()) {
167            stages.push(next);
168        }
169        stages
170    }
171
172    /// 31-02 D-08: an idle timeout is TERMINAL. Auto-resuming would restart
173    /// from a dirty, partly-done state whose extent nobody has established —
174    /// the run went quiet, it did not report. Asserted for every stage, not
175    /// just `Code`, because `decide_action`'s mapping is stage-independent
176    /// today and a future stage-sensitive arm must not quietly reintroduce a
177    /// retry here.
178    #[test]
179    fn idle_timeout_is_never_auto_resumed() {
180        let stages = every_stage();
181        assert_eq!(stages.len(), 5, "stage chain changed; review this test");
182        for stage in stages {
183            let action = decide_action(stage, AgentStatus::IdleTimeout);
184            assert_ne!(
185                action,
186                Action::AutoResume,
187                "IdleTimeout must never auto-resume at {stage:?}"
188            );
189            assert_ne!(
190                action,
191                Action::Advance,
192                "IdleTimeout must never advance at {stage:?}"
193            );
194        }
195    }
196
197    /// Negative control for the test above: the assertion loop has teeth only
198    /// if it can actually fail. `RateLimited` is the one status that DOES
199    /// auto-resume, so running the same loop over it must produce the opposite
200    /// result at every stage. If this ever stops holding, the loop above is
201    /// vacuous and its green is meaningless.
202    #[test]
203    fn the_never_auto_resume_loop_can_actually_fail() {
204        for stage in every_stage() {
205            assert_eq!(
206                decide_action(stage, AgentStatus::RateLimited),
207                Action::AutoResume,
208                "negative control: RateLimited must auto-resume at {stage:?}"
209            );
210        }
211    }
212}