From 819420a60afa1bfeadcddd8d1c11c5edc306fe3a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:33:51 -0600 Subject: [PATCH] fix(runner): give a cold OpenCode restore the 60 s admission budget (#19487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Native OpenCode agents run through a local runner. After a control-plane restart, a replacement runner restores the saved provider session. > - The replacement runner does run preparation, `opencode serve` startup and session resume inside one `run.attach` command. > - The controller waits only 30 s for that command. A fresh start gets separate bounds for `run.prepare` and `session.open` for the same work. > - On a loaded host, a cold OpenCode restore took 10-38 s. When it took more than 30 s, the continuation failed with `runner_local_connect_failed: PRP command run.attach timed out`. > - This pull request gives a cold OpenCode restore the same 60 s cold-admission budget that Pi already uses. > - The benefit is that a task that waited on a question across a restart continues when the user answers, without a failed run. ## Linked Issues or Issue Description **What happened?** A native OpenCode agent asked a structured question. The control plane restarted while the task waited. The user answered. The continuation run failed with `native_session_recovery_failed: Error: runner_local_connect_failed: PRP command run.attach timed out`, about 35 s after the runner transport connected. This happened in 1 of 2 runs of the `local-session-integrity.runner-opencode.local.structured-question-restart-resume` runner E2E cell. **Expected behavior** The continuation restores the saved OpenCode session and finishes the task. A restart must not cause a failed run. **Steps to reproduce** 1. Run `pnpm test:e2e:runner -- --max-automatic-retries 0 --id local-session-integrity.runner-opencode.local.structured-question-restart-resume` on a host with other load. 2. The case asks a question, restarts Paperclip and answers the question. 3. When the cold restore takes more than 30 s, the continuation run fails at `run.attach`. **Paperclip version or commit** `master` at 0252421d5c. Evidence from the runner spans (`runner.session.startup` of the continuation run): 31.0 s (passed, close to the bound), 38.0 s (failed), 14.0 s and 10.1 s (passed). The first (fresh) runs took 4.9-13.7 s. ## What Changed - `coldAdmissionTimeoutMs` returns 60 s for a cold OpenCode runner process. Live adoption (a warm runner) keeps 30 s. Other providers keep 30 s, except Pi, which already had 60 s. - The unit test for the cold admission budget covers the OpenCode cold and live cases. ## Verification - Red before the fix: `cd packages/paperclip-runner && npx vitest run src/live/runnerd-codex-transport.test.ts -t "cold process admission budget"` failed on `coldAdmissionTimeoutMs("opencode", undefined, true)` (30000, expected 60000). - Green after the fix: the same command passes. - Live runner E2E on macOS arm64 with OpenRouter (`openrouter/deepseek/deepseek-v4-flash-0731`), local runner, at the fixed revision. Command: `pnpm test:e2e:runner -- --max-automatic-retries 0 --id local-session-integrity.runner-opencode.local.structured-question-restart-resume`. - Before the fix: 1 of 2 campaign runs failed at `run.attach` (restore 38.0 s). The passing run restored in 31.0 s. Two more local runs passed (restore 14.0 s and 10.1 s). - After the fix: 4 of 4 runs that reached the restart passed (restore 6.2 s, 11.3 s, 5.1 s and 11.8 s). One more run failed before the restart because the model sent malformed `request_human_input` arguments. That failure is not related to this change. - Limitation: host load was lower during the after-fix runs, so no restore took more than 30 s. The unit test proves the new bound. - Other failures in `runnerd-codex-transport.test.ts` are unchanged on `master` in this local environment (the fake Rust fixture binary is not built locally). CI builds it. ## Risks - Low risk. The change only makes a cold OpenCode admission wait longer before it reports a failure. A runner that is really stuck now fails after 60 s instead of 30 s. - A fresh OpenCode start now shares one absolute 60 s deadline for `session.open` and provider identity, the same as Pi. Fresh OpenCode starts took 5-14 s. - Inner bounds stay in place: the runner still bounds each provider request (30 s) and OpenCode health checks (10 s). ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits). Reasoning mode was on. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../src/live/runnerd-codex-transport.test.ts | 6 +++++- .../paperclip-runner/src/live/runnerd-codex-transport.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 87253a57f48..e16292d7641 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -5890,12 +5890,16 @@ describe("cold process admission budget", () => { it("bounds Pi cold startup and recovery at 60 seconds", () => { expect(coldAdmissionTimeoutMs("acpx", "pi", true)).toBe(60_000); }); + it("bounds a cold OpenCode restore at 60 seconds", () => { + expect(coldAdmissionTimeoutMs("opencode", undefined, true)).toBe(60_000); + }); it("preserves live adoption and every other provider at 30 seconds", () => { expect(coldAdmissionTimeoutMs("acpx", "pi", false)).toBe(30_000); + expect(coldAdmissionTimeoutMs("opencode", undefined, false)).toBe(30_000); for (const agent of ["codex", "claude", "cursor", "copilot", "grok", undefined]) { expect(coldAdmissionTimeoutMs("acpx", agent, true)).toBe(30_000); } - for (const provider of ["codex", "opencode", undefined]) { + for (const provider of ["codex", "cursor_cloud", undefined]) { expect(coldAdmissionTimeoutMs(provider, "pi", true)).toBe(30_000); } }); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 551fef7991e..2f19d3aa461 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -8288,7 +8288,8 @@ export function resolveRunnerdCodexSkillInputs( /** Controller admission budget; ordinary command and turn deadlines are independent. */ export function coldAdmissionTimeoutMs(provider: string | undefined, agent: string | undefined, coldProcess: boolean): number { - return coldProcess && provider === "acpx" && agent === "pi" ? 60_000 : 30_000; + if (!coldProcess) return 30_000; + return (provider === "acpx" && agent === "pi") || provider === "opencode" ? 60_000 : 30_000; } export interface RunnerCommandFailure extends Error {