Files
PaperClipAI/packages
DottaandClaude Opus 5.5 20fc37778f fix(runner): keep settled OpenCode sessions across a controller proxy rebuild (#19641)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The native Runner keeps an OpenCode conversation in durable state,
so a task can continue after a control-plane restart.
> - Saved OpenCode state binds one digest of the launch profile: the
Node command, the proxy bundle and the OpenCode executable, each by path
and SHA-256.
> - The Node command and the proxy bundle are controller runtime. A
rebuild or deploy between a hard kill and recovery replaces their bytes.
> - The recovery probe then refused a settled conversation with
`provider_state_incompatible`, although the OpenCode release and the
conversation were unchanged.
> - ACPX already re-binds a settled session after a Runner upgrade
(#18879). OpenCode did not.
> - This pull request records the OpenCode release digest in saved state
and re-binds a settled session when that release is unchanged.
> - The benefit is that a task continues automatically after a restart
that also changed the controller build. A real release change still
fails closed.

## Linked Issues or Issue Description

Refs #19558, #19501, #18879.

**What happened?**

A restart-chaos wave killed the control plane with SIGKILL while an
OpenCode run was settled between turns. The first recovery attempt
restored the session. The controller's runner `dist` was then rebuilt in
the same checkout. The next recovery attempt failed in the
`runner.drain` resume probe with `OpenCode runner launch profile changed
across durable recovery` (`native_provider_state_incompatible`). The
saved state matched the original proxy bytes exactly. Only the proxy
bundle bytes had changed. The OpenCode executable, the paths and the
model were the same.

**Expected behavior**

A settled OpenCode session continues after a restart when only
controller runtime bytes changed. A changed OpenCode release, or a
session with work in flight, fails closed. The existing disposition then
offers one Retry that continues in a fresh provider session.

**Steps to reproduce**

1. Start an OpenCode run and let it settle between turns.
2. Kill the runner and the controller.
3. Replace the qualified proxy bundle with valid new bytes (rebuild or
deploy).
4. Recover the run. The resume probe fails with
`provider_state_incompatible`.

**Paperclip version or commit**

`master` at a08f9494ea.

## What Changed

- `CodexProviderState` records `opencodeReleaseDigest`: the SHA-256 of
the qualified OpenCode executable, which owns the saved conversation.
- Restore re-binds a saved OpenCode session to the current launch
profile only when all of these are true:
  - The saved release digest equals the current executable digest.
- The saved configuration names the current command and proxy paths (the
existing binding check).
- The session is settled: no active or ambiguous turn, startup attempt,
receipt-limit interrupt, pending tool call, undelivered or queued
provider event, or working goal. The lifecycle is `prepared`,
`session_open`, or `provider_exited` (an observed idle exit that restore
already restarts).
- Every other mismatch keeps the exact binding and fails closed with
`provider_state_incompatible`. State written before the new field has no
release digest, so it stays exact.
- Session start still verifies every launch artifact and the restored
provider identity.
- `docs/durable-recovery.md` documents the OpenCode re-bind rule next to
the ACPX rule.
- The #19558 regression that required refusal of a new proxy for a
prepared run now asserts the re-bind. The remote pack retention from
#19558 is unchanged.

## Verification

- Red first:
`settled_opencode_session_continues_after_a_controller_proxy_rebuild`
failed on unchanged production code with the production message, from
the same `runner.drain` probe. It passes after the change. It also
checks that the conversation is resumed (`thread/resume`), that a new
turn completes, and that the re-bound digest persists.
- Counter-tests that still refuse: changed OpenCode release on a settled
session (state bytes unchanged), proxy rebuild with an active turn
(state bytes unchanged), and legacy state without a release digest. The
existing executable-drift test still refuses.
`exited_opencode_session_continues_after_a_controller_proxy_rebuild`
covers a kill after an observed idle provider exit (red before the
follow-up commit, green after).
- `cargo test -p paperclip-runner-core --test native_provider_backend`:
45 passed (one unrelated Pi lifetime-fence test flaked once under load
35 and passed 3/3 alone). `cargo test -p paperclip-runner-core --lib
provider_backend`: 145 passed. `cargo fmt` and `cargo clippy` are clean
for the changed code.
- Live restart-chaos waves (local env, OpenCode only, concurrency 2,
`--rc06-masked`). A drift injector appended a valid comment to the local
OpenCode proxy bundle after every fault took the controller down, so
each recovery saw a changed proxy digest.
- Wave 1, seed 3201, 4 tasks, 4 × K1: 3 tasks finished automatically.
The fourth was killed with a turn in flight. Recovery failed closed with
`native_provider_state_incompatible`, as designed. One Retry worked on
the first click and completed the task (R3 pass). That task's grade is
`R5:silent_dead_run`: the Retry run was quiet for 180 s during the B5
benchmark. This is not a launch-profile failure.
- Wave 2, seed 3207, 3 tasks, K1 × 2 and K6: 3/3 pass. The K6 task also
had a turn in flight. It failed closed and one Retry completed it.
- In both waves every kill landed with a turn in flight or before a
durable session. The settled re-bind path was therefore not reached
live. The Rust executor regression above covers that path.
- Environment: macOS arm64, Node 26.4.0, local env, load average 17–39.

## Risks

- A settled OpenCode session now runs on the current proxy bundle after
a controller upgrade. The proxy keeps no state of its own; OpenCode owns
the conversation store. ACPX already accepts the same trade-off for its
sidecar.
- Sessions with work in flight are still refused. This is conservative
and matches ACPX.
- Runs saved by an older Runner have no release digest, so they keep the
old exact behavior once.
- Cursor Cloud keeps its exact binding. It is out of scope.

## Model Used

Anthropic Claude, model ID `claude-opus-5-5`, used through Claude Code
with tool use (shell, file edits) and extended reasoning.

## 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
- [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 19:18:29 -06:00
..