fix(runner): clean partial GitHub launcher staging failures

This commit is contained in:
Dotta committed 2026-09-20 06:19:12 -05:00
1 parent db827c2af2
commit db3857807b
2 files changed
+39 -2

No files matched your search

@@ -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 },
@@ -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<typeof prepareGitHubOperationLaunchers>[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;
}
}