fix(native): recover a run killed between runner launch and its identity record (#19694)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs start a local `paperclip-runnerd` process. The server
records the request to start it, then records its process identity after
it spawns.
> - If the server is killed between those two records, there is no
process ID to check after the restart
> - Restart recovery then restarted the bootstrap, but the executor
failed with `durable_identity_unreadable` because the runner never wrote
its state
> - The user's Retry then waited forever with "Retry is waiting for
Paperclip to verify that the previous execution stopped", because the
stop check needs a process ID
> - This pull request restarts that bootstrap and proves on the host
that no runner for the run exists
> - The benefit is that a restart in the runner launch window no longer
loses the task or leaves Retry permanently stuck

## Linked Issues or Issue Description

**What happened?**
In restart-chaos testing, an ACPX Claude run was created and the server
was SIGKILLed 1.3 s later, right after `native.process_start_requested`
and before the runner identity was recorded. On restart, recovery
claimed `bootstrap_incomplete` (`dead_runner_before_provider_identity`).
The executor then threw `runner_state_identity_mismatch:
durable_identity_unreadable`: the state root existed, but
`control-plane/control-plane-state.json` had never been written. The run
failed with `native_continuation_requires_reconciliation`. A Retry was
accepted (HTTP 200), but no run was ever created. The issue stayed
blocked with `retryWait.reason=process_identity_missing` for over 20
minutes and through another restart.

**Expected behavior**
When the runner never wrote state, no provider started, so the bootstrap
can start again. A Retry should be admitted once the host proves that no
runner for the run exists.

**Paperclip version or commit**
`master` at `fb3cf1a072`.

**Steps to reproduce**
Create a native local task and SIGKILL the server about 1 s after the
run starts, after the runner launch is requested and before its identity
is recorded. Restart the server, then click Retry.

## What Changed

- `migrateRunnerdStateRootForExecution` (`native-session-executor.ts`):
for a `bootstrap_incomplete` restart on a local target, a root is
quarantined (kept for inspection) and the bootstrap starts again only
when both of these hold:
  - the root has no `control-plane-state.json`;
  - no live runner for the run exists on this host.

A state file that exists but cannot be read, or a runner that started
but has not connected yet, still fails closed with
`durable_identity_unreadable`.
- `unidentifiedLocalRunnerProvenAbsent` (`native-session-executor.ts`)
proves that a runner is absent when both of these hold:
- no host process carries the run's `--run-id <runId>` argument
(`paperclip-runnerd` is always started with it, detached and
unreferenced);
- no saved control-plane state for this run exists, in the canonical
root or in quarantine.

A provider starts only after its runner connects and writes that state.
- `native.process_start_requested` now records the launching `hostname`.
- `recordUnidentifiedLocalRunnerStop` (`native-local-process-stop.ts`)
applies the proof only when all of these hold:
  - the run is native;
  - the run has no process ID;
- its latest launch event is `native.process_start_requested`, from this
host;
  - all of its leases are local.

  A launch recorded without a host stays blocked.
- The process listing runs asynchronously with a 5 s deadline. A failed
or slow listing proves nothing.

It then writes `native.local_process_stopped` with null process IDs and
`basis: "unidentified_runner_absent"`. A later launch request
invalidates it as usual.
- The continuation and Retry gate (`explicit-native-continuation.ts`)
uses this proof before returning `process_identity_missing`. A dry run
checks the proof but records nothing.
- `doc/run-log-events.md` documents the new receipt basis.

## Verification

- `native-session-executor.test.ts`:
- The bootstrap restart with absent state was red before the fix
(`durable_identity_unreadable`) and is green after it (root quarantined,
backend admitted).
- Unreadable state, and a live runner that has not connected, still
reject.
- Proof cases: absent → true; a live process with `--run-id <runId>` →
false; this run's saved state → false; another run ID → false.
- `explicit-native-continuation.test.ts` (embedded Postgres, real
admission):
- The `absent` and `dry_run` cases were red before the fix (blocked) and
are green after it (admitted). Only the real admission records the
receipt.
- The `live_runner`, `saved_state`, `foreign_host` and `unbound_launch`
cases stay blocked.
- Broader local run after the review fixes:
`explicit-native-continuation`, `native-session-executor`,
`native-recovered-owner-followup`,
`native-runner-restart-recovery.integration` and `task-retry` gave 1836
passed, 0 failed.
- A chaos replay of the original task (`--replay
w-20261106T232854-s7103/t26`) passed (R1 PASS). Its kill landed after
the runner identity was recorded (`resume_dead_runner`), not in the
launch window, so the tests above are what reproduce that window.
- `tsc --noEmit` for `server` passes.

## Risks

- The proof depends on `ps -axww` listing every process on the launching
host. If `ps` fails or times out, the proof fails closed (still
blocked). Remote runners and other hosts are excluded.
- A runner that was spawned but has not yet written its state could be
mid-start when admission runs. It would still carry `--run-id` in its
arguments, so the proof refuses.
- Only the bootstrap path with no state file changes. Ownership,
checkpoint and reattach checks are unchanged.

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

## 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
- [x] All Paperclip CI gates are green
- [x] 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 20:06:36 -06:00
1 parent aca908a165
commit 30857db55e
6 files changed
+228 -5

No files matched your search

@@ -115,7 +115,7 @@ import {
EXECUTION_CONTROL_DEADLINE_MS,
} from "../execution-control-deadline.js";
import { createHash, randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { execFile, execFileSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
@@ -137,7 +137,7 @@ import {
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir, userInfo } from "node:os";
import { hostname, tmpdir, userInfo } from "node:os";
import { DatabaseSync } from "node:sqlite";
import { NATIVE_CHECKPOINT_HEADER_INSPECTION_MAX_BYTES, loadNativeRunnerCheckpointHeader, loadNativeUnclaimedBootstrapCheckpoint, nativeRunnerCheckpointPersistence, nativeRunnerCheckpointRegistration, restoreNativeRunnerCheckpoint } from "./native-runner-checkpoint.js";
import { REMOTE_CONTROLLER_LEASE_MS, REMOTE_RUNNER_RECONNECT_GRACE_MS } from "./native-runner-recovery-contract.js";
@@ -2119,6 +2119,54 @@ function runnerdStateBase(): string {
);
}
/**
* A runner launch requested without a recorded process identity leaves no PID
* to verify. On this host it is proven absent when no process carries the
* run's runner arguments and no saved runner state belongs to the run: a
* provider starts only after its runner connects and writes that state.
*/
export async function unidentifiedLocalRunnerProvenAbsent(input: { runId: string; execution: unknown }): Promise<boolean> {
let execution: NativeExecutionInput;
try {
execution = parseNativeExecutionInput(input.execution);
} catch {
return false;
}
if (execution.binding.runId !== input.runId) return false;
if (!(await noLocalRunnerProcessForRun(input.runId))) return false;
const root = scopedRunnerdStateRoot(execution);
const candidates = [root];
const quarantine = resolve(runnerdStateBase(), "quarantine");
try {
if (existsSync(quarantine)) {
for (const name of readdirSync(quarantine)) {
if (name.startsWith(`${basename(root)}.`)) candidates.push(resolve(quarantine, name));
}
}
} catch {
return false;
}
for (const candidate of candidates) {
if (!existsSync(resolve(candidate, "control-plane", "control-plane-state.json"))) continue;
try {
if (record(readRunnerdControlPlaneState(candidate).identity).runId === input.runId) return false;
} catch {
return false;
}
}
return true;
}
/** `paperclip-runnerd` is always launched with `--run-id <runId>`. A failed or
* slow process listing proves nothing, so it keeps the caller blocked. */
async function noLocalRunnerProcessForRun(runId: string): Promise<boolean> {
const listing = await new Promise<string | null>(resolve => {
execFile("ps", ["-axww", "-o", "command="], { encoding: "utf8", timeout: 5_000, maxBuffer: 64 * 1024 * 1024 },
(error, stdout) => resolve(error ? null : stdout));
});
return listing !== null && !listing.split("\n").some(line => line.includes(`--run-id ${runId}`));
}
export function scopedRunnerdStateRoot(execution: NativeExecutionInput): string {
// Antigravity's first release owns a fresh per-turn daemon. Its native
// conversation is stored separately and may load under each new run's grant.
@@ -5563,6 +5611,17 @@ async function migrateRunnerdStateRootForExecution(input: {
}
await assertCleanupActivationCommitted(input.db, scoped, input.execution);
const identity = readRunnerdDurableIdentity(scoped);
if (!identity && input.restartRecovery?.kind === "bootstrap_incomplete" &&
(!input.runnerExecutionTarget || input.runnerExecutionTarget.kind === "local") &&
!existsSync(resolve(scoped, "control-plane", "control-plane-state.json")) &&
await noLocalRunnerProcessForRun(input.execution.binding.runId)) {
// The server was killed between creating the root and the local runner's
// first state write. No runner for the run is alive on this host and none
// connected, so no provider started and no command was acknowledged.
// Keep the partial root for inspection and start the bootstrap again.
quarantineRunnerdStateRoot(scoped, "identity_indeterminate");
return;
}
if (!identity) {
if (input.restartRecovery?.kind !== "reattach_existing_runner" &&
input.restartRecovery?.kind !== "reattach_remote_runner") {
@@ -10415,6 +10474,8 @@ async function executePaperclipNativeSessionWithinScope(
stream: "system",
level: "info",
message: "Native execution requested; prior local stop evidence no longer applies.",
// Binds a later identity-less stop proof to the host that launched.
payload: { hostname: hostname() },
});
},
input: runnerExecution,