Files
PaperClipAI/server
DottaandClaude Opus 5.5 884a9e19ea fix(native): launch a warm follow-up from its predecessor's saved journal, and restart a Runner that exited before session.open (#20928)
## Thinking Path

- Paperclip runs agent work through a controller (server) and a runner
(`paperclip-runnerd`) that exchange numbered commands; the runner
refuses any command whose number is not exactly one past the last it
processed.
- Staging's most common failure is "Runner gone"
(`runner_remote_process_identity_unavailable`,
`native.remote_runner_recovery_failed` with `process_not_running`,
`runnerLifecycle: ready`, `runnerSettled: false`). Chaos wave
`w-20261111T192658-s5201` (Daytona, warm, Codex, W2/W2Q/W2C re-wake
cells) captured it live: the new runner wrote `controller sequence must
be contiguous: expected 13, received 11` (and `expected 5, received 3`)
and exited 0.15 s after authenticating.
- In all three captured failures the follow-up run started 1–2 s before
the predecessor's warm session was closed (`transport_close_requested`
for the predecessor arrives after the follow-up's `run started`). The
close is issued by the follow-up's own admission.
- Admission first materializes the controller projection for the session
root. With an idle retained warm owner the root only holds the
external-persistence marker, so `restoreWarmRunnerdController` copies
that owner's saved checkpoint into the root. Later in the same
admission, `executePaperclipNativeSessionWithinScope` finds the owner
cannot be reused (configuration, process or GitHub access changed) and
retires it. Retirement sends `runner.drain` and `runner.suspend`; both
are saved to the owner's checkpoint before they are sent, but the root
still holds the projection taken before the close.
- The follow-up then opens its controller from that projection. Its
journal is two commands behind the runner's durable
`lastControllerCommandSeq`, so its first command repeats sequence 11;
the runner treats this as fatal and exits, and every later attempt only
reattaches to a process that is gone.
- So the saved controller journal was persisted correctly; it was not
selected: the projection is never refreshed after the retirement that
extends it.

## Linked Issues or Issue Description

