Files
PaperClipAI/server
Devin FoleyandPaperclip 80c308f872 fix(runner): admit follow-ups after recovered per-turn owners (#18846)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native Runner sessions keep one conversation across task turns.
> - After a per-turn sandbox turn, the controller saves a Runner
suspension receipt or a failover backup.
> - A full sandbox disk can stop that save after the turn is complete.
> - Recovery then commits the result, but the controller has no proof
that the Runner suspended.
> - Each follow-up rebuilds the old controller journal from the database
and rejects it again.
> - This pull request lets admission retire that old authority when the
prior owner is provably finished.
> - The benefit is that the task continues with its saved workspace and
does not stay blocked.

## Linked Issues or Issue Description

Refs #18705 (checkpoint save on a full sandbox disk). Refs #18829,
#18718 and #18343 (related admission and controller recovery). Refs
#18811 (a related warm continuation wait).

**What happened?**

A per-turn native run in a remote sandbox finished its turn. The
post-turn `harness_state.backup.persist` and
`session.checkpoint.persist` steps failed because the sandbox disk was
full. The controller got no Runner suspension receipt and no failover
backup. Workspace recovery later saved the files. The run committed its
result and ended as `succeeded`. A board comment then started a
follow-up in a new sandbox. Admission failed after about two minutes
with `runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate`. Execution recovery then
blocked the task.

The cause is in durable-root admission.
`restorePerTurnRunnerdController` rebuilds the prior controller journal
from the database checkpoint. `verifyPriorRunnerdStateForSessionScope`
finds a terminal owner in the same scope with no suspended lifecycle and
no retained warm owner. It returns `terminal_state_indeterminate`.
Admission quarantines the root and fails. The database checkpoint does
not change, so every later admission rebuilds the same journal and fails
again.

A board Retry after that failed follow-up does not repeat the same
error. Runtime selection picks the failed follow-up as the resume
source, because it carries an inherited provider checkpoint. Controller
recovery then fails with
`native_controller_recovery_unsupported:metadata_format`. The task stays
blocked. This pull request prevents that failed follow-up. It does not
change source selection (see Risks).

**Expected behavior**

When the prior owner is provably finished, the follow-up must start. It
must keep the saved workspace. It must not start while the prior owner
can still be alive, while terminal delivery or provider cleanup is
unsettled, or while quarantine holds evidence from a different owner.

**Steps to reproduce**

1. Complete a per-turn remote sandbox run. Commit its result through
recovery and release its controller lease.
2. Keep its database controller checkpoint and recovery pointer. Do not
save a suspension receipt or a failover backup.
3. Admit a follow-up in a new sandbox with no local controller
projection.
4. Admission rebuilds the old journal, quarantines it, and fails with
the error above.
5. Admit the same run again. It rebuilds the journal and fails the same
way.

The new regression test runs these steps against PostgreSQL.

**Paperclip version or commit**

Reproduced on `e0276e0166`.

**Deployment mode**

Native Runner with a per-turn lifecycle, the built-in remote sandbox
transport, and the ACPX provider. The admission path is the same for
other per-turn sandbox providers.

## What Changed

- Add `retireFinishedPerTurnRunnerdOwner` to durable-root admission in
`native-session-executor.ts`. It runs only when the existing verifier
returns `terminal_state_indeterminate`, and only without a restart claim
or a retained warm owner.
- The function requires all of these. Otherwise the existing quarantine
and rejection apply.
- Per-turn lifecycle on the built-in remote sandbox transport, with a
`created` or `replacement` sandbox. A resumed sandbox, a warm session,
and a plugin task keep their existing paths.
- No local Runner lifecycle evidence for the old owner. The session root
contains only known authority entries.
- A journal for that owner with its terminal delivered and only idle
goal snapshots after it. All commands are completed. Each tool input has
a completed result. No warm transition or staged attachment exists.
- A terminal prior run in the same task. Its coordinator is `committed`
with a result, has no lease owner, and has no recovery state.
- A stopped prior controller, from `evaluateNativeControllerTakeover`
with `requireStoppedController`. A durable controller is fenced by its
released database lease, as in remote warm recovery.
- Each quarantined root for the scope passes the same journal and local
Runner evidence checks for the same owner. A retry rebuilds only the
database journal, so quarantined Runner evidence must stay decisive.
- When the proof holds, the function reads the journal again, moves the
old authority into `continuity-breaks/<time>-<uuid>` in the session
root, and logs one line. The follow-up then starts a new Runner
authority. The provider session follows the existing resume-or-replace
policy.
- Share the list of continuity archive entries with provider replacement
(`archiveContinuityState`). Provider replacement behavior does not
change.
- Add `native-recovered-owner-followup.test.ts`. It uses real
PostgreSQL, a real encrypted controller checkpoint, and the production
source selector and checkpoint rebinder.
- Document the rule in `doc/execution-semantics.md` and the test in
`doc/testing-native-remote-continuity.md`.

### Design choice

I considered two approaches:

1. Start a new Runner authority for the scope at admission (this pull
request).
2. Make the recovery path that committed the prior run also settle its
Runner and controller projection.

I chose the first approach for these reasons:

- The provider history stayed in the unreachable sandbox. A settled
projection cannot make it resumable. Admission would pass, but a
replacement sandbox would then fail on the missing backup, and a new
sandbox would only try to resume a session that is gone.
- A settled receipt written by recovery would claim a suspension that
the Runner never wrote. Other checks rely on that receipt as Runner
evidence.
- Several paths can commit a result. Only runs that commit after a
settle-at-commit fix would benefit. The admission fix also unblocks
tasks that were committed before it.
- Admission already owns this decision. It already has the scope claim,
the journal, and the database rows.

### Safety argument

- **No live prior owner.** The coordinator has no lease owner, and the
controller must be proven stopped. The follow-up runs in a different
sandbox than the old Runner. A resumed sandbox keeps the existing path,
which reads the old Runner's own lifecycle there.
- **No unsettled terminal delivery or effects.** The controller
acknowledged the terminal and completed every command, including the
suspend command. Every tool input has an answer, and the coordinator
committed the result. The unreadable Runner outbox can then hold only
post-terminal telemetry.
- **No unsettled cleanup.** Any local Runner receipt, including an
unsuspended one, keeps the hold. A coordinator recovery state keeps the
hold.
- **No ambiguous quarantine.** A quarantined root from a different owner
keeps the hold. A quarantined root with Runner lifecycle evidence,
unknown entries, or an unsettled journal also keeps the hold.
- **No lost evidence.** The move keeps the old authority in the session
root. Existing retention expires it like other continuity breaks. A
journal change during the database reads cancels the move.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-recovered-owner-followup.test.ts`: 12
tests pass.
- Before the change, the four admission tests failed with the exact
error above. This includes a re-admission that rebuilt the journal from
the database and failed again.
- After the change, `created` and `replacement` sandboxes are admitted.
A re-admission is admitted. A task with an earlier quarantined copy of
the same owner is admitted.
- Eight controls keep the hold: uncommitted result, live controller
lease, unexpired controller lease, recovery state, pending command,
unanswered tool input, a different owner in quarantine, and an
unsuspended Runner receipt in quarantine after a retry rebuilds the
root.
- I disabled each new guard in turn. Each change made its matching
control fail.
- An exploratory run of the same fixture before the change answered the
Retry question above. The second follow-up selected the failed follow-up
as its source and failed with
`native_controller_recovery_unsupported:metadata_format`.
- `cd server && npx vitest run
src/services/native-runtime/native-session-executor.test.ts`: all 1,087
tests pass.
- Related suites pass: `task-workspace-binding`,
`native-controller-recovery` (unit and integration),
`native-harness-backup-expiration`, `native-session-resume`, and the
restart and diskless integration suites. Their container and Runner
binary cases skip locally.
- `node scripts/typecheck-low-memory.mjs`, `pnpm
check:module-boundaries`, and `git diff --check` pass.
- CI passes at `879bb6a5c7`. Two attempts lost jobs when CI runners
received a shutdown signal; the third attempt passed every job.
- I did not run the full `pnpm test:run`, `pnpm build`, or the opt-in
Linux Runner container cases locally. CI runs them.
- I did not test a live sandbox provider or a live model. I could not
inspect the original checkpoint contents. If a stranded run's journal
does not show a delivered terminal and completed commands, its follow-up
stays held.

## Risks

- A follow-up after this path cannot use the lost provider history. The
provider session then follows the existing resume-or-replace policy, and
the task context comes from Paperclip. A checkpoint that requires the
same session, such as an unfinished goal, still fails at provider
recovery.
- Post-terminal Runner telemetry in the lost outbox, such as a late
usage report, stays lost. It cannot carry a workspace effect.
- A task that already has a failed follow-up from this error can still
be blocked on a board Retry. Source selection picks that failed run and
fails with `native_controller_recovery_unsupported:metadata_format`.
This pull request does not change source selection. A same-run retry of
the failed follow-up is admitted.
- The change is limited to per-turn runs on the built-in sandbox
transport. Warm sessions, plugin tasks, resumed sandboxes, and restart
recovery do not change.
- No migration or configuration change is required.

## Model Used

- Anthropic Claude Opus 5.5 (model ID `claude-opus-5-5`) in Claude Code,
with extended thinking, repository tools, shell and code execution, and
local test runs. The runtime does not expose the context window size.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-03 13:24:16 -08:00
..