fix(native): read the scheduled-resume evidence the resume reconciler claimed (#20656)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #20645 lets a local same-run scheduled resume send the one
same-conversation continuation for a turn lost on restore. It applies
only when durable records prove three facts: the previous runner
stopped, the attempt was admitted within the budget, and it is the same
checkpointed conversation.
> - It read the third proof from the coordinator's saved
`failure_detail` while the phase was `retryable_failure`.
> - In production, the resume reconciler
(`claimNativeSessionResumptions`) claims the run first. It moves the
coordinator to `observed` and clears `failure_detail`. So the executor
never saw that evidence, and #20645 refused every real scheduled resume.
> - The restart-chaos L7 wave showed this live. Codex B5 TM2 hit the
exact path (`runner.drain` timeout, scheduled resume,
`provider_turn_lost_on_restore`), sent no continuation, and needed
Retry.
> - This pull request makes the reconciler record that evidence durably
when it claims the run, and makes the executor read it from there.
> - The benefit is that #20645 works on the real path. The proof rule is
unchanged.

## Linked Issues or Issue Description

Refs #20645

**What happened?**
Wave `w-20261110T212307-s9107` (L7, seed 9107, local, master plus
#20645), task codex B5 TM2:
- `native_session_recovery_failed: runner_local_connect_failed: PRP
command runner.drain timed out`;
- `retryable_failure` with `recoveryMode: "exact_checkpoint_resume"`,
`recoverable`, `providerSessionEstablished` and `checkpointExists` all
set;
- `native.local_process_stopped`;
- attempt 2 started;
- `turn.failed` with `provider_turn_lost_on_restore` for the same turn
ID that the checkpoint was running.

No continuation was sent. The run failed with
`native_provider_terminal_failed` and was preserved with
`uncertain_provider_action`.

**Expected behavior**
The proven scheduled resume sends the single continuation.

**Steps to reproduce**
The new integration test below. The live path is timing-dependent (the
L7 command in #20645).

## What Changed

- `native-finalization-reconciler.ts`: when it claims a same-run resume,
the reconciler appends one `same_run_resume` entry to
`recovery_history`. The append is bounded like other history writes, and
it runs in the same update that clears the failure. The entry copies
`recoveryMode`, `recoverable`, `providerSessionEstablished`,
`checkpointExists`, `successorRunId` and `replacementDenied` from the
saved failure, with the controller boot and provider attempt.
- `scheduled-resume-continuation.ts`: the evidence for the third proof
now comes from either of two durable records:
- the saved failure, when the executor claims a `retryable_failure`
coordinator directly (unchanged); or
- a reconciler claim: an `observed` coordinator whose last history entry
is a `same_run_resume` entry naming the executor's own controller boot.
The two other proofs are unchanged: the stop receipt, and an admitted
attempt from 1 to 3.
- `native-session-executor.ts`: passes the coordinator's last history
entry and the controller boot to the decision.
- `doc/execution-semantics.md`: describes the recorded evidence.

Readers of `recovery_history` that look at the last entry do so only for
restart claims, Stop recovery and shutdown suspension. Restart
recovery's previous-claim lookup skips non-claim dispositions, so a
`same_run_resume` entry does not change those paths.

**Still refused** (tested):
- every #20645 case;
- the last history entry is not the resume claim, or there is no
history;
- the resume claim was recorded by another boot;
- the claimed evidence is a bootstrap retry, has no checkpoint, or
records a replacement;
- a reconciler-claimed resume without a stop receipt.

## Verification

- `native-scheduled-resume-continuation.integration.test.ts` now also
runs through the real reconciler claim (`claimNativeSessionResumptions`)
before `executePaperclipNativeSession`. On master the reconciler-claimed
allowed case fails (`resumeInterruptedTurn` is false). With this change
it is true, and the claim admits attempt 2. A reconciler-claimed resume
without a stop receipt still passes false.
- `scheduled-resume-continuation.test.ts`: decision cases for the
reconciler-claim entry.
- `native-session-resumption.test.ts` passes. 54 tests pass across the
three files.
- `cd server && npx tsc --noEmit` passes.
- Live: not yet. The failing wave above is the reproduction. A rerun is
timing-dependent.

## Risks

- One more `recovery_history` entry per scheduled resume, inside the
existing 20-entry bound.
- The proof rule itself is unchanged. This only reads the same evidence
from the record where it now survives.
- No schema change. A run claimed by an earlier build has no
`same_run_resume` entry, so it keeps the existing failure: refused, with
Retry offered as today.

## Model Used

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

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude authored and GitHub committed 2026-11-10 18:21:25 -06:00
1 parent 48c6e996da
commit c14f2fe45d
9 files changed
+104 -17

No files matched your search

+8 -3
View File
@@ -3349,9 +3349,14 @@ that process is gone. Second, the resume claimed a provider attempt within the
three-attempt budget from a `retryable_failure` coordinator. Third, that failure
saved `recoveryMode: "exact_checkpoint_resume"`, `recoverable`,
`providerSessionEstablished` and `checkpointExists`, with no replacement recorded,
and the runner restores that same checkpointed conversation. A remote run, a
bootstrap retry, an ambiguous state, a missing or superseded stop record, an
exhausted budget, or a replacement session keeps the existing failure.
and the runner restores that same checkpointed conversation. The resume reconciler
clears the saved failure when it claims the run. So it first appends a
`same_run_resume` entry to `recovery_history` that copies those fields, together
with its controller boot. The executor then reads that entry. It must be the last
entry, and it must name the executor's boot. A remote run, a bootstrap retry, an
ambiguous state, a missing or superseded stop record, an exhausted budget, a
replacement session, or a claim entry from another boot or followed by another
entry keeps the existing failure.
A restart can also land after the agent proposed its result but before the
provider turn ended. When the restored runner proves that turn ended with the
@@ -50,7 +50,18 @@ describe("interruptedExactResumeCause", () => {
});
});
it("finds the interruption behind the resume reconciler's claim entry for the same attempt", () => {
const resumeClaim = { at: "2099-01-01T00:01:00.000Z", disposition: "same_run_resume", controllerBootId: "boot-1",
providerAttempt: 1, evidence: { recoveryMode: "exact_checkpoint_resume" } };
expect(interruptedExactResumeCause({ ...claimed, recoveryHistory: [interruption, resumeClaim] })).toEqual({
originalFailureCode: "native_session_interrupted",
originalMessage: "PRP command turn.start failed: classification=session_resume_required",
interruptedAttempt: 1,
});
});
it.each([
["a resume claim entry for a later provider attempt", { disposition: "same_run_resume", providerAttempt: 2 }],
["a wait for a later provider attempt", { disposition: "blocked", reason: "held", providerAttempt: 2 }],
["a later recovery entry that is not a wait", { disposition: "resume_dead_runner", reason: "restart", providerAttempt: 1 }],
])("does not reach past %s", (_name, later) => {
@@ -16,7 +16,7 @@ const FAILURE_DETAIL_MAX_CHARS = 2_000;
const REFUSAL_MAX_CHARS = 480;
const REFUSAL_SEPARATOR = "; exact checkpoint resume refused: ";
/** Restart-recovery waits that keep the interrupted provider attempt. */
const RECOVERY_WAIT_DISPOSITIONS: ReadonlySet<unknown> = new Set(["blocked", "awaiting_evidence"]);
const RECOVERY_WAIT_DISPOSITIONS: ReadonlySet<unknown> = new Set(["blocked", "awaiting_evidence", "same_run_resume"]);
/**
* Cuts to at most `max` UTF-16 code units without splitting a surrogate pair.
@@ -1,3 +1,5 @@
import { sameRunResumeClaimEntry } from "./scheduled-resume-continuation.js";
import { appendBoundedRecoveryHistory } from "./native-restart-recovery.js";
import { notifyLiveRunRecovery } from "../live-run-recovery-notifications.js";
import { currentControllerProcessStamp } from "../controller-process-identity.js";
import { NATIVE_SESSION_RESUMPTION_ISSUE_STATUSES } from "./native-session-resumption-statuses.js";
@@ -309,15 +311,16 @@ export async function claimNativeSessionResumptions(input: {
const leaseExpiresAt = new Date(now.getTime() + 20 * 60_000);
await notifyLiveRunRecovery(tx, { companyId: row.run.companyId, runId: row.run.id, agentId: row.run.agentId });
const stamp = currentControllerProcessStamp();
const resumeClaim = sameRunResumeClaimEntry({ at: now, controllerBootId: stamp.bootId,
providerAttempt: row.coordinator.attempt, failureDetail: row.coordinator.failureDetail });
const updated = await tx.update(nativeRunFinalizations).set({
phase: "observed",
leaseOwner,
leaseExpiresAt,
...(() => {
const stamp = currentControllerProcessStamp();
return { controllerBootId: stamp.bootId, controllerPid: stamp.pid, controllerHostHash: stamp.hostHash,
controllerProcessStartedAt: stamp.processStartedAt ? new Date(stamp.processStartedAt) : null };
})(),
controllerBootId: stamp.bootId, controllerPid: stamp.pid, controllerHostHash: stamp.hostHash,
controllerProcessStartedAt: stamp.processStartedAt ? new Date(stamp.processStartedAt) : null,
recoveryHistory: appendBoundedRecoveryHistory(resumeClaim),
failureCode: null,
failureDetail: null,
nextAttemptAt: null,
@@ -963,7 +963,7 @@ function historyEntry(input: {
} satisfies Record<string, unknown>;
}
function appendBoundedRecoveryHistory(entry: Record<string, unknown>) {
export function appendBoundedRecoveryHistory(entry: Record<string, unknown>) {
const encoded = JSON.stringify(entry);
return sql`(
select coalesce(jsonb_agg(item order by ordinal), '[]'::jsonb)
@@ -14,6 +14,7 @@ vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => ({
import { executePaperclipNativeSession } from "./native-session-executor.js";
import { buildNativeCompletionContract } from "./completion-contracts.js";
import { prepareNativeHeartbeatRun } from "./prepare-native-run.js";
import { claimNativeSessionResumptions } from "./native-finalization-reconciler.js";
const EXITED_PID = 4_194_304;
@@ -35,7 +36,7 @@ describe("a scheduled same-run resume reaching the provider runtime", () => {
const exactResume = { recoveryMode: "exact_checkpoint_resume", recoverable: true, providerSessionEstablished: true,
checkpointExists: true, originalFailureCode: "native_session_recovery_failed" };
async function resumeOnce(options: { failureDetail: Record<string, unknown>; stopRecorded: boolean }) {
async function resumeOnce(options: { failureDetail: Record<string, unknown>; stopRecorded: boolean; viaReconciler?: boolean }) {
const issueId = randomUUID();
const runId = randomUUID();
const [issue] = await db.insert(issues).values({ id: issueId, companyId, title: "Resume the lost turn",
@@ -58,7 +59,12 @@ describe("a scheduled same-run resume reaching the provider runtime", () => {
contract: buildNativeCompletionContract(issue!, { revision: contract!.revision }) },
interactionResponses: [], credentialBindings: [],
};
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeExecutionInput: execution } }).where(eq(heartbeatRuns.id, runId));
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeExecutionInput: execution,
sessionCheckpoint: { backendKind: "runner", driverKind: "codex_app_server", sessionId: native.normalizedSessionId,
identity: { companyId, issueId, runId, agentId, sessionId: native.normalizedSessionId },
providerSessionId: "provider-thread", cursor: "1", activeTurnId: "lost-turn", pendingRuntimeRequests: [], lineage: [] } },
...(options.viaReconciler ? { status: "failed" as const, nativePhase: "retryable_failure" } : {}) })
.where(eq(heartbeatRuns.id, runId));
await db.insert(nativeRunFinalizations).values({ runId, companyId, issueId, phase: "retryable_failure", attempt: 1,
failureCode: "native_session_interrupted", failureDetail: options.failureDetail });
const identity = { processPid: EXITED_PID, processGroupId: EXITED_PID, processStartedAt: new Date().toISOString() };
@@ -80,7 +86,13 @@ describe("a scheduled same-run resume reaching the provider runtime", () => {
passed.push(input.resumeInterruptedTurn);
throw new Error("scheduled_resume_fixture_stop");
});
await executePaperclipNativeSession({ db, execution, runnerInstanceId: native.runnerInstanceId, backend: {
let leaseOwner: string | undefined;
if (options.viaReconciler) {
const claims = await claimNativeSessionResumptions({ db, runnerInstanceId: native.runnerInstanceId, runIds: [runId] });
expect(claims).toEqual([expect.objectContaining({ runId })]);
leaseOwner = claims[0]!.leaseOwner;
}
await executePaperclipNativeSession({ db, execution, runnerInstanceId: native.runnerInstanceId, leaseOwner, backend: {
descriptor: async () => { throw new Error("Unexpected provider call"); },
openSession: async () => { throw new Error("Unexpected provider call"); },
} }).catch(() => undefined);
@@ -88,12 +100,17 @@ describe("a scheduled same-run resume reaching the provider runtime", () => {
return { passed, coordinator: coordinator! };
}
it("lets the runtime continue a lost turn after a verified stop and an admitted attempt", async () => {
const { passed, coordinator } = await resumeOnce({ failureDetail: exactResume, stopRecorded: true });
it.each([false, true])("lets the runtime continue a lost turn after a verified stop and an admitted attempt (claimed by the resume reconciler: %s)", async (viaReconciler) => {
const { passed, coordinator } = await resumeOnce({ failureDetail: exactResume, stopRecorded: true, viaReconciler });
expect(passed).toEqual([true]);
expect(coordinator.attempt).toBe(2);
});
it("keeps the lost turn failing when the reconciler claimed a resume without a stop receipt", async () => {
const { passed } = await resumeOnce({ failureDetail: exactResume, stopRecorded: false, viaReconciler: true });
expect(passed).toEqual([false]);
});
it.each([
["no stop of the previous runner was recorded", { failureDetail: exactResume, stopRecorded: false }],
["the saved failure is a bootstrap retry", { failureDetail: { ...exactResume, recoveryMode: "bootstrap_retry" }, stopRecorded: true }],
@@ -10371,6 +10371,9 @@ async function executePaperclipNativeSessionAttempt(
targetKind: input.runnerExecutionTarget?.kind === "remote" ? "remote" : "local",
coordinatorPhase: coordinator.phase,
failureDetail: coordinator.failureDetail,
resumeClaim: Array.isArray(coordinator.recoveryHistory)
? (coordinator.recoveryHistory.at(-1) as Record<string, unknown> | undefined) ?? null : null,
controllerBootId: controller.bootId,
admittedAttempt: nextAttempt,
})).allowed;
const nextControllerGeneration = recovering
@@ -8,6 +8,7 @@ import {
} from "../../__tests__/helpers/embedded-postgres.js";
import {
decideScheduledResumeContinuation,
sameRunResumeClaimEntry,
scheduledResumeContinuationAllowed,
type ScheduledResumeContinuationEvidence,
} from "./scheduled-resume-continuation.js";
@@ -53,6 +54,27 @@ describe("same-run scheduled resume continuation of a turn lost on restore", ()
])("refuses when %s", (_name, evidence, reason) => {
expect(decideScheduledResumeContinuation(evidence)).toEqual({ allowed: false, reason });
});
const resumeClaim = (patch: Record<string, unknown> = {}, detail: Record<string, unknown> = {}) =>
({ ...sameRunResumeClaimEntry({ at: new Date(), controllerBootId: "boot-1", providerAttempt: 1,
failureDetail: { ...proven.failureDetail, ...detail } }), ...patch });
const claimed = (claim: Record<string, unknown> | null, patch: Partial<ScheduledResumeContinuationEvidence> = {}) =>
({ ...proven, coordinatorPhase: "observed", failureDetail: null, resumeClaim: claim, controllerBootId: "boot-1", ...patch });
it("reads the evidence the resume reconciler recorded when it claimed the run", () => {
expect(decideScheduledResumeContinuation(claimed(resumeClaim()))).toEqual({ allowed: true });
});
it.each<[string, ScheduledResumeContinuationEvidence, string]>([
["the last history entry is not the resume claim", claimed(resumeClaim({ disposition: "retryable_failure" })), "not_a_scheduled_same_run_resume"],
["no history entry exists", claimed(null), "not_a_scheduled_same_run_resume"],
["another boot recorded the resume claim", claimed(resumeClaim(), { controllerBootId: "boot-2" }), "not_a_scheduled_same_run_resume"],
["the claimed failure was a bootstrap retry", claimed(resumeClaim({}, { recoveryMode: "bootstrap_retry" })), "not_an_exact_checkpoint_resume"],
["the claimed failure had no checkpoint", claimed(resumeClaim({}, { checkpointExists: false })), "checkpoint_missing"],
["the claimed failure recorded a replacement", claimed(resumeClaim({}, { successorRunId: "run-2" })), "replacement_recorded"],
])("refuses a reconciler-claimed resume when %s", (_name, evidence, reason) => {
expect(decideScheduledResumeContinuation(evidence)).toEqual({ allowed: false, reason });
});
});
const support = await getEmbeddedPostgresTestSupport();
@@ -2,6 +2,28 @@ import type { Db } from "@paperclipai/db";
import { readNativeLocalProcessStop } from "../native-local-process-stop.js";
import { NATIVE_PROVIDER_RECOVERY_ATTEMPT_LIMIT } from "./native-restart-recovery.js";
export const SAME_RUN_RESUME_DISPOSITION = "same_run_resume";
export function sameRunResumeClaimEntry(input: {
at: Date; controllerBootId: string; providerAttempt: number; failureDetail: Record<string, unknown> | null | undefined;
}) {
const detail = input.failureDetail ?? {};
return {
at: input.at.toISOString(),
disposition: SAME_RUN_RESUME_DISPOSITION,
controllerBootId: input.controllerBootId,
providerAttempt: input.providerAttempt,
evidence: {
recoveryMode: detail.recoveryMode ?? null,
recoverable: detail.recoverable ?? null,
providerSessionEstablished: detail.providerSessionEstablished ?? null,
checkpointExists: detail.checkpointExists ?? null,
successorRunId: detail.successorRunId ?? null,
replacementDenied: detail.replacementDenied ?? null,
},
};
}
export type ScheduledResumeContinuationRefusal =
| "restart_recovery_claim"
| "remote_target"
@@ -19,6 +41,8 @@ export interface ScheduledResumeContinuationEvidence {
targetKind: "local" | "remote";
coordinatorPhase: string | null;
failureDetail: Record<string, unknown> | null | undefined;
resumeClaim?: Record<string, unknown> | null;
controllerBootId?: string;
admittedAttempt: number;
localStop: { processPid: number } | null;
}
@@ -33,8 +57,10 @@ export function decideScheduledResumeContinuation(
const refuse = (reason: ScheduledResumeContinuationRefusal) => ({ allowed: false as const, reason });
if (evidence.restartRecovery) return refuse("restart_recovery_claim");
if (evidence.targetKind !== "local") return refuse("remote_target");
if (evidence.coordinatorPhase !== "retryable_failure") return refuse("not_a_scheduled_same_run_resume");
const detail = evidence.failureDetail ?? {};
const claimed = evidence.coordinatorPhase === "observed" && evidence.resumeClaim?.disposition === SAME_RUN_RESUME_DISPOSITION
&& typeof evidence.controllerBootId === "string" && evidence.resumeClaim.controllerBootId === evidence.controllerBootId;
if (evidence.coordinatorPhase !== "retryable_failure" && !claimed) return refuse("not_a_scheduled_same_run_resume");
const detail = (claimed ? evidence.resumeClaim?.evidence as Record<string, unknown> | undefined : evidence.failureDetail) ?? {};
if (detail.recoveryMode !== "exact_checkpoint_resume") return refuse("not_an_exact_checkpoint_resume");
if (detail.recoverable !== true) return refuse("failure_not_recoverable");
if (detail.providerSessionEstablished !== true) return refuse("provider_session_not_established");