Files
PaperClipAI/packages
DottaandClaude Opus 5.5 39096a55a4 fix(runner): answer lease renewal outside the PRP frame chain and name lease loss (#19419)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs in a managed sandbox talk to the server over one
authenticated PRP connection. The runner (`paperclip-runnerd`) renews
its connection lease on that connection.
> - The server serializes every inbound PRP frame through one promise
chain per connection. Before this change, `lease_renew` waited in that
chain behind earlier events. An event whose business commit stalls (for
example the commit of a tool event) therefore also stalls the lease
reply. The runner then stops polling its provider (ACK backpressure) and
exits with "active connection lease expired" when the lease runs out.
> - When that happens, the server sees only a socket close. The run log
and the terminal error say "transport loss", not "lease expired".
> - A follow-up from #19407 asked for renewal to be handled outside the
chain and for lease loss to be named.
> - This pull request answers `lease_renew` (and `pong`) as soon as the
frame is decrypted. It keeps in-order decryption and the order of
state-changing frames. It also records why a lease was lost and reports
it as `runner_connection_lease_expired`.
> - The benefit is that a slow event commit can no longer starve lease
renewal, and a lease loss is visible in the run log and error code.

## Linked Issues or Issue Description

Refs #19407

**What happened?**
On staging, native runs with ACPX providers (Grok, Kimi, and Pi with
OpenRouter/DeepSeek) ran a long foreground shell command that printed
nothing (a 15–20 minute `sleep 60` loop). Each run went silent soon
after `tool.execution.started`. The command finished in the sandbox, but
the run never got the completion and stayed `running` until someone
cancelled it. It happened in 3 of 3 attempts. The same task on Codex
kept reporting activity and succeeded.

**What did you expect to happen?**
A quiet tool call keeps its runner connection and its lease. If the
connection or lease is lost, the run log and the error code say why.

**Steps to reproduce**
1. Assign a native ACPX agent a task that runs a foreground shell loop
with no output for longer than 2 minutes.
2. Observe that the run goes silent and does not finish after the
command ends.

**Root cause analysis**
- The ACPX path emits no PRP frames during a silent tool call. The
runner has no timer, and the agent sends no `tool_call_update`. Codex
keeps the connection busy, because its model polls the running command.
Silence is the only adapter-specific factor that I found. I found no
ACPX code path in runnerd that blocks the main loop: polls use a 1 ms
timeout, and synchronous sidecar requests run only for server commands.
- The staging build (`de1143448b`) did not include #19407. That build
had no server pings. With a 60-minute connection lease (the default for
`runner-prp-coordinator` and `DurablePrpCodexTransport`), the runner
renews only every 30 minutes. A fully silent connection that goes
through the sandbox preview proxy therefore carried no traffic at all. A
route that the proxy drops without a FIN then becomes half-open forever.
#19407 added 20-second pings and a 90-second termination, which closes
that window.
- The lease hypothesis from #19407 is real but secondary. The 60-second
lease applies only when the TTL is not set. Renewal waited behind any
slow event commit, and the server could not report lease loss. This pull
request fixes both.

## What Changed

- `DurablePrpControlPlane.attachWireConnection` decrypts each secure
frame when it arrives, in counter order. Frames that arrive before the
channel is secure, or behind such a frame, still decrypt in the chain.
- `lease_renew` and `pong` are handled at once, outside the
per-connection chain. Admission against persisted authority is
unchanged. Events, command results, and handoff frames keep their
serialized order.
- `#renewLease` persists the renewed expiry with `save()`, so an event
write that is already staged is absorbed into the same write. Before,
the renewal used `commit()`, which would invalidate that staged event
and close a healthy connection when renewal runs concurrently.
- New `connectionLeaseFailure` getter (exported type
`DurablePrpConnectionLeaseFailure`). It records
`connection_lease_expired` when an authenticated connection is refused
or closes with an expired lease. A new authenticated connection clears
it. A credential revoked by a warm handoff is not reported, because a
replacement runner owns the run.
- `connectRunnerPrpIngress` reads that reason after a close. It logs
`state=reconnecting failure=runner_connection_lease_expired` in the run
log. If reconnect grace runs out, it fails with that code instead of
`runner_ingress_unavailable` or `runner_ingress_auth_failed`, and keeps
the reconnect error as the cause.
- `packages/paperclip-runner/docs/durable-recovery.md` documents the new
behavior.

## Verification

- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/control-plane` (220 passed).
- New: "answers lease renewal while an earlier event commit is still in
flight". It was red before the fix (no `lease_renewed` while
`onCommittedEvent` waits), and it is green now. It also checks that the
event later commits and ACKs, and that the renewed expiry survives.
- New: "keeps a staged tool-progress write when a lease renewal lands
mid-write". It holds a file-backed `tool.execution.progressed` write at
`fsync`. It is red with the old `commit()` renewal (the event is dropped
and the connection closes), and green with `save()`.
- New: "reports an expired connection lease instead of closing
silently". It was red before the fix and is green now.
- `pnpm --filter @paperclipai/server exec vitest run
src/realtime/runner-prp-outbound.test.ts
src/realtime/runner-prp-ws.test.ts
src/services/native-runtime/runner-prp-coordinator.test.ts` (all
passed).
- New: "names an expired runner connection lease in the run log and
terminal error when reconnect is refused/unauthenticated". It was red
before the fix and is green now.
- `src/live/runnerd-codex-transport.test.ts` has the same result with
and without this change with a locally built `paperclip-runnerd`: the
same 48 tests fail locally on both, for environment reasons, and the
other 207 pass.

## Risks

- Lease renewal now runs concurrently with an in-flight event handler.
The handler builds its state candidate at commit time, and `save()`
absorbs a staged event write. A new regression test covers the
file-backed race. External checkpoint persistence commits events
synchronously, and the renewal reply still waits for the checkpoint
flush.
- Renewal still stays silent while a warm handoff or a terminal
lifecycle command is pending. The runner treats any other control frame
inside those fences as fatal, so this pull request does not change that
behavior.
- No protocol, schema, or contract-digest change. The runner is
unchanged.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, test runs) and a read-only
exploration subagent on the same model.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] 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>
2026-11-06 02:01:01 -06:00
..