From 2f2b698d0e065a3dced4af4e92ac16aaa9d0962f Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 7 Oct 2026 13:33:46 +0530 Subject: [PATCH] feat(command_output): explain the 128+N exit codes a host's pipefail surfaces `exit_code_hint` covered 127 and 126, so every other status reached the model as a bare number. That left the signal codes unexplained, and a host that wraps commands in `set -o pipefail` produces them constantly: piping a large output into a reader that closes early (`| head`, `| grep -q`, `| sed -n '1,Np'`) kills the writer with SIGPIPE, and the pipeline reports 141 even though the reader got everything it asked for. In one agent run, 9 of 44 recorded command failures were this, indistinguishable from real ones. Explain the four conventional statuses, which need opposite responses: 141 SIGPIPE the output above is what the reader accepted and is usually complete; treat it as data, not an error 137 SIGKILL killed by the OOM killer or a hard timeout; an unchanged retry is killed again 143 SIGTERM stopped before finishing, so the output is partial 139 SIGSEGV a fault in the program or its input, not in the invocation Only these and the two existing codes are annotated. An adjacent-looking status (138, 140, 142) is not a convention and is left alone, as is any application's own exit code. Co-Authored-By: Claude Opus 5 (cherry picked from commit 6daae62750c16a6e3e8671273a5e7571a760e85b) --- crates/tinytools/src/command_output/mod.rs | 39 ++++++++++- .../tinytools/src/command_output/mod_tests.rs | 65 +++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/crates/tinytools/src/command_output/mod.rs b/crates/tinytools/src/command_output/mod.rs index 2b07269..0903b64 100644 --- a/crates/tinytools/src/command_output/mod.rs +++ b/crates/tinytools/src/command_output/mod.rs @@ -27,9 +27,17 @@ use crate::ToolResult; -/// Hint appended after the exit code for the exit statuses that almost always -/// mean "this exact command cannot succeed on retry here". Empty for every other -/// code so an ordinary application failure is never editorialised. +/// Hint appended after the exit code for the exit statuses whose meaning is a +/// shell convention rather than an application's own choice, so the agent can +/// tell "cannot succeed on retry" from "did not actually fail" without +/// guessing. Empty for every other code: an ordinary application failure is +/// never editorialised. +/// +/// The `128 + N` codes matter because a host that wraps commands in +/// `set -o pipefail` reports the *pipeline's* signal death, so the most common +/// idiom in an agent's toolkit -- piping a large output into an early-closing +/// reader like `head` -- arrives as a failed command with a bare `141` and no +/// indication that the requested output was in fact delivered in full. fn exit_code_hint(code: i32) -> &'static str { match code { 127 => { @@ -42,6 +50,31 @@ fn exit_code_hint(code: i32) -> &'static str { restriction. This will not succeed on retry — report the blocker \ or request escalation instead of repeating the command" } + 141 => { + " — SIGPIPE: a reader closed the pipe before the writer finished, \ + which is what `| head`, `| grep -q` and `| sed -n '1,Np'` do \ + once they have what they asked for. Under `set -o pipefail` \ + that surfaces as a failed pipeline. The output above is \ + whatever the reader accepted, and is usually complete for what \ + was requested — treat it as data, not as an error, and only \ + re-run without the early-closing reader if you need the rest" + } + 137 => { + " — SIGKILL: the process was killed rather than exiting, most often \ + by the out-of-memory killer or a hard timeout. Retrying the \ + same command unchanged will be killed again; reduce what it \ + holds in memory, process the input in parts, or raise the limit" + } + 143 => { + " — SIGTERM: the process was asked to stop before it finished, \ + usually by a timeout or a shutdown. Any output above is \ + partial. Re-run with a longer timeout or less work per call" + } + 139 => { + " — SIGSEGV: the process crashed. This is a fault in the program or \ + its input, not in how it was invoked, so the same command will \ + crash again — change the input or use a different tool" + } _ => "", } } diff --git a/crates/tinytools/src/command_output/mod_tests.rs b/crates/tinytools/src/command_output/mod_tests.rs index fd0bc85..7234fa5 100644 --- a/crates/tinytools/src/command_output/mod_tests.rs +++ b/crates/tinytools/src/command_output/mod_tests.rs @@ -71,3 +71,68 @@ fn sandbox_negative_exit_code_maps_to_signal() { assert_eq!(sandbox_exit_code(0), Some(0)); assert_eq!(sandbox_exit_code(7), Some(7)); } + +/// A host that wraps commands in `set -o pipefail` turns the commonest idiom +/// in an agent's toolkit -- a large output piped into an early-closing reader +/// -- into a failed command. The code alone does not say the requested output +/// arrived, so the hint has to. +#[test] +fn exit_141_explains_that_sigpipe_is_usually_not_a_failure() { + let rendered = render_command_failure(Some(141), "first-five-lines", ""); + assert!(rendered.contains("exit code 141")); + assert!(rendered.contains("SIGPIPE")); + assert!( + rendered.contains("head"), + "141 should name the idiom that causes it: {rendered}" + ); + assert!( + rendered.contains("treat it as data, not as an error"), + "141 should say the output is usable: {rendered}" + ); + assert!( + rendered.contains("first-five-lines"), + "the accepted output must still be shown: {rendered}" + ); +} + +/// 137 and 143 are the two ways a long agent run loses a process to the host +/// rather than to its own exit, and they need opposite responses from 141: +/// the output is gone or partial, and an unchanged retry repeats the kill. +#[test] +fn killed_and_terminated_codes_hint_at_the_host_not_the_command() { + let killed = render_command_failure(Some(137), "", ""); + assert!(killed.contains("SIGKILL")); + assert!( + killed.contains("out-of-memory") && killed.contains("timeout"), + "137 should name both usual causes: {killed}" + ); + let terminated = render_command_failure(Some(143), "half-the-output", ""); + assert!(terminated.contains("SIGTERM")); + assert!( + terminated.contains("partial"), + "143 should warn the output is incomplete: {terminated}" + ); +} + +#[test] +fn exit_139_hints_a_crash_rather_than_a_bad_invocation() { + let rendered = render_command_failure(Some(139), "", ""); + assert!(rendered.contains("SIGSEGV")); + assert!( + rendered.contains("not in how it was invoked"), + "139 should steer away from re-tuning the flags: {rendered}" + ); +} + +/// The signal hints must not leak into an ordinary application failure, and a +/// code that merely looks adjacent (140, 142, 138) is not a convention. +#[test] +fn adjacent_signal_codes_are_not_editorialised() { + for code in [138, 140, 142, 2] { + let rendered = render_command_failure(Some(code), "", "boom"); + assert!( + !rendered.contains("SIG"), + "exit {code} is not a known convention: {rendered}" + ); + } +}