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}