Files
PaperClipAI/server
DottaandClaude Opus 5.5 66344b76ae fix(native): restart a bootstrap whose Runner exited before writing provider state on a resumed sandbox (#20972)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs execute through a Runner (`paperclip-runnerd`) inside a
remote sandbox. A warm Daytona sandbox is reused by later runs.
> - When a Runner exits before `session.open`, the run does a bounded
same-run bootstrap retry. Durable evidence proves that no provider
session and no provider event exist.
> - The retry fails when the Runner wrote `runner-state.json` but exited
before it wrote its provider state. Host preparation then calls the
sandbox state "unavailable" and refuses with
`runner_harness_state_mismatch: resumed_unavailable`. The task needs
Retry.
> - This pull request reads the Runner state on its own when the full
check fails, but only for a proven bootstrap restart of the same run. A
non-suspended Runner state of this run is then the run's own interrupted
bootstrap, and the existing restart path moves it aside and claims a new
session root.
> - The benefit is that a Runner that exits before `session.open` on a
reused sandbox recovers without a person. Saved state, a verified backup
and every other mismatch keep their refusal.

## Linked Issues or Issue Description

**What happened?**
Restart-chaos wave `w-20261111T230537-s6402` (Claude through ACPX,
Daytona warm sandboxes, `--ephemeral-server`, K1/K2/K15 with
`--k15-fresh-home`, seed 6402) needed Retry on 4 of 6 tasks, with the
same signature after each restart kind. The first attempt of each run
reused the warm sandbox. Its Runner failed with
`runner_ingress_bind_conflict: failed to bind listener: Address already
in use` and exited with `lifecycle=connecting`. Recovery logged
"Restarting an incomplete native runner bootstrap on the same heartbeat
run". The second attempt reused the environment and failed with
`runner_harness_state_mismatch: resumed_unavailable`. The earlier wave
`w-20261111T212429-s6402` had one run with the same error code.

**Root cause**
`inspectRemoteHarnessState` in
`server/src/services/native-runtime/native-session-executor.ts` checks
the persistence directories, `runner-state.json` and the provider state
file in one shell test. A Runner that exits at bind time wrote
`runner-state.json` but no provider state, so the one test fails and the
result is `incompleteReason: "unavailable"` before the Runner state is
read. The restart path (`restartableBootstrapState`) accepts only
`not_suspended` and `provider_unopened`, so it is skipped.
`claimUntouchedRemoteSession` then cannot create the session root that
the first attempt created, and the resumed branch throws
`resumed_unavailable`. On a created lease the same state fell through
without a restart.

**Expected behavior**
A proven, interrupted bootstrap of the same run restarts on the same
sandbox. The run finishes without Retry.

**Steps to reproduce**
Run the s6402 cell above, or add `--runner-bind-fault` (this pull
request) to force the Runner exit on every run's first launch.

## What Changed

- `server/src/services/native-runtime/native-session-executor.ts`: when
the full harness check fails during host preparation,
`inspectOwnUnpreparedBootstrap` reads `runner-state.json` alone. It
returns `not_suspended` for this run only when all of these are true:
this run has a proven bootstrap restart (`bootstrap_incomplete` recovery
plus the stop proof), no failover backup exists, no archived rotation
snapshot is in use, and the Runner state names this run, this Runner
instance and this session with a lifecycle other than `suspended`.
Otherwise the result stays `unavailable`, as before. The existing
restart path then moves the session root aside (it is kept, not deleted)
and claims a new one.
- Chaos coverage: `--runner-bind-fault` in `tests/restart-chaos` gives
every server of the wave
`PAPERCLIP_TEST_RUNNER_BIND_FAULT=first_launch`. With `NODE_ENV=test`
only, the remote Runner launcher starts a short-lived listener on the
Runner's listen port before a Runner's first launch on that server, so
the Runner exits with a real `runner_ingress_bind_conflict`. Documented
in `tests/restart-chaos/README.md`.

## Verification

- Red on master: `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/native-session-executor.test.ts -t
"bootstraps only an untouched provider session"` failed 2 of the new
cells. Resumed lease: `Error: runner_harness_state_mismatch:
resumed_unavailable` (the staging error). Created lease: no restart
happened (`expected [] to have a length of 1 but got +0`).
- Green: the same cells pass. The negative cells keep
`resumed_unavailable`: no restart proof, a Runner state of another run,
a `suspended` Runner state with missing provider state, and a failover
backup present.
- `native-session-executor.test.ts`: 1406 of 1406 pass. A new test
proves the bind fault holds the listen port (a second bind gets
`EADDRINUSE`) and fires once per Runner instance.
- `tests/restart-chaos`: `runner-bind-fault.test.ts`,
`question-handoff.test.ts`, `shape-cells.test.ts` pass. Server and
harness `tsc --noEmit` pass.
- Chaos wave: see the comment below for the before/after counts.

## Risks

- The new read runs only for a proven bootstrap restart of the same run
with a failed full check, so the normal path has no extra command and no
wait before `turn.start`.
- A partly saved session (`suspended` Runner state without provider
state), a Runner state of another run and any failover backup keep
today's refusal. The moved-aside root is kept for inspection.
- A Runner that started under an older server and is restarted by this
version takes the same path, because it reads only files that older
Runners already write.
- The bind fault is test instrumentation. It is inert unless
`NODE_ENV=test` and the variable are both set.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use (shell, file edit, test runs). Default context window,
extended reasoning 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
- [x] I will address all Greptile and reviewer comments before
requesting merge

Related: #20928 (restart a Runner that exited before session.open),
#20874, #20863, #20953.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:36:33 -06:00
..