Files
PaperClipAI/packages/paperclip-runner/runner/DURABLE_TRANSPORT.md
T
DottaandClaude Opus 5.5 c0c26188db fix(runner): stamp event generations and record an earlier generation's events as history (#19960)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs execute through the Paperclip Runner, which turns
provider output (Codex, OpenCode, Claude through ACPX) into durable
runner events
> - In the restart chaos campaign, events from an earlier turn or runner
generation failed the current turn about ten times (RC-15, 20, 20b, 20c,
33, 33b, 59 and 61)
> - #19718, #19722, #19772 and #19783 each special-cased one variant,
and other variants still fail the run
> - The durable-ownership plan (section G, invariant 6) asks for one
rule: every event carries its generation and turn, and an earlier
generation's events are recorded as history and never fail the current
turn
> - This pull request stamps the runner generation on every event and
applies one stale-event rule in the ACPX provider state, the Codex
driver (which Runnerd also uses for OpenCode) and the pending
`turn.start` check
> - The benefit is that a late report about a settled or restored turn
no longer stops a healthy turn, while events for a turn the runner never
saw still fail closed

## Linked Issues or Issue Description

Refs #19718, #19722, #19772, #19783

**What happened?**
A runner or provider can report a turn after that turn settled: a
resumed provider reports the lost turn's usage or terminal, an ACPX
sidecar emits a last delta after the turn terminal, or a held failure of
the previous turn sits in front of a new `turn.start`. Several of these
paths fail the current turn: ACPX `AcpxEventScope` rejects any event for
a non-active turn ("named a stale turn"), the Codex driver fails
`turn_binding_mismatch` for a settled turn's late item between turns,
and `runnerdPendingTurnStartFailure` reports any held non-recoverable
failure, whatever turn it names. Runner events carried no generation.

**Expected behavior**
An event for a turn that is not active, and that an earlier generation
accepted or this generation already settled, is recorded as history and
never fails the current turn. An event for a turn the runner never saw
still fails closed.

**Steps to reproduce**
ACPX: settle turn 1, start turn 2, and deliver a late `runtime.event`
for turn 1. Before this change, the provider state returns an error and
the run fails.

## What Changed

- Runner (`durable/state.rs`, `durable/runner.rs`): every event payload
carries `generation`, the runner process generation that emitted it. A
runner process that recovers state starts the next generation
(`begin_recovered_generation`); it is saved with the next normal write,
so recovery adds no write before the first turn. Loading stays pure. Old
saved state loads as generation 0. Outbox events keep their original
generation.
- PRP event schemas v1–v3 declare the optional `generation` property;
generated validators and the manifest are regenerated.
- ACPX (`acpx_event_scope.rs`, `acpx_provider_state.rs`): an event that
names a settled turn of this run becomes a bounded
`stale_generation_event` diagnostic (32 per state) and changes no state.
Unknown turns still fail.
- Codex driver (`codex-session-state.ts`,
`codex-session-notifications.ts`): one predicate, `isHistoryTurn()`: the
turn is not active, and it was restored from the checkpoint (an earlier
generation) or settled in this generation. The classifier's stale-turn
branch, the resume-usage branch, restored-terminal replay and the main
fail point (`notificationNamesActiveTurn`) all use it. A terminal for an
earlier generation's turn is history even when it disagrees with the
checkpoint. A terminal that contradicts a turn this same generation
settled is still a `conflicting_turn_terminal` integrity failure, and a
provider that starts a settled turn again is still rejected.
- Transport (`runnerd-codex-transport.ts`): a pending `turn.start`
ignores a held failure that names another provider turn.
- `DURABLE_TRANSPORT.md` documents the stamp and the rule.

## Verification

- `cargo test -p paperclip-runner-core`: new
`records_late_events_for_a_settled_turn_as_history` (red before),
`stamps_every_event_with_the_runner_process_generation`; two existing
ACPX tests now assert "recorded as history, no state change" instead of
"rejected". Durable tests: 112 passed. `cargo fmt` clean.
- `codex-app-server-driver.stale-generation.test.ts` (new, 9 tests):
late item, delta, terminal and workspace change for a settled turn
during and before the next turn, an earlier generation's conflicting
terminal after recovery, and an unknown turn that still fails. Red on
master: 8 of 9 (the during-turn cases on master already dropped the
event, with a different diagnostic; the between-turn and recovery cases
failed the session).
- `runnerd-pending-turn-start-failure.test.ts` (new): an earlier turn's
held failure no longer fails the pending start; the pending turn's own
failure and a failed run terminal still do.
- `vitest run src/drivers/codex src/live/runnerd-codex-transport`: 787
passed; 2 Codex executable-resolution tests timed out under host load
and pass when run alone.
- `tsc -p tsconfig.json` for the runner package, `check:protocol-types`,
`check:replay-goldens`: clean.
- Not run: chaos waves (the coordinator schedules them on this host).

