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