agentd/exit.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The public exit-code contract. RFC 0011 §5 — this is a stable,
3//! machine-actionable API (e.g. for a Kubernetes `podFailurePolicy`); treat
4//! changes as breaking.
5//!
6//! | Code | Meaning | Scheduler hint |
7//! |------|-----------------------------------------------------|----------------|
8//! | 0 | success (one-shot completed / clean SIGTERM drain) | Complete |
9//! | 1 | generic/unspecified failure | retriable |
10//! | 2 | config / usage error (validation) | non-retriable |
11//! | 3 | partial result | policy |
12//! | 4 | intelligence unreachable / auth after retries | retriable |
13//! | 5 | semantic — task cannot be done / refused | non-retriable |
14//! | 6 | required MCP server failed to connect/handshake/die | retriable |
15//! | 7 | budget exceeded (steps/tokens/deadline/tree) | policy |
16//! | 124 | hard wall-clock deadline (mnemonic to `timeout(1)`) | — |
17//! | 137 | killed by SIGKILL (128+9, OS-set) — often OOM | raise memory |
18//! | 143 | killed by SIGTERM (128+15, OS-set) — ungraceful | — |
19//!
20//! A clean SIGTERM drain returns **0, not 143** (RFC 0011 §5.1). 137/143 are
21//! set by the OS when the kernel kills us; we never `exit(137)` ourselves.
22//!
23//! RFC 0016 §5 freezes the *contract* around this table: it pins a version
24//! ([`EXIT_CODES`], surfaced at `surfaces.exit_codes`) and maps each code to a
25//! `podFailurePolicy` *intent* ([`pod_failure_intent`]) agentctl compiles into
26//! `onExitCodes` rules. This module owns neither the table values (RFC 0011 §5)
27//! nor the policy (agentctl) — only the frozen, versioned intent mapping.
28
29use crate::agentloop::stop::TerminalStatus;
30
31/// The exit-code *contract* version (major.minor), surfaced in the manifest at
32/// `surfaces.exit_codes` (RFC 0016 §5.1 / §8.1). RFC 0011 §5 owns the table of
33/// code→meaning; this const freezes that mapping as a versioned public API a
34/// control plane authors `podFailurePolicy` rules against. Additive within a
35/// major; **any** change to a code's meaning or to the [`pod_failure_intent`]
36/// mapping is breaking and bumps the major (RFC 0016 §8.2). agentctl refuses to
37/// compile rules for an `exit_codes` major it does not understand (§8.3).
38pub const EXIT_CODES: &str = "1.0";
39
40pub const SUCCESS: i32 = 0;
41pub const GENERIC: i32 = 1;
42pub const USAGE: i32 = 2;
43pub const PARTIAL: i32 = 3;
44pub const INTEL_UNAVAILABLE: i32 = 4;
45pub const REFUSED: i32 = 5;
46pub const MCP_REQUIRED_DOWN: i32 = 6;
47pub const BUDGET: i32 = 7;
48pub const DEADLINE: i32 = 124;
49
50/// Map a one-shot root subagent's outcome to an exit code (RFC 0011 §5.2).
51/// `partial` is the result-body property, not a status: a `Completed` run
52/// that only partially satisfied the objective exits `3`. A budget-bounded
53/// run that nonetheless produced usable output is still reported under its
54/// budget code (`7`) with the partial flag carried in the result JSON.
55pub fn once_exit(status: TerminalStatus, partial: bool) -> i32 {
56 use TerminalStatus::*;
57 match status {
58 Completed => {
59 if partial {
60 PARTIAL
61 } else {
62 SUCCESS
63 }
64 }
65 Refused => REFUSED,
66 ExhaustedSteps | ExhaustedTokens | Deadline => BUDGET,
67 Stalled | LoopDetected => PARTIAL,
68 Cancelled => GENERIC,
69 Crashed => GENERIC,
70 }
71}
72
73/// The OS-set codes (`128 + signo`). agentd never returns these itself
74/// ([`once_exit`] tops out at `DEADLINE` = 124, RFC 0011 §5.1); the kernel sets
75/// them when it kills us. We name them so [`pod_failure_intent`] can classify
76/// the kernel exit code an agentctl reader observes (RFC 0016 §5.3).
77pub const SIGKILL_EXIT: i32 = 137; // 128 + 9 — OOM / kubelet hard-kill
78pub const SIGTERM_EXIT: i32 = 143; // 128 + 15 — ungraceful SIGTERM (drain forced past budget)
79
80/// The `podFailurePolicy` *intent* a control plane compiles each exit code into
81/// (RFC 0016 §5.2). agentd emits the **code**; agentctl owns the actual
82/// `FailJob`/`Ignore`/`Count` choice and any operator override — this is the
83/// frozen hint it branches on, not a policy.
84///
85/// The five intents (RFC 0016 §5.2):
86/// - `complete` — `0`: not a failure; never retry.
87/// - `terminal` — config/semantic error; a retry never helps ⇒ `FailJob`.
88/// - `retriable` — usually transient ⇒ left to `backoffLimit` (`Count`).
89/// - `policy` — default `Count`, but the operator's `--budget-exit-code`
90/// remap (RFC 0011 §5.2) is honoured when present.
91/// - `infra` — kernel-set kill (OOM / ungraceful SIGTERM); a *resource/config*
92/// fix (memory, grace period), never authored as a retry rule (§5.3).
93///
94/// An unrecognised code defaults to `retriable` — the conservative posture: an
95/// unknown failure is treated like a generic one and left to the backoff limit,
96/// never silently `FailJob`'d. (A code outside the contract should not occur at
97/// the frozen `EXIT_CODES` major; this is belt-and-suspenders for a future
98/// additive code an older agentctl has not learned.)
99pub fn pod_failure_intent(code: i32) -> &'static str {
100 match code {
101 SUCCESS => "complete",
102 USAGE | REFUSED => "terminal",
103 PARTIAL | BUDGET | DEADLINE => "policy",
104 GENERIC | INTEL_UNAVAILABLE | MCP_REQUIRED_DOWN => "retriable",
105 SIGKILL_EXIT | SIGTERM_EXIT => "infra",
106 _ => "retriable",
107 }
108}
109
110/// Apply the operator's `--budget-exit-code` remap (RFC 0011 §5.2; ACC
111/// exit-codes.table.json `x-budget-exit-code-remap`). ONLY the two
112/// operator-tunable `policy`-intent budget codes are remappable — `EXIT_PARTIAL`
113/// (3) and `EXIT_BUDGET` (7); every other code (a clean `0`, a terminal refusal
114/// `5`, the `policy` deadline `124`, a kernel `137`) is returned UNCHANGED. With
115/// no remap configured (`None`) the canonical table applies verbatim.
116///
117/// This is applied ONLY to the final *process* exit code a Job's
118/// `podFailurePolicy` observes — the run report keeps the canonical 3/7
119/// projection (and the precise terminal `status`), so the durable record stays
120/// truthful and `report.schema`-valid regardless of the remap.
121pub fn apply_budget_remap(code: i32, budget_exit_code: Option<i32>) -> i32 {
122 match (code, budget_exit_code) {
123 (PARTIAL | BUDGET, Some(remapped)) => remapped,
124 _ => code,
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::agentloop::stop::TerminalStatus::*;
132
133 #[test]
134 fn budget_remap_touches_only_partial_and_budget() {
135 // The two operator-tunable `policy` budget codes remap…
136 assert_eq!(apply_budget_remap(PARTIAL, Some(0)), 0);
137 assert_eq!(apply_budget_remap(BUDGET, Some(0)), 0);
138 assert_eq!(apply_budget_remap(BUDGET, Some(1)), 1);
139 // …and NOTHING else does, even though some share the `policy` intent.
140 for code in [
141 SUCCESS,
142 GENERIC,
143 USAGE,
144 INTEL_UNAVAILABLE,
145 REFUSED,
146 MCP_REQUIRED_DOWN,
147 DEADLINE,
148 ] {
149 assert_eq!(
150 apply_budget_remap(code, Some(0)),
151 code,
152 "code {code} must never be remapped by --budget-exit-code"
153 );
154 }
155 // No remap configured ⇒ the canonical table is verbatim.
156 assert_eq!(apply_budget_remap(PARTIAL, None), PARTIAL);
157 assert_eq!(apply_budget_remap(BUDGET, None), BUDGET);
158 }
159
160 #[test]
161 fn mapping_matches_table() {
162 assert_eq!(once_exit(Completed, false), SUCCESS);
163 assert_eq!(once_exit(Completed, true), PARTIAL);
164 assert_eq!(once_exit(Refused, false), REFUSED);
165 assert_eq!(once_exit(ExhaustedSteps, false), BUDGET);
166 assert_eq!(once_exit(ExhaustedTokens, false), BUDGET);
167 assert_eq!(once_exit(Deadline, false), BUDGET);
168 assert_eq!(once_exit(Stalled, false), PARTIAL);
169 assert_eq!(once_exit(LoopDetected, false), PARTIAL);
170 assert_eq!(once_exit(Cancelled, false), GENERIC);
171 assert_eq!(once_exit(Crashed, false), GENERIC);
172 }
173
174 #[test]
175 fn codes_are_distinct_and_in_documented_bands() {
176 let table = [
177 SUCCESS,
178 GENERIC,
179 USAGE,
180 PARTIAL,
181 INTEL_UNAVAILABLE,
182 REFUSED,
183 MCP_REQUIRED_DOWN,
184 BUDGET,
185 DEADLINE,
186 ];
187 // pairwise distinct — a collision would make a podFailurePolicy ambiguous
188 for (i, a) in table.iter().enumerate() {
189 for b in &table[i + 1..] {
190 assert_ne!(a, b, "exit codes must be distinct");
191 }
192 }
193 // every code is POSIX-portable (0..=125) except the OS-mnemonic 124
194 assert!(table.iter().all(|&c| (0..=124).contains(&c)));
195 }
196
197 #[test]
198 fn pod_failure_intent_matches_the_contract_table() {
199 // RFC 0016 §5.2 — the exact code→intent mapping agentctl compiles.
200 assert_eq!(pod_failure_intent(SUCCESS), "complete");
201 assert_eq!(pod_failure_intent(GENERIC), "retriable");
202 assert_eq!(pod_failure_intent(USAGE), "terminal");
203 assert_eq!(pod_failure_intent(PARTIAL), "policy");
204 assert_eq!(pod_failure_intent(INTEL_UNAVAILABLE), "retriable");
205 assert_eq!(pod_failure_intent(REFUSED), "terminal");
206 assert_eq!(pod_failure_intent(MCP_REQUIRED_DOWN), "retriable");
207 assert_eq!(pod_failure_intent(BUDGET), "policy");
208 assert_eq!(pod_failure_intent(DEADLINE), "policy");
209 // Kernel-set codes are infra fixes, never retry rules (§5.3).
210 assert_eq!(pod_failure_intent(SIGKILL_EXIT), "infra");
211 assert_eq!(pod_failure_intent(SIGTERM_EXIT), "infra");
212 }
213
214 #[test]
215 fn pod_failure_intent_is_total_over_the_contract_and_defaults_safely() {
216 // Every code the table defines maps to one of the five §5.2 intents.
217 let intents = ["complete", "terminal", "retriable", "policy", "infra"];
218 for code in [
219 SUCCESS,
220 GENERIC,
221 USAGE,
222 PARTIAL,
223 INTEL_UNAVAILABLE,
224 REFUSED,
225 MCP_REQUIRED_DOWN,
226 BUDGET,
227 DEADLINE,
228 SIGKILL_EXIT,
229 SIGTERM_EXIT,
230 ] {
231 assert!(
232 intents.contains(&pod_failure_intent(code)),
233 "code {code} mapped outside the §5.2 intent set"
234 );
235 }
236 // An unknown code is treated conservatively — retriable, never a silent
237 // FailJob (a terminal verdict on an unrecognised code would be unsafe).
238 assert_eq!(pod_failure_intent(99), "retriable");
239 assert_eq!(pod_failure_intent(-1), "retriable");
240 }
241
242 #[test]
243 fn intent_never_authors_a_retry_rule_for_a_terminal_or_infra_code() {
244 // The control-plane invariant: a `terminal` config/semantic error and an
245 // `infra` kernel-kill must never be classified `retriable` (RFC 0016
246 // §5.2/§5.3) — retrying either is the wrong fix.
247 for code in [USAGE, REFUSED, SIGKILL_EXIT, SIGTERM_EXIT] {
248 assert_ne!(
249 pod_failure_intent(code),
250 "retriable",
251 "code {code} must not be authored as a retry rule"
252 );
253 }
254 }
255
256 #[test]
257 fn exit_codes_contract_version_is_frozen_at_one_zero() {
258 // The manifest's surfaces.exit_codes value (RFC 0016 §5.1/§8.1).
259 assert_eq!(EXIT_CODES, "1.0");
260 }
261
262 #[test]
263 fn once_exit_never_returns_success_for_a_non_completed_status() {
264 for s in [
265 Refused,
266 ExhaustedSteps,
267 ExhaustedTokens,
268 Deadline,
269 Stalled,
270 LoopDetected,
271 Cancelled,
272 Crashed,
273 ] {
274 assert_ne!(
275 once_exit(s, false),
276 SUCCESS,
277 "{s:?} must not look like success"
278 );
279 }
280 }
281}