fix: partial log stream + better error messages for fast-exit containers (FAR-122 follow-up) #6
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isK8s404, buildPartialRunError } from "./execute.js";
|
||||
|
||||
describe("isK8s404", () => {
|
||||
it("returns false for non-Error values", () => {
|
||||
expect(isK8s404(null)).toBe(false);
|
||||
expect(isK8s404(undefined)).toBe(false);
|
||||
expect(isK8s404("string error")).toBe(false);
|
||||
expect(isK8s404(404)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unrelated errors", () => {
|
||||
expect(isK8s404(new Error("something went wrong"))).toBe(false);
|
||||
expect(isK8s404(new Error("HTTP-Code: 500 Message: Internal Server Error"))).toBe(false);
|
||||
});
|
||||
|
||||
it("detects 404 from v1.0+ message format", () => {
|
||||
const err = new Error("HTTP-Code: 404 Message: Unknown API Status Code! Body: ...");
|
||||
expect(isK8s404(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 404 from v0.x response.statusCode", () => {
|
||||
const err = Object.assign(new Error("Not Found"), {
|
||||
response: { statusCode: 404 },
|
||||
});
|
||||
expect(isK8s404(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 404 from v1.0+ response.status", () => {
|
||||
const err = Object.assign(new Error("Not Found"), {
|
||||
response: { status: 404 },
|
||||
});
|
||||
expect(isK8s404(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 404 from direct statusCode property", () => {
|
||||
const err = Object.assign(new Error("Not Found"), { statusCode: 404 });
|
||||
expect(isK8s404(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match non-404 status codes on response", () => {
|
||||
const err = Object.assign(new Error("Forbidden"), {
|
||||
response: { statusCode: 403 },
|
||||
});
|
||||
expect(isK8s404(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPartialRunError", () => {
|
||||
const initLine = JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
model: "claude-sonnet-4-6",
|
||||
session_id: "sess_abc",
|
||||
});
|
||||
|
||||
it("returns parse-failure message when exitCode is 0", () => {
|
||||
expect(buildPartialRunError(0, "", "")).toBe("Failed to parse Claude JSON output");
|
||||
expect(buildPartialRunError(0, "claude-sonnet-4-6", initLine)).toBe(
|
||||
"Failed to parse Claude JSON output",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns generic exit message when stdout is empty", () => {
|
||||
expect(buildPartialRunError(1, "", "")).toBe("Claude exited with code 1");
|
||||
expect(buildPartialRunError(null, "", "")).toBe("Claude exited with code -1");
|
||||
});
|
||||
|
||||
it("skips system/init events and returns generic message when only init captured", () => {
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes model from parsedStream when stdout is init-only", () => {
|
||||
const msg = buildPartialRunError(null, "MiniMax-M2.7", initLine);
|
||||
expect(msg).toContain("MiniMax-M2.7");
|
||||
expect(msg).not.toContain("type");
|
||||
expect(msg).not.toContain("system");
|
||||
});
|
||||
|
||||
it("uses first non-system line as content when present", () => {
|
||||
const stdout = [initLine, "Error: no API key configured"].join("\n");
|
||||
const msg = buildPartialRunError(1, "claude-sonnet-4-6", stdout);
|
||||
expect(msg).toBe("Claude exited with code 1: Error: no API key configured");
|
||||
});
|
||||
|
||||
it("uses first non-system JSON event as content", () => {
|
||||
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("rate limit");
|
||||
expect(msg).toContain("code 2");
|
||||
});
|
||||
|
||||
it("null exitCode renders as -1 in message", () => {
|
||||
const msg = buildPartialRunError(null, "", "Some plain error text");
|
||||
expect(msg).toBe("Claude exited with code -1: Some plain error text");
|
||||
});
|
||||
|
||||
it("skips multiple consecutive system events", () => {
|
||||
const anotherSystem = JSON.stringify({ type: "system", subtype: "other" });
|
||||
const stdout = [initLine, anotherSystem, "real error line"].join("\n");
|
||||
const msg = buildPartialRunError(1, "model-x", stdout);
|
||||
expect(msg).toBe("Claude exited with code 1: real error line");
|
||||
});
|
||||
});
|
||||
+62
-13
@@ -19,8 +19,9 @@ const MAX_LOG_RECONNECT_ATTEMPTS = 50;
|
||||
/**
|
||||
* Detect a Kubernetes 404 (Not Found) error from @kubernetes/client-node.
|
||||
* Works for both v0.x (response.statusCode) and v1.0+ (response.status, message).
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
function isK8s404(err: unknown): boolean {
|
||||
export function isK8s404(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const e = err as unknown as Record<string, unknown>;
|
||||
const resp = e.response as Record<string, unknown> | undefined;
|
||||
@@ -29,6 +30,45 @@ function isK8s404(err: unknown): boolean {
|
||||
return /HTTP-Code:\s*404\b/.test(err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function buildPartialRunError(
|
||||
exitCode: number | null,
|
||||
model: string,
|
||||
stdout: string,
|
||||
): string {
|
||||
if (exitCode === 0) return "Failed to parse Claude JSON output";
|
||||
|
||||
// Walk stdout lines, skip system events, return the first real content line.
|
||||
const firstContentLine = stdout.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.find((l) => {
|
||||
if (!l) return false;
|
||||
try {
|
||||
const obj = JSON.parse(l);
|
||||
if (typeof obj === "object" && obj !== null && (obj as Record<string, unknown>).type === "system") return false;
|
||||
} catch {
|
||||
// not JSON — treat as content
|
||||
}
|
||||
return true;
|
||||
}) ?? "";
|
||||
|
||||
// If we only have system/init events and nothing else, surface the model
|
||||
// name so the operator can diagnose missing credentials or unsupported 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`;
|
||||
}
|
||||
|
||||
return firstContentLine
|
||||
? `Claude exited with code ${exitCode ?? -1}: ${firstContentLine}`
|
||||
: `Claude exited with code ${exitCode ?? -1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the Job's pod to reach a terminal or running state.
|
||||
* Returns the pod name once logs can be streamed, or throws on failure.
|
||||
@@ -640,13 +680,27 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
stdout = logResult.value;
|
||||
}
|
||||
|
||||
// If the follow stream missed output (container exited quickly), do a
|
||||
// one-shot log read as fallback before the pod is cleaned up.
|
||||
if (!stdout.trim()) {
|
||||
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
|
||||
stdout = await readPodLogs(namespace, podName, kubeconfigPath);
|
||||
if (stdout.trim()) {
|
||||
// One-shot log fallback: handles two failure modes with a single read.
|
||||
// Mode 1 — empty stream: the follow stream returned nothing (fast exit before connection).
|
||||
// Mode 2 — partial stream: we have some output but no result event (follow stream raced
|
||||
// with container exit and captured only the init line before the connection dropped).
|
||||
// A one-shot readPodLogs is more reliable for already-terminated containers and reads
|
||||
// from the beginning of the log, giving us the full output.
|
||||
// We use a cheap string scan for the result-event guard (avoids a full JSON parse here;
|
||||
// the authoritative parse happens once below after all fallbacks complete).
|
||||
const hasResultEvent = stdout.includes('"type":"result"');
|
||||
const needsOneShot = !stdout.trim() || (stdout.trim() && !hasResultEvent);
|
||||
if (needsOneShot) {
|
||||
if (!stdout.trim()) {
|
||||
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
|
||||
}
|
||||
const oneShotLogs = await readPodLogs(namespace, podName, kubeconfigPath);
|
||||
if (!stdout.trim() && oneShotLogs.trim()) {
|
||||
stdout = oneShotLogs;
|
||||
await onLog("stdout", stdout);
|
||||
} else if (oneShotLogs && oneShotLogs.length > stdout.length) {
|
||||
await onLog("stdout", `[paperclip] Log stream captured partial output — supplemental one-shot read returned more content.\n`);
|
||||
stdout = oneShotLogs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,16 +793,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
const stderrLine = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
|
||||
return {
|
||||
exitCode,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: exitCode === 0
|
||||
? "Failed to parse Claude JSON output"
|
||||
: stderrLine
|
||||
? `Claude exited with code ${exitCode ?? -1}: ${stderrLine}`
|
||||
: `Claude exited with code ${exitCode ?? -1}`,
|
||||
errorMessage: buildPartialRunError(exitCode, parsedStream.model, stdout),
|
||||
resultJson: { stdout },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user