Skip to main content

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}