- Refs #20874 (the runner-exit proof this extends from a rejected
`session.open` to a Runner that exited before answering it)
- Refs #20907 (makes the runnerd exit text, including `controller
sequence must be contiguous`, visible in the run log; independent of
this fix)
- Refs #20853 (related: resumes a remote Runner that died at a settled
`ready` cut; this PR covers the unsettled `ready` state left by an exit
before `session.open`)
- Duplicate search: searched open and closed PRs for "controller
sequence contiguous", "runner_remote_process_identity_unavailable" and
"Runner gone"; none fixes the stale controller projection.

## What Changed

- `server/src/services/native-runtime/warm-controller-journal.ts` (new):
- `refreshRetiredWarmOwnerProjection` reloads the retired owner's
canonical checkpoint into the session root after retirement, only when
the root still projects that owner and the saved journal extends the
projected one. A diverged journal holds admission with
`native_runner_checkpoint_projection_held:retired_owner_journal_diverged`;
a projection changed underneath holds with `...:journal_changed`.
- `reconcileControllerJournalWithRunner` is the defence for state
already saved in the bad shape (old-saved/new-resumed, mid-run deploys).
When the remote runner state shows its journal ahead of the controller
projection and the runner's last command is a completed
`runner.suspend`, it reconciles only if every extra sequence is a
completed `command_close_drain_*`/`command_close_suspend_*` in the
runner's processed-command journal and the predecessor's canonical
checkpoint holds exactly those commands. Anything else refuses before
launch with
`native_controller_recovery_missing_evidence:controller_journal_behind_runner`
instead of guessing. A runner whose last command is not a completed
suspend is left to the existing live-adoption checks.
- Defence in depth for the server side of the same failure
(`native_session_recovery_failed: runnerd exited while waiting for
session.open`, then `runner_remote_process_identity_unavailable` on
every same-run retry): a Runner proven to have exited while this
attempt's opening command (`session.open`, or the recovery probe) was
still unanswered is a failed fresh start, not a live Runner to reattach.
- `runnerd-codex-transport.ts`: `runnerExitConfirmed()` (#20874's proof)
now also holds for a resumed session whose provider identity was
preloaded, when the attempt's opening command is still pending at the
proven exit.
- The Codex driver's recovery result, `HarnessDriverBackend` and
`native-session-runtime` carry that proof on the
`native_session_recovery_failed` error.
- The executor records `runnerExitedBeforeSessionOpen` on the settled
failure; restart recovery puts `exitedBeforeSessionOpen` on the
`reattach_remote_runner` claim; `verifyRemoteRunnerOrSettledExit` and
`verifyStagedRemoteRunner` then start a new Runner when the process is
gone and the saved Runner state has no pending terminal delivery,
provider cleanup or warm transition (any lifecycle other than an active
turn). Attempt budget and identity checks are unchanged.
- `native-session-executor.ts`: calls the refresh right after the
configuration-changed retirement of a warm owner, and the reconciliation
in `restoreRemoteSuspendedRunnerCheckpoint` with the runner state it
already reads (no added remote read, no wait before `turn.start`).
- Tests:
- `native-session-executor.test.ts`: new `changed-external-checkpoint`
scenario of "rotates an owned warm codex remote runner before checking
changed configuration" with a real `DurablePrpControlPlane`: the retired
owner's close saves drain/suspend, and the journal the follow-up
launches from must end with them.
- `warm-controller-journal.test.ts`: refresh, divergence hold, untouched
marker/other run, reconcile of a settled close, refusal when the
canonical journal cannot prove the close, refusal for any other extra
command, and no action for a live runner.
-
`packages/paperclip-runner/src/live/runnerd-stale-controller-journal.test.ts`:
real `paperclip-runnerd` + fake Codex. A follow-up controller on the
predecessor's suspended runner with a journal that lacks the close
commands reproduces `controller sequence must be contiguous` and loses
the runner; with the current journal it resumes.

## Verification

- Red for the added part: `verifyRemoteRunnerOrSettledExit` with the
staging state (process gone, lifecycle `ready`, unsettled outbox)
rejects with `runner_remote_process_identity_unavailable` without the
change; with `exitedBeforeSessionOpen` it resolves to a fresh start, and
stays fail-closed for a pending cleanup, terminal delivery, active turn
or exhausted budget. Without the propagation, the backend recovery
result lacks `runnerExitConfirmed`; the real-runnerd stale-journal test
asserts the transport's exit proof after the contiguity exit (`expected
false to be true` on the old transport).

- Red on master (executor unchanged, new scenario): `AssertionError:
expected [] to deeply equal [ 'runner.drain', 'runner.suspend' ]` — the
follow-up launched from the projection taken before the predecessor's
close.
- Green: `cd server && npx vitest run
src/services/native-runtime/native-session-executor.test.ts -t "rotates
an owned warm codex remote runner before checking changed
configuration"` (14 passed), `npx vitest run
src/services/native-runtime/warm-controller-journal.test.ts` (7 passed).
- Real runner: `cd packages/paperclip-runner && npx vitest run
src/live/runnerd-stale-controller-journal.test.ts` (stale journal →
`controller sequence must be contiguous`; current journal → resumes).
- `pnpm --filter @paperclipai/server typecheck`, `pnpm --filter
@paperclipai/paperclip-runner typecheck:typescript`; `pnpm verify`
selected broader verification (a new runner test file is outside the
resolved graphs), so broad coverage comes from required CI.

## Risks

- The refresh replaces the root projection only with a checkpoint whose
identity matches and whose journal extends the projection; otherwise it
holds admission with an existing
`native_runner_checkpoint_projection_held:*` code, which is safer than
launching a runner that will exit.
- The defence refuses launch when a runner is ahead by anything other
than a provable settled close. Before this change that launch always
ended in a runner exit and a failed run, so the refusal only changes the
error code and the moment of failure.
- This PR does not change the runner. The runner still exits on an
out-of-order new command; a follow-up runner PR will turn that into a
rejected command result.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), 1M-token context, tool
use (shell, file edits, tests) in Claude Code.

## 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-11 15:41:03 -06:00
..