diff --git a/src/server/execute.test.ts b/src/server/execute.test.ts index 1fd96e4..c27730b 100644 --- a/src/server/execute.test.ts +++ b/src/server/execute.test.ts @@ -245,6 +245,44 @@ describe("buildPartialRunError", () => { const msg = buildPartialRunError(1, "model-x", stdout); expect(msg).toBe("Claude exited with code 1: real error line"); }); + + it("appends pod terminated reason/message when state is provided (FAR-100)", () => { + const msg = buildPartialRunError(1, "claude-sonnet-4-6", initLine, { + exitCode: 1, + reason: "Error", + message: "model not supported", + signal: null, + }); + expect(msg).toContain("Claude exited immediately after init"); + expect(msg).toContain("claude-sonnet-4-6"); + expect(msg).toContain("[pod: reason=Error, message=model not supported]"); + }); + + it("flags exit 137 as OOMKilled in pod cause", () => { + const msg = buildPartialRunError(137, "claude-sonnet-4-6", initLine, { + exitCode: 137, + reason: "OOMKilled", + message: null, + signal: null, + }); + expect(msg).toContain("[pod: reason=OOMKilled, SIGKILL (commonly OOMKilled)]"); + }); + + it("appends pod cause to content-line message", () => { + const stdout = [initLine, "Error: bad request"].join("\n"); + const msg = buildPartialRunError(1, "claude-sonnet-4-6", stdout, { + exitCode: 1, + reason: "Error", + message: null, + signal: null, + }); + expect(msg).toBe("Claude exited with code 1: Error: bad request [pod: reason=Error]"); + }); + + it("does not append anything when podState is null (back-compat)", () => { + const msg = buildPartialRunError(1, "claude-sonnet-4-6", initLine, null); + expect(msg).not.toContain("[pod:"); + }); }); describe("classifyOrphan", () => { diff --git a/src/server/execute.ts b/src/server/execute.ts index 1a77deb..b2f1c72 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -147,15 +147,36 @@ function isInitOnlyRun(model: string, stdout: string): boolean { return hasModelInit; } +/** + * Append the pod's terminated-state detail (reason/message/signal) to a + * partial-run error message when available. Exit code is already in the + * caller-supplied message, so we only append fields that add new signal — + * specifically reason (e.g. OOMKilled, Error, ContainerCannotRun), message + * (kubelet diagnostic text), and signal. Saves the operator a kubectl trip. + */ +function appendPodCause(message: string, state: PodTerminatedState | null): string { + if (!state) return message; + const parts: string[] = []; + if (state.reason) parts.push(`reason=${state.reason}`); + if (state.message) parts.push(`message=${state.message}`); + if (state.signal !== null) parts.push(`signal=${state.signal}`); + if (state.exitCode === 137) parts.push("SIGKILL (commonly OOMKilled)"); + if (parts.length === 0) return message; + return `${message} [pod: ${parts.join(", ")}]`; +} + /** * Build the error message when Claude's stdout contains no result event. * Skips system/init event lines so the UI doesn't display the raw init JSON. + * When `podState` is provided, appends the K8s container terminated reason/ + * message so failures self-explain without requiring `kubectl`. * Exported for unit tests. */ export function buildPartialRunError( exitCode: number | null, model: string, stdout: string, + podState: PodTerminatedState | null = null, ): string { if (exitCode === 0) return "Failed to parse Claude JSON output"; @@ -164,21 +185,27 @@ export function buildPartialRunError( // or unsupported/misconfigured model. const contentLine = firstContentLine(stdout); if (contentLine) { - return `Claude exited with code ${exitCode ?? -1}: ${contentLine}`; + return appendPodCause(`Claude exited with code ${exitCode ?? -1}: ${contentLine}`, podState); } if (isInitOnlyRun(model, stdout) && (exitCode ?? 0) !== 0) { const modelHint = model ? ` (model: ${model})` : ""; - return `Claude exited immediately after init${modelHint} (exit code ${exitCode ?? -1}) — the model may be unsupported or the session may have been rejected before producing output`; + return appendPodCause( + `Claude exited immediately after init${modelHint} (exit code ${exitCode ?? -1}) — the model may be unsupported or the session may have been rejected before producing output`, + podState, + ); } const initOnlyOutput = stdout.trim() !== "" && model !== ""; if (initOnlyOutput) { const modelHint = model ? ` (model: ${model})` : ""; - return `Claude started but did not produce a result${modelHint} — check API credentials, model support, and adapter config`; + return appendPodCause( + `Claude started but did not produce a result${modelHint} — check API credentials, model support, and adapter config`, + podState, + ); } - return `Claude exited with code ${exitCode ?? -1}`; + return appendPodCause(`Claude exited with code ${exitCode ?? -1}`, podState); } export type OrphanClassification = @@ -1463,7 +1490,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise