Failure Triage — From Observable Signal to §N
A failed call gives the agent four observables: exit code, stdout, stderr, and timing. This table routes them to the failure modes most likely responsible. Walk it top to bottom; first match wins. Every failure mode file opens its
### Agent Workaroundwith a**Signature:**line stating the same observables, so a match here can be confirmed there.
The workaround files assume you already know which failure mode you hit. This document is the missing first step: classification from raw evidence, requiring no judgment beyond pattern matching.
How to use
- Capture all four observables verbatim: the exact command, the integer exit code, full stdout, full stderr; note whether the process hung before being killed
- Walk the decision table top to bottom; stop at the first matching row
- Open the linked §N file(s), confirm the match against the
**Signature:**line, apply that file's Agent Workaround - If no row matches, apply the default retry policy at the bottom, then escalate with all four observables attached
Signatures are greppable across the corpus:
grep -rn "^\*\*Signature:" challenges/
Workaround capability tiers
Every ### Agent Workaround carries a **Tier:** line stating the minimum caller capability the full workaround demands. Not all models are equally capable; the tier tells a weak model whether to attempt the workaround or take the fallback:
| Tier | Demands | Weak-model behavior |
|---|---|---|
A |
One safe command, possibly with a fixed env prefix; no branching | Execute directly |
B |
One observable check, then one command | Execute directly |
C |
Stateful logic: loops, accumulated state, judgment | Skip the body; apply the file's **Fallback:** line |
Tier C files carry a **Fallback:** line: a single command or single action that degrades gracefully (typically: one bounded retry with the right flags, then escalate with all four observables). A weak model executing only Signature → Tier → Fallback never enters logic it cannot reliably complete.
Decision table
| # | Signal (observable) | Likely §N | First action |
|---|---|---|---|
| 1 | stdout parses as a JSON envelope containing "ok": false |
§18 §19 | Branch on error.code; obey retryable, retry_after_ms, fix_required; run fix_command verbatim when present, then reissue once |
| 2 | exit 0 but output text indicates failure, or a pipeline swallowed the code |
§1 §56 | Trust the envelope over the exit code; check PIPESTATUS; add set -o pipefail |
| 3 | Process hangs with no output; completes only when killed by timeout | §10 §50 §37 §62 §64 §45 §60 §11 | Re-run once with the non-interactive bundle below: stdin closed, suppression env vars, timeout wrapper |
| 4 | exit 127, or command not found in stderr |
§20 §71 | Check the binary is on PATH; run tool doctor if available; install non-interactively |
| 5 | SyntaxError, ImportError, or a version complaint at startup |
§38 | Compare the runtime version against the tool's declared requirement |
| 6 | exit 2, or usage/help text in stderr |
§1 §14 §35 §54 §69 | Re-read --help for the exact flags; correct the arguments; reissue once |
| 7 | Input rejected as invalid JSON | §67 | Normalize the payload to strict JSON; use error.corrected_input when provided |
| 8 | Traceback, panic:, or an unhandled exception in stderr |
§18 | Extract the final stack line as the cause; treat as non-retryable |
| 9 | stdout contains JSON mixed with prose, banners, or ANSI codes | §41 §68 §3 §8 | Apply the JSON extraction rule below; only if it recovers nothing, re-run with NO_COLOR=1 |
| 10 | stdout is enormous, or ends mid-structure | §43 §55 §5 | Check meta.truncated; re-run with --limit, pagination, or field selectors |
| 11 | 429, rate limit, or quota in output, or exit 11 |
§19 | Wait error.retry_after_ms (default 60 s), then retry with exponential back-off |
| 12 | 401/403, unauthorized, forbidden, or token text, or exit 7/exit 8 |
§53 §24 §45 §74 | Distinguish expiry from denial via error.code; run the remediation command (fix_command, reauth_command, or refresh_command) when present; never retry denial |
| 13 | ECONNREFUSED, DNS failure, TLS error, or proxy text in stderr |
§31 | Check HTTPS_PROXY/NO_PROXY env vars; test connectivity outside the tool |
| 14 | Exit code in 129–143 (128 + signal number) |
§11 §16 | The process was killed externally (outer timeout, OOM); inspect partial state before any retry |
| 15 | Error text in a non-English locale | §57 | Re-run with LC_ALL=C LANG=C |
| 16 | exit 1 with prose stderr; none of the above |
— | Apply the default retry policy below; then classify with /cli-agent-diagnose or escalate |
The JSON extraction rule
Several failure modes pollute stdout around a JSON body: update banners (§41), third-party library logs (§68), warnings and help text on the wrong stream (§2, §3), ANSI escape codes (§8, neutralized by the strip step). §2, §3, §41, and §68 embed this same reference implementation, so the rule is learned once and applied everywhere:
- Strip ANSI escape sequences
- Parse the whole stream; if it parses, done
- Otherwise collect every maximal JSON value in the stream by brace-matched decoding (never by regex)
- Prefer the last candidate object containing the
"ok"key: the envelope invariant makes it unambiguous - Otherwise take the last complete JSON value
- If nothing parses, the output is unstructured: stop, do not guess
import json, re
def extract_envelope(stdout: str):
"""Canonical JSON extraction rule — defined in challenges/triage.md."""
text = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", stdout) # 1. strip ANSI codes
try:
return json.loads(text) # 2. fast path: clean stream
except json.JSONDecodeError:
pass
candidates = [] # 3. every maximal JSON value
decoder = json.JSONDecoder()
i = 0
while True:
starts = [s for s in (text.find(c, i) for c in "{[") if s != -1]
if not starts:
break
start = min(starts)
try:
obj, end = decoder.raw_decode(text[start:])
candidates.append(obj)
i = start + end
except json.JSONDecodeError:
i = start + 1
envelopes = [c for c in candidates if isinstance(c, dict) and "ok" in c]
if envelopes:
return envelopes[-1] # 4. last envelope wins
if candidates:
return candidates[-1] # 5. last complete value
return None # 6. unstructured: do not guess
Why "last", not "first": banners and logs are usually emitted before the payload, and progress objects before the final envelope; the final envelope is authoritative. Text appended after the payload is not valid JSON, so it produces no candidate and cannot displace the envelope. The one unrecoverable case is pollution interleaved inside a single JSON value (a banner printed mid-object): no extraction rule fixes that, only suppression at the source.
The non-interactive bundle
One fixed prefix neutralizes the entire hang family (row 3) without diagnosing which member fired. Apply it on the single retry:
PAGER=cat GIT_PAGER=cat MANPAGER=cat EDITOR=true VISUAL=true \
CI=true NO_COLOR=1 TERM=dumb DEBIAN_FRONTEND=noninteractive \
BROWSER=true DISPLAY= \
timeout 60 tool <args> </dev/null
- `