feat: external-cancel polling via heartbeat-runs endpoint (FAR-42)
Poll PAPERCLIP_API_URL/api/heartbeat-runs/{runId} at keepalive cadence
during log streaming. When status != "running", delete the Job with
propagationPolicy=Background and return errorCode="cancelled" as a
distinct result, matching the claude_k8s reference implementation.
Also includes: reattachOrphanedJobs config field that lets the adapter
reattach to a same-task Job left over from a prior server restart;
task-id and session-id K8s labels on Job manifests for observability.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -125,6 +125,14 @@ export function getConfigSchema(): AdapterConfigSchema {
|
|||||||
hint: "Skip cleanup on completion for debugging",
|
hint: "Skip cleanup on completion for debugging",
|
||||||
group: "Kubernetes",
|
group: "Kubernetes",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "reattachOrphanedJobs",
|
||||||
|
label: "Reattach Orphaned Jobs",
|
||||||
|
type: "toggle",
|
||||||
|
default: false,
|
||||||
|
hint: "When a running Job for the same task is found after a server restart, reattach (stream logs and await completion) instead of blocking",
|
||||||
|
group: "Kubernetes",
|
||||||
|
},
|
||||||
|
|
||||||
// Operational fields (timeoutSec and graceSec are provided by the platform)
|
// Operational fields (timeoutSec and graceSec are provided by the platform)
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -70,6 +70,48 @@ describe("buildJobManifest", () => {
|
|||||||
expect(result.job.metadata?.labels?.["paperclip.io/run-id"]).toBe("run123456");
|
expect(result.job.metadata?.labels?.["paperclip.io/run-id"]).toBe("run123456");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets paperclip.io/task-id label when context.taskId is present", () => {
|
||||||
|
const ctx = {
|
||||||
|
...mockCtx,
|
||||||
|
context: { ...mockCtx.context, taskId: "7e0829c0-cbf7-4652-9554-4d777ce84bff" },
|
||||||
|
};
|
||||||
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
expect(result.job.metadata?.labels?.["paperclip.io/task-id"]).toBe("7e0829c0-cbf7-4652-9554-4d777ce84bff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to context.issueId for paperclip.io/task-id when taskId is null", () => {
|
||||||
|
const ctx = {
|
||||||
|
...mockCtx,
|
||||||
|
context: { ...mockCtx.context, taskId: null, issueId: "issue-uuid-xyz" },
|
||||||
|
};
|
||||||
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
expect(result.job.metadata?.labels?.["paperclip.io/task-id"]).toBe("issue-uuid-xyz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits paperclip.io/task-id label when context.taskId and issueId are both null", () => {
|
||||||
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
expect(result.job.metadata?.labels?.["paperclip.io/task-id"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets paperclip.io/session-id label when runtime.sessionParams.sessionId is present", () => {
|
||||||
|
const ctx = {
|
||||||
|
...mockCtx,
|
||||||
|
runtime: { ...mockCtx.runtime, sessionParams: { sessionId: "ses_abc123" } },
|
||||||
|
};
|
||||||
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
expect(result.job.metadata?.labels?.["paperclip.io/session-id"]).toBe("ses_abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits paperclip.io/session-id label when no session exists", () => {
|
||||||
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
expect(result.job.metadata?.labels?.["paperclip.io/session-id"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("creates init container for prompt", () => {
|
it("creates init container for prompt", () => {
|
||||||
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
||||||
|
|
||||||
|
|||||||
@@ -320,6 +320,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
|||||||
"paperclip.io/company-id": sanitizeLabelValue(agent.companyId, warnLabel),
|
"paperclip.io/company-id": sanitizeLabelValue(agent.companyId, warnLabel),
|
||||||
"paperclip.io/adapter-type": "opencode_k8s",
|
"paperclip.io/adapter-type": "opencode_k8s",
|
||||||
};
|
};
|
||||||
|
const taskId = asString(context.taskId ?? context.issueId, "").trim();
|
||||||
|
if (taskId) labels["paperclip.io/task-id"] = sanitizeLabelValue(taskId, warnLabel);
|
||||||
|
if (runtimeSessionId) labels["paperclip.io/session-id"] = sanitizeLabelValue(runtimeSessionId, warnLabel);
|
||||||
for (const [key, value] of Object.entries(extraLabels)) {
|
for (const [key, value] of Object.entries(extraLabels)) {
|
||||||
if (typeof value === "string") labels[key] = sanitizeLabelValue(value, warnLabel);
|
if (typeof value === "string") labels[key] = sanitizeLabelValue(value, warnLabel);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user