## Risks

- A late report about a settled turn is now dropped with a diagnostic
instead of failing. A provider that keeps working on a settled turn
could have its output ignored silently; a provider that starts a settled
turn again, or names a turn the runner never saw, still fails closed.
- `generation` is a new optional field. Consumers that do not know it
ignore it, as the PRP contract requires.
- The generation counter is written with the next normal state save, so
there is no new write before `turn.start`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, test execution). Extended reasoning
enabled.

## 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-07 23:16:00 -06:00

16 KiB
Raw Blame History

Durable PRP transport

Core uses durable PRP v1 for a selected, flag-enabled paperclip_runner agent or recovery of its persisted run. On a fresh launch or restart, paperclip-runnerd selects durable mode from its connection arguments.

  • With --connect-url <url>, the Runner dials Core.
  • With --listen-address 0.0.0.0 --listen-path /api/runner/v1/connect/<run-id>, the Runner listens for Core. --listen-port defaults to 43127.

Choose one form. Both require PAPERCLIP_RUNNER_BOOTSTRAP_TICKET. Recovery uses the same entry point. The Runner rejects --source-reconcile.

Trust boundary

  • For outbound ws://, every resolved address must be loopback. The Runner resolves the destination once and reuses the pinned addresses on reconnect.
  • A bootstrap ticket is read from PAPERCLIP_RUNNER_BOOTSTRAP_TICKET, removed from the environment immediately, and never sent over the socket. Both peers prove possession through HMAC-SHA-256.
  • A successful bootstrap exchanges the one-use ticket for a connection-bound, expiring lease held only in memory. Once authentication starts, a failed bootstrap attempt is not replayed automatically.
  • Post-authentication frames use AES-256-GCM with direction-specific keys, monotonically increasing counters, and session-bound associated data. Plaintext, replayed, out-of-order, oversized, or incorrectly bound frames fail closed.
  • Cross-language authentication primitives use the UTF-8 domain bytes followed by a NUL byte, then each input as an unsigned 64-bit big-endian byte length and its raw bytes. Challenge proofs cover the lexicographically key-sorted, compact JSON challenge payload. The server-to-runner integration test is the parity gate for these TypeScript and Rust encodings.
  • The durable state directory is private, symlinks are rejected, and updates use a private temporary file, file sync, atomic rename, and directory sync. Credentials and lease tokens are never written to this state.

Recovery contract

Events enter the outbox before delivery. A cumulative ACK may advance only to a source sequence the runner has produced; acknowledged prefixes are removed atomically from durable state. After disconnect, every remaining event is sent again with the same identity and source sequence.

After disconnect, runnerd tries Core when reconnect is due. Between attempts, live Codex and ACPX processes can drain local output into the outbox. Work that needs a provider request waits for Core, including managed-provider history reads and unanswered tools or approvals. An ACPX sidecar can include a fresh goalAtTerminal snapshot in runtime.turn_terminal to settle the turn offline. A missing or stale snapshot waits for a connected goal read. Runnerd suspends intake during terminal delivery or an authority transition.

If Codex hits its tool-receipt limit during reconnect, runnerd stops its local process group at the original deadline. It reads the remaining stdout in short intervals, then replays the frames in order across as many batches as needed. Only confirmed EOF with no terminal permits a missing-terminal result. If stdout stays open or the buffer lacks room for one maximum-size frame, runnerd reports uncertainty and keeps the events already committed to disk.

Executors retain polled events until runnerd acknowledges each event after its outbox commit. Batches commit one event at a time, so a later oversized event or capacity failure cannot roll back the accepted prefix or discard the unacknowledged suffix. Each retained executor event has a stable identity that runnerd derives into its PRP sourceEventId. If the process stops after the outbox commit but before the executor acknowledgement, a bounded durable receipt journal recognizes and byte-validates the retained copy without appending a second event. Receipts outlive transport ACK removal; because the provider queue is ordered and bounded, a possibly retained front event cannot be evicted while later events advance the journal.

Commands require a contiguous controller sequence. The runner journals a pending command before invoking its executor and persists its result afterward. An exact duplicate returns the stored result without repeating the effect. If the process dies inside the effect window, recovery records an indeterminate result and refuses to execute that command again. Recent results are bounded; commands older than the compacted controller cursor fail closed.

