Compare commits

...

4 Commits

Author SHA1 Message Date
Chris Farhood d9928030d6 0.1.48 2026-04-26 14:48:22 +00:00
Chris Farhood 76fc6fcdfc fix: surface pod terminated reason/message in adapter_failed errors (FAR-100)
The init-only and partial-run error paths now embed the K8s container
terminated state (reason, message, signal, OOM hint) directly in the
errorMessage. This eliminates the kubectl round-trip when diagnosing
adapter_failed runs — the surfaced error self-explains.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 14:48:12 +00:00
Chris Farhood 3169f49f23 0.1.47 2026-04-26 13:04:54 +00:00
Chris Farhood e0b35d230f fix: distinguish init-only non-zero exits in buildPartialRunError (FAR-100)
Init-only runs that exit with a non-zero code now surface a more actionable
message naming the exit code and the likely cause (unsupported model or
rejected session) instead of the generic "did not produce a result" text.
Helps operators diagnose model-id / billing-tier failures (e.g. opus 4.6).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 13:04:43 +00:00
4 changed files with 123 additions and 34 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.46",
"version": "0.1.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.46",
"version": "0.1.48",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.46",
"version": "0.1.48",
"description": "Paperclip adapter plugin that runs Claude Code agents as Kubernetes Jobs",
"license": "MIT",
"repository": {
+44 -6
View File
@@ -150,10 +150,10 @@ describe("buildPartialRunError", () => {
expect(buildPartialRunError(null, "", "")).toBe("Claude exited with code -1");
});
it("skips system/init events and returns generic message when only init captured", () => {
it("returns init-only message when stdout is init-only with non-zero exit code (FAR-101)", () => {
const msg = buildPartialRunError(1, "claude-sonnet-4-6", initLine);
expect(msg).toBe(
"Claude started but did not produce a result (model: claude-sonnet-4-6) — check API credentials, model support, and adapter config",
"Claude exited immediately after init (model: claude-sonnet-4-6) (exit code 1) — the model may be unsupported or the session may have been rejected before producing output",
);
});
@@ -170,15 +170,15 @@ describe("buildPartialRunError", () => {
expect(msg).toBe("Claude exited with code 1: Error: no API key configured");
});
it("skips result events (structured protocol artefact not surfaced verbatim)", () => {
it("returns init-only message when stdout has init + result event but no plain content (structured artefact, not surfaced verbatim)", () => {
// In production, buildPartialRunError is only called when parseClaudeStreamJson
// returns null (no result event). If somehow a result event appears here, the
// raw JSON blob must not be shown — the "did not produce a result" message is
// cleaner and avoids leaking protocol internals to the UI.
// raw JSON blob must not be shown — the init-only message is cleaner and avoids
// leaking protocol internals to the UI.
const resultLike = JSON.stringify({ type: "result", subtype: "error", result: "rate limit" });
const stdout = [initLine, resultLike].join("\n");
const msg = buildPartialRunError(2, "claude-sonnet-4-6", stdout);
expect(msg).toContain("did not produce a result");
expect(msg).toContain("Claude exited immediately after init");
expect(msg).toContain("claude-sonnet-4-6");
expect(msg).not.toMatch(/\{.*type.*result/);
});
@@ -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", () => {
+76 -25
View File
@@ -110,24 +110,12 @@ export function shouldAbortForCancellation(runStatus: string | undefined): boole
}
/**
* 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.
* Exported for unit tests.
* Returns the first non-JSON/plain-text line in stdout, treating JSON objects
* with a "type" field as protocol artefacts and skipping them.
* Used by buildPartialRunError to detect init-only runs.
*/
export function buildPartialRunError(
exitCode: number | null,
model: string,
stdout: string,
): string {
if (exitCode === 0) return "Failed to parse Claude JSON output";
// Walk stdout lines and skip every structured streaming event (any JSON
// object that carries a non-empty "type" field: system, assistant, user,
// rate_limit_event, result, …). All of these are protocol artefacts and
// produce confusing raw-JSON blobs when surfaced verbatim as an error
// message. Only plain-text lines (non-JSON, or JSON without a type field)
// are treated as human-readable content worth including in the error.
const firstContentLine = stdout.split(/\r?\n/)
function firstContentLine(stdout: string): string {
return stdout.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => {
if (!l) return false;
@@ -142,19 +130,82 @@ export function buildPartialRunError(
}
return true;
}) ?? "";
}
/**
* Returns true when stdout contains only init/system/assistant events from the
* given model with no human-readable content lines. Used to detect init-only
* non-zero-exit runs that should be classified as claude_init_failed rather than
* the generic "Claude exited with code N" message.
*/
function isInitOnlyRun(model: string, stdout: string): boolean {
if (!stdout.trim() || !model) return false;
const content = firstContentLine(stdout);
if (content) return false;
// Check that at least the init event for this model was seen
const hasModelInit = stdout.includes(`"model":"${model}"`) || stdout.includes(`"model":"${model.replace(/-/g, "_")}"`);
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";
// If the stream contained only structured events with no plain-text output,
// surface the model name so the operator can diagnose missing credentials
// or unsupported/misconfigured model.
const initOnlyOutput = stdout.trim() !== "" && model !== "" && !firstContentLine;
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`;
const contentLine = firstContentLine(stdout);
if (contentLine) {
return appendPodCause(`Claude exited with code ${exitCode ?? -1}: ${contentLine}`, podState);
}
return firstContentLine
? `Claude exited with code ${exitCode ?? -1}: ${firstContentLine}`
: `Claude exited with code ${exitCode ?? -1}`;
if (isInitOnlyRun(model, stdout) && (exitCode ?? 0) !== 0) {
const modelHint = model ? ` (model: ${model})` : "";
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 appendPodCause(
`Claude started but did not produce a result${modelHint} — check API credentials, model support, and adapter config`,
podState,
);
}
return appendPodCause(`Claude exited with code ${exitCode ?? -1}`, podState);
}
export type OrphanClassification =
@@ -1439,7 +1490,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
exitCode,
signal: null,
timedOut: false,
errorMessage: buildPartialRunError(exitCode, parsedStream.model, stdout),
errorMessage: buildPartialRunError(exitCode, parsedStream.model, stdout, podTerminatedState),
resultJson: { stdout },
};
}