agentmux_srv\agents/
failure.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Classification of agent (Claude CLI) run failures into a small,
5//! stable taxonomy with a human-readable explanation.
6//!
7//! `classify()` is a **pure** function: it takes the exit code, the
8//! terminating signal, the tail of the child's stderr, and an optional
9//! terminal `result` frame, and returns an [`AgentFailure`]. It does no
10//! IO and is exhaustively unit-tested against the real Anthropic error
11//! phrasings, so the "why" behind a non-zero exit can be surfaced to the
12//! user instead of a bare `exit 1`.
13//!
14//! See `docs/specs/SPEC_AGENT_FAILURE_DIAGNOSTICS_2026_06_11.md`. This
15//! is the P1 (capture + classify) slice; live-stream emission and UI
16//! banners are P2/P3.
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21/// Stable failure taxonomy. Wire format is snake_case to match the rest
22/// of the agent event surface (`frontend/types/gotypes.d.ts`).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum FailureClass {
26    /// Server-side rate limit (HTTP 429). Transient, not the account quota.
27    RateLimited,
28    /// API overloaded (HTTP 529). Transient.
29    Overloaded,
30    /// Plan / usage / billing limit reached. Not transient.
31    UsageLimit,
32    /// Credentials rejected (HTTP 401).
33    Auth,
34    /// Conversation exceeded the model's context window.
35    ContextExceeded,
36    /// Hit the configured `--max-turns` cap.
37    MaxTurns,
38    /// Network / connectivity error reaching the API.
39    Network,
40    /// Killed by a signal (OOM killer or external stop).
41    Killed,
42    /// Exited cleanly but produced no final result.
43    NoOutput,
44    /// Could not launch the `claude` binary at all.
45    SpawnFailure,
46    /// Non-zero exit with no recognized cause.
47    UnknownNonZero,
48}
49
50/// A classified agent failure: the class, a user-facing title + detail,
51/// the raw exit evidence, the stderr tail, and whether a retry is
52/// worth attempting.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54#[serde(rename_all = "camelCase")]
55pub struct AgentFailure {
56    pub code: FailureClass,
57    pub title: String,
58    pub detail: String,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub exit_code: Option<i32>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub signal: Option<i32>,
63    #[serde(skip_serializing_if = "String::is_empty")]
64    pub stderr_tail: String,
65    pub retryable: bool,
66}
67
68impl AgentFailure {
69    /// Render the single-string explanation that becomes a run's
70    /// terminal error (drone `error` column, sidecar log, future agent
71    /// pane banner). Title + detail + exit evidence + retryable hint,
72    /// followed by the raw stderr tail so the original text is never
73    /// more than a glance away.
74    pub fn explain(&self) -> String {
75        let mut s = self.title.clone();
76        if !self.detail.is_empty() {
77            s.push_str(" — ");
78            s.push_str(&self.detail);
79        }
80        match (self.signal, self.exit_code) {
81            (Some(sig), _) => s.push_str(&format!(" [signal {sig}]")),
82            (None, Some(code)) => s.push_str(&format!(" [exit {code}]")),
83            (None, None) => {}
84        }
85        if self.retryable {
86            s.push_str(" (retryable)");
87        }
88        if !self.stderr_tail.is_empty() {
89            s.push_str("\n--- claude stderr (tail) ---\n");
90            s.push_str(&self.stderr_tail);
91        }
92        s
93    }
94}
95
96/// Classify an agent run failure.
97///
98/// `exit_code` / `signal` come from the child's `ExitStatus`. `stderr`
99/// is the captured (capped) child stderr. `result_frame` is the
100/// terminal stream-json `result` object when one was seen — the CLI
101/// sometimes reports an error there while still exiting 0, so it is
102/// folded into the evidence.
103pub fn classify(
104    exit_code: Option<i32>,
105    signal: Option<i32>,
106    stderr: &str,
107    result_frame: Option<&Value>,
108) -> AgentFailure {
109    let frame_reported_error = result_frame.is_some_and(is_error_result_frame);
110    let frame_text = result_frame.map(frame_error_text).unwrap_or_default();
111
112    let combined = if frame_text.is_empty() {
113        stderr.to_string()
114    } else {
115        format!("{stderr}\n{frame_text}")
116    };
117    let tail = tail_lines(&combined, 24, 1600);
118    let hay = combined.to_ascii_lowercase();
119
120    // Explicit terminal-frame subtype: turn cap reached.
121    if result_frame
122        .and_then(|f| f.get("subtype"))
123        .and_then(|v| v.as_str())
124        .is_some_and(|s| s.contains("max_turns"))
125    {
126        return build(
127            FailureClass::MaxTurns,
128            "Hit the turn limit",
129            "The agent reached its configured `--max-turns` cap before finishing. Raise the cap or split the task.",
130            false,
131            exit_code,
132            signal,
133            &tail,
134        );
135    }
136
137    // Killed by a signal (OOM killer or external stop). 137 == 128 + SIGKILL(9).
138    if signal.is_some() || exit_code == Some(137) {
139        return build(
140            FailureClass::Killed,
141            "Agent process was killed",
142            "Terminated by a signal — most often the OS out-of-memory killer or an external stop. Reduce concurrent load / memory pressure and retry.",
143            false,
144            exit_code,
145            signal,
146            &tail,
147        );
148    }
149
150    // Keyword matches. Order matters: rate-limit and overload are checked
151    // before usage-limit because the server-side rate-limit message
152    // literally contains "not your usage limit".
153    if hay.contains("rate limited")
154        || hay.contains("temporarily limiting requests")
155        || hay.contains("rate_limit")
156        || mentions_http_status(&hay, "429")
157    {
158        return build(
159            FailureClass::RateLimited,
160            "Rate-limited by the API",
161            "Server-side rate limit — transient, and not your account quota. Retry shortly; consider lowering parallelism.",
162            true,
163            exit_code,
164            signal,
165            &tail,
166        );
167    }
168    if hay.contains("overloaded") || mentions_http_status(&hay, "529") {
169        return build(
170            FailureClass::Overloaded,
171            "API temporarily overloaded",
172            "The API reported it is overloaded (HTTP 529). Transient — retry with backoff.",
173            true,
174            exit_code,
175            signal,
176            &tail,
177        );
178    }
179    if hay.contains("authentication_error")
180        || hay.contains("authentication_failed")
181        || hay.contains("invalid authentication")
182        || hay.contains("invalid api key")
183        || hay.contains("invalid x-api-key")
184        || hay.contains("unauthorized")
185        || hay.contains("please run /login")
186        || mentions_http_status(&hay, "401")
187    {
188        return build(
189            FailureClass::Auth,
190            "Not authenticated",
191            "The API rejected the credentials (HTTP 401). Re-authenticate via the Identity tab or `claude /login`.",
192            false,
193            exit_code,
194            signal,
195            &tail,
196        );
197    }
198    if hay.contains("model_context_window_exceeded")
199        || hay.contains("prompt is too long")
200        || (hay.contains("context") && (hay.contains("exceed") || hay.contains("window") || hay.contains("too long")))
201    {
202        return build(
203            FailureClass::ContextExceeded,
204            "Context window exceeded",
205            "The conversation exceeded the model's context window. Clear or compact the agent's history and retry.",
206            false,
207            exit_code,
208            signal,
209            &tail,
210        );
211    }
212    if hay.contains("max turns") || hay.contains("max-turns") || hay.contains("maximum number of turns") {
213        return build(
214            FailureClass::MaxTurns,
215            "Hit the turn limit",
216            "The agent reached its configured `--max-turns` cap before finishing. Raise the cap or split the task.",
217            false,
218            exit_code,
219            signal,
220            &tail,
221        );
222    }
223    if hay.contains("usage limit")
224        || hay.contains("out of credits")
225        || hay.contains("quota")
226        || hay.contains("billing")
227        || hay.contains("insufficient")
228    {
229        return build(
230            FailureClass::UsageLimit,
231            "Usage limit reached",
232            "Your plan or usage limit appears to be reached. Check plan / billing — retrying won't help until it resets.",
233            false,
234            exit_code,
235            signal,
236            &tail,
237        );
238    }
239    if hay.contains("apiconnectionerror")
240        || hay.contains("connection error")
241        || hay.contains("could not resolve")
242        || hay.contains("econnreset")
243        || hay.contains("network")
244        || hay.contains("timed out")
245        || hay.contains("timeout")
246        || hay.contains("dns")
247    {
248        return build(
249            FailureClass::Network,
250            "Network error reaching the API",
251            "A network/connection error occurred talking to the API. Check connectivity and retry.",
252            true,
253            exit_code,
254            signal,
255            &tail,
256        );
257    }
258
259    // Fallbacks.
260    if frame_reported_error {
261        return build(
262            FailureClass::UnknownNonZero,
263            "Agent reported an error",
264            "The agent reported a terminal error with no recognized cause. The raw output tail is included below.",
265            false,
266            exit_code,
267            signal,
268            &tail,
269        );
270    }
271    if exit_code == Some(0) {
272        return build(
273            FailureClass::NoOutput,
274            "Agent produced no output",
275            "The agent exited cleanly (code 0) but never produced a final result. Inspect the transcript / sidecar log.",
276            false,
277            exit_code,
278            signal,
279            &tail,
280        );
281    }
282    build(
283        FailureClass::UnknownNonZero,
284        "Agent failed",
285        "The agent exited with a non-zero status and no recognized error. The raw stderr tail is included below.",
286        false,
287        exit_code,
288        signal,
289        &tail,
290    )
291}
292
293#[allow(clippy::too_many_arguments)]
294fn build(
295    code: FailureClass,
296    title: &str,
297    detail: &str,
298    retryable: bool,
299    exit_code: Option<i32>,
300    signal: Option<i32>,
301    tail: &str,
302) -> AgentFailure {
303    AgentFailure {
304        code,
305        title: title.to_string(),
306        detail: detail.to_string(),
307        exit_code,
308        signal,
309        stderr_tail: tail.to_string(),
310        retryable,
311    }
312}
313
314/// Best-effort error text from a terminal stream-json `result` frame:
315/// `error.message`, else a string `error`/`result`, else the `subtype`.
316fn frame_error_text(frame: &Value) -> String {
317    if let Some(msg) = frame.pointer("/error/message").and_then(|v| v.as_str()) {
318        return msg.to_string();
319    }
320    if let Some(err) = frame.get("error").and_then(|v| v.as_str()) {
321        return err.to_string();
322    }
323    if let Some(res) = frame.get("result").and_then(|v| v.as_str()) {
324        return res.to_string();
325    }
326    frame
327        .get("subtype")
328        .and_then(|v| v.as_str())
329        .unwrap_or_default()
330        .to_string()
331}
332
333/// True if a terminal `result` frame represents a failure.
334pub(crate) fn is_error_result_frame(frame: &Value) -> bool {
335    frame.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false)
336        || frame
337            .get("subtype")
338            .and_then(|v| v.as_str())
339            .is_some_and(|s| s.starts_with("error"))
340}
341
342/// Last `max_lines` non-empty lines of `s`, further capped to the last
343/// `max_chars` characters (char-safe — never slices mid-codepoint).
344fn tail_lines(s: &str, max_lines: usize, max_chars: usize) -> String {
345    let mut lines: Vec<&str> = s
346        .lines()
347        .map(|l| l.trim_end())
348        .filter(|l| !l.is_empty())
349        .collect();
350    if lines.len() > max_lines {
351        lines = lines.split_off(lines.len() - max_lines);
352    }
353    let joined = lines.join("\n");
354    let n = joined.chars().count();
355    if n > max_chars {
356        joined.chars().skip(n - max_chars).collect()
357    } else {
358        joined
359    }
360}
361
362/// True if `hay` mentions HTTP status `code` in a status-like context
363/// (e.g. "http 429", "status 429", "error 429", "[429]"). Anchoring on a
364/// context word avoids the false positives a bare substring would hit —
365/// an ISO date like `2026-04-29` contains "429". (reagent P2 on #1353.)
366fn mentions_http_status(hay: &str, code: &str) -> bool {
367    [
368        format!("http {code}"),
369        format!("http/1.1 {code}"),
370        format!("http/2 {code}"),
371        format!("status {code}"),
372        format!("status: {code}"),
373        format!("status code {code}"),
374        format!("code {code}"),
375        format!("code: {code}"),
376        format!("error {code}"),
377        format!("error: {code}"),
378        format!("[{code}]"),
379        format!("({code})"),
380    ]
381    .iter()
382    .any(|p| hay.contains(p.as_str()))
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use serde_json::json;
389
390    #[test]
391    fn rate_limit_incident_string_classifies_retryable() {
392        // The exact string this work was motivated by.
393        let stderr =
394            "API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited";
395        let f = classify(Some(1), None, stderr, None);
396        assert_eq!(f.code, FailureClass::RateLimited);
397        assert!(f.retryable);
398        let e = f.explain();
399        assert!(e.contains("Rate-limited"), "explain: {e}");
400        assert!(e.contains("retryable"), "explain: {e}");
401        assert!(e.to_lowercase().contains("rate limited"), "explain: {e}");
402    }
403
404    #[test]
405    fn not_your_usage_limit_does_not_classify_as_usage_limit() {
406        // Guard: the rate-limit message contains "usage limit"; it must
407        // win as RateLimited (checked first), not UsageLimit.
408        let f = classify(
409            Some(1),
410            None,
411            "temporarily limiting requests (not your usage limit) · Rate limited",
412            None,
413        );
414        assert_eq!(f.code, FailureClass::RateLimited);
415    }
416
417    #[test]
418    fn iso_date_in_stderr_does_not_false_positive() {
419        // "2026-04-29" contains "429"; "2026-04-01" -> "401";
420        // "2026-05-29" -> "529". A bare substring match would misclassify
421        // these; anchored matching must not. (reagent P2 on #1353.)
422        for stderr in [
423            "panic at 2026-04-29T10:00:00: unexpected eof",
424            "build 2026-04-01 failed: assertion",
425            "ts=2026-05-29 fatal: segfault",
426        ] {
427            let f = classify(Some(2), None, stderr, None);
428            assert_eq!(
429                f.code,
430                FailureClass::UnknownNonZero,
431                "stderr {stderr:?} must not match an HTTP status"
432            );
433        }
434    }
435
436    #[test]
437    fn anchored_http_status_still_classifies() {
438        assert_eq!(
439            classify(Some(1), None, "Error: HTTP 429 Too Many Requests", None).code,
440            FailureClass::RateLimited
441        );
442        assert_eq!(
443            classify(Some(1), None, "request failed with status 401", None).code,
444            FailureClass::Auth
445        );
446    }
447
448    #[test]
449    fn overloaded_is_retryable() {
450        let f = classify(Some(1), None, "overloaded_error: please retry", None);
451        assert_eq!(f.code, FailureClass::Overloaded);
452        assert!(f.retryable);
453    }
454
455    #[test]
456    fn invalid_api_key_is_auth_not_retryable() {
457        let f = classify(Some(1), None, "authentication_error: Invalid API key provided", None);
458        assert_eq!(f.code, FailureClass::Auth);
459        assert!(!f.retryable);
460    }
461
462    #[test]
463    fn sigkill_is_killed() {
464        let f = classify(None, Some(9), "", None);
465        assert_eq!(f.code, FailureClass::Killed);
466        assert_eq!(f.signal, Some(9));
467        assert!(f.explain().contains("[signal 9]"));
468    }
469
470    #[test]
471    fn exit_137_is_killed() {
472        let f = classify(Some(137), None, "", None);
473        assert_eq!(f.code, FailureClass::Killed);
474    }
475
476    #[test]
477    fn context_window_exceeded_classifies() {
478        let f = classify(
479            Some(1),
480            None,
481            "prompt is too long: 1200000 tokens > 200000 maximum context window",
482            None,
483        );
484        assert_eq!(f.code, FailureClass::ContextExceeded);
485    }
486
487    #[test]
488    fn network_error_is_retryable() {
489        let f = classify(Some(1), None, "APIConnectionError: Connection error.", None);
490        assert_eq!(f.code, FailureClass::Network);
491        assert!(f.retryable);
492    }
493
494    #[test]
495    fn clean_exit_no_output_is_no_output() {
496        let f = classify(Some(0), None, "", None);
497        assert_eq!(f.code, FailureClass::NoOutput);
498        assert!(!f.retryable);
499    }
500
501    #[test]
502    fn unknown_nonzero_is_default() {
503        let f = classify(Some(2), None, "something unexpected happened", None);
504        assert_eq!(f.code, FailureClass::UnknownNonZero);
505        assert!(f.explain().contains("[exit 2]"));
506    }
507
508    #[test]
509    fn authentication_failed_string_is_auth() {
510        // In-band 401: claude wraps the error in a synthetic assistant message
511        // with `"error":"authentication_failed"`. The inband_text is appended
512        // to the stderr tail before classify() is called, so it must match.
513        let f = classify(
514            Some(0),
515            None,
516            "authentication_failed Failed to authenticate. API Error: 401 Invalid authentication credentials",
517            None,
518        );
519        assert_eq!(f.code, FailureClass::Auth);
520        assert!(!f.retryable);
521    }
522
523    #[test]
524    fn invalid_authentication_credentials_is_auth() {
525        let f = classify(Some(0), None, "invalid authentication credentials", None);
526        assert_eq!(f.code, FailureClass::Auth);
527    }
528
529    #[test]
530    fn api_error_colon_401_is_auth() {
531        // "API Error: 401" lowercased → "api error: 401" → matches "error: 401".
532        let f = classify(Some(0), None, "api error: 401 invalid authentication credentials", None);
533        assert_eq!(f.code, FailureClass::Auth);
534    }
535
536    #[test]
537    fn result_frame_max_turns_subtype() {
538        let frame = json!({ "type": "result", "is_error": true, "subtype": "error_max_turns" });
539        let f = classify(Some(0), None, "", Some(&frame));
540        assert_eq!(f.code, FailureClass::MaxTurns);
541    }
542
543    #[test]
544    fn result_frame_error_message_feeds_classification() {
545        // CLI reported an overload on stdout but exited 0 — must still
546        // classify, not fall through to NoOutput.
547        let frame = json!({
548            "type": "result",
549            "is_error": true,
550            "subtype": "error_during_execution",
551            "error": { "message": "overloaded_error: upstream busy" }
552        });
553        let f = classify(Some(0), None, "", Some(&frame));
554        assert_eq!(f.code, FailureClass::Overloaded);
555    }
556
557    #[test]
558    fn tail_lines_keeps_last_lines_char_safe() {
559        let s = (0..100).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
560        let t = tail_lines(&s, 5, 1000);
561        assert_eq!(t.lines().count(), 5);
562        assert!(t.contains("line99"));
563        assert!(!t.contains("line0\n"));
564    }
565
566    #[test]
567    fn serializes_snake_case_code_camel_fields() {
568        let f = classify(Some(1), None, "Rate limited", None);
569        let v = serde_json::to_value(&f).unwrap();
570        assert_eq!(v["code"], json!("rate_limited"));
571        assert_eq!(v["retryable"], json!(true));
572        assert!(v.get("stderrTail").is_some(), "camelCase stderrTail expected: {v}");
573    }
574}