diff --git a/server/src/__tests__/adapter-cost-failure-paths.test.ts b/server/src/__tests__/adapter-cost-failure-paths.test.ts index b831a1e0476..2b6e5cd24e0 100644 --- a/server/src/__tests__/adapter-cost-failure-paths.test.ts +++ b/server/src/__tests__/adapter-cost-failure-paths.test.ts @@ -176,3 +176,32 @@ describe("incremental protocol accounting", () => { expect(second.usage).toMatchObject(Object.fromEntries(Object.entries(fixture.tokens!).map(([key, count]) => [key, count * multiplier]))); }); }); + + +describe("process adapter bootstrap accounting proof", () => { + const context = (config: Record): AdapterExecutionContext => ({ + runId: `bootstrap-${crypto.randomUUID()}`, + agent: { id: "agent", companyId: "company", name: "Process", adapterType: "process", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config, context: {}, onLog: async () => {}, + }); + it.each([{}, { command: "/nonexistent-paperclip-accounting-command" }, + { command: process.execPath, cwd: "/nonexistent-paperclip-accounting-directory" }])( + "proves no provider work for a failure before spawn: %j", async (config) => { + const { execute } = await import("../adapters/process/execute.js"); + const onSpawn = vi.fn(); + const result = await execute({ ...context(config), onSpawn }); + expect(result).toMatchObject({ exitCode: 1, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }); + expect(result.errorMessage).toBeTruthy(); + expect(onSpawn).not.toHaveBeenCalled(); + }, + ); + it("does not invent bootstrap proof when a spawned process fails", async () => { + const { execute } = await import("../adapters/process/execute.js"); + const onSpawn = vi.fn(async () => { throw new Error("Metadata persistence failed"); }); + const result = await execute({ ...context({ command: process.execPath, args: ["-e", "process.exit(7)"] }), onSpawn }); + expect(onSpawn).toHaveBeenCalledOnce(); + expect(result.exitCode).toBe(7); + expect(result.executionRecovery).toBeUndefined(); + }); +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 187135d1959..d2b2e3b8b3a 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -43,6 +43,7 @@ import { releaseRuntimeServicesForRun, UnresolvedWorkspaceBaseRefError, resetRuntimeServicesForTests, + resetRuntimeServicePortReservationsForTests, MANAGED_RUNTIME_PUBLIC_URL_ENV, resolveManagedPaperclipRuntimePublicOrigin, resolveRuntimeProvisionCommand, @@ -454,6 +455,9 @@ afterEach(async () => { delete process.env.PAPERCLIP_WORKTREES_DIR; delete process.env.DATABASE_URL; await resetRuntimeServicesForTests(); + // Registry reset does not clear the process-local allocation claims. A + // failed-start fixture must not reserve another test's ephemeral port. + resetRuntimeServicePortReservationsForTests(); }); describe("sanitizeRuntimeServiceBaseEnv", () => { @@ -4099,6 +4103,7 @@ describe("realizeExecutionWorkspace", () => { "utf8", ); process.env.PAPERCLIP_WORKTREES_DIR = worktreesDir; + const canonicalInstanceRoot = await fs.realpath(instanceRoot); await cleanupExecutionWorkspaceArtifacts({ workspace: { @@ -4133,7 +4138,7 @@ describe("realizeExecutionWorkspace", () => { expect(operations[0]?.command).toBe("printf 'cleanup ok\\n'"); expect(operations[1]?.metadata).toMatchObject({ cleanupAction: "remove_worktree_instance", - instanceRoot, + instanceRoot: canonicalInstanceRoot, }); expect(operations[2]?.metadata).toMatchObject({ cleanupAction: "worktree_remove", @@ -7643,16 +7648,25 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { process.env.PAPERCLIP_HOME = paperclipHome; process.env.PAPERCLIP_INSTANCE_ID = `runtime-desired-reconcile-${randomUUID()}`; - const reservePort = async () => { - const probe = net.createServer(); - await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); - const address = probe.address(); - const port = typeof address === "object" && address ? address.port : null; - await new Promise((resolve, reject) => { - probe.close((error) => error ? reject(error) : resolve()); - }); - if (!port) throw new Error("Failed to reserve runtime reconciliation test port"); - return port; + const reservePorts = async () => { + const probes = [net.createServer(), net.createServer()]; + try { + // Keep both sockets open until both allocations finish. Closing the + // first probe early lets the kernel return that same port again. + for (const probe of probes) await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen(0, "127.0.0.1", resolve); + }); + return probes.map(probe => { + const address = probe.address(); + if (!address || typeof address !== "object") throw new Error("Failed to reserve runtime reconciliation test port"); + return address.port; + }); + } finally { + await Promise.all(probes.filter(probe => probe.listening).map(probe => new Promise((resolve, reject) => { + probe.close(error => error ? reject(error) : resolve()); + }))); + } }; const isLoopbackPortFree = async (port: number) => { const probe = net.createServer(); @@ -7673,8 +7687,8 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { } throw new Error(`Port ${port} did not become free in time`); }; - const stoppedPort = await reservePort(); - const livePort = await reservePort(); + const [stoppedPort, livePort] = await reservePorts(); + expect(stoppedPort).not.toBe(livePort); const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -9728,7 +9742,7 @@ describe("realizeExecutionWorkspace with an exact existing branch", () => { const workspace = await realizeExistingBranch(repoRoot, "feature/legacy-checkout"); - expect(workspace.cwd).toBe(path.resolve(legacyPath)); + expect(workspace.cwd).toBe(await fs.realpath(legacyPath)); expect(workspace.branchName).toBe("feature/legacy-checkout"); expect(workspace.created).toBe(false); expect(await readGit(workspace.cwd, ["rev-parse", "HEAD"])).toBe(branchTip); diff --git a/server/src/adapters/process/execute.ts b/server/src/adapters/process/execute.ts index 16ef40457e4..1087f688418 100644 --- a/server/src/adapters/process/execute.ts +++ b/server/src/adapters/process/execute.ts @@ -15,6 +15,24 @@ import { } from "../utils.js"; export async function execute(ctx: AdapterExecutionContext): Promise { + let spawned = false; + try { + return await executeProcess({ ...ctx, onSpawn: async (meta) => { + // Set this before metadata persistence: a failed callback cannot prove + // that the already-created process did no billable work. + spawned = true; + await ctx.onSpawn?.(meta); + } }); + } catch (error) { + if (spawned) throw error; + return { exitCode: 1, signal: null, timedOut: false, + errorMessage: error instanceof Error ? error.message : String(error), + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }; + } +} + +async function executeProcess(ctx: AdapterExecutionContext): Promise { const { runId, agent, config, onLog, onMeta, authToken } = ctx; const command = asString(config.command, ""); if (!command) throw new Error("Process adapter missing command");