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}