fix(paperclip): k8s plugin returns a termination receipt (REN-841) #1155

Merged
Ghost merged 1 commits from atlas/ren-841-k8s-plugin-termination-receipt into master 2026-10-06 14:30:55 +00:00
2 changed files with 395 additions and 0 deletions
+11
View File
@@ -41,6 +41,17 @@ stable fingerprint without the transcript makes the resumed turn fail with
"Resource not found" (homelab#1111, reverted in #1113). Remote runs are
unchanged. No upstream PR yet.
A cancelled or failed k8s pod run blocks its card for good (REN-841). The
owner's next comment waits on `remote_cleanup`, because the server continues
only when every remote lease carries a provider termination receipt
(`explicit-native-continuation.ts`), and the Kubernetes plugin's
`onEnvironmentReleaseLease` / `onEnvironmentDestroyLease` returned `void`.
`ren-841-k8s-termination-receipt.patch` makes both return
`{ providerLeaseId, state: "destroyed" }`, but only after the Sandbox CR or
Job (and, on destroy, the pod) reads 404, polling for up to 20 s, below the
server's 30 s plugin RPC timeout. If that check times out or fails, the hook
returns no receipt, as before. No upstream PR yet.
## Files
- `upstream.env` — pinned commit and the version string stamped into the
@@ -0,0 +1,384 @@
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts b/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
index 87e18ae..2224e60 100644
--- a/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/src/lease-lifecycle.ts
@@ -21,6 +21,7 @@ import { deleteJob, findPodForJob, getJobStatus } from "./job-orchestrator.js";
import {
deleteSandboxCr,
findPodForSandbox,
+ getSandboxCrStatus,
waitForSandboxReady,
} from "./sandbox-cr-orchestrator.js";
@@ -191,3 +192,60 @@ export async function destroyLeaseResources(
);
}
}
+
+export interface WorkloadGoneInput {
+ namespace: string;
+ /** Workload resource name (Sandbox CR name or Job name) == providerLeaseId. */
+ name: string;
+ backend: "sandbox-cr" | "job";
+ podName?: string | null;
+ /** Stays below the server's 30 s default plugin RPC timeout. */
+ timeoutMs?: number;
+ pollMs?: number;
+}
+
+/**
+ * Poll until the workload (and the named pod, if any) returns 404. Deletes use
+ * Foreground propagation, so the workload disappears only after its pod is
+ * gone. True is the evidence behind a `destroyed` termination receipt; false
+ * (still present at the deadline, or a read failed) means the caller must not
+ * claim one. Never throws: the delete itself already succeeded.
+ */
+export async function waitForWorkloadGone(
+ clients: KubeClients,
+ input: WorkloadGoneInput,
+): Promise<boolean> {
+ const deadline = Date.now() + (input.timeoutMs ?? 20_000);
+ const pollMs = input.pollMs ?? 500;
+ const gone = async (read: () => Promise<unknown>): Promise<boolean> => {
+ try {
+ await read();
+ return false;
+ } catch (err) {
+ if (isKubeNotFoundError(err)) return true;
+ throw err;
+ }
+ };
+ try {
+ return await pollUntilGone();
+ } catch {
+ return false;
+ }
+
+ async function pollUntilGone(): Promise<boolean> {
+ for (;;) {
+ const workloadGone = await gone(() =>
+ input.backend === "sandbox-cr"
+ ? getSandboxCrStatus(clients, input.namespace, input.name)
+ : getJobStatus(clients, input.namespace, input.name),
+ );
+ const podName = input.podName;
+ const podGone = !podName || await gone(() =>
+ clients.core.readNamespacedPod({ namespace: input.namespace, name: podName }),
+ );
+ if (workloadGone && podGone) return true;
+ if (Date.now() >= deadline) return false;
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
+ }
+ }
+}
diff --git a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
index 9801638..bf5ce0a 100644
--- a/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/src/plugin.ts
@@ -15,6 +15,7 @@ import type {
PluginEnvironmentSyncInParams,
PluginEnvironmentSyncOutParams,
PluginEnvironmentSyncResult,
+ PluginEnvironmentTerminationReceipt,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
@@ -38,7 +39,11 @@ import {
} from "./sandbox-cr-orchestrator.js";
import { execInPod, execInPodStreaming, wrapCommandWithEnv } from "./pod-exec.js";
import { performSyncIn, performSyncOut, type PodStreamExec } from "./file-sync.js";
-import { checkLeaseResumable, destroyLeaseResources } from "./lease-lifecycle.js";
+import {
+ checkLeaseResumable,
+ destroyLeaseResources,
+ waitForWorkloadGone,
+} from "./lease-lifecycle.js";
import {
appendNetworkEgressDenyHint,
createScopedNetworkEgressPolicyOrReleaseWorkload,
@@ -578,7 +583,7 @@ const plugin = definePlugin({
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
- ): Promise<void> {
+ ): Promise<PluginEnvironmentTerminationReceipt | void> {
if (!params.providerLeaseId) return;
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
@@ -613,11 +618,22 @@ const plugin = definePlugin({
?? (err as { code?: number; statusCode?: number }).statusCode;
if (code !== 404) throw err;
}
+
+ // The server grants continuation after a stopped run only with a
+ // termination receipt; without one a cancelled run's queued comment
+ // waits on `remote_cleanup` forever (REN-841). Claim `destroyed` only
+ // once the workload is confirmed gone.
+ const gone = await waitForWorkloadGone(clients, {
+ namespace,
+ name: params.providerLeaseId,
+ backend: leaseBackend,
+ });
+ if (gone) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
},
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
- ): Promise<void> {
+ ): Promise<PluginEnvironmentTerminationReceipt | void> {
if (!params.providerLeaseId) return;
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
@@ -658,6 +674,13 @@ const plugin = definePlugin({
podName,
secretName,
});
+ const gone = await waitForWorkloadGone(clients, {
+ namespace,
+ name: params.providerLeaseId,
+ backend: leaseBackend,
+ podName,
+ });
+ if (gone) return { providerLeaseId: params.providerLeaseId, state: "destroyed" };
},
async onEnvironmentExecute(
diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
index af0875f..d775799 100644
--- a/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/lease-lifecycle.test.ts
@@ -3,6 +3,7 @@ import {
checkLeaseResumable,
destroyLeaseResources,
isKubeNotFoundError,
+ waitForWorkloadGone,
} from "../../src/lease-lifecycle.js";
const SANDBOX_GROUP = "agents.x-k8s.io";
@@ -346,3 +347,64 @@ describe("destroyLeaseResources", () => {
).rejects.toThrow("forbidden");
});
});
+
+describe("waitForWorkloadGone", () => {
+ const input = { namespace: "ns", name: "pc-abc", backend: "sandbox-cr" as const, pollMs: 1 };
+
+ it("is true once the Sandbox CR and the pod return 404", async () => {
+ const getCr = vi.fn()
+ .mockResolvedValueOnce(readySandboxCr("pc-abc-pod"))
+ .mockRejectedValue(notFound());
+ const readPod = vi.fn().mockRejectedValue(notFound());
+ const clients = { custom: { getNamespacedCustomObject: getCr }, core: { readNamespacedPod: readPod } };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, podName: "pc-abc-pod" }),
+ ).resolves.toBe(true);
+ expect(getCr).toHaveBeenCalledTimes(2);
+ expect(getCr).toHaveBeenCalledWith(expect.objectContaining({
+ group: SANDBOX_GROUP, version: SANDBOX_VERSION, plural: SANDBOX_PLURAL, namespace: "ns", name: "pc-abc",
+ }));
+ });
+
+ it("is false while the workload still exists at the deadline", async () => {
+ const clients = {
+ custom: { getNamespacedCustomObject: vi.fn().mockResolvedValue(readySandboxCr()) },
+ };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, timeoutMs: 5 }),
+ ).resolves.toBe(false);
+ });
+
+ it("is false while the pod still exists even if the workload is gone", async () => {
+ const clients = {
+ custom: { getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
+ core: { readNamespacedPod: vi.fn().mockResolvedValue({ status: { phase: "Running" } }) },
+ };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, podName: "pc-abc-pod", timeoutMs: 5 }),
+ ).resolves.toBe(false);
+ });
+
+ it("checks the Job for job-backend leases", async () => {
+ const readJob = vi.fn().mockRejectedValue(notFound());
+ const clients = { batch: { readNamespacedJobStatus: readJob } };
+
+ await expect(
+ waitForWorkloadGone(clients as never, { ...input, backend: "job" }),
+ ).resolves.toBe(true);
+ expect(readJob).toHaveBeenCalledWith({ namespace: "ns", name: "pc-abc" });
+ });
+
+ it("is false (never throws) when a read fails with another error", async () => {
+ const clients = {
+ custom: {
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(Object.assign(new Error("x"), { code: 403 })),
+ },
+ };
+
+ await expect(waitForWorkloadGone(clients as never, input)).resolves.toBe(false);
+ });
+});
diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
index 4c23410..f4fd073 100644
--- a/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
+++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/plugin-lease-lifecycle.test.ts
@@ -184,12 +184,19 @@ describe("onEnvironmentDestroyLease", () => {
const deletePod = vi.fn().mockResolvedValue({});
const deleteSecret = vi.fn().mockResolvedValue({});
h.clients = {
- custom: { deleteNamespacedCustomObject: deleteCr },
- core: { deleteNamespacedPod: deletePod, deleteNamespacedSecret: deleteSecret },
+ custom: {
+ deleteNamespacedCustomObject: deleteCr,
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
+ core: {
+ deleteNamespacedPod: deletePod,
+ deleteNamespacedSecret: deleteSecret,
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
+ },
batch: { deleteNamespacedJob: vi.fn() },
};
- await plugin.definition.onEnvironmentDestroyLease!({
+ const receipt = await plugin.definition.onEnvironmentDestroyLease!({
driverKey: "kubernetes",
companyId: "acme",
environmentId: "env-1",
@@ -209,14 +216,47 @@ describe("onEnvironmentDestroyLease", () => {
namespace: "paperclip-acme",
name: "pc-abc-env",
});
+ expect(receipt).toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
});
it("is idempotent: resolves cleanly when every resource is already gone (404)", async () => {
h.clients = {
- custom: { deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()) },
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
core: {
deleteNamespacedPod: vi.fn().mockRejectedValue(notFound()),
deleteNamespacedSecret: vi.fn().mockRejectedValue(notFound()),
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
+ },
+ batch: { deleteNamespacedJob: vi.fn() },
+ };
+
+ await expect(
+ plugin.definition.onEnvironmentDestroyLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ }),
+ ).resolves.toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
+ });
+
+ it("returns no receipt when the gone check cannot read the cluster", async () => {
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockResolvedValue({}),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(
+ Object.assign(new Error("forbidden"), { code: 403 }),
+ ),
+ },
+ core: {
+ deleteNamespacedPod: vi.fn().mockResolvedValue({}),
+ deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
},
batch: { deleteNamespacedJob: vi.fn() },
};
@@ -261,8 +301,12 @@ describe("onEnvironmentDestroyLease", () => {
core: {
deleteNamespacedPod: vi.fn().mockResolvedValue({}),
deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
+ readNamespacedPod: vi.fn().mockRejectedValue(notFound()),
+ },
+ batch: {
+ deleteNamespacedJob: deleteJob,
+ readNamespacedJobStatus: vi.fn().mockRejectedValue(notFound()),
},
- batch: { deleteNamespacedJob: deleteJob },
};
await plugin.definition.onEnvironmentDestroyLease!({
@@ -280,3 +324,71 @@ describe("onEnvironmentDestroyLease", () => {
expect(deleteCr).not.toHaveBeenCalled();
});
});
+
+describe("onEnvironmentReleaseLease", () => {
+ it("returns a destroyed receipt once the Sandbox CR is gone (REN-841)", async () => {
+ const deleteCr = vi.fn().mockResolvedValue({});
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: deleteCr,
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
+ };
+
+ const receipt = await plugin.definition.onEnvironmentReleaseLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ });
+
+ expect(deleteCr).toHaveBeenCalledWith(
+ expect.objectContaining({ namespace: "paperclip-acme", name: "pc-abc" }),
+ );
+ expect(receipt).toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
+ });
+
+ it("returns a destroyed receipt when the Sandbox CR was already deleted", async () => {
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(notFound()),
+ },
+ };
+
+ await expect(
+ plugin.definition.onEnvironmentReleaseLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ }),
+ ).resolves.toEqual({ providerLeaseId: "pc-abc", state: "destroyed" });
+ });
+
+ it("returns no receipt when the gone check fails", async () => {
+ h.clients = {
+ custom: {
+ deleteNamespacedCustomObject: vi.fn().mockResolvedValue({}),
+ getNamespacedCustomObject: vi.fn().mockRejectedValue(
+ Object.assign(new Error("server error"), { code: 500 }),
+ ),
+ },
+ };
+
+ await expect(
+ plugin.definition.onEnvironmentReleaseLease!({
+ driverKey: "kubernetes",
+ companyId: "acme",
+ environmentId: "env-1",
+ config: CONFIG,
+ providerLeaseId: "pc-abc",
+ leaseMetadata: leaseMetadata(),
+ }),
+ ).resolves.toBeUndefined();
+ });
+});