A controller can die after it sends turn.start but before it checkpoints the accepted turn. The provider keeps running that turn. On reattach, the host driver adopts the single active turn only when the latest completed turn.start result for this run accepted that exact turn id, and no run attachment came after it. The driver then emits the missing turn.accepted and waits for that turn instead of starting another. Any other active turn still fails closed as an unexpected turn.

State written before complete-command fingerprints existed is migrated by compacting its legacy command journal through the last recorded controller sequence. The runner can recover, but it rejects redelivery of those older commands instead of guessing an identity or repeating an uncertain effect.

The outbox has separate hard and reserved limits. P1/P2 events cannot consume the P0 reserve. When the soft limit is reached, the runner enters backpressure and rejects the new non-P0 event rather than silently losing it. Exhausting the P0 reserve is an explicit unrecoverable condition.

While disconnected, soft-limit backpressure retains the rejected event and its suffix at the provider owner until replay ACKs free outbox space. Oversized events or exhausted hard limits stop the owned provider and preserve the accepted durable prefix. The ACPX sidecar separately caps queued stdout at 4 MiB; exceeding that bound requests owned provider shutdown and records a failure rather than allowing unbounded process memory while the Runner cannot read.

Event generations and stale events

Every runner event payload carries generation: the runner process generation that emitted it. A runner process that recovers existing durable state starts the next generation. The counter is saved with the next state write, so recovery adds no write before the first turn. Events that are still in the outbox keep the generation that emitted them. State saved before this field existed loads as generation 0.

Consumers apply one rule to provider events. An event that names a turn which is not the active turn, and which an earlier generation accepted or this generation already settled, is history. The consumer records it and it never fails the current turn. This covers late usage, items, workspace changes, and terminals for a turn lost on restore. An event for a turn the consumer never saw is not history and still fails closed. A terminal that contradicts a turn the same generation settled is still a conflict. The ACPX provider state and the Codex driver (which Runnerd also uses for OpenCode) apply this rule, and a pending turn.start ignores a held failure that names another turn.

Recovering Codex between turns

Suppose run A finishes a turn, its Runner exits, and run B wants to continue the conversation. Core uses ordinary recovery for A, then attaches B with authenticated run.attach. If the original process is alive, its owner can verify and adopt it instead.

Is A idle? Before launch, the host driver (codexSourceRecoveryVersion: 1) checks that A’s process stopped and its saved session was idle. After launch, the Runner (recoveryIntegrityVersion: 1) verifies that Codex restored an idle session when no command effect is uncertain. It reads the goal, then the thread. An unexpected turn or active goal blocks recovery, even if the goal changes during the thread read. Accepted updates stay queued for delivery.

Active runs use same-run recovery. Indeterminate commands follow the replay rules above. A prepared session can continue normally, but this handoff requires a completed turn. Reads and cleanup leave a prepared provider unopened.

Who owns the session? Core matches A’s Runner instance, environment lease, run, normalized session, turn and item. History reads require A’s thread. A resume request returns saved state without asking Codex to resume twice. Retained events and usage belong to A. The authenticated authorityRunId identifies the current owner (A for same-run recovery). Losing that owner blocks new commands and dispatch. Only a transport that starts under a later owner sends A's events to recovery accounting, and only while its event identity is still A. After recovery moves the transport to B, a later warm attachment treats B like any live owner. A live warm run.attach also moves authorityRunId to B before the Runner flushes A's outbox. Those events use ordinary accounting, which drains A's usage under B.

If Core loses its cached preparation, the preparer uses A’s frozen recoveryLaunch.requirements, exact identity and authenticated owner. It leaves A’s configuration in place and the cache empty. B inherits A’s recorded launch policy on later attachments. Explicit settings must agree after provider defaults are applied. Authenticated context and instruction grants keep their usual precedence and compatibility checks.

When can B start work? After authenticated attachment, Core persists B’s fresh snapshot and checkpoint and runs any recovery callback. B can then start work. If a write or callback fails, Core can retry the handoff. Cancellation cleanup only settles retained work.

Live qualification still needs to verify that restoring a provider starts no work, deployed storage honors fsync, and the old process has exited.

Recovering ACPX between turns

paperclip-runnerd --build-metadata prints acpxRecoveryIntegrityVersion: 1 when the Runner implements these ACPX checks. The hosted ROM build copies that field into its manifest. The host driver advertises a separate acpxSourceRecoveryVersion: 1 and checks whether this run qualifies.

