fix(runner): keep an accepted result when ACPX reports its turn lost on restore (#19691)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native Codex, OpenCode, and ACPX runs survive control-plane
restarts: runnerd restores the conversation, and the control plane
continues the task from its checkpoint
> - #19658 made recovery keep a durable result when a Codex restore
loses the turn that proposed it. It covered the recovery read path only
> - ACPX reports the lost turn differently. After the restored
conversation reopens, a live `turn.failed` arrives with
`provider_turn_lost_on_restore`
> - That live terminal still turned an accepted `done` result into a
failed run. The run ended `failed` (`adapter_failed`), and the finished
work needed a Retry
> - This pull request moves the rule into the shared terminal mapping. A
restore-lost turn with a held result for that exact turn settles as
completed, whichever path reports it
> - The benefit is that ACPX and OpenCode tasks that already finished
keep their accepted result after a restart, as Codex tasks now do

## Linked Issues or Issue Description

Refs #19658, #19525, #19501, #19539

**What happened?**
In a restart chaos wave, an ACPX Claude task (workload A1) wrote its
brief and proposed a `done` result (`run.result.proposed`, then a
completed `paperclip_finish` tool call). Then a K1 and a K6 crash loop
hit the control plane, and `resume_dead_runner` ran at controller
generations 2 and 3. The restored ACPX conversation emitted
`turn.failed` with `provider_turn_lost_on_restore` (`recoverable: true`)
for the checkpointed turn. The control plane then emitted
`run.result.accepted` with the `done` result, but `run.terminal` came
back `runTerminalState: failed` / `turnTerminalState: failed` /
`reportedWorkDisposition: done`. The run ended `failed` /
`adapter_failed`, "Native session failed".

**Steps to reproduce**
1. Start a native ACPX (or OpenCode) task through runnerd.
2. Let the agent call `paperclip_finish`, and let the control plane
persist the proposal.
3. Kill the control plane and runner before the turn ends.
4. Let restart recovery resume the run. The restored runner reports the
turn lost on restore as a live terminal.

**Expected behavior**
Recovery finalizes the accepted result, and the run succeeds.

**Actual behavior**
ACPX and OpenCode use the same Codex harness session as Codex
(`codex-native-backend.ts`). Recovery restored the held result for the
turn from the durable proposal. But the live `turn/completed`
notification with `status: failed` and `provider_turn_lost_on_restore`
went through `mapTerminalTurn`, which emitted `turn.failed`. The backend
maps `turn.failed` to `runTerminalState: failed`. #19658 handled only
the `thread/read` reconciliation path.

## What Changed

- `codex-session-terminal.ts`: a new
`settleRestoreLostTurnWithHeldResult` helper. When a terminal is
`failed` with `provider_turn_lost_on_restore` and the session holds the
semantic result that this exact turn proposed, the turn settles as
`completed`. `mapTerminalTurn` applies it first, so live notifications
and recovery reads follow one rule.
- `codex-session-terminal.ts`: the helper also covers
`acpx_active_turn_recovery_closed`. Runnerd's ACPX backend
(`acpx_provider_backend.rs`, `restore_session_if_needed`) emits that
code instead of restoring the conversation when its own state already
recorded the accepted result. It is the same restart loss, with the
result already accepted. #19525 already keeps accepted results when
close cannot prove suspension, and session close still handles
`provider_exit_unconfirmed`.
- `codex-harness-session.ts`: `reconcile()` keeps only the missing-turn
case from #19658. The restore-lost read case and the prior-terminal
check now use the shared helper.
- `doc/execution-semantics.md`: the restart paragraph now states that
the rule covers Codex, OpenCode, and ACPX, and both report paths.

## Verification

- Red before the fix, with the real Codex native backend
(`createCodexNativeSessionBackend`) and a fake transport through
`executeNativeSession`: `pnpm --filter @paperclipai/paperclip-runner
exec vitest run src/native-session-runtime.test.ts -t "journaled
completion proposal"`. The new `restore-lost live` scenario failed.
Green after the fix: the run completes with the journaled result on
`turn-1` and a succeeded terminal, and no `turn/start` is sent. The
`restore-lost read` scenario passes on both (#19658 already covered it).
- Driver red/green:
`src/drivers/codex/codex-app-server-driver.recovery.test.ts -t "live
restore-lost"`. With a held result, the live lost terminal was
`turn.failed` before the fix and is `turn.completed` after it. With no
held result, it stays `turn.failed` with the lost cause, so the #19501
continuation is unchanged.
- The driver case was extended to `acpx_active_turn_recovery_closed`
(red with a held result before the extension, then green). It was found
in a live replay where the run ended `failed`/`adapter_failed` with an
accepted `done` result.
- Suites: `src/drivers/codex/` and `src/native-session-runtime.test.ts`
(580 tests), plus `src/backends/` and
`src/live/runnerd-restart-continuation.test.ts`, pass.
`typecheck:typescript` passes.
- Live chaos replays (ACPX Claude): results and a separate new signature
are in a comment below.

## Risks

- The rule needs a held result whose turn id equals the lost turn.
Recovery restores that result only from the durable
`run.result.proposed` for the checkpointed turn, so a result for another
turn does not settle it.
- A restore-lost turn without a held result keeps its failure and the
bounded same-conversation continuation.
- Real provider failures and other terminal states are unchanged.
- No schema, profile, pack contract, or database changes.

## 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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-06 21:07:20 -06:00
1 parent 8a34c1b62c
commit ece09c156a
5 files changed
+169 -24

No files matched your search

+5 -2
View File
@@ -2576,8 +2576,11 @@ provider turn ended. When the restored runner proves that turn ended with the
result Paperclip accepted, it records a reconciliation and the run terminal,
which settle that turn. When provider history no longer contains the checkpointed
turn, or the runner reports it lost on restore, but Paperclip durably holds the
result that exact turn proposed, recovery also settles the turn with that result. Either way, recovery finalizes the
accepted result instead of failing and rerunning finished work. A missing turn
result that exact turn proposed, recovery also settles the turn with that result.
This rule applies to Codex, OpenCode, and ACPX, both when the recovery read reports
the lost turn and when the restored conversation reports it live. Either way,
recovery finalizes the accepted result instead of failing and rerunning finished
work. A missing turn
without such a result still fails closed, because its outcome is unknown.
Each admitted restart attempt that loses a turn this way gets its own continuation.
@@ -43,6 +43,7 @@ import {
type PrpStructuredRunResult,
} from "./codex-app-server-driver.test-support.js";
import { NativeSessionProtocolIntegrityError, nativeRestartInterruptedTurnId } from "../../contracts/native-session-backend.js";
import { canonicalJson } from "./codex-driver-values.js";
function cursorProviderIdentity(mode: unknown) {
return {
@@ -1324,6 +1325,93 @@ describe("Codex app-server Codex driver", () => {
});
});
it.each([
{ held: true, lost: { code: "provider_turn_lost_on_restore", recoverable: true } },
{ held: false, lost: { code: "provider_turn_lost_on_restore", recoverable: true } },
{ held: true, lost: { code: "acpx_active_turn_recovery_closed", recoverable: false } },
{ held: false, lost: { code: "acpx_active_turn_recovery_closed", recoverable: false } },
])("settles a live restart-lost terminal from its durable result only when one is held ($lost.code, held=$held)", async ({ held, lost }) => {
// ACPX reports the lost turn as a live notification after the session
// reopens, not through thread/read. When runnerd already recorded the
// accepted result, it closes the turn instead of restoring it. An accepted
// result for that exact turn must still finalize as a completed turn.
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
second.readResponse = {
thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE,
turns: [{ id: "turn-1", status: "inProgress", items: [] }] },
};
const driver = makeDriver([first, second]);
const original = await driver.openSession({
runId: `run-live-lost-${held}`,
normalizedSessionId: `normalized-live-lost-${held}`,
workingDirectory: WORKSPACE,
});
await original.startTurn({ message: { role: "user", text: "Complete." } });
if (held) {
await first.invoke({
id: "call-before-kill",
method: "item/tool/call",
params: { threadId: "thread-1", turnId: "turn-1", callId: "call-before-kill", tool: "paperclip_finish", arguments: result },
});
}
const snapshot = await original.snapshot();
await original.close({ reason: "killed before the turn terminal" });
const recovery = await driver.recoverSession?.(snapshot);
expect(recovery).toMatchObject({ recovered: true });
const collecting = collectUntilTerminal(recovery!.session!.events());
second.push("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "failed", error: lost, items: [] } });
const events = await collecting;
const terminal = events.find((event) => event.eventType.startsWith("turn."));
expect(terminal?.eventType).toBe(held ? "turn.completed" : "turn.failed");
if (!held) expect(terminal?.payload).toMatchObject({ error: lost });
if (held) expect(events.filter((event) => event.eventType === "run.result.proposed")).toHaveLength(0);
});
it("accepts a stored restart-lost failure for a held result without a replay conflict", async () => {
// A runner from before this rule recorded the restart-lost turn as failed
// with its held result. Replaying that same terminal is not a conflict.
// (A held result plus a committed terminal makes the session terminal, so
// the recovery read is the path that can replay it.)
const lost = { code: "provider_turn_lost_on_restore", recoverable: true };
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
second.readResponse = {
thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE,
turns: [{ id: "turn-1", status: "failed", error: lost, items: [] }] },
};
const driver = makeDriver([first, second]);
const original = await driver.openSession({
runId: "run-stored-lost",
normalizedSessionId: "normalized-stored-lost",
workingDirectory: WORKSPACE,
});
await original.startTurn({ message: { role: "user", text: "Complete." } });
await first.invoke({
id: "call-before-kill",
method: "item/tool/call",
params: { threadId: "thread-1", turnId: "turn-1", callId: "call-before-kill", tool: "paperclip_finish", arguments: result },
});
const snapshot = await original.snapshot();
await original.close({ reason: "killed before the turn terminal" });
const stored = canonicalJson({ terminalState: "failed", error: lost, result });
const recovery = await driver.recoverSession?.({
...snapshot,
activeTurnId: null,
terminalTurns: [{ turnId: "turn-1", fingerprint: stored }],
});
expect(recovery).toMatchObject({ recovered: true });
await recovery!.session!.reconcile?.();
const events: string[] = [];
const draining = (async () => { for await (const event of recovery!.session!.events()) events.push(event.eventType); })();
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
await recovery!.session!.close({ reason: "test complete" });
await draining;
expect(events).not.toContain("session.failed");
expect(events).not.toContain("harness.protocol_failed");
expect((await recovery!.session!.snapshot().catch((error: unknown) => error)) instanceof Error).toBe(false);
});
it.each([true, false])("settles a restore-lost turn from its durable result only when one is held (held=%s)", async (held) => {
// The runner can report the checkpointed turn lost on restore when it
// cannot prove the accepted result itself, while Paperclip already holds it.
@@ -2,7 +2,7 @@ import { readCodexThreadState, readCodexTurnMetadata, readCodexTurnItems } from
import type { DurableRecoveryIdentity } from "../../contracts/durable-recovery.js";
import { codexRunUsage, codexUsageBaselineForNextRun, observeCodexUsage } from "./codex-usage-baseline.js";
import { randomUUID } from "node:crypto";
import { isNativeRestartInterruption, NativeProviderTerminalFailure, NativeSessionProtocolIntegrityError } from "../../contracts/native-session-backend.js";
import { NativeProviderTerminalFailure, NativeSessionProtocolIntegrityError } from "../../contracts/native-session-backend.js";
import type {
HarnessGoalOperation,
@@ -53,7 +53,8 @@ import { pumpNotifications } from "./codex-session-notifications.js";
import { handleServerRequest } from "./codex-session-server-requests.js";
import {
mapTerminalTurn,
terminalReplayConflict,
settleRestoreLostTurnWithHeldResult,
settledTerminalReplayConflict,
} from "./codex-session-terminal.js";
import { boundedText, record, text, userInput } from "./codex-driver-values.js";
@@ -800,15 +801,14 @@ export class CodexHarnessSession
const expectedTurn = turns.find(
(turn) => text(turn.id) === expectedTurnId,
);
const holdsTurnResult = this.result !== null && this.resultTurnId === expectedTurnId;
if (holdsTurnResult && (expectedTurn === undefined || (
text(expectedTurn.status) === "failed" && isNativeRestartInterruption(expectedTurn.error)))) {
if (expectedTurn === undefined && this.result !== null && this.resultTurnId === expectedTurnId) {
// Provider history can lose a turn whose process was killed before its
// terminal was flushed, or the restored runner can report it lost on
// restore. Paperclip already holds the semantic result this exact turn
// proposed, so that durable result is the turn's outcome: settle it
// rather than fail recovery and rerun finished work. Without such a
// result the outcome stays unknown and recovery fails closed.
// terminal was flushed. Paperclip already holds the semantic result this
// exact turn proposed, so that durable result is the turn's outcome:
// settle it rather than fail recovery and rerun finished work. Without
// such a result the outcome stays unknown and recovery fails closed.
// (A turn reported lost on restore settles the same way in
// mapTerminalTurn.)
reconciledTerminalTurnId = expectedTurnId;
mapTerminalTurn(this, { id: expectedTurnId, status: "completed", items: [] }, expectedTurnId, true);
} else if (expectedTurn === undefined) {
@@ -833,12 +833,11 @@ export class CodexHarnessSession
}
for (const [turnId, fingerprint] of this.terminalTurns) {
const observed = turns.find((turn) => text(turn.id) === turnId);
if (observed === undefined) continue;
// A turn settled from its durable result keeps that outcome when the
// restored runner reports the same turn lost on restore.
if (this.result !== null && this.resultTurnId === turnId &&
text(observed.status) === "failed" && isNativeRestartInterruption(observed.error)) continue;
const reported = turns.find((turn) => text(turn.id) === turnId);
if (reported === undefined) continue;
// A turn settled from its held result keeps that outcome when the
// restored runner later reports the same turn lost on restore.
const observed = settleRestoreLostTurnWithHeldResult(this, reported, turnId);
if (
!["completed", "failed", "interrupted", "cancelled"].includes(
text(observed.status),
@@ -848,7 +847,7 @@ export class CodexHarnessSession
`previously terminal turn ${turnId} is no longer terminal in thread/read`,
);
}
const conflict = terminalReplayConflict(this, observed, fingerprint);
const conflict = settledTerminalReplayConflict(this, reported, observed, fingerprint);
if (conflict !== null) {
throw new HarnessReconciliationError(
`previously terminal turn ${turnId} ${conflict.message}`,
@@ -101,6 +101,53 @@ function finalize(
state.terminal = true;
}
/**
* ACPX closes, rather than restores, a turn that a restart interrupted after
* the runner had recorded its accepted semantic result.
*/
const ACPX_RESTART_CLOSED_TURN_CODE = "acpx_active_turn_recovery_closed";
/**
* The restored runner reports a turn lost on restore when it cannot prove the
* turn's outcome itself, and ACPX closes the turn when its runner already
* recorded the accepted result. In both cases a restart lost only the provider
* turn. When Paperclip already holds the semantic result this exact turn
* proposed, that result is the turn's outcome: the turn settles as completed
* instead of failing the run and rerunning finished work. Live notifications
* and recovery reads use the same rule. Any other terminal, or a lost turn
* without a held result, is returned unchanged.
*/
export function settleRestoreLostTurnWithHeldResult(
state: CodexSessionState,
turn: Record<string, unknown>,
turnId = text(turn.id),
): Record<string, unknown> {
const restartLost = isNativeRestartInterruption(turn.error) ||
record(turn.error).code === ACPX_RESTART_CLOSED_TURN_CODE;
if (text(turn.status) !== "failed" || !restartLost ||
state.result === null || turnId.length === 0 || state.resultTurnId !== turnId) {
return turn;
}
return { ...turn, status: "completed", error: null };
}
/**
* A replay of an already committed terminal matches when either the terminal
* as reported or its settled form matches. A checkpoint from before the
* restart-loss rule can hold the reported failure with the same held result;
* that is the same terminal, not a conflicting one.
*/
export function settledTerminalReplayConflict(
state: CodexSessionState,
reported: Record<string, unknown>,
settled: Record<string, unknown>,
expectedFingerprint: string,
): TerminalReplayConflict | null {
const conflict = terminalReplayConflict(state, settled, expectedFingerprint);
if (conflict === null || settled === reported) return conflict;
return terminalReplayConflict(state, reported, expectedFingerprint) === null ? null : conflict;
}
export function mapTerminalTurn(
state: CodexSessionState,
turn: Record<string, unknown>,
@@ -108,10 +155,12 @@ export function mapTerminalTurn(
reconciling = false,
): void {
const turnId = text(turn.id, fallbackTurnId || state.activeTurnId || "");
const reported = turn;
turn = settleRestoreLostTurnWithHeldResult(state, turn, turnId);
const status = text(turn.status, "completed");
const previous = state.terminalTurns.get(turnId);
if (previous !== undefined) {
const conflict = terminalReplayConflict(state, turn, previous);
const conflict = settledTerminalReplayConflict(state, reported, turn, previous);
if (conflict !== null) {
if (reconciling) {
throw new HarnessReconciliationError(
@@ -338,7 +338,7 @@ describe("executeNativeSession recovery", () => {
it.each([
"completed", "pending", "duplicate", "conflicting", "other turn", "newer turn",
"wrong session", "invalid",
"wrong session", "invalid", "restore-lost live", "restore-lost read",
])("recovers a journaled completion proposal after restart: %s", async (scenario) => {
const originalTransport = new FakeCodexTransport();
const recoveredTransport = new FakeCodexTransport();
@@ -388,7 +388,9 @@ describe("executeNativeSession recovery", () => {
await original.close({ reason: "controller disconnected after persisting the proposal" });
recoveredTransport.readResponse = { thread: {
id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE,
turns: [{ id: "turn-1", status: scenario === "pending" ? "inProgress" : "completed", items: [
turns: [{ id: "turn-1", ...(scenario === "restore-lost read"
? { status: "failed", error: { code: "provider_turn_lost_on_restore", recoverable: true } }
: { status: ["pending", "restore-lost live"].includes(scenario) ? "inProgress" : "completed" }), items: [
{ id: "answer", type: "agentMessage", text: "The review was published." },
] }],
} };
@@ -430,11 +432,15 @@ describe("executeNativeSession recovery", () => {
expect(recoveredTransport.calls.filter(call => call.method === "turn/start")).toEqual([]);
return;
}
if (scenario === "pending") {
if (scenario === "pending" || scenario === "restore-lost live") {
await vi.waitFor(() => expect(checkpoints[0]?.semanticResult).toEqual(result));
expect(completeRun).not.toHaveBeenCalled();
// ACPX reports a turn lost on restore as a live terminal once the
// restored runner reopens the conversation.
recoveredTransport.push("turn/completed", {
threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] },
threadId: "thread-1", turn: scenario === "pending"
? { id: "turn-1", status: "completed", items: [] }
: { id: "turn-1", status: "failed", error: { code: "provider_turn_lost_on_restore", recoverable: true }, items: [] },
});
}
await expect(execution).resolves.toMatchObject({ result, turnId: "turn-1", terminal });