import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; import { execute, ensureAgentDbPvc, tailPodLogFile } from "./execute.js"; import { getSelfPodInfo, getBatchApi, getCoreApi, getPvc, createPvc } from "./k8s-client.js"; import { buildJobManifest, buildPodLogPath } from "./job-manifest.js"; // Mock node:fs/promises so tailPodLogFile (used by execute()) reads a // configurable JSONL payload and returns. Individual tests override the // payload via setMockJsonl(...) before calling execute(). const { readMock, statMock, fhStatMock, resetFsMocks, setMockJsonl } = vi.hoisted(() => { const HAPPY = [ JSON.stringify({ type: "text", part: { text: "Task complete" }, sessionID: "ses_happy" }), JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }), ].join("\n"); let payload = HAPPY; let buffer = Buffer.from(payload); let readOffset = 0; const apply = (next: string) => { payload = next; buffer = Buffer.from(payload); readOffset = 0; }; return { readMock: vi.fn().mockImplementation(async (buf: Buffer, off: number, len: number, _pos: number) => { if (readOffset >= buffer.byteLength) return { bytesRead: 0, buffer: buf }; const remaining = buffer.byteLength - readOffset; const toRead = Math.min(len, remaining); buffer.copy(buf, off, readOffset, readOffset + toRead); readOffset += toRead; return { bytesRead: toRead, buffer: buf }; }), statMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })), fhStatMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })), resetFsMocks: () => { apply(HAPPY); }, setMockJsonl: (jsonl: string) => { apply(jsonl); }, }; }); vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, stat: statMock, open: vi.fn().mockResolvedValue({ stat: fhStatMock, read: readMock, close: vi.fn().mockResolvedValue(undefined), }), unlink: vi.fn().mockResolvedValue(undefined), }; }); vi.mock("./k8s-client.js", () => ({ getSelfPodInfo: vi.fn(), getBatchApi: vi.fn(), getCoreApi: vi.fn(), getPvc: vi.fn().mockResolvedValue({ metadata: { name: "opencode-db-agent-id-test" } }), createPvc: vi.fn().mockResolvedValue({}), })); vi.mock("./job-manifest.js", () => ({ buildJobManifest: vi.fn(), buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) => `/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson` ), LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024, })); // Prevent skill loading from reading real SKILL.md files during tests — the // real filesystem read delays timer registration and breaks fake-timer tests. vi.mock("@paperclipai/adapter-utils/server-utils", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readPaperclipRuntimeSkillEntries: vi.fn().mockResolvedValue([]) }; }); const MOCK_SELF_POD = { namespace: "test-ns", image: "test-image:latest", imagePullSecrets: [], dnsConfig: undefined, pvcClaimName: null, secretVolumes: [], inheritedEnv: {}, inheritedEnvValueFrom: [], inheritedEnvFrom: [], }; const MOCK_JOB = { spec: { template: { spec: { containers: [{ image: "test-image:latest" }], }, }, }, }; const JOB_NAME = "agent-opencode-testjob"; const NAMESPACE = "test-ns"; const POD_NAME = "agent-opencode-testjob-abcde"; const HAPPY_JSONL = [ JSON.stringify({ type: "text", part: { text: "Task complete" }, sessionID: "ses_happy" }), JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }), ].join("\n"); function makeCtx(configOverrides: Record = {}, contextOverrides: Record = {}, authToken = "test-auth-token"): AdapterExecutionContext { return { runId: "run-test-123", agent: { id: "agent-id-test", name: "Test Agent", companyId: "co-1", adapterType: null, adapterConfig: null }, runtime: { sessionId: null, sessionParams: {}, sessionDisplayId: null, taskKey: null }, config: configOverrides, authToken, context: { taskId: null, issueId: null, paperclipWorkspace: null, issueIds: null, paperclipWorkspaces: null, paperclipRuntimeServiceIntents: null, paperclipRuntimeServices: null, ...contextOverrides, }, onLog: vi.fn().mockResolvedValue(undefined), } as unknown as AdapterExecutionContext; } function makeBatchApi(runningJobItems: unknown[] = []) { return { listNamespacedJob: vi.fn().mockResolvedValue({ items: runningJobItems }), createNamespacedJob: vi.fn().mockResolvedValue({ metadata: { uid: "test-job-uid" } }), readNamespacedJob: vi.fn().mockResolvedValue({ status: { conditions: [{ type: "Complete", status: "True" }] }, }), deleteNamespacedJob: vi.fn().mockResolvedValue({}), }; } function makeCoreApi( exitCode: number | null = 0, terminatedReason: string | null = null, ) { const exitCodePod = exitCode === null ? { items: [] } : { items: [ { status: { containerStatuses: [ { name: "opencode", state: { terminated: { exitCode, ...(terminatedReason ? { reason: terminatedReason } : {}), }, }, }, ], }, }, ], }; return { listNamespacedPod: vi.fn() .mockResolvedValueOnce({ items: [{ metadata: { name: POD_NAME }, status: { phase: "Running" } }], }) .mockResolvedValueOnce(exitCodePod), createNamespacedSecret: vi.fn().mockResolvedValue({}), deleteNamespacedSecret: vi.fn().mockResolvedValue({}), patchNamespacedSecret: vi.fn().mockResolvedValue({}), }; } beforeEach(() => { vi.clearAllMocks(); resetFsMocks(); vi.mocked(getSelfPodInfo).mockResolvedValue(MOCK_SELF_POD as ReturnType extends Promise ? T : never); vi.mocked(buildJobManifest).mockReturnValue({ job: MOCK_JOB as ReturnType["job"], jobName: JOB_NAME, namespace: NAMESPACE, prompt: "Test prompt", opencodeArgs: [], promptMetrics: null, podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`, } as unknown as ReturnType); const batchApi = makeBatchApi(); const coreApi = makeCoreApi(); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); }); describe("execute — concurrency guard", () => { it("blocks when a running job already exists for the agent", async () => { const batchApi = makeBatchApi([ { metadata: { name: "old-job" }, status: { conditions: [] }, // no Complete/Failed → still running }, ]); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBe("k8s_concurrent_run_blocked"); expect(result.exitCode).toBeNull(); expect(batchApi.createNamespacedJob).not.toHaveBeenCalled(); }); it("proceeds when existing job has Complete condition", async () => { const batchApi = makeBatchApi([ { metadata: { name: "finished-job" }, status: { conditions: [{ type: "Complete", status: "True" }] }, }, ]); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); expect(batchApi.createNamespacedJob).toHaveBeenCalled(); }); it("proceeds when existing job has Failed condition", async () => { const batchApi = makeBatchApi([ { metadata: { name: "failed-job" }, status: { conditions: [{ type: "Failed", status: "True" }] }, }, ]); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); expect(batchApi.createNamespacedJob).toHaveBeenCalled(); }); it("proceeds when no running jobs exist", async () => { const ctx = makeCtx(); await execute(ctx); expect(vi.mocked(getBatchApi)().createNamespacedJob).toHaveBeenCalled(); }); it("returns k8s_concurrency_guard_unreachable when concurrency check throws (fail-closed)", async () => { const batchApi = makeBatchApi(); batchApi.listNamespacedJob.mockRejectedValue(new Error("RBAC denied")); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBe("k8s_concurrency_guard_unreachable"); expect(result.exitCode).toBeNull(); expect(batchApi.createNamespacedJob).not.toHaveBeenCalled(); }); it("blocks (k8s_concurrent_run_blocked) when a different-task job is running even with reattachOrphanedJobs=true", async () => { const batchApi = makeBatchApi([ { metadata: { name: "other-task-job", labels: { "paperclip.io/task-id": "other-task-id" } }, status: { conditions: [] }, }, ]); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx({ reattachOrphanedJobs: true }); // ctx.context.taskId is null in makeCtx, so the running job is always an "other" job const result = await execute(ctx); expect(result.errorCode).toBe("k8s_concurrent_run_blocked"); expect(batchApi.createNamespacedJob).not.toHaveBeenCalled(); }); it("reattaches and streams logs when same-task orphaned Job exists and reattachOrphanedJobs=true", async () => { const TASK_ID = "task-uuid-123"; const ORPHAN_JOB = "orphaned-job-abc"; const batchApi = makeBatchApi([ { metadata: { name: ORPHAN_JOB, labels: { "paperclip.io/task-id": TASK_ID }, }, status: { conditions: [] }, }, ]); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = { ...makeCtx({ reattachOrphanedJobs: true }), context: { taskId: TASK_ID, issueId: null, paperclipWorkspace: null, issueIds: null, paperclipWorkspaces: null, paperclipRuntimeServiceIntents: null, paperclipRuntimeServices: null }, } as unknown as AdapterExecutionContext; const result = await execute(ctx); // Should NOT create a new Job — reattached to the orphan expect(batchApi.createNamespacedJob).not.toHaveBeenCalled(); // Should have succeeded by reattaching expect(result.exitCode).toBe(0); expect(result.sessionId).toBe("ses_happy"); }); it("blocks (k8s_concurrent_run_blocked) when same-task orphaned Job exists but reattachOrphanedJobs=false", async () => { const TASK_ID = "task-uuid-456"; const batchApi = makeBatchApi([ { metadata: { name: "orphaned-job-xyz", labels: { "paperclip.io/task-id": TASK_ID }, }, status: { conditions: [] }, }, ]); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = { ...makeCtx({ reattachOrphanedJobs: false }), context: { taskId: TASK_ID, issueId: null, paperclipWorkspace: null, issueIds: null, paperclipWorkspaces: null, paperclipRuntimeServiceIntents: null, paperclipRuntimeServices: null }, } as unknown as AdapterExecutionContext; const result = await execute(ctx); expect(result.errorCode).toBe("k8s_concurrent_run_blocked"); expect(batchApi.createNamespacedJob).not.toHaveBeenCalled(); }); }); describe("execute — mutex serialization", () => { it("second call waits for first job to finish then creates its own job (no permanent block)", async () => { // First call's createNamespacedJob blocks until we release it. let releaseFn!: () => void; const releasePromise = new Promise((resolve) => { releaseFn = resolve; }); const batchApi = makeBatchApi(); batchApi.createNamespacedJob.mockImplementationOnce(async () => { await releasePromise; return { metadata: { uid: "uid-1" } }; }); // First guard call: no running jobs; second guard call: sees the running job. // Third call (re-check after wait): default [] from makeBatchApi. batchApi.listNamespacedJob .mockResolvedValueOnce({ items: [] }) .mockResolvedValueOnce({ items: [{ metadata: { name: JOB_NAME }, status: { conditions: [] } }], }); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); // Extended coreApi that satisfies both execute() calls' pod queries: // waitForPod needs phase=Running; getPodTerminatedInfo needs terminated.exitCode. const coreApi = { listNamespacedPod: vi.fn().mockResolvedValue({ items: [{ metadata: { name: POD_NAME }, status: { phase: "Running", containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }], }, }], }), readNamespacedPodLog: vi.fn().mockResolvedValue(HAPPY_JSONL), createNamespacedSecret: vi.fn().mockResolvedValue({}), deleteNamespacedSecret: vi.fn().mockResolvedValue({}), patchNamespacedSecret: vi.fn().mockResolvedValue({}), }; vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const first = execute(ctx); // Second call races for the mutex; will wait until first releases. const second = execute(ctx); // Unblock first call's job creation so it completes and releases the mutex. releaseFn(); const [, secondResult] = await Promise.all([first, second]); // Second call should NOT be permanently blocked — it waited and created its own job. expect(secondResult.errorCode).toBeUndefined(); expect(secondResult.exitCode).toBe(0); // Both tasks created jobs (sequential, not concurrent). expect(batchApi.createNamespacedJob).toHaveBeenCalledTimes(2); }); it("returns k8s_concurrent_run_blocked if job is still running after wait", async () => { // Simulate a job that never completes from listNamespacedJob's perspective // (always returns running), even though readNamespacedJob says Complete. // This covers the re-check failing after the wait (e.g. a new job appeared). const batchApi = makeBatchApi([ { metadata: { name: "persistent-job" }, status: { conditions: [] }, // always appears running in list }, ]); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); // Waited for the job (readNamespacedJob returned Complete), re-checked (still // appears running in list), returned blocked. expect(result.errorCode).toBe("k8s_concurrent_run_blocked"); expect(batchApi.createNamespacedJob).not.toHaveBeenCalled(); }); }); describe("execute — SIGTERM handler", () => { it("registers a SIGTERM handler via process.once on first execute() call", async () => { // Spy before execute() so we can verify the handler is installed. const onceSpy = vi.spyOn(process, "once"); const ctx = makeCtx(); await execute(ctx); // ensureSigtermHandler() should call process.once('SIGTERM', ...) at most once per process. // Since we can't reset module state between tests, assert it was called at some point. const sigtermCalls = onceSpy.mock.calls.filter(([event]) => event === "SIGTERM"); // Either this test triggered the registration, or an earlier test did. // In either case, the handler must exist on process. const listenerCount = process.listenerCount("SIGTERM"); expect(listenerCount).toBeGreaterThanOrEqual(1); onceSpy.mockRestore(); }); it("SIGTERM handler deletes tracked jobs and calls process.exit(0)", async () => { // Capture the SIGTERM handler by temporarily intercepting process.once. let capturedHandler: (() => void) | null = null; const onceSpy = vi.spyOn(process, "once").mockImplementation( (event: string | symbol, handler: (...args: unknown[]) => void) => { if (event === "SIGTERM") capturedHandler = handler as () => void; return process; }, ); const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); // Reset the SIGTERM flag by re-importing with resetModules (only works first-time per suite). // Instead, we test the handler by intercepting the first registration that fires. // If the handler was already installed, capturedHandler stays null and we skip. const ctx = makeCtx(); await execute(ctx); onceSpy.mockRestore(); exitSpy.mockRestore(); if (capturedHandler) { const batchApi = vi.mocked(getBatchApi)(); // Fire the handler and verify it deletes the job that was just created. try { (capturedHandler as () => void)(); } catch { /* swallow process.exit throw */ } await new Promise((r) => setTimeout(r, 50)); // let async handler tick expect(batchApi.deleteNamespacedJob).toHaveBeenCalled(); } // If capturedHandler is null, sigtermHandlerInstalled was already true from // a prior test — handler registration is idempotent, which is also correct. }); }); describe("execute — job creation failure", () => { it("returns k8s_job_create_failed when createNamespacedJob throws", async () => { const batchApi = makeBatchApi(); batchApi.createNamespacedJob.mockRejectedValue(new Error("Namespace not found")); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBe("k8s_job_create_failed"); expect(result.exitCode).toBeNull(); }); }); describe("execute — pod scheduling failure", () => { it("returns k8s_pod_schedule_failed when init container fails", async () => { const coreApi = { listNamespacedPod: vi.fn().mockResolvedValue({ items: [ { metadata: { name: POD_NAME }, status: { phase: "Pending", initContainerStatuses: [ { name: "write-prompt", state: { terminated: { exitCode: 1, reason: "Error" } } }, ], }, }, ], }), readNamespacedPodLog: vi.fn().mockResolvedValue(""), }; vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBe("k8s_pod_schedule_failed"); expect(result.exitCode).toBeNull(); }); it("returns k8s_pod_schedule_failed when image pull fails", async () => { const coreApi = { listNamespacedPod: vi.fn().mockResolvedValue({ items: [ { metadata: { name: POD_NAME }, status: { phase: "Pending", containerStatuses: [ { name: "opencode", state: { waiting: { reason: "ImagePullBackOff", message: "Back-off pulling image" } } }, ], }, }, ], }), readNamespacedPodLog: vi.fn().mockResolvedValue(""), }; vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBe("k8s_pod_schedule_failed"); }); it("returns k8s_pod_schedule_failed when pod is unschedulable", async () => { const coreApi = { listNamespacedPod: vi.fn().mockResolvedValue({ items: [ { metadata: { name: POD_NAME }, status: { phase: "Pending", conditions: [ { type: "PodScheduled", status: "False", reason: "Unschedulable", message: "0/3 nodes available" }, ], }, }, ], }), readNamespacedPodLog: vi.fn().mockResolvedValue(""), }; vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBe("k8s_pod_schedule_failed"); }); }); describe("execute — happy path", () => { it("returns success with sessionId and usage metrics", async () => { const ctx = makeCtx(); const result = await execute(ctx); expect(result.exitCode).toBe(0); expect(result.timedOut).toBe(false); expect(result.sessionId).toBe("ses_happy"); expect(result.summary).toBe("Task complete"); expect(result.usage?.inputTokens).toBe(100); expect(result.usage?.outputTokens).toBe(50); expect(result.usage?.cachedInputTokens).toBe(20); expect(result.costUsd).toBeCloseTo(0.002); expect(result.errorMessage).toBeNull(); expect(result.clearSession).toBe(false); }); it("cleans up the job after completion", async () => { const batchApi = makeBatchApi(); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); expect(batchApi.deleteNamespacedJob).toHaveBeenCalledWith( expect.objectContaining({ name: JOB_NAME, namespace: NAMESPACE }), ); }); it("creates job in the correct namespace", async () => { const batchApi = makeBatchApi(); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); expect(batchApi.createNamespacedJob).toHaveBeenCalledWith( expect.objectContaining({ namespace: NAMESPACE }), ); }); it("calls onMeta when provided", async () => { const onMeta = vi.fn().mockResolvedValue(undefined); const ctx = { ...makeCtx(), onMeta } as unknown as AdapterExecutionContext; await execute(ctx); expect(onMeta).toHaveBeenCalledWith( expect.objectContaining({ adapterType: "opencode_k8s" }), ); }); }); describe("execute — session unavailable (reattach classification)", () => { it("returns clearSession=true and session_unavailable code for unknown session error", async () => { setMockJsonl(JSON.stringify({ type: "error", error: { message: "Unknown session ses_xxx" } })); const coreApi = makeCoreApi(1); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.clearSession).toBe(true); expect(result.errorCode).toBe("session_unavailable"); }); it("returns clearSession=true for 'session not found' error", async () => { setMockJsonl(JSON.stringify({ type: "error", error: { message: "Session ses_xxx not found" } })); const coreApi = makeCoreApi(1); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.clearSession).toBe(true); }); it("does not set clearSession for unrelated errors", async () => { const coreApi = makeCoreApi(1); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.clearSession).toBe(false); }); }); describe("execute — timeout", () => { it("returns timedOut=true when job reports DeadlineExceeded", async () => { const batchApi = makeBatchApi(); batchApi.readNamespacedJob.mockResolvedValue({ status: { conditions: [{ type: "Failed", status: "True", reason: "DeadlineExceeded" }] }, }); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx({ timeoutSec: 300 }); const result = await execute(ctx); expect(result.timedOut).toBe(true); expect(result.errorCode).toBe("timeout"); }); }); describe("execute — retainJobs config", () => { it("does not delete job when retainJobs=true", async () => { const batchApi = makeBatchApi(); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx({ retainJobs: true }); await execute(ctx); expect(batchApi.deleteNamespacedJob).not.toHaveBeenCalled(); }); it("deletes job when retainJobs=false (default)", async () => { const batchApi = makeBatchApi(); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); expect(batchApi.deleteNamespacedJob).toHaveBeenCalled(); }); }); describe("execute — exit code handling", () => { it("propagates non-zero exit code from pod", async () => { const coreApi = makeCoreApi(2); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.exitCode).toBe(2); expect(result.errorMessage).toBeTruthy(); }); it("synthesizes exitCode=1 when error message exists but pod reported exitCode=0", async () => { setMockJsonl(JSON.stringify({ type: "error", error: { message: "something went wrong" } })); const coreApi = makeCoreApi(0); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); // Exit code should be synthesized to 1 because errorMessage is non-empty expect(result.exitCode).toBe(1); }); it("handles null exit code gracefully (pod not found — 404 tolerance)", async () => { const coreApi = makeCoreApi(null); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); // null exitCode with no error → synthesized to null (no forced failure) expect(result.exitCode).toBeNull(); expect(result.timedOut).toBe(false); }); }); describe("execute — pod failure classification", () => { it("includes pod terminated reason in errorMessage when reason is OOMKilled", async () => { // OOMKilled: process is killed by kernel — no JSONL error event, just empty output const coreApi = makeCoreApi(137, "OOMKilled"); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.exitCode).toBe(137); expect(result.errorMessage).toContain("OOMKilled"); }); it("includes pod terminated reason for Error exit", async () => { const coreApi = makeCoreApi(1, "Error"); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorMessage).toContain("Error"); expect(result.errorMessage).toContain("exit 1"); }); it("falls back gracefully when no terminated reason is available", async () => { const coreApi = makeCoreApi(1, null); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.exitCode).toBe(1); expect(result.errorMessage).toBeTruthy(); }); }); describe("execute — llm_api_error signal", () => { it("returns llm_api_error when session exists but LLM produced no output tokens", async () => { // JSONL has a sessionID but no step_finish tokens and no text messages const emptyOutputJsonl = JSON.stringify({ sessionID: "ses_empty", type: "step_finish", part: { tokens: { input: 100, output: 0, cache: {} }, cost: 0 } }); setMockJsonl(emptyOutputJsonl); const coreApi = makeCoreApi(0); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBe("llm_api_error"); expect(result.errorMessage).toMatch(/empty response/i); }); it("does not emit llm_api_error when there are output tokens", async () => { const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBeUndefined(); expect(result.exitCode).toBe(0); }); it("does not emit llm_api_error when there is an explicit error event", async () => { const errorJsonl = [ JSON.stringify({ sessionID: "ses_err", type: "error", error: { message: "API quota exceeded" } }), ].join("\n"); setMockJsonl(errorJsonl); const coreApi = makeCoreApi(1); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).not.toBe("llm_api_error"); expect(result.errorMessage).toContain("quota"); }); it("does not emit llm_api_error when sessionId is null", async () => { const coreApi = makeCoreApi(0); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBeUndefined(); }); }); describe("execute — log dedup (waitForPod status dedup)", () => { it("logs pod Running status only once when pod is immediately Running", async () => { const logMessages: string[] = []; const ctx = { ...makeCtx(), onLog: vi.fn(async (_type: string, msg: string) => { logMessages.push(msg); }), } as unknown as AdapterExecutionContext; await execute(ctx); // "Pod running: " should appear at most once const runningMsgs = logMessages.filter((m) => m.includes(`Pod running: ${POD_NAME}`)); expect(runningMsgs.length).toBeLessThanOrEqual(1); }); it("logs each distinct pod phase transition exactly once", async () => { const logMessages: string[] = []; const coreApi = { listNamespacedPod: vi.fn() .mockResolvedValueOnce({ items: [{ metadata: { name: POD_NAME }, status: { phase: "Pending" } }], }) .mockResolvedValueOnce({ // Same Pending state — should NOT produce duplicate log items: [{ metadata: { name: POD_NAME }, status: { phase: "Pending" } }], }) .mockResolvedValueOnce({ items: [{ metadata: { name: POD_NAME }, status: { phase: "Running" } }], }) .mockResolvedValueOnce({ // getPodExitCode call items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] }, }], }), readNamespacedPodLog: vi.fn().mockResolvedValue(HAPPY_JSONL), }; vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = { ...makeCtx(), onLog: vi.fn(async (_type: string, msg: string) => { logMessages.push(msg); }), } as unknown as AdapterExecutionContext; await execute(ctx); // Pending status should appear exactly once even though listNamespacedPod was called twice const pendingMsgs = logMessages.filter((m) => m.includes("phase=Pending")); expect(pendingMsgs.length).toBe(1); }); }); describe("execute — external cancel polling", () => { const KEEPALIVE_MS = 15_000; afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); delete process.env.PAPERCLIP_API_URL; }); it("returns errorCode=cancelled and deletes job when issue status is cancelled", async () => { vi.useFakeTimers(); process.env.PAPERCLIP_API_URL = "http://test-api"; const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ status: "cancelled" }), }); vi.stubGlobal("fetch", fetchMock); let jobDeleted = false; const batchApi = makeBatchApi(); batchApi.deleteNamespacedJob.mockImplementation(() => { jobDeleted = true; return Promise.resolve({}); }); batchApi.readNamespacedJob.mockImplementation(() => { if (jobDeleted) { const err = Object.assign(new Error("not found"), { statusCode: 404 }); return Promise.reject(err); } return Promise.resolve({ status: { conditions: [] } }); // non-terminal until deleted }); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx({}, { issueId: "issue-test-456" }, "run-jwt-token"); const executePromise = execute(ctx); // Advance in 1-second steps. vi.advanceTimersByTimeAsync fires fake timers // but only drains one microtask level per call. Advancing in small chunks // gives multi-level Promise chains (fetch → json → cancel logic) time to // fully settle between steps before we await the resolved execute result. for (let i = 0; i < 20; i++) { await vi.advanceTimersByTimeAsync(1_000); } const result = await executePromise; expect(result.errorCode).toBe("cancelled"); expect(result.exitCode).toBeNull(); expect(result.timedOut).toBe(false); expect(batchApi.deleteNamespacedJob).toHaveBeenCalledWith( expect.objectContaining({ name: JOB_NAME, namespace: NAMESPACE, body: { propagationPolicy: "Background" } }), ); expect(fetchMock).toHaveBeenCalledWith( "http://test-api/api/issues/issue-test-456", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer run-jwt-token" }) }), ); }); it("does not cancel when PAPERCLIP_API_URL is absent", async () => { // No PAPERCLIP_API_URL set — cancel polling is skipped; normal completion runs. delete process.env.PAPERCLIP_API_URL; const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBeUndefined(); expect(result.exitCode).toBe(0); }); it("does not cancel when issue status is not cancelled", async () => { vi.useFakeTimers(); process.env.PAPERCLIP_API_URL = "http://test-api"; vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ status: "in_progress" }), })); const ctx = makeCtx({}, { issueId: "issue-test-456" }); const executePromise = execute(ctx); await vi.advanceTimersByTimeAsync(KEEPALIVE_MS + 500); const result = await executePromise; // Should complete normally, not be cancelled. expect(result.errorCode).toBeUndefined(); expect(result.exitCode).toBe(0); }); }); describe("execute — large-prompt Secret path", () => { const LARGE_PROMPT = "x".repeat(300 * 1024); // 300 KiB > 256 KiB threshold function mockLargePrompt() { vi.mocked(buildJobManifest).mockReturnValue({ job: MOCK_JOB as ReturnType["job"], jobName: JOB_NAME, namespace: NAMESPACE, prompt: LARGE_PROMPT, opencodeArgs: [], promptMetrics: null, podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`, } as unknown as ReturnType); } it("calls buildJobManifest twice and passes promptSecretName on second call", async () => { mockLargePrompt(); const ctx = makeCtx(); await execute(ctx); expect(vi.mocked(buildJobManifest)).toHaveBeenCalledTimes(2); const secondCall = vi.mocked(buildJobManifest).mock.calls[1][0]; expect(secondCall.promptSecretName).toBe(`${JOB_NAME}-prompt`); }); it("creates a Secret with the prompt content before creating the Job", async () => { mockLargePrompt(); const coreApi = makeCoreApi(); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const batchApi = makeBatchApi(); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); expect(coreApi.createNamespacedSecret).toHaveBeenCalledWith( expect.objectContaining({ namespace: NAMESPACE, body: expect.objectContaining({ metadata: expect.objectContaining({ name: `${JOB_NAME}-prompt` }), stringData: expect.objectContaining({ prompt: LARGE_PROMPT }), }), }), ); // Secret must be created before Job const secretOrder = coreApi.createNamespacedSecret.mock.invocationCallOrder[0]; const jobOrder = batchApi.createNamespacedJob.mock.invocationCallOrder[0]; expect(secretOrder).toBeLessThan(jobOrder); }); it("patches the Secret with a Job ownerReference after Job creation", async () => { mockLargePrompt(); const batchApi = makeBatchApi(); batchApi.createNamespacedJob.mockResolvedValue({ metadata: { uid: "uid-abc-123" } }); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const coreApi = makeCoreApi(); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); expect(coreApi.patchNamespacedSecret).toHaveBeenCalledWith( expect.objectContaining({ name: `${JOB_NAME}-prompt`, namespace: NAMESPACE, body: expect.objectContaining({ metadata: expect.objectContaining({ ownerReferences: [ expect.objectContaining({ kind: "Job", name: JOB_NAME, uid: "uid-abc-123", controller: true, }), ], }), }), }), ); }); it("cleans up the Secret in the finally block", async () => { mockLargePrompt(); const coreApi = makeCoreApi(); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); expect(coreApi.deleteNamespacedSecret).toHaveBeenCalledWith( expect.objectContaining({ name: `${JOB_NAME}-prompt`, namespace: NAMESPACE }), ); }); it("cleans up the Secret when Job creation fails", async () => { mockLargePrompt(); const batchApi = makeBatchApi(); batchApi.createNamespacedJob.mockRejectedValue(new Error("quota exceeded")); vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); const coreApi = makeCoreApi(); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBe("k8s_job_create_failed"); expect(coreApi.deleteNamespacedSecret).toHaveBeenCalledWith( expect.objectContaining({ name: `${JOB_NAME}-prompt` }), ); }); it("does not create a Secret for prompts within threshold", async () => { // Default beforeEach mock returns "Test prompt" (11 bytes < 256 KiB) const coreApi = makeCoreApi(); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); expect(vi.mocked(buildJobManifest)).toHaveBeenCalledTimes(1); expect(coreApi.createNamespacedSecret).not.toHaveBeenCalled(); }); }); describe("ensureAgentDbPvc — unit", () => { const NAMESPACE = "test-ns"; const AGENT_ID = "agent-uuid-1234"; const EXPECTED_PVC_NAME = `opencode-db-${AGENT_ID}`; it("returns null when agentDbMode is ephemeral", async () => { const result = await ensureAgentDbPvc(AGENT_ID, NAMESPACE, { agentDbMode: "ephemeral" }); expect(result).toBeNull(); expect(vi.mocked(getPvc)).not.toHaveBeenCalled(); expect(vi.mocked(createPvc)).not.toHaveBeenCalled(); }); it("returns existing PVC claim name without calling createPvc", async () => { vi.mocked(getPvc).mockResolvedValueOnce({ metadata: { name: EXPECTED_PVC_NAME } } as Awaited>); const result = await ensureAgentDbPvc(AGENT_ID, NAMESPACE, { agentDbMode: "dedicated_pvc", agentDbStorageClass: "standard" }); expect(result).toBe(EXPECTED_PVC_NAME); expect(vi.mocked(getPvc)).toHaveBeenCalledWith(NAMESPACE, EXPECTED_PVC_NAME, undefined); expect(vi.mocked(createPvc)).not.toHaveBeenCalled(); }); it("creates PVC and returns claim name when PVC does not exist", async () => { vi.mocked(getPvc).mockResolvedValueOnce(null); vi.mocked(createPvc).mockResolvedValueOnce({} as Awaited>); const result = await ensureAgentDbPvc(AGENT_ID, NAMESPACE, { agentDbMode: "dedicated_pvc", agentDbStorageClass: "standard", agentDbStorageCapacity: "5Gi", }); expect(result).toBe(EXPECTED_PVC_NAME); expect(vi.mocked(createPvc)).toHaveBeenCalledWith( NAMESPACE, expect.objectContaining({ metadata: expect.objectContaining({ name: EXPECTED_PVC_NAME }), spec: expect.objectContaining({ accessModes: ["ReadWriteOnce"], storageClassName: "standard", resources: { requests: { storage: "5Gi" } }, }), }), undefined, ); }); it("defaults to 1Gi capacity when agentDbStorageCapacity is not set", async () => { vi.mocked(getPvc).mockResolvedValueOnce(null); vi.mocked(createPvc).mockResolvedValueOnce({} as Awaited>); await ensureAgentDbPvc(AGENT_ID, NAMESPACE, { agentDbMode: "dedicated_pvc", agentDbStorageClass: "fast" }); expect(vi.mocked(createPvc)).toHaveBeenCalledWith( NAMESPACE, expect.objectContaining({ spec: expect.objectContaining({ resources: { requests: { storage: "1Gi" } } }), }), undefined, ); }); it("throws when agentDbStorageClass is missing and PVC does not exist", async () => { vi.mocked(getPvc).mockResolvedValueOnce(null); await expect( ensureAgentDbPvc(AGENT_ID, NAMESPACE, { agentDbMode: "dedicated_pvc" }), ).rejects.toThrow(/agentDbStorageClass/); expect(vi.mocked(createPvc)).not.toHaveBeenCalled(); }); it("defaults to dedicated_pvc when agentDbMode is not set", async () => { vi.mocked(getPvc).mockResolvedValueOnce({ metadata: { name: EXPECTED_PVC_NAME } } as Awaited>); const result = await ensureAgentDbPvc(AGENT_ID, NAMESPACE, {}); expect(result).toBe(EXPECTED_PVC_NAME); }); it("derives PVC name from agent ID, keeping alphanumeric and hyphens", async () => { const weirdAgentId = "Agent_ID/with+special@chars"; const expectedSlug = "agentidwithspecialchars"; // [^a-z0-9-] stripped vi.mocked(getPvc).mockResolvedValueOnce({ metadata: { name: `opencode-db-${expectedSlug}` } } as Awaited>); const result = await ensureAgentDbPvc(weirdAgentId, NAMESPACE, { agentDbMode: "dedicated_pvc" }); expect(result).toBe(`opencode-db-${expectedSlug}`); expect(vi.mocked(getPvc)).toHaveBeenCalledWith(NAMESPACE, `opencode-db-${expectedSlug}`, undefined); }); }); describe("isK8s404", () => { it("recognizes @kubernetes/client-node v1.x ApiException with code=404", async () => { const { isK8s404 } = await import("./execute.js"); const err = Object.assign(new Error("not found"), { code: 404 }); expect(isK8s404(err)).toBe(true); }); it("still recognizes legacy errors with statusCode=404", async () => { const { isK8s404 } = await import("./execute.js"); const err = Object.assign(new Error("not found"), { statusCode: 404 }); expect(isK8s404(err)).toBe(true); }); it("still recognizes errors with response.statusCode=404", async () => { const { isK8s404 } = await import("./execute.js"); const err = Object.assign(new Error("not found"), { response: { statusCode: 404 } }); expect(isK8s404(err)).toBe(true); }); it("returns false for non-404 errors", async () => { const { isK8s404 } = await import("./execute.js"); expect(isK8s404(Object.assign(new Error("server error"), { code: 500 }))).toBe(false); expect(isK8s404(new Error("plain error"))).toBe(false); expect(isK8s404(null)).toBe(false); }); }); describe("parseModelProvider", () => { it("returns null for null input", async () => { const { parseModelProvider } = await import("./execute.js"); expect(parseModelProvider(null)).toBeNull(); }); it("returns null when model has no slash separator", async () => { const { parseModelProvider } = await import("./execute.js"); expect(parseModelProvider("gpt-4")).toBeNull(); expect(parseModelProvider(" ")).toBeNull(); }); it("returns the provider segment from a slash-separated model id", async () => { const { parseModelProvider } = await import("./execute.js"); expect(parseModelProvider("anthropic/claude-opus-4")).toBe("anthropic"); expect(parseModelProvider("openai/gpt-4o")).toBe("openai"); }); it("trims whitespace inside the provider segment", async () => { const { parseModelProvider } = await import("./execute.js"); expect(parseModelProvider(" bedrock /claude")).toBe("bedrock"); }); it("returns null when provider segment is whitespace only", async () => { const { parseModelProvider } = await import("./execute.js"); expect(parseModelProvider(" /model")).toBeNull(); }); }); describe("completionWithGrace", () => { it("returns the completion result when it resolves before grace expires", async () => { const { completionWithGrace } = await import("./execute.js"); const result = await completionWithGrace( Promise.resolve({ succeeded: true, timedOut: false, jobGone: false }), 1000, ); expect(result).toEqual({ succeeded: true, timedOut: false, jobGone: false }); }); it("returns timedOut result when grace expires first", async () => { const { completionWithGrace } = await import("./execute.js"); vi.useFakeTimers(); try { const slowCompletion = new Promise<{ succeeded: boolean; timedOut: boolean; jobGone: boolean }>(() => {}); const racePromise = completionWithGrace(slowCompletion, 50); await vi.advanceTimersByTimeAsync(60); const result = await racePromise; expect(result).toEqual({ succeeded: false, timedOut: true, jobGone: false }); } finally { vi.useRealTimers(); } }); it("returns timedOut result when completion promise rejects", async () => { const { completionWithGrace } = await import("./execute.js"); const result = await completionWithGrace(Promise.reject(new Error("boom")), 1000); expect(result).toEqual({ succeeded: false, timedOut: true, jobGone: false }); }); }); describe("execute — config edge paths", () => { it("logs a warning but continues when instructionsFilePath cannot be read", async () => { const ctx = makeCtx({ instructionsFilePath: "/does/not/exist/AGENTS.md" }); const result = await execute(ctx); expect(result.errorCode).toBeUndefined(); const logCalls = vi.mocked(ctx.onLog).mock.calls; const warning = logCalls.find(([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("instructionsFilePath not readable")); expect(warning).toBeDefined(); }); it("returns k8s_job_create_failed when ensureAgentDbPvc throws (PVC create rejected)", async () => { vi.mocked(getPvc).mockResolvedValueOnce(null); vi.mocked(createPvc).mockRejectedValueOnce(new Error("storage class missing")); const ctx = makeCtx({ agentDbMode: "dedicated_pvc", agentDbStorageClass: "fast", }); const result = await execute(ctx); expect(result.errorCode).toBe("k8s_job_create_failed"); expect(result.errorMessage).toContain("storage class missing"); }); it("returns k8s_job_create_failed when ensureAgentDbPvc throws because storage class is missing", async () => { vi.mocked(getPvc).mockResolvedValueOnce(null); const ctx = makeCtx({ agentDbMode: "dedicated_pvc" }); const result = await execute(ctx); expect(result.errorCode).toBe("k8s_job_create_failed"); expect(result.errorMessage).toContain("agentDbStorageClass is required"); }); }); describe("execute — large-prompt Secret create failure", () => { const LARGE_PROMPT = "y".repeat(300 * 1024); it("returns k8s_job_create_failed when createNamespacedSecret throws", async () => { vi.mocked(buildJobManifest).mockReturnValue({ job: MOCK_JOB as ReturnType["job"], jobName: JOB_NAME, namespace: NAMESPACE, prompt: LARGE_PROMPT, opencodeArgs: [], promptMetrics: null, podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`, } as unknown as ReturnType); const coreApi = makeCoreApi(); coreApi.createNamespacedSecret.mockRejectedValue(new Error("etcd full")); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); const result = await execute(ctx); expect(result.errorCode).toBe("k8s_job_create_failed"); expect(result.errorMessage).toContain("Failed to create prompt Secret"); expect(result.errorMessage).toContain("etcd full"); }); }); describe("ensureAgentDbPvc — verification failure (FAR-85 belt-and-suspenders)", () => { it("throws when getPvc returns null after createPvc resolved (verification failed)", async () => { vi.mocked(getPvc) .mockResolvedValueOnce(null) // first existence check: not found .mockResolvedValueOnce(null); // post-create verification: still not found vi.mocked(createPvc).mockResolvedValueOnce({} as never); await expect( ensureAgentDbPvc("agent-x", "ns-x", { agentDbMode: "dedicated_pvc", agentDbStorageClass: "fast" }), ).rejects.toThrow(/PVC opencode-db-agent-x was not created/); }); }); describe("execute — step limit detection", () => { it("logs that the step limit was reached when a step_finish event has reason=max_steps", async () => { const STEP_LIMIT_JSONL = [ JSON.stringify({ type: "text", part: { text: "partial" }, sessionID: "ses_step" }), JSON.stringify({ type: "step_finish", part: { reason: "max_steps", tokens: { input: 10, output: 5 }, cost: 0 } }), ].join("\n"); setMockJsonl(STEP_LIMIT_JSONL); const coreApi = makeCoreApi(0); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); const logCalls = vi.mocked(ctx.onLog).mock.calls; const limitLog = logCalls.find( ([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("step limit reached"), ); expect(limitLog).toBeDefined(); }); }); describe("execute — waitForPod 'no pod yet' messaging", () => { it("emits a 'Waiting for Job controller to create pod' log when pod is not yet present", async () => { const coreApi = makeCoreApi(); // First listNamespacedPod call returns empty (no pod yet), second returns Running coreApi.listNamespacedPod = vi.fn() .mockResolvedValueOnce({ items: [] }) .mockResolvedValueOnce({ items: [{ metadata: { name: POD_NAME }, status: { phase: "Running" } }], }) .mockResolvedValue({ items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] } }], }); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const ctx = makeCtx(); await execute(ctx); const logCalls = vi.mocked(ctx.onLog).mock.calls; const waitLog = logCalls.find( ([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("Waiting for Job controller to create pod"), ); expect(waitLog).toBeDefined(); }); }); describe("execute — pod scheduling failure (extra paths)", () => { it("returns k8s_pod_schedule_failed when init container is in ImagePullBackOff", async () => { const coreApi = { listNamespacedPod: vi.fn().mockResolvedValue({ items: [ { metadata: { name: POD_NAME }, status: { phase: "Pending", initContainerStatuses: [ { name: "write-prompt", state: { waiting: { reason: "ImagePullBackOff", message: "back-off" } } }, ], }, }, ], }), readNamespacedPodLog: vi.fn().mockResolvedValue(""), }; vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const result = await execute(makeCtx()); expect(result.errorCode).toBe("k8s_pod_schedule_failed"); expect(result.errorMessage).toMatch(/Init container.*image pull failed/); }); it("returns k8s_pod_schedule_failed when init container is in CrashLoopBackOff", async () => { const coreApi = { listNamespacedPod: vi.fn().mockResolvedValue({ items: [ { metadata: { name: POD_NAME }, status: { phase: "Pending", initContainerStatuses: [ { name: "write-prompt", state: { waiting: { reason: "CrashLoopBackOff", message: "loop" } } }, ], }, }, ], }), readNamespacedPodLog: vi.fn().mockResolvedValue(""), }; vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const result = await execute(makeCtx()); expect(result.errorCode).toBe("k8s_pod_schedule_failed"); expect(result.errorMessage).toMatch(/Init container.*crash loop/); }); it("returns k8s_pod_schedule_failed when main container is in CrashLoopBackOff", async () => { const coreApi = { listNamespacedPod: vi.fn().mockResolvedValue({ items: [ { metadata: { name: POD_NAME }, status: { phase: "Pending", containerStatuses: [ { name: "opencode", state: { waiting: { reason: "CrashLoopBackOff", message: "loop" } } }, ], }, }, ], }), readNamespacedPodLog: vi.fn().mockResolvedValue(""), }; vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const result = await execute(makeCtx()); expect(result.errorCode).toBe("k8s_pod_schedule_failed"); expect(result.errorMessage).toMatch(/crash loop/); }); it("proceeds when all init containers terminated successfully and main is running", async () => { const coreApi = { listNamespacedPod: vi.fn() .mockResolvedValueOnce({ items: [ { metadata: { name: POD_NAME }, status: { phase: "Pending", initContainerStatuses: [ { name: "write-prompt", state: { terminated: { exitCode: 0 } } }, ], containerStatuses: [{ name: "opencode", state: { running: {} } }], }, }, ], }) .mockResolvedValue({ items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] } }], }), readNamespacedPodLog: vi.fn().mockResolvedValue(HAPPY_JSONL), }; vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); const result = await execute(makeCtx()); expect(result.errorCode).toBeUndefined(); expect(result.exitCode).toBe(0); }); }); describe("execute — skill bundle source loading", () => { it("reads SKILL.md from entry.source dir and bundles content into the prompt", async () => { const { mkdtempSync, writeFileSync, mkdirSync } = await import("node:fs"); const os = await import("node:os"); const path = await import("node:path"); const tmpDir = mkdtempSync(path.join(os.tmpdir(), "skills-test-")); const skillDir = path.join(tmpDir, "skill-a"); mkdirSync(skillDir); writeFileSync(path.join(skillDir, "SKILL.md"), "skill A content"); const utils = await import("@paperclipai/adapter-utils/server-utils"); vi.mocked(utils.readPaperclipRuntimeSkillEntries).mockResolvedValueOnce([ { key: "paperclip/skill-a", runtimeName: "skill-a", source: skillDir, required: true } as never, ]); const ctx = makeCtx(); await execute(ctx); // buildJobManifest should have received the skills bundle content const buildArgs = vi.mocked(buildJobManifest).mock.calls[0][0]; expect(buildArgs.skillsBundleContent).toContain("skill A content"); }); it("falls back to reading entry.source as a file when SKILL.md path read throws", async () => { const { mkdtempSync, writeFileSync } = await import("node:fs"); const os = await import("node:os"); const path = await import("node:path"); const tmpDir = mkdtempSync(path.join(os.tmpdir(), "skills-flat-")); const skillFile = path.join(tmpDir, "skill-b.md"); writeFileSync(skillFile, "skill B flat content"); const utils = await import("@paperclipai/adapter-utils/server-utils"); vi.mocked(utils.readPaperclipRuntimeSkillEntries).mockResolvedValueOnce([ { key: "paperclip/skill-b", runtimeName: "skill-b", source: skillFile, required: true } as never, ]); const ctx = makeCtx(); await execute(ctx); const buildArgs = vi.mocked(buildJobManifest).mock.calls[0][0]; expect(buildArgs.skillsBundleContent).toContain("skill B flat content"); }); }); describe("execute — SIGTERM handler body (FAR-86 coverage)", () => { it("invoking the captured SIGTERM handler deletes tracked Jobs and Secrets", async () => { // Force a fresh module so sigtermHandlerInstalled starts false again. vi.resetModules(); vi.doMock("./k8s-client.js", () => ({ getSelfPodInfo: vi.fn().mockResolvedValue(MOCK_SELF_POD), getBatchApi: vi.fn(), getCoreApi: vi.fn(), getPvc: vi.fn().mockResolvedValue({ metadata: { name: "opencode-db-x" } }), createPvc: vi.fn().mockResolvedValue({}), })); vi.doMock("./job-manifest.js", () => ({ buildJobManifest: vi.fn().mockReturnValue({ job: MOCK_JOB, jobName: "fresh-job", namespace: NAMESPACE, prompt: "p", opencodeArgs: [], promptMetrics: null, podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`, }), buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) => `/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson` ), LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024, })); const fresh = await import("./execute.js"); const k8s = await import("./k8s-client.js"); const batchApi = makeBatchApi(); const coreApi = makeCoreApi(); vi.mocked(k8s.getBatchApi).mockReturnValue(batchApi as unknown as ReturnType); vi.mocked(k8s.getCoreApi).mockReturnValue(coreApi as unknown as ReturnType); let capturedHandler: (() => void) | null = null; const onceSpy = vi.spyOn(process, "once").mockImplementation( (event: string | symbol, handler: (...args: unknown[]) => void) => { if (event === "SIGTERM") capturedHandler = handler as () => void; return process; }, ); const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never); await fresh.execute(makeCtx()); onceSpy.mockRestore(); expect(capturedHandler).not.toBeNull(); (capturedHandler as unknown as () => void)(); // Wait long enough for the async handler body to settle await new Promise((r) => setTimeout(r, 50)); expect(batchApi.deleteNamespacedJob).toHaveBeenCalled(); expect(exitSpy).toHaveBeenCalled(); exitSpy.mockRestore(); vi.doUnmock("./k8s-client.js"); vi.doUnmock("./job-manifest.js"); }); }); // tailPodLogFile tests deferred — requires file-system module isolation // not available in the shared test suite's vi.mock("node:fs/promises") setup