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