Files
PaperClipAI/doc
DottaandClaude Opus 5.5 2539004201 fix(ephemeral): start a restarted volume-free server over the Runner controller files it wrote itself (#20952)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Cloud servers run volume-free (`PAPERCLIP_EPHEMERAL_SERVER=true`).
Postgres and object storage hold the durable state. A startup gate
refuses legacy volume data that was not migrated.
> - The gate reads one family of data from disk: Runner controller files
under `runtime/paperclip-runner/durable-sessions/`. It treats a
controller file that the database does not prove as legacy data.
> - A volume-free server also writes these files during normal work. A
warm follow-up restores the previous run's checkpoint as a full
`durable.control-plane-state.v2` file. The file stays full until the new
controller registers its database checkpoint writer.
> - When the server crashes in that window and restarts on the same
filesystem, the gate finds its own file and refuses to start. Every
later start refuses the same way. This is a full outage that no Retry
can fix.
> - This pull request gives the gate a durable marker. A volume-free
server claims an empty state directory before any controller can write.
Files in a claimed directory belong to a volume-free server and do not
block startup.
> - The benefit is that a crash restart of a volume-free server starts
again. Genuine legacy data keeps its protection, and the refusal now
names the run, task and file.

## Linked Issues or Issue Description

**What happened?**
The first restart-chaos waves in volume-free server mode (harness flag
`--ephemeral-server`, #20930) found this outage. Wave
`w-20261111T212429-s6402` (Claude through ACPX, Daytona warm sandboxes)
did a fresh-home deploy and then a `kill -9` of that server. The
replacement server exited with `Volume-free server data is not eligible:
native_controllers_require_migration`. A second start at +608 s exited
the same way. The server stayed down.

**Root cause**
`hasLocalNativeControllerObligations` in
`server/src/services/native-runtime/native-controller-migration.ts`
scans every Runner session in the state directory. The kept instance
shows the cause. The session's newest run `4a4d7ca1` was `running`
(admitted 3 s before the kill). Its controller root held the full
`paperclip.runner.durable.control-plane-state.v2` file of its warm
predecessor `9c13b7f8`. A controller writes that full file when it opens
and replaces it with the `external-control-plane-state.v1` marker only
in `setCheckpointPersistence`. `preservedActiveCheckpoint` accepts only
the marker, so the gate returned `true`. A Codex wave with the same
fault sequence passed because no warm follow-up was in this window at
the kill. Claude W2 cells start a new warm follow-up every 15 to 35 s,
so the window is open much more often.

**Can staging hit it?**
Yes, with a restart that keeps the container. Railway documents that a
manual Restart "restarts the process within the deployment's container",
so the filesystem stays. Railway does not document whether an automatic
crash restart reuses the container. A redeploy makes a new container, so
it does not hit this.

**Expected behavior**
A volume-free server starts again after any restart. State that it wrote
itself is disposable, because the database checkpoint is the durable
record. Data that a volume-backed server wrote keeps its current
protection.

## What Changed

- Add
`server/src/services/native-runtime/volume-free-controller-state.ts`.
`claimVolumeFreeControllerState(true)` writes `.volume-free-owner.json`
atomically (temporary file, fsync, rename, directory fsync) when the
state directory is absent or empty. It returns `owned` for a directory
that has the marker and `unclaimed` for a directory with other files.
`claimVolumeFreeControllerState(false)` removes the marker.
- `server/src/index.ts` calls the claim right after the volume-free
configuration check. This is before any controller or recovery can
write. A server started without volume-free mode releases the claim,
because it may write files that exist only on its disk. An `unclaimed`
result logs a warning.
- `hasLocalNativeControllerObligations` ignores the marker entries. It
returns no obligation for a claimed directory. It now delegates to
`findLocalNativeControllerObligation`, which returns the run, task and
file (or `database_checkpoint`).
- `assertEphemeralServerData` adds that detail to the
`native_controllers_require_migration` refusal. The error says which run
and file the database does not prove and why startup refuses.
- Document the marker in `doc/DEVELOPING.md` ("Runner controller state
on a restarted volume-free server").

## Verification

- Red, before the gate reads the marker: `pnpm --filter
@paperclipai/server exec vitest run
src/services/native-runtime/native-controller-migration.integration.test.ts
-t volume_free_owner` failed with `AssertionError: expected true to be
false` at the first gate check. The fixture is a running warm run whose
root holds a full `durable.control-plane-state.v2` file, the same shape
as the incident.
- Green: the same test passes. It checks: claimed directory gives no
obligation; release gives the obligation back; a non-empty unclaimed
directory stays blocked; the refusal names the run; the controller file
is never changed or removed.
- `ephemeral-server-data.test.ts`, `ephemeral-server-contract.test.ts`
and all 57 cells of `native-controller-migration.integration.test.ts`
pass. Server `tsc --noEmit` passes.
- Probe on a copy of the kept instance database and the killed server's
state directory: the unmodified gate returned an obligation for run
`4a4d7ca1` (source file
`.../7dbf2d5c.../control-plane/control-plane-state.json`).
- Chaos: the s6402 shape (`--ephemeral-server --adapters claude
--workloads W2 --faults K1,K2,K15 --k15-fresh-home --seed 6402`) runs on
this branch merged with #20930. The result is added in a comment.

## Risks

- A filesystem that a volume-free server claimed and that a pre-marker,
volume-backed server then wrote is not detected. This needs a downgrade
and a mode change on one disk. A current server in volume mode removes
the marker, which closes the normal path.
- A volume-free server on a directory written before this change (no
marker) keeps today's refusal. A new deploy makes a new container, so
staging claims the empty directory on first start after this deploy.
- Other scanners of the state directory filter on the 64-hex session
pattern, so the marker file does not change them.

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:59:57 -06:00
..
…
…
…
…