Files
PaperClipAI/packages
DottaandClaude Opus 5.5 f4b19e4231 fix(runner): finalize a durable proposed result when restore loses its Codex turn (#19658)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native Codex runs survive control-plane restarts: runnerd restores
the provider conversation, and the control plane continues the task from
its checkpoint
> - A hard kill can land after the agent proposed its result
(`run.result.proposed`) but before the provider turn ended
> - The restored runner then proves that the turn ended with the
accepted result. It records `session.reconciled` and `run.terminal`, but
no provider turn terminal
> - The Codex recovery read did not see that turn as ended, so recovery
failed with "persisted active turn … is missing from thread/read".
Retries ran out, and the Retry re-ran finished work
> - This pull request lets recovery settle that turn with its durable
result
> - The benefit is that a task that already finished its work continues
to finalization after a restart, instead of failing and redoing the work

## Linked Issues or Issue Description

Refs #19525, #19591, #19539, #19501

**What happened?**
In a restart storm, a native Codex task (B1) proposed a complete
`paperclip.run_result.v1` with passing tests. The control plane accepted
the completion tool call. About 9 s later, a K1 (SIGKILL and restart of
the control plane) killed the controller, runnerd, and the Codex
app-server. After startup, `resume_dead_runner` resumed the same run two
times. Each resume spent about 25 s in `runner.session.startup` and
failed with `native_session_recovery_failed: HarnessReconciliationError:
persisted active turn <id> is missing from thread/read`. Then
`native_session_retry_exhausted`, replay was blocked
(`preserve_without_replay_v1`), and the run ended
`native_session_interrupted`. The task needed a Retry, which re-ran the
finished work.

**Steps to reproduce**
1. Start a native Codex task through runnerd.
2. Let the agent call `paperclip_finish`, and let Paperclip accept the
result.
3. Kill the controller, runnerd, and Codex before the provider turn
ends.
4. Let restart recovery resume the run.

Durable evidence from the kept instance:
- The run events have `run.result.proposed` for the turn, then a
`completion.tool_accepted` span, then the kill.
- The saved checkpoint has `activeTurnId` set to that turn and
`semanticResult: null`.
- The Codex rollout on disk does contain the turn. The provider did
persist it.
- The runnerd journal for the first resume has `session.reconciled
{previousProviderTurnId: <turn>, activeProviderTurnId: null}` and then
`run.terminal {turnTerminalState: completed, runTerminalState:
succeeded, reportedWorkDisposition: done}`. It has no `turn.*` event for
that turn. The runnerd Codex provider state shows
`completedTurnAuthoritative: true` and `completedProviderTurnId:
<turn>`.

**Expected behavior**
Recovery finalizes the accepted result. It does not re-run the task.

**Actual behavior**
The runnerd Codex transport builds the recovery `thread/read` reply from
runnerd's journal. It looked only for
`turn.completed|failed|interrupted|cancelled` events that name the turn.
Runnerd's "turn ended with an accepted result" branch
(`provider_backend.rs`, `recovered_turn_ended_with_result`) emits only
the reconciliation and the run terminal. So the reply omitted the turn,
and `CodexHarnessSession.reconcile()` threw at
`codex-harness-session.ts:796`. The second resume read the same journal
and failed the same way.

## What Changed

- `runnerd-codex-transport.ts`: a new `runnerdRecoveredTurnTerminal`
helper selects the terminal for a restored turn. A provider `turn.*`
terminal that names the turn wins, as before, with its cause. A
`run.terminal` that follows a `session.reconciled` that names the turn
as the ended previous turn (no active turn, no later `turn.started`) now
also settles that turn, with the run terminal's `turnTerminalState`. The
`thread/read` wait loop uses this helper.
- `codex-harness-session.ts`: `reconcile()` now settles a checkpointed
active turn that provider history no longer contains when the session
holds the semantic result that this exact turn proposed (`resultTurnId`
equals the turn). Recovery restores that result from the durable
`run.result.proposed` event. Without such a result, a missing turn still
fails closed.
- `codex-harness-session.ts`: the same rule applies when the restored
runner reports that turn `failed` with `provider_turn_lost_on_restore`.
This happens when the runner cannot prove the accepted result itself.
Later recoveries keep the settled outcome. Without a held result, a
restore-lost turn keeps its existing failure and same-conversation
continuation.
- `fake-codex-app-server`: a test-only `--hold-after-terminal-result`
flag keeps the turn open after an accepted completion report.
- `doc/execution-semantics.md`: one additive paragraph on this recovery
rule.

## Verification

- Red before the fix, real `paperclip-runnerd` and fake Codex: `pnpm
--filter @paperclipai/paperclip-runner exec vitest run
src/live/runnerd-restart-continuation.test.ts -t "durable proposed
result"` failed with `native_session_recovery_failed:
HarnessReconciliationError: persisted active turn provider-turn-1 is
missing from thread/read` at `native-session-runtime.ts:2267`. This is
the same stack as the incident.
- Green after the fix: the run finalizes on `provider-turn-1` with the
proposed result and `runTerminalState: succeeded`. It has no
`session.failed` or `turn.failed`, and Codex received only one
`turn/start` (no re-run). With the transport fix alone, the test passes
in about 5 s. With only the session fix, it also passes (about 30 s,
through the 5 s read wait).
- Driver red/green:
`src/drivers/codex/codex-app-server-driver.recovery.test.ts -t "missing
persisted turn"`. The settle case failed before the fix (`recovered:
false`) and passes after it. The guard case (the held result belongs to
another turn) still fails closed. The existing "fails recovery
explicitly when the persisted active turn is missing or replaced" test
still passes.
- Driver red/green for the restore-lost case: `-t "restore-lost turn"`.
The held=true case failed before the fix and passes after it. The
held=false case still maps `turn.failed` with the lost cause.
- Transport unit test: `runnerd-codex-transport.test.ts -t "reconciled
result terminal"` covers the run-terminal rule and its negative cases.
- Suites: `src/drivers/codex/`, `src/native-session-runtime.test.ts`,
`src/backends/`, `src/live/runnerd-restart-continuation.test.ts`, and
`src/live/runnerd-codex-transport.test.ts` pass. Some load-sensitive
tests time out at host load 25–30 and pass when rerun alone. The two
`proves local suspension after an event backlog … (1024 suffix deltas)`
cases also time out on unmodified master.
- `pnpm --filter @paperclipai/paperclip-runner typecheck:typescript`
passes.
- Live chaos replay with real Codex: K1 0–3 s after
`run.result.proposed`, 3/3 PASS (R1). Each case resumed the same run,
which ended `succeeded`. No Retry was needed and no work was re-run.
Details are in the comment below.

## Risks

- The transport rule accepts a run terminal only directly after
runnerd's own reconciliation that names the ended turn with no active
turn. Runnerd emits that pair only after it restored the conversation
and proved the turn ended. A provider turn terminal still wins. Other
run terminals do not settle a turn.
- The session rules use only a result that Paperclip already holds for
the exact turn. The control plane accepted that result before the kill.
A later provider terminal for that turn cannot change the outcome. The
finalizer can already interrupt a provider after an accepted result.
- A missing turn without a durable result still fails closed. Its
outcome is unknown, and the existing Retry stays available.
- No profile, schema, pack contract, or database changes. The Rust
change touches only the fake Codex test binary.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## 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-06 18:02:40 -06:00
..