fix(runner): give a cold OpenCode restore the 60 s admission budget (#19487)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-06 10:33:51 -06:00
1 parent fcf5dc2395
commit 819420a60a
2 files changed
+7 -2

No files matched your search

@@ -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);
}
});
@@ -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 {