Skip to main content

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}