diff --git a/packages/paperclip-runner/docs/durable-recovery.md b/packages/paperclip-runner/docs/durable-recovery.md index 3f8938c272a..63e25852e53 100644 --- a/packages/paperclip-runner/docs/durable-recovery.md +++ b/packages/paperclip-runner/docs/durable-recovery.md @@ -83,6 +83,13 @@ a scripted harness, removes the controller's launch files, then recovers the same process and provider session and completes another turn. This exercises the real transport; it does not qualify a hosted OpenCode or ACPX deployment. +## Warm Runner release + +Before parking a settled Runner, the controller waits for its durable drain +receipt within the same 30-second budget used for warm admission. A Claude +turn-end provider restart can delay that receipt beyond one second. The wait +returns as soon as the drain is proved; it adds no fixed delay to a ready Runner. + ## ACPX checkpoints after a rejected turn start A matching `turn.start` response with `SESSION_RESUME_REQUIRED` and diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport-release-resume.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport-release-resume.test.ts index 36b8843de43..d0f174eb3bc 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport-release-resume.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport-release-resume.test.ts @@ -78,16 +78,19 @@ async function nativeWire(port: number, path: string, closeGate?: Promise) }); } -it.skipIf(process.platform === "win32").each(["cached", "lost claim reply", "cache adoption", "acpx cache adoption", "unavailable", "claim cancellation", "expired ticket", "late claim reply", "held past deadline", "release admission", "no cleanup callback", "adoption no cleanup callback", "resume no cleanup callback", "deferred turn after close", "late wire close", "empty goal snapshot", "early claim", "early claim abandoned", "early claim cancellation", "early claim expired", "early claim stale ticket"] as const)( +it.skipIf(process.platform === "win32").each(["cached", "lost claim reply", "cache adoption", "acpx cache adoption", "acpx slow restart", "unavailable", "claim cancellation", "expired ticket", "late claim reply", "held past deadline", "release admission", "no cleanup callback", "adoption no cleanup callback", "resume no cleanup callback", "deferred turn after close", "late wire close", "empty goal snapshot", "early claim", "early claim abandoned", "early claim cancellation", "early claim expired", "early claim stale ticket"] as const)( "releases and resumes the native Codex source before attachment (%s)", async mode => { const root = await mkdtemp(join(tmpdir(), "runner-release-resume-")); - const acpx = mode === "acpx cache adoption"; + const acpx = mode === "acpx cache adoption" || mode === "acpx slow restart"; const sidecar = join(root, "sidecar"), sidecarScript = join(root, "sidecar-entry"); + const restartStarted = join(root, "restart-started"); if (acpx) { const quote = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`; const fake = resolve(import.meta.dirname, "../../runner/target/debug/fake-acpx-sidecar"); await writeFile(sidecarScript, "qualified fixture entry"); - await writeFile(sidecar, `#!/bin/sh\nexec ${quote(fake)} --mode source-reconcile --profile-digest ${quote(QUALIFIED_ACPX_PROFILES.claude.commandDigest)} --journal ${quote(join(root, "calls"))} --source-fault ${quote(join(root, "fault"))}\n`, { mode: 0o700 }); + const restartDelay = mode === "acpx slow restart" + ? `if [ -e ${quote(join(root, "calls"))} ]; then touch ${quote(restartStarted)}; sleep 1.5; fi\n` : ""; + await writeFile(sidecar, `#!/bin/sh\n${restartDelay}exec ${quote(fake)} --mode source-reconcile --profile-digest ${quote(QUALIFIED_ACPX_PROFILES.claude.commandDigest)} --journal ${quote(join(root, "calls"))} --source-fault ${quote(join(root, "fault"))}\n`, { mode: 0o700 }); } const listener = createServer(); await new Promise(resolve => listener.listen(0, "127.0.0.1", resolve)); @@ -214,6 +217,12 @@ it.skipIf(process.platform === "win32").each(["cached", "lost claim reply", "cac const opened = await bundle.transport.request("thread/start", { cwd: root, dynamicTools: [], ...(acpx ? { model: "explicit-source-model" } : {}) }); const threadId = String((opened.thread as { id: string }).id); await complete(); + if (mode === "acpx slow restart") { + await vi.waitFor(async () => expect(await readFile(restartStarted, "utf8")).toBe("")); + expect(await bundle.transport.release!()).toEqual(identity); + expect(pair.release).toHaveBeenCalledOnce(); + expect(authority!.store.state.commands.filter(command => command.type === "runner.drain")).toHaveLength(1); + } if (mode === "empty goal snapshot") { await writeFile(goalGate, "release late goal"); await vi.waitFor(() => expect(authority!.store.state.committedEvents.some(event => event.eventType === "session.goal.updated" diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 700127cbc2e..bddf9effa94 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -5185,8 +5185,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { return false; } + // Release shares the warm admission budget: a turn-end restart can delay the drain receipt. async #drainSettledProviderEventsBeforeSuspend( - timeoutMs = 1_000, + timeoutMs = coldAdmissionTimeoutMs(this.options.provider, this.options.acpxAgent, false), providerStopConfirmed = false, ): Promise { const core = this.#core;