fix(native): an inherited checkpoint with a pending runtime request is evidence (#20226)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #20153 and #20214 let restart recovery set aside a remote
follow-up's admission-rebound checkpoint when the checkpoint is blank
and nothing of the run reached a runner. Recovery then restarts the
bootstrap instead of blocking
> - "Blank" checked the cursor, terminal turns, active turn, semantic
result and terminal, but not pending runtime requests
> - A rebound checkpoint that still carried an outstanding runtime
request would therefore be treated as no evidence. The run would restart
as a fresh bootstrap and drop that request
> - This pull request also requires no pending runtime requests before
the checkpoint counts as a blank admission checkpoint
> - The benefit is that an outstanding request always keeps the run on
its reconciling path

## Linked Issues or Issue Description

Refs #20214

**What happened?**
After #20214 merged, ponytail review noted that
`admissionCheckpointOnly` (shared by #20153's
`remoteRunnerNeverReceivedCommands` and #20214's
`inheritedCheckpointOnly`) did not inspect `pendingRuntimeRequests`. A
rebound checkpoint with a pending request would be demoted to "no
evidence", and the run restarted as `remote_execution_never_claimed`,
dropping the request.

**Expected behavior**
A checkpoint with any pending runtime request is evidence. The run is
not restarted as a fresh bootstrap.

**Steps to reproduce**
1. Claim restart recovery for an unclaimed remote follow-up whose
rebound checkpoint carries a pending runtime request and no other state.
2. Before this change, recovery claims `bootstrap_incomplete`.

**Paperclip version or commit**
Current master after #20214.

## What Changed

- `native-restart-recovery.ts`: `admissionCheckpointOnly` additionally
requires `pendingRuntimeRequests` to be absent or empty.
- `native-runner-restart-recovery.integration.test.ts`: a held case, an
unclaimed remote follow-up whose inherited checkpoint carries a pending
runtime request.

## Verification

- Red/green: on master the held case is restarted as
`bootstrap_incomplete`. With the change it is not, and the other cases
are unchanged.
- `vitest run` of `native-runner-restart-recovery.integration.test.ts`
and `native-restart-recovery.test.ts`: 2 files, 201 tests passed (14
skipped).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Minimal. The change only narrows when a checkpoint counts as blank.
Every run it affects keeps the existing, more conservative
classification.

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

🤖 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-09 03:31:35 -06:00
1 parent 8525bb534c
commit df4562ab38
2 files changed
+8 -5

No files matched your search

@@ -1327,7 +1327,9 @@ export async function claimNativeRestartRecoveries(input: {
const admissionCheckpointOnly = !hasCheckpoint || (checkpointIdentityMatches && checkpointRecord.cursor == null
&& (!Array.isArray(checkpointRecord.terminalTurns) || checkpointRecord.terminalTurns.length === 0)
&& checkpointRecord.activeTurnId == null && checkpointRecord.semanticResult == null && checkpointRecord.terminal == null);
&& checkpointRecord.activeTurnId == null && checkpointRecord.semanticResult == null && checkpointRecord.terminal == null
&& (checkpointRecord.pendingRuntimeRequests == null
|| (Array.isArray(checkpointRecord.pendingRuntimeRequests) && checkpointRecord.pendingRuntimeRequests.length === 0)));
const inheritedCheckpointOnly = hasCheckpoint && admissionCheckpointOnly && remoteSandbox && !pluginTask
&& !durableAuthority && stagedAttachment === null && providerEvents.length === 0;
const inheritedRunnerIdentity = inheritedCheckpointOnly && Boolean(row.run.processPid || row.run.processGroupId)
@@ -2762,8 +2762,9 @@ process.on("SIGTERM", () => child.kill("SIGTERM"));
{ label: "restarts with its predecessor's runner identity", providerEvent: false, process: "inherited" },
{ label: "keeps its own runner identity held instead of restarting", providerEvent: false, process: "own" },
{ label: "keeps a projected provider event held instead of restarting", providerEvent: true, process: "none" },
])("$label an unclaimed remote follow-up that carries only its inherited checkpoint", async ({ providerEvent, process }) => {
const fixture = await seedUnlaunchedRemoteClaim(`UNCLAIMED-INHERITED-${providerEvent}-${process}`);
{ label: "keeps a pending runtime request held instead of restarting", providerEvent: false, process: "none", pendingRequest: true },
])("$label an unclaimed remote follow-up that carries only its inherited checkpoint", async ({ providerEvent, process, pendingRequest }) => {
const fixture = await seedUnlaunchedRemoteClaim(`UNCLAIMED-INHERITED-${providerEvent}-${process}-${Boolean(pendingRequest)}`);
if (process !== "none") {
const processStartedAt = new Date(Date.now() - 120_000);
await fixture.db.update(heartbeatRuns).set({ processPid: 596, processGroupId: null, processStartedAt, processHostHash: "remote" })
@@ -2785,13 +2786,13 @@ process.on("SIGTERM", () => child.kill("SIGTERM"));
driverKind: "codex_app_server", backendKind: "runner", sessionId: "inherited-thread", cursor: null,
identity: { companyId, issueId: fixture.issueId, runId: fixture.runId, agentId, sessionId: fixture.native.normalizedSessionId },
providerSessionId: "inherited-thread", activeTurnId: null, terminal: null, terminalTurns: [], semanticResult: null,
pendingRuntimeRequests: [], lineage: [],
pendingRuntimeRequests: pendingRequest ? [{ requestId: "input-1", kind: "user_input" }] : [], lineage: [],
} } }).where(eq(heartbeatRuns.id, fixture.runId));
if (providerEvent) await fixture.db.insert(heartbeatRunEvents).values({ companyId, runId: fixture.runId, agentId, seq: 901,
stream: "system", level: "info", eventType: "session.started" });
const [claim] = await claimNativeRestartRecoveries({ db: fixture.db, controller: successor,
restartKind: "hard", runIds: [fixture.runId] });
if (providerEvent || process === "own") {
if (providerEvent || process === "own" || pendingRequest) {
expect(claim).not.toMatchObject({ kind: "bootstrap_incomplete" });
return;
}