diff --git a/server/src/services/heartbeat-github-launchers.test.ts b/server/src/services/heartbeat-github-launchers.test.ts index 012ab7d771a..4d8771bc896 100644 --- a/server/src/services/heartbeat-github-launchers.test.ts +++ b/server/src/services/heartbeat-github-launchers.test.ts @@ -24,6 +24,31 @@ describe("heartbeat GitHub launcher lifetime", () => { expect(second.cleanupLocation).toBeNull(); expect(first.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "", PAPERCLIP_GITHUB_BROKER_URL: "" }); }); + it.each([false, true])("cleans partial run-scoped staging and preserves its error (cleanup fails: %s)", async (cleanupFails) => { + const stagingError = new Error("remote launcher staging failed"); + const prepareLaunchers = vi.fn(async () => { throw stagingError; }); + const cleanupLaunchers = vi.fn(async () => { + if (cleanupFails) throw new Error("cleanup unavailable"); + }); + await expect(prepareHeartbeatGitHubLaunchers({ + native: true, githubConfigured: true, agentId: "agent-a", target, + runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test", + createBrokerToken: () => "current-run-secret", + }, prepareLaunchers, cleanupLaunchers)).rejects.toBe(stagingError); + expect(cleanupLaunchers).toHaveBeenCalledExactlyOnceWith({ runId: "failed-run", target }); + }); + + it("does not remove shared anonymous wrappers when staging a later run fails", async () => { + const stagingError = new Error("remote launcher staging failed"); + const cleanupLaunchers = vi.fn(async () => undefined); + await expect(prepareHeartbeatGitHubLaunchers({ + native: true, githubConfigured: false, agentId: "agent-a", target, + runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test", + createBrokerToken: () => { throw new Error("must not mint a capability"); }, + }, async () => { throw stagingError; }, cleanupLaunchers)).rejects.toBe(stagingError); + expect(cleanupLaunchers).not.toHaveBeenCalled(); + }); + it.each([ { native: true, githubConfigured: true, target }, { native: false, githubConfigured: false, target }, diff --git a/server/src/services/heartbeat-github-launchers.ts b/server/src/services/heartbeat-github-launchers.ts index 8978ef81916..5295c1f4564 100644 --- a/server/src/services/heartbeat-github-launchers.ts +++ b/server/src/services/heartbeat-github-launchers.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher"; -import { prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target"; +import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target"; type LauncherInput = Parameters[0]; @@ -13,6 +13,7 @@ export async function prepareHeartbeatGitHubLaunchers( createBrokerToken: () => string; }, prepareLaunchers = prepareGitHubOperationLaunchers, + cleanupLaunchers = cleanupGitHubOperationLaunchers, ) { // An unconfigured sandbox has no managed GitHub identity to broker. Its // token-free wrappers still isolate image credentials, but may live as long @@ -30,5 +31,16 @@ export async function prepareHeartbeatGitHubLaunchers( url: anonymous ? "" : input.brokerUrl, token: anonymous ? "" : input.createBrokerToken(), }); - return { env: await prepareLaunchers({ ...location, cwd: input.cwd, env }), cleanupLocation: anonymous ? null : location }; + try { + return { + env: await prepareLaunchers({ ...location, cwd: input.cwd, env }), + cleanupLocation: anonymous ? null : location, + }; + } catch (error) { + // The caller cannot retain a cleanup location until staging returns. Clean + // partial run-specific files here, preserving the original staging failure. + // Anonymous files may still belong to a live warm provider; never delete them. + if (!anonymous) await cleanupLaunchers(location).catch(() => undefined); + throw error; + } }