From 022ae6ff8297e4343d67d55bbb342b89ea78821b Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Fri, 4 Sep 2026 10:14:19 -0400 Subject: [PATCH 1/4] fix(adapters): a spawn that throws fails the run instead of crashing axis spawn() throws synchronously for unusable arguments (e.g. a null byte in an arg). Wrap it in try/catch and return a failed AgentOutput through the same metadata shape the normal exit path builds, instead of letting the exception escape adapter.run() and kill the whole run. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01S6cesfFBiaNe5ttYyXpLce --- src/adapters/base/agent-adapter.ts | 33 ++++++++++++++++--- test/unit/adapters/base/agent-adapter.test.ts | 14 ++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/adapters/base/agent-adapter.ts b/src/adapters/base/agent-adapter.ts index 2b91e01..30ac995 100644 --- a/src/adapters/base/agent-adapter.ts +++ b/src/adapters/base/agent-adapter.ts @@ -20,6 +20,22 @@ export const MAX_STDERR_BYTES = 100_000; /** Grace period between SIGTERM and SIGKILL for non-responsive processes. */ export const SIGTERM_TO_SIGKILL_MS = 5_000; +/** Failed `AgentOutput` for a process that never started (`spawn()` threw synchronously). */ +function failedToStart(startTime: Date, message: string): AgentOutput { + const endTime = new Date(); + return { + result: null, + transcript: [], + metadata: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + durationMs: endTime.getTime() - startTime.getTime(), + exitCode: 1, + error: message, + }, + }; +} + // --------------------------------------------------------------------------- // Context types passed to adapter callbacks // --------------------------------------------------------------------------- @@ -244,11 +260,18 @@ export function createAgentAdapter(spec: AgentAdapterSpec): AgentA }; // 5. Spawn - const child: ChildProcess = spawn(command, [...prefixArgs, ...args], { - cwd: input.workingDirectory, - stdio: ["pipe", "pipe", "pipe"], - env: input.env ?? { ...process.env }, - }); + let child: ChildProcess; + try { + child = spawn(command, [...prefixArgs, ...args], { + cwd: input.workingDirectory, + stdio: ["pipe", "pipe", "pipe"], + env: input.env ?? { ...process.env }, + }); + } catch (err) { + // spawn() throws synchronously for unusable arguments (e.g. a null + // byte in an arg) — fail the run instead of crashing the process. + return failedToStart(startTime, (err as Error).message); + } child.stdin?.end(); diff --git a/test/unit/adapters/base/agent-adapter.test.ts b/test/unit/adapters/base/agent-adapter.test.ts index 2a557d0..8c75fe1 100644 --- a/test/unit/adapters/base/agent-adapter.test.ts +++ b/test/unit/adapters/base/agent-adapter.test.ts @@ -424,6 +424,20 @@ describe("createAgentAdapter", () => { expect(out.metadata.error?.length).toBeLessThan(200_000); }); + it("a synchronous spawn throw fails the run instead of crashing it", async () => { + mockSpawn.mockImplementation(() => { + throw new TypeError("The argument 'args[1]' must be a string without null bytes"); + }); + const adapter = createLinesTestAdapter(); + + const out = await adapter.run(makeInput()); + + expect(out.result).toBeNull(); + expect(out.transcript).toEqual([]); + expect(out.metadata.exitCode).not.toBe(0); + expect(out.metadata.error).toContain("null bytes"); + }); + it("custom resolveCommand overrides default resolution", async () => { let usedCmd = ""; mockSpawn.mockImplementation(((cmd: string) => { From b0e7b48664ec21446f42c87f497d11f336b0450d Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Fri, 4 Sep 2026 10:16:38 -0400 Subject: [PATCH 2/4] fix(adapters): a child that fails to start fails the run instead of crashing axis Node emits `error` on the child (not a throw) when the process cannot be started asynchronously, e.g. `spawn test-bin ENOENT`. Listen for it alongside `close` in the exit promise and surface the message through the existing error-precedence chain, ahead of stderr and the generic default. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01S6cesfFBiaNe5ttYyXpLce --- src/adapters/base/agent-adapter.ts | 9 ++++++- test/unit/adapters/base/agent-adapter.test.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/adapters/base/agent-adapter.ts b/src/adapters/base/agent-adapter.ts index 30ac995..e48d77a 100644 --- a/src/adapters/base/agent-adapter.ts +++ b/src/adapters/base/agent-adapter.ts @@ -281,8 +281,15 @@ export function createAgentAdapter(spec: AgentAdapterSpec): AgentA }); // 7. Register close listener BEFORE reading stdout (ordering matters) + // `error` fires when the child can't be started at all (e.g. ENOENT); + // a promise resolves once, so a later `close` is harmless. + let spawnError: Error | undefined; const exitPromise = new Promise((resolve) => { child.on("close", (code) => resolve(code ?? 1)); + child.on("error", (err) => { + spawnError = err; + resolve(1); + }); }); // 8. Buffer stderr with a size cap (and mirror to debug callback if any) @@ -429,7 +436,7 @@ export function createAgentAdapter(spec: AgentAdapterSpec): AgentA }); // 14. Error precedence - let error = extracted.metadata?.error; + let error = extracted.metadata?.error ?? spawnError?.message; if (!error && exitCode !== 0 && extracted.result === null) { error = stderr || "Agent process exited with non-zero code"; } diff --git a/test/unit/adapters/base/agent-adapter.test.ts b/test/unit/adapters/base/agent-adapter.test.ts index 8c75fe1..62dc534 100644 --- a/test/unit/adapters/base/agent-adapter.test.ts +++ b/test/unit/adapters/base/agent-adapter.test.ts @@ -438,6 +438,31 @@ describe("createAgentAdapter", () => { expect(out.metadata.error).toContain("null bytes"); }); + it("a child error event (e.g. ENOENT) fails the run instead of hanging", async () => { + mockSpawn.mockImplementation((() => { + const stdout = new Readable({ read() {} }); + const stderr = new Readable({ read() {} }); + const proc = Object.assign(new EventEmitter(), { + stdout, + stderr, + stdin: { end: vi.fn() }, + kill: vi.fn(), + }); + setTimeout(() => { + stdout.push(null); + stderr.push(null); + proc.emit("error", new Error("spawn test-bin ENOENT")); + }, 5); + return proc; + }) as any); + + const adapter = createLinesTestAdapter(); + const out = await adapter.run(makeInput()); + + expect(out.metadata.error).toContain("ENOENT"); + expect(out.metadata.exitCode).not.toBe(0); + }); + it("custom resolveCommand overrides default resolution", async () => { let usedCmd = ""; mockSpawn.mockImplementation(((cmd: string) => { From ce36b2999ec38ea7c81541f3b74acdd6f80292eb Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Fri, 4 Sep 2026 10:18:02 -0400 Subject: [PATCH 3/4] docs: note that a process that fails to start is a failed run, not a crash Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01S6cesfFBiaNe5ttYyXpLce --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index acf9c12..6df426a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Built-in adapters split into two factories. NDJSON-style adapters (`claude-code` - Raw output capture (NDJSON lines for `lines` mode, raw chunks for `aggregate`) - Token estimator wiring via `StreamContext.feedAssistantText` - CLI resolution (direct command → `npx --yes ` fallback) -- Error precedence: `extracted.metadata.error` → `stderr` → `"Agent process exited with non-zero code"` +- Error precedence: `extracted.metadata.error` → spawn error → `stderr` → `"Agent process exited with non-zero code"` The NDJSON-style adapters (`claude-code`, `codex`) use `lines` mode for NDJSON parsing. Custom adapters can use either `lines` or `aggregate` mode (raw stdout capture). ACP-based adapters bypass `streamConfig` entirely - the ACP SDK handles framing. @@ -72,6 +72,7 @@ For built-in adapters, register the factory in `src/adapters/registry.ts`. Exter ### Error Handling - `AgentMetadata.error` is the canonical error field for failed runs +- A process that fails to start — `spawn()` throwing synchronously, or the child emitting `error` (e.g. `ENOENT`) — is a failed run with `metadata.error` set, never a thrown error; the runner and scoring treat it like any other failed run - Runner checks both `exitCode !== 0` and `metadata.error` for failure status - Friendly error classification in `src/ui/format.ts` via `friendlyError()` -maps common patterns (quota, rate limit, auth, timeout, network) to one-line messages - Error display: `↳ friendly message` below failed rows in tables, `Error:` line in detail views From 41299daa5e0c5894198ad4be6596031a4ffde308 Mon Sep 17 00:00:00 2001 From: Sean C Davis Date: Fri, 4 Sep 2026 10:23:24 -0400 Subject: [PATCH 4/4] refactor: drop the stale precedence comment and reuse the mock-process fixture Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01S6cesfFBiaNe5ttYyXpLce --- src/adapters/base/agent-adapter.ts | 5 ---- test/unit/adapters/base/agent-adapter.test.ts | 28 +++++++------------ 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/src/adapters/base/agent-adapter.ts b/src/adapters/base/agent-adapter.ts index e48d77a..7dab818 100644 --- a/src/adapters/base/agent-adapter.ts +++ b/src/adapters/base/agent-adapter.ts @@ -192,11 +192,6 @@ export type AgentAdapterSpec = { * SIGTERM → SIGKILL (with proper timer cleanup), exit promise ordering, raw * output capture, token estimator wiring, and the three outcome branches * (timed-out / non-zero exit with no result / success). - * - * Error precedence on failure: - * 1. `getResult(...).metadata.error` — wins if set - * 2. `stderr` — if non-empty - * 3. Generic `"Agent process exited with non-zero code"` */ export function createAgentAdapter(spec: AgentAdapterSpec): AgentAdapter { const timeoutMs = spec.timeoutMs ?? DEFAULT_TIMEOUT_MS; diff --git a/test/unit/adapters/base/agent-adapter.test.ts b/test/unit/adapters/base/agent-adapter.test.ts index 62dc534..f72c86a 100644 --- a/test/unit/adapters/base/agent-adapter.test.ts +++ b/test/unit/adapters/base/agent-adapter.test.ts @@ -20,8 +20,10 @@ function createMockProcess(opts: { exitCode?: number; delayMs?: number; hang?: boolean; + /** When set, emit an "error" event instead of "close" after the streams end (e.g. ENOENT). */ + error?: Error; }) { - const { stdout: stdoutLines = [], stderr: stderrLines = [], exitCode = 0, delayMs = 0, hang = false } = opts; + const { stdout: stdoutLines = [], stderr: stderrLines = [], exitCode = 0, delayMs = 0, hang = false, error } = opts; const stdout = new Readable({ read() {} }); const stderr = new Readable({ read() {} }); const stdin = { end: vi.fn() }; @@ -32,7 +34,11 @@ function createMockProcess(opts: { for (const line of stderrLines) stderr.push(line); stdout.push(null); stderr.push(null); - if (!hang) proc.emit("close", exitCode); + if (error) { + proc.emit("error", error); + } else if (!hang) { + proc.emit("close", exitCode); + } }, delayMs); return proc; @@ -439,22 +445,8 @@ describe("createAgentAdapter", () => { }); it("a child error event (e.g. ENOENT) fails the run instead of hanging", async () => { - mockSpawn.mockImplementation((() => { - const stdout = new Readable({ read() {} }); - const stderr = new Readable({ read() {} }); - const proc = Object.assign(new EventEmitter(), { - stdout, - stderr, - stdin: { end: vi.fn() }, - kill: vi.fn(), - }); - setTimeout(() => { - stdout.push(null); - stderr.push(null); - proc.emit("error", new Error("spawn test-bin ENOENT")); - }, 5); - return proc; - }) as any); + mockSpawn.mockImplementation((() => + createMockProcess({ stdout: [], error: new Error("spawn test-bin ENOENT") })) as any); const adapter = createLinesTestAdapter(); const out = await adapter.run(makeInput());