fix: reattach to orphaned K8s Jobs across Paperclip restarts (FAR-124)
When the Paperclip pod restarts mid-run, the in-process setInterval keepalive dies, `updatedAt` goes stale, and the server's orphan reaper fails the run with the (misleading) "child pid 1 is no longer running" message. Paperclip then dispatches a continuation run, whose execute() finds the previous run's K8s Job still happily running and deletes it as an "orphan" — throwing away work and producing the transcript/run cascade reported on FAR-124. Changes: - job-manifest: add `paperclip.io/task-id` and `paperclip.io/session-id` labels (sanitized via new `sanitizeLabelValue` helper) so a later execute() can identify an orphan as the continuation of the same logical unit of work. - execute: in the concurrency guard, when `reattachOrphanedJobs` is on (default) and an orphan matches agent + task + session + is not terminal, pick it as the reattach target; delete only the other orphans. Branch the build/create/waitForPod block so the reattach path skips manifest building, Secret creation, Job creation, and scheduling wait — it jumps straight to streaming logs and waiting for the existing pod's completion. - config-schema: expose `reattachOrphanedJobs` toggle (default true). - Tests: `sanitizeLabelValue`, `isReattachableOrphan`, new label presence/absence, config default. No server-side changes; the misleading reaper message and lack of a non-local retry path will be addressed in a follow-up upstream PR. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { buildJobManifest, buildRtkSetupCommands } from "./job-manifest.js";
|
||||
import { buildJobManifest, buildRtkSetupCommands, sanitizeLabelValue } from "./job-manifest.js";
|
||||
import type { SelfPodInfo } from "./k8s-client.js";
|
||||
|
||||
function makeCtx(overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
|
||||
@@ -136,6 +136,36 @@ describe("buildJobManifest", () => {
|
||||
expect(job.metadata?.labels?.env).toBe("prod");
|
||||
expect(job.metadata?.labels?.["paperclip.io/adapter-type"]).toBe("claude_k8s");
|
||||
});
|
||||
|
||||
it("adds task-id label when context provides taskId", () => {
|
||||
ctx.context = { taskId: "task-xyz-789" };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBe("task-xyz-789");
|
||||
});
|
||||
|
||||
it("falls back to issueId when taskId absent", () => {
|
||||
ctx.context = { issueId: "issue-42" };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBe("issue-42");
|
||||
});
|
||||
|
||||
it("adds session-id label when runtime provides sessionId", () => {
|
||||
ctx.runtime = { ...ctx.runtime, sessionId: "sess-abc-1234" };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBe("sess-abc-1234");
|
||||
});
|
||||
|
||||
it("reads sessionId from runtime.sessionParams when sessionId prop missing", () => {
|
||||
ctx.runtime = { ...ctx.runtime, sessionParams: { sessionId: "sess-from-params" } };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBe("sess-from-params");
|
||||
});
|
||||
|
||||
it("omits task-id and session-id labels when neither is provided", () => {
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBeUndefined();
|
||||
expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("annotations", () => {
|
||||
@@ -729,3 +759,32 @@ describe("buildJobManifest", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeLabelValue", () => {
|
||||
it("passes through already-valid UUIDs and slugs", () => {
|
||||
expect(sanitizeLabelValue("abc-123-def")).toBe("abc-123-def");
|
||||
expect(sanitizeLabelValue("0d8b4472-c42c-4052-aab1-e32897909afa")).toBe("0d8b4472-c42c-4052-aab1-e32897909afa");
|
||||
});
|
||||
|
||||
it("strips characters outside [a-zA-Z0-9._-]", () => {
|
||||
expect(sanitizeLabelValue("task:xyz/123")).toBe("taskxyz123");
|
||||
expect(sanitizeLabelValue("abc 123")).toBe("abc123");
|
||||
});
|
||||
|
||||
it("trims leading/trailing non-alphanumeric characters", () => {
|
||||
expect(sanitizeLabelValue("--abc--")).toBe("abc");
|
||||
expect(sanitizeLabelValue("...123...")).toBe("123");
|
||||
});
|
||||
|
||||
it("truncates to the configured maxLen", () => {
|
||||
const long = "a".repeat(200);
|
||||
const out = sanitizeLabelValue(long, 63);
|
||||
expect(out?.length).toBe(63);
|
||||
});
|
||||
|
||||
it("returns null when no alphanumeric characters remain", () => {
|
||||
expect(sanitizeLabelValue("---")).toBeNull();
|
||||
expect(sanitizeLabelValue("")).toBeNull();
|
||||
expect(sanitizeLabelValue(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user