The Runner compares the restored session identity, pending requests, turn and goal with A’s saved idle state. After reading the goal, it checks updates that arrive during the final status read and keeps them queued for delivery. As with Codex, a prepared session can continue or be cleaned up. This handoff requires a completed turn. Suspended sessions use their usual recovery path.

Before B attaches, Runner saves the events it already received from A's sidecar. The readiness snapshot stays blocked until Core acknowledges those events. Another turn within A also keeps its buffered events without waiting for an ACK. ACPX still replaces the sidecar at the next turn or run attachment. The saved provider identity continues through that replacement.

Host park eligibility

CommandExecutor::host_park_ready defaults to false. Native Codex opts in only for the existing process that completed the current turn. An eligibility check never selects or restores a provider. A restored process must complete its own authoritative turn before it can be eligible. Reconstructing an accepted finish during recovery preserves result delivery but does not qualify that process. Active turns, goals, startup attempts, tool calls, pending credentials, and unsettled events block parking. Known helper threads also block it: their diagnostic lineage does not prove that their work finished. The bounded completed-turn tail drain retains late accounting and goal changes for durable state and normal event delivery.

Claude and Codex through ACPX also need a successful turn in the live sidecar. Runner checks its qualified profile, session identity and source run A. It gives that sidecar 100 ms to report its goal and session state. If the sidecar is slow or declines, Runner stays attached. Runner keeps at most one unanswered read and recognizes its late reply. Events received during that read still belong to A and are delivered before B attaches. The existing policy still replaces the sidecar at the next turn or run attachment; the saved provider identity continues.

The check also inspects the live provider after that drain, so a late active-goal notification cannot be hidden by the older executor journal. This hook does not detach a controller, stop a process, or advertise hosted parking support. The host-control consumer must separately validate the exact Runner identity and acknowledged sequence cut.

Hosted park and reclaim loop

On Unix builds, paperclip-runnerd --build-metadata reports privateHostControlVersion: 1 for the existing private fd 4 protocol below. Other builds omit this field. A consumer must verify the selected executable's identity and require the exact supported version before using this channel; an absent or unknown version does not establish support. Recovery and warm-task versions do not establish it either. Ordinary artifact admission can ignore this optional field.

This field describes protocol support only. It does not mean a provider is ready to park or that a host implements the public releaseResumeVersion contract. Runner readiness checks and host sleep/wake qualification are still required.

A trusted Launcher may pass a connected Unix stream at fd 4 and start durable listener mode with --host-control-fd 4. The descriptor is close-on-exec before provider startup. The private channel accepts the park, claim, and retire requests defined in the host-control codec; it cannot execute PRP commands.

park requires the exact six-field authority, acknowledged command/event cut, no unfinished transition or cleanup, and the provider eligibility proof above. The Runner drops its controller connection and credentials, retains its provider and journals, and blocks on host control without provider polling. Ordinary reconnect grace does not expire a parked Runner. The configured process lifetime still applies. Mount ownership stays with Launcher.

claim names the accepted park request and supplies a fresh bootstrap ticket. It resumes authenticated PRP under the retained authority; ordinary run.attach must still establish the next authority before new work. Pending reconnect and live commands both reject execution before that attachment; snapshots, drain, and cleanup remain available. Replaying the same claim returns its acknowledgement without consuming another ticket. A parked retirement names the park request. Cancelling a claim additionally requires its claim ID, the unchanged authority and sequence cut, and provider quiescence. Retirement, parked host-channel loss, or process-lifetime expiry uses the existing suspended lifecycle and provider shutdown path. There is no new checkpoint state.

This native loop does not advertise hosted parking support: Launcher retention, Core orchestration, provider-specific qualification, and live VM sleep/wake verification are required before rollout. Providers without an eligibility implementation remain ineligible.

Optional controller release and resume

An explicit session release() returns a copy of A's confirmed six-field source identity. It returns false when support is absent or work is still active. Those refusals leave active work and its controller route in place. Release and released-source adoption require a registration checkpoint hook that owns process cleanup. Without it, release returns false and adoption fails before claim.

For an eligible release, the controller first makes its existing route quiet, then waits for the host acknowledgement before it closes that route. A failed request can have taken effect. Its retry keeps the same source identity; a resume retry also keeps the exact bootstrap ticket. The route stays quiet while the result is uncertain. Resume's pinned ticket deadline bounds claim reconciliation. A late reply cannot grant authentication or work permission.

A resumed result permits authentication under A. Ordinary run.attach must still establish B before B can start work. An unavailable result reports known absence of the retained Runner; it does not prove that descendants have stopped or release their cleanup ownership. This optional contract adds no automatic release policy or capability advertisement.