Files
PaperClipAI/server
DottaandClaude a09093a5c5 fix(native): resume a run whose turn.start a restart cut off before Codex created the turn (#20507)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native Codex run sends `turn.start` to its runner, and the runner
sends `turn/start` to Codex. The runner marks the start "ambiguous"
before it sends the request
> - A control-plane restart can kill the runner and Codex while that
request is in flight. After the restart, neither journal says whether
Codex created the turn, and the resumed thread has no active turn
> - The restored runner then failed closed on every same-run resume with
"cannot safely recover an ambiguous Codex turn start without an active
replacement turn". The answer never reached the agent
> - The next run on the task was refused at the runner-state fence
(`prior_owner_terminal_state_indeterminate;
retirement_refused=journal_commands_unsettled(turn.start.failed)`)
before it asked for a runner. Retry after that run then waited forever
on `process_identity_missing` for a process that never existed
> - This pull request lets the runner prove from Codex's own thread
history that the start never created a turn, and then resume and start
the turn once. It also lets Retry continue after a run that the
runner-state fence refused before launch
> - The benefit is that a turn start cut off by a restart continues by
itself when Codex never saw it, and a person's Retry is no longer a dead
end

## Linked Issues or Issue Description

**What happened?**
Local restart-chaos wave `w-20261110T033426-s108`. Command: `--env local
--adapters codex --workloads Q1,Q2 --faults K1 --triggers answered
--tasks 4 --concurrency 2 --seed 108`. Task t03 (Q2): the answer run
`92a57f50` resumed after the first restart. A second K1 restart landed
0.25 s after `session.resumed`, while its `turn.start` was in flight.
Each same-run resume then failed with `PRP command session.snapshot
failed: cannot safely recover an ambiguous Codex turn start without an
active replacement turn`. When the prerequisite finished, the next run
`0e6aa9d1` failed before launch with `runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate;
retirement_refused=journal_commands_unsettled(turn.start.failed)`. The
accepted Retry (202) never created a run: the task stayed on
`retryWait.reason=process_identity_missing` until the wave timed out
(`R3:retry_no_new_run`).

**Expected behavior**
If Codex never created the turn, the run resumes and starts it once by
itself. If that cannot be proven, a person's Retry starts a new run
instead of waiting forever.

**Steps to reproduce**
1. Run the wave command above on master.
2. Or run `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/live/runnerd-codex-transport-cut-turn-start.test.ts` on master (case
"before" fails with the wave error).

## What Changed

- Runner (`codex_provider.rs`, `provider_backend.rs`): when the restored
provider state has an ambiguous turn start, no active turn before the
loss, and the resumed thread has no active turn, the runner reads the
newest turn of the thread (`thread/turns/list`, limit 1, metadata only,
the same request `read_thread` already sends). If there is no turn, or
the newest turn is one Paperclip already settled, Codex never created
the turn. The runner clears the ambiguity, emits a `harness.diagnostic`
with code `ambiguous_turn_start_not_created`, and restores an idle
session. The old `turn.start` stays `execution_indeterminate` and is
never executed again. The same-run resume then starts the run's turn
once, through the existing "recovered session without an active turn"
path.
- Any other history (a turn Paperclip never settled, an in-progress
turn, an incomplete page, a non-Codex provider) keeps today's
fail-closed error. The probe runs only on this recovery path. The normal
turn path does no new work before `turn.start`.
- Server (`native-local-process-stop.ts`): `nativeRunNeverLaunched` also
accepts a failed native run whose error is the runner-state fence
refusal `runner_state_identity_mismatch: prior_owner_*` (or
`legacy_prior_owner_*`). The executor throws it only while it migrates
another run's saved state, before it requests a launch. The existing
evidence check still applies: any launch request, identity record,
`adapter.invoke` or runner event keeps the run held. Other runner-state
errors (for example `authority_indeterminate`) stay held.
- Fake Codex (`fake-codex-app-server.rs`): keeps the thread's turn
history in a `<state-file>.turns` sidecar, so `thread/turns/list`
returns history like real Codex (newest first, honours `limit`). New
`--hang-turn-start <file>` holds `turn/start` "before" or "after" the
thread records the turn, to model a kill in flight.
- Tests:
- New real-runnerd test
`runnerd-codex-transport-cut-turn-start.test.ts`. A real runner and fake
Codex start a turn; both processes are killed while `turn/start` is in
flight. "before": the resumed run completes, the journal holds the old
`turn.start` (`execution_indeterminate`) and one new completed
`turn.start` with the same task text, and Codex saw exactly one more
`turn/start`. "after": recovery still fails closed and no new
`turn/start` is sent.
- Rust:
`ambiguous_turn_start_that_never_created_a_turn_recovers_and_starts_once`
and
`ambiguous_turn_start_that_created_an_unsettled_turn_still_fails_closed`.
Both rewrite saved provider state the way a master runner wrote it, so
they also cover state saved before this change.
- `explicit-native-continuation.test.ts`: five new rows for the
runner-state fence. Admitted: refused before launch (with and without a
coordinator). Held on `process_identity_missing`: refused after a launch
request, refused after a runner event, and a run that failed its own
`authority_indeterminate` check.
- `docs/durable-recovery.md`: describes both behaviours.

## Verification

- Red/green, real runnerd: on master (the staged master runnerd),
`runnerd-codex-transport-cut-turn-start.test.ts` "before" fails with
`PRP command runner.drain failed: ... cannot safely recover an ambiguous
Codex turn start without an active replacement turn`, the wave error.
With this change, both cases pass.
- Red/green, Rust: without the `provider_backend.rs` change, `cargo test
-p paperclip-runner-core --test codex_provider
ambiguous_turn_start_that` fails the "never created" test. With it, both
pass. The full `cargo test -p paperclip-runner-core` passes.
- Red/green, server: without the `native-local-process-stop.ts` change,
the two "refused at the prior-owner runner-state fence" rows fail with
`process_identity_missing`. With it, all 14 rows pass.
- Related suites pass: 11 Runner transport files, 419 tests
(`runnerd-codex-transport`, `-attachment-recovery`, `-cold-restore`,
`-warm-attachment`, `-release-resume`, `-cut-attach`, `-cut-turn-start`,
`-settlement`, `-suspension`, `runnerd-restart-continuation`,
`runnerd-saved-continuation`); server `explicit-native-continuation`,
`quarantined-task-retry` and `issue-confirm-run-stopped-routes`, 3
files, 470 tests; full `cargo test -p paperclip-runner-core`.
- `pnpm --filter @paperclipai/paperclip-runner exec tsc --noEmit` and
server `tsc --noEmit` pass.
- Pack `contractDigest` is unchanged: `sha256:3164357d…` before and
after (`scripts/contracts/contract-fingerprints.mjs`).
- Live proof: the wave command above on this branch. Results follow in a
comment.

**No duplicate provider work.** The runner starts a new turn only when
Codex's own history proves the cut-off start created nothing. The old
command is never executed again (the runner refuses an `indeterminate`
command). A recorded turn that Paperclip never settled keeps the run
failed closed.

## Risks

- Low-medium risk. The runner change runs only while it restores a
session whose saved state has an ambiguous turn start and no active
turn. Before this change, that run could never continue.
- The proof relies on Codex recording a turn in the thread before it
runs anything. If Codex accepted the request but died before it wrote
the turn, the runner treats the start as not created and starts the turn
once. Codex writes the user turn to the thread when it starts the turn,
before it asks the model for output, so no model output or tool call is
expected without a recorded turn.
- If the thread's newest turn is not in the runner's settled set (for
example after a provider identity epoch rollover forgot older
identities), the runner keeps failing closed, as today.
- **Open decision (not changed here):** when Codex did record the
cut-off turn, it may have done work. This pull request keeps the run
failed closed and does not replay or adopt that turn. Continuing is a
person's decision through Retry or a new message.
- The server change trusts the executor's own error text
`runner_state_identity_mismatch: prior_owner_*`, together with the
absence of every launch and runner event. A run that requested a launch
keeps every stop proof.
- **Runner release:** the pack `contractDigest` does not change. Local
runners use the runnerd built with the app, so the fix applies with this
release.
- Not covered: the related `authority_indeterminate` variant in the same
wave (t04, Q1) is a separate cause; its Retry already worked in that
wave.

> 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`), via Claude Code with tool use
(shell, file edits, test execution).

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

Refs #20477, #20482

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 03:49:22 -06:00
..