From 602afa9b8456cae87c58b7ca4db25f05c3581e5d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 24 Apr 2026 19:58:46 +0000 Subject: [PATCH] fix: return k8s_job_deleted_externally error code when job deleted mid-run (FAR-31) When a K8s Job is deleted externally (kubectl delete job or TTL before terminal condition observed) and stdout has no result event, the adapter now returns errorCode "k8s_job_deleted_externally" with the message "K8s Job was deleted externally before Claude could complete" instead of the misleading "Claude exited with code -1". Tracks a jobDeletedExternally flag in execute() on the jobGone path and checks it in the !parsed branch before falling through to buildPartialRunError. Only applies when exitCode is null (pod gone alongside the job). Adds regression test: FAR-31 scenario where job 404s mid-run with partial stdout and missing pod produces the new error code. Co-Authored-By: Paperclip --- src/server/execute.test.ts | 44 ++++++++++++++++++++++++++++++++++++++ src/server/execute.ts | 25 ++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/server/execute.test.ts b/src/server/execute.test.ts index 84a344c..247aa9c 100644 --- a/src/server/execute.test.ts +++ b/src/server/execute.test.ts @@ -914,6 +914,50 @@ describe("execute: happy path", () => { expect(result.sessionId).toBe("sess_test123"); }); + it("returns k8s_job_deleted_externally when job 404s mid-run and stdout has no result event (FAR-31)", async () => { + // Reproduces the observed scenario: kubectl delete job while Claude is mid-run. + // The log stream captures only partial output (no result event), and the pod + // is also gone so getPodExitCode returns null. The adapter must emit a + // descriptive error instead of the misleading "Claude exited with code -1". + + // Log stream writes only the init line — no result event (mid-run deletion) + const partialOutput = JSON.stringify({ + type: "system", + subtype: "init", + model: "claude-sonnet-4-6", + session_id: "sess_x", + }) + "\n"; + mockLogFn.mockImplementation( + async (_ns: string, _pod: string, _ctr: string, writable: Writable) => { + writable.write(partialOutput); + }, + ); + + // Job is gone (404) — matches the kubectl-delete-job-mid-run scenario + mockBatchReadJob.mockRejectedValue( + Object.assign(new Error("Not Found"), { response: { statusCode: 404 } }), + ); + + // Pod is also gone — getPodExitCode returns null (no pod found) + mockCoreListPods.mockReset(); + mockCoreListPods + .mockResolvedValueOnce({ + items: [{ + metadata: { name: "pod-abc" }, + status: { phase: "Running", containerStatuses: [], initContainerStatuses: [] }, + }], + }) + .mockResolvedValue({ items: [] }); // pod gone → exitCode null + + const executePromise = execute(makeCtx()); + await vi.advanceTimersByTimeAsync(3_100); + const result = await executePromise; + + expect(result.errorCode).toBe("k8s_job_deleted_externally"); + expect(result.errorMessage).toBe("K8s Job was deleted externally before Claude could complete"); + expect(result.exitCode).toBeNull(); + }); + it("reconnects log stream and logs status when job completion takes > 3s", async () => { // Make waitForJobCompletion take 4s so the 3s stream reconnect fires first. // timeoutSec=4, graceSec=0 → completionTimeoutMs=4000. diff --git a/src/server/execute.ts b/src/server/execute.ts index a7445c7..072ae6f 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -975,6 +975,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise