fix(native): restart an interrupted remote bootstrap on a resumed reusable lease (t06) (#20103)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run on a remote sandbox must survive a control-plane
restart; a bootstrap interrupted before its first turn is restarted on
the same run
> - With a reusable (warm) sandbox lease, the restart always resumes the
same sandbox. The interrupted runner's state is still on disk there:
never suspended, and with no provider turn
> - The resumed check accepted only a suspended checkpoint or no state
at all, so it failed closed with `resumed_not_suspended`, and the task
needed a Retry (restart chaos t06)
> - The pre-phase had already proven no turn was sent and had stopped
the stale runner, but that proof never reached the resumed check
> - This pull request carries that proof forward, for this run only, and
starts the bootstrap again on the resumed sandbox
> - The benefit is that a warm task interrupted during startup continues
on its own after the restart

## Linked Issues or Issue Description

**What happened?**
In restart-chaos wave w-20261108T192459-s9501 (Daytona warm, claude C1),
run 9bf86b59's runner reached transport-ready at 19:37:42 UTC. The
control plane was then down (another task's K4, then this task's K1).
Restart recovery classified the run `bootstrap_incomplete` and restarted
it on the same run at 19:41:51. The reusable lease resumed the same
sandbox. `inspectRemoteHarnessState` found this run's runner state with
`lifecycle` other than `suspended`, and the run failed at 19:41:59 with
`runner_ingress_unavailable: runnerd process failed:
runner_harness_state_mismatch: resumed_not_suspended`. The task needed a
manual Retry. Per-turn and non-reusable leases do not resume, so they
take another branch and were unaffected.

**Expected behavior**
A same-run restart of a bootstrap that provably sent no turn starts the
bootstrap again on the resumed sandbox, with no Retry. It must never
take over a live provider session or another run's warm session.

**Steps to reproduce**
1. Run a task on a reusable (warm) Daytona lease.
2. Kill the control plane after the runner connected and before the
first turn, so the run is `bootstrap_incomplete`.
3. Before this change, the restart fails with `resumed_not_suspended` on
the resumed lease.

**Paperclip version or commit**
master at d007afbf86 (the wave ran on c5a172a183).

## What Changed

- `native-session-executor.ts`:
- **Pre-phase:** the `bootstrap_incomplete` remote-sandbox pre-phase
clears any earlier proof for the run, then records a per-run proof
(`recordRemoteBootstrapRestartProof`). It records one only for a
confirmed stop:
    - `absent_confirmed`: verification reported `process_not_running`;
    - `stopped` or `killed`: the exit was confirmed after the signal.

An uncertain probe or a runner that outlives SIGKILL records nothing
(`stopStaleRemoteRunnerBeforeBootstrap` now distinguishes these
outcomes).
- **Inspection:** `inspectRemoteHarnessState` also returns the `runId`
of a not-suspended runner state (`notSuspendedRunId`). Its other fields
are unchanged.
- **Resumed check:** on a `bootstrap_incomplete` restart, a
not-suspended state with no backup is restarted only when all of these
hold:
    - the on-disk state is this run's own;
    - the proof is present;
- the interrupted session folder moved aside by rename, kept as evidence
and never deleted. The runtime root, the sessions parent and the folder
must each be a real, non-symlink directory.

    The proof is consumed only after the move succeeds.

It then claims a fresh session through the existing
`claimUntouchedRemoteSession`, which still requires an unconnected
controller with matching identity.
- Every other case keeps `runner_harness_state_mismatch:
resumed_not_suspended`.

## Verification

- Red/green: a new resumed-lease scenario, `interrupted_bootstrap`, in
"bootstraps only an untouched provider session in a $outcome workspace
lease". It fails without the change and passes with it. It checks that
the old session folder is kept beside the new claim and that the claim
runs exactly once.
- Counter-cases that still fail closed, with nothing moved aside:
`interrupted_bootstrap_other_run` (a previous warm run's state on disk),
`interrupted_bootstrap_unproven` (no pre-phase proof) and
`interrupted_bootstrap_symlink_parent` (a symlinked sessions parent).
The existing warm "no-proof" case keeps `resumed_not_suspended`.
- Stale-runner stop: "does not confirm absence when the process probe is
uncertain", "reports a runner that outlives SIGKILL as not stopped", and
"records a bootstrap restart proof only for a confirmed stop" (one case
per outcome).
- `pnpm exec vitest run
src/services/native-runtime/native-session-executor.test.ts
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts`
(server): 2 files, 1440 tests passed (14 skipped).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (server): passed.

## Risks

- **Taking over a live session:** this is the risk to watch. It is
bounded by every gate together: the `bootstrap_incomplete` claim, the
pre-phase proof from the restored controller record (no turn sent) with
the stale runner stopped, this run's own on-disk state, no backup, the
folder move, and the existing unconnected-controller claim. A failure at
any step keeps the current fail-closed error.
- **Upgrade safety:** the old runner's session folder is kept by rename,
never deleted. The change applies only to state a restart left behind,
so old-saved state takes the same gated path.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, file edits, test execution, a read-only research subagent).

## 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-08 15:01:30 -06:00
1 parent c4ba3974a7
commit 776f4cb602
2 files changed
+129 -8

No files matched your search

@@ -401,6 +401,10 @@ import {
createGovernedWaitEventObservation,
createRemoteRunnerProcessLauncher,
createRunnerdBackend,
noteRemoteBootstrapRestartProven,
recordRemoteBootstrapRestartProof,
hasRemoteBootstrapRestartProof,
takeRemoteBootstrapRestartProof,
unidentifiedLocalRunnerProvenAbsent,
executePaperclipNativeSession,
detachNativeSessionsForRestart,
@@ -627,10 +631,23 @@ describe("remote controller restart adoption", () => {
});
it("escalates to SIGKILL when the runner ignores SIGTERM", async () => {
const { execute, target } = fixture();
const live = execute.getMockImplementation()!;
let killed = false;
execute.mockImplementation(async request => {
if (killed && request.args[1]!.includes("expected_pid")) return { stdout: "", stderr: "", exitCode: 45, timedOut: false };
if (request.args.at(-1) === "KILL") killed = true;
return live(request);
});
await expect(stopStaleRemoteRunnerBeforeBootstrap({ execution, runnerExecutionTarget: target as never, identity },
{ waitMs: 20, pollMs: 1 })).resolves.toBe("killed");
expect(signalled(execute)).toEqual(["TERM", "KILL"]);
});
it("reports a runner that outlives SIGKILL as not stopped", async () => {
const { execute, target } = fixture();
await expect(stopStaleRemoteRunnerBeforeBootstrap({ execution, runnerExecutionTarget: target as never, identity },
{ waitMs: 20, pollMs: 1 })).resolves.toBe("stop_failed");
expect(signalled(execute)).toEqual(["TERM", "KILL"]);
});
it("signals nothing when the sandbox runner belongs to another identity", async () => {
const { execute, target } = fixture({ runnerInstanceId: "different" });
await expect(stopStaleRemoteRunnerBeforeBootstrap({ execution, runnerExecutionTarget: target as never, identity },
@@ -643,7 +660,21 @@ describe("remote controller restart adoption", () => {
execute.mockImplementation(async request => request.args[1]!.includes("expected_pid")
? { stdout: "", stderr: "", exitCode: 45, timedOut: false } : live(request));
await expect(stopStaleRemoteRunnerBeforeBootstrap({ execution, runnerExecutionTarget: target as never, identity },
{ waitMs: 20, pollMs: 1 })).resolves.toBe("no_prior_runner");
{ waitMs: 20, pollMs: 1 })).resolves.toBe("absent_confirmed");
expect(signalled(execute)).toEqual([]);
});
it.each([["absent_confirmed", true], ["stopped", true], ["killed", true], ["stop_failed", false], ["no_prior_runner", false]] as const)(
"records a bootstrap restart proof only for a confirmed stop: %s", (stopped, proven) => {
noteRemoteBootstrapRestartProven("run", "stopped");
expect(recordRemoteBootstrapRestartProof("run", stopped)).toBe(proven);
expect(hasRemoteBootstrapRestartProof("run")).toBe(proven);
takeRemoteBootstrapRestartProof("run");
});
it("does not confirm absence when the process probe is uncertain", async () => {
const { execute, target } = fixture();
const verify = async () => { throw new RemoteRunnerRecoveryError({ stage: "process_verification", reason: "probe_timeout" } as never); };
await expect(stopStaleRemoteRunnerBeforeBootstrap({ execution, runnerExecutionTarget: target as never, identity },
{ waitMs: 20, pollMs: 1, verify: verify as never })).resolves.toBe("no_prior_runner");
expect(signalled(execute)).toEqual([]);
});
});
@@ -19570,6 +19601,8 @@ describe("runnerd provider runtime wiring", () => {
["fresh", "external_checkpoint", "external_pending_turn", "external_wrong_identity", "external_unregistered", "existing_state", "symlink_parent", "wrong_identity", "wrong_runner", "wrong_lease", "missing_control_plane", "connected", "committed_event", "committed_command", "pending_turn", "remote_probe_failed", "backup_present"]
.map((scenario) => ({ outcome, scenario, homeMode: "isolated" }))),
...["resumed", "replacement"].map(outcome => ({ outcome, scenario: "fresh", homeMode: "preconfigured" })),
...["interrupted_bootstrap", "interrupted_bootstrap_other_run", "interrupted_bootstrap_unproven", "interrupted_bootstrap_symlink_parent"]
.map(scenario => ({ outcome: "resumed", scenario, homeMode: "isolated" })),
])(
"bootstraps only an untouched provider session in a $outcome workspace lease: $scenario ($homeMode)", async ({ outcome, scenario, homeMode }) => {
const remoteCwd = join(isolatedStateDirectory, "remote");
@@ -19577,7 +19610,12 @@ describe("runnerd provider runtime wiring", () => {
await mkdir(runtimeRoot, { recursive: true });
const sessionRoot = join(runtimeRoot, "sessions", createHash("sha256").update(execution.session.normalizedSessionId!).digest("hex"));
if (scenario === "existing_state") await mkdir(sessionRoot, { recursive: true });
if (scenario === "symlink_parent") await symlink(isolatedStateDirectory, join(runtimeRoot, "sessions"));
const symlinkTarget = scenario === "interrupted_bootstrap_symlink_parent" ? join(isolatedStateDirectory, "elsewhere") : isolatedStateDirectory;
if (scenario === "symlink_parent" || scenario === "interrupted_bootstrap_symlink_parent") {
await mkdir(symlinkTarget, { recursive: true });
await symlink(symlinkTarget, join(runtimeRoot, "sessions"));
}
if (scenario.startsWith("interrupted_bootstrap")) await mkdir(sessionRoot, { recursive: true });
const syncIn = vi.fn(async () => undefined);
const remoteExecute = vi.fn(async (command: { command: string; args?: string[] }) => {
if (command.args?.[2] === "paperclip-runner-claim-unstarted-session") {
@@ -19604,6 +19642,16 @@ describe("runnerd provider runtime wiring", () => {
if (command.args?.[2] === "paperclip-runner-launch") {
throw new Error("fixture_stop_after_launch_staging");
}
if (command.args?.[2] === "paperclip-runner-bootstrap-restart") {
try { execFileSync("sh", command.args, { stdio: "pipe" }); return { exitCode: 0, timedOut: false, stdout: "", stderr: "" }; }
catch { return { exitCode: 1, timedOut: false, stdout: "", stderr: "" }; }
}
if (command.args?.[1]?.includes("base64") && scenario.startsWith("interrupted_bootstrap")) return {
exitCode: 0, timedOut: false, stderr: "", stdout: Buffer.from(JSON.stringify({
runnerInstanceId: "runner-new-in-retained-workspace", normalizedSessionId: execution.session.normalizedSessionId,
runId: scenario === "interrupted_bootstrap_other_run" ? "previous-warm-run" : execution.binding.runId,
lifecycle: "ready", outbox: [], pendingTerminalDelivery: null })).toString("base64"),
};
if (command.args?.[1]?.includes("base64")) return {
exitCode: 1, timedOut: false, stdout: "", stderr: "",
};
@@ -19621,6 +19669,9 @@ describe("runnerd provider runtime wiring", () => {
const backend = await createRunnerdBackend({
db: leaseDb(executionWithContext), execution: executionWithContext, runnerInstanceId: "runner-new-in-retained-workspace",
...(scenario.startsWith("external_") ? { runnerRemoteBinaryPath: controllerArtifact, runnerRemoteCodexPath: controllerArtifact } : {}),
...(scenario.startsWith("interrupted_bootstrap") ? { restartRecovery: { kind: "bootstrap_incomplete" as const,
runId: execution.binding.runId, leaseOwner: "controller", controllerGeneration: 3, providerAttempt: 1,
restartKind: "hard" as const, recoveryRequestId: null } } : {}),
trace: createNativeRunTrace({ runId: execution.binding.runId, onEvent: onTraceEvent }),
runnerIngressAuthorized: true,
runnerExecutionTarget: {
@@ -19690,7 +19741,16 @@ describe("runnerd provider runtime wiring", () => {
await mkdir(join(options.stateDirectory!, "failover-backups", "current"), { recursive: true });
await writeFile(join(options.stateDirectory!, "failover-backups", "current", "manifest.json"), "{}");
}
if (scenario === "fresh" || scenario === "external_checkpoint") {
if (["interrupted_bootstrap", "interrupted_bootstrap_other_run", "interrupted_bootstrap_symlink_parent"].includes(scenario)) {
noteRemoteBootstrapRestartProven(execution.binding.runId, "stopped");
}
if (scenario === "interrupted_bootstrap") {
await expect(options.prepareExternalRunnerState()).resolves.toBeUndefined();
const siblings = await readdir(dirname(sessionRoot));
expect(siblings.filter(name => name.startsWith(`${basename(sessionRoot)}.bootstrap-restart-`))).toHaveLength(1);
expect((await lstat(sessionRoot)).isDirectory()).toBe(true);
expect(remoteExecute.mock.calls.filter(([command]) => command.args?.[2] === "paperclip-runner-claim-unstarted-session")).toHaveLength(1);
} else if (scenario === "fresh" || scenario === "external_checkpoint") {
await expect(options.prepareExternalRunnerState()).resolves.toBeUndefined();
expect(onTraceEvent).toHaveBeenCalledWith(expect.objectContaining({
payload: expect.objectContaining({
@@ -19748,6 +19808,13 @@ describe("runnerd provider runtime wiring", () => {
);
expect(remoteExecute.mock.calls.some(([command]) => command.args?.[1]?.includes("install -d"))).toBe(false);
expect(syncIn).not.toHaveBeenCalled();
if (scenario === "interrupted_bootstrap_symlink_parent") {
expect((await readdir(symlinkTarget)).some(name => name.includes(".bootstrap-restart-"))).toBe(false);
expect((await lstat(sessionRoot)).isDirectory()).toBe(true);
} else {
expect(remoteExecute.mock.calls.some(([command]) => command.args?.[2] === "paperclip-runner-bootstrap-restart")).toBe(false);
}
takeRemoteBootstrapRestartProof(execution.binding.runId);
}
});
@@ -5748,7 +5748,11 @@ async function migrateRunnerdStateRootForExecution(input: {
// holds no provider work (RC-29d).
if (runnerdStateProvesIncompleteBootstrap(scoped, { localProcesses })
|| (remoteSandbox && runnerdStateProvesNoRemoteProviderTurn(scoped))) {
if (remoteSandbox) await stopStaleRemoteRunnerBeforeBootstrap({ ...input, identity });
if (remoteSandbox) {
takeRemoteBootstrapRestartProof(input.execution.binding.runId);
recordRemoteBootstrapRestartProof(input.execution.binding.runId,
await stopStaleRemoteRunnerBeforeBootstrap({ ...input, identity }));
}
quarantineRunnerdStateRoot(scoped, "identity_indeterminate");
return;
}
@@ -6287,6 +6291,28 @@ const STALE_REMOTE_RUNNER_STOP_WAIT_MS = 10_000;
* runner can be verified there is nothing to stop, and a remaining conflict is
* still caught by the bounded same-run bootstrap retry.
*/
const provenRemoteBootstrapRestarts = new Map<string, "absent_confirmed" | "stopped" | "killed">();
export function noteRemoteBootstrapRestartProven(runId: string, stopped: "absent_confirmed" | "stopped" | "killed"): void {
provenRemoteBootstrapRestarts.set(runId, stopped);
}
export function recordRemoteBootstrapRestartProof(runId: string,
stopped: Awaited<ReturnType<typeof stopStaleRemoteRunnerBeforeBootstrap>>): boolean {
provenRemoteBootstrapRestarts.delete(runId);
if (stopped !== "absent_confirmed" && stopped !== "stopped" && stopped !== "killed") return false;
provenRemoteBootstrapRestarts.set(runId, stopped);
return true;
}
export function hasRemoteBootstrapRestartProof(runId: string): boolean {
return provenRemoteBootstrapRestarts.has(runId);
}
export function takeRemoteBootstrapRestartProof(runId: string): boolean {
return provenRemoteBootstrapRestarts.delete(runId);
}
export async function stopStaleRemoteRunnerBeforeBootstrap(input: {
execution: NativeExecutionInput;
runnerExecutionTarget?: AdapterExecutionTarget | null;
@@ -6296,7 +6322,7 @@ export async function stopStaleRemoteRunnerBeforeBootstrap(input: {
verify?: typeof verifyRemoteRunnerReattachment;
waitMs?: number;
pollMs?: number;
} = {}): Promise<"no_prior_runner" | "stopped" | "killed" | "stop_failed"> {
} = {}): Promise<"no_prior_runner" | "absent_confirmed" | "stopped" | "killed" | "stop_failed"> {
const target = input.runnerExecutionTarget;
if (target?.kind !== "remote" || target.transport !== "sandbox" || !target.runner) return "no_prior_runner";
const providerLeaseId = target.sandboxLeaseAcquisition?.providerLeaseId;
@@ -6309,9 +6335,10 @@ export async function stopStaleRemoteRunnerBeforeBootstrap(input: {
claim: { runId, remote: { providerLeaseId, remoteCwd: target.remoteCwd } },
target, identity: input.identity, runId, normalizedSessionId: nativeSessionKey(input.execution),
});
} catch {
} catch (error) {
// No runner with this run's exact identity and generation is alive here.
return "no_prior_runner";
return error instanceof RemoteRunnerRecoveryError && error.remoteRunnerRecovery.reason === "process_not_running"
? "absent_confirmed" : "no_prior_runner";
}
try {
let outcome: "stopped" | "killed" = "stopped";
@@ -6323,6 +6350,11 @@ export async function stopStaleRemoteRunnerBeforeBootstrap(input: {
if (await runner.isAlive()) {
await runner.signal("SIGKILL");
outcome = "killed";
const killDeadline = Date.now() + (options.waitMs ?? STALE_REMOTE_RUNNER_STOP_WAIT_MS);
while (Date.now() < killDeadline && await runner.isAlive()) {
await new Promise(done => setTimeout(done, options.pollMs ?? 500));
}
if (await runner.isAlive()) return "stop_failed";
}
}
await input.onLog?.("stdout", `[paperclip-runner] Stopped the interrupted runner ${runner.pid} before restarting the bootstrap.\n`);
@@ -15073,6 +15105,7 @@ async function createRunnerdBackendWithinSessionClaim(
runnerState: Record<string, unknown> | null;
providerSessionIdentity: Record<string, unknown> | null;
incompleteReason: "unavailable" | "not_suspended" | null;
notSuspendedRunId?: string | null;
}> => {
if (!remoteCommandRunner || !remoteStateDirectory) {
return {
@@ -15140,6 +15173,7 @@ async function createRunnerdBackendWithinSessionClaim(
runnerState: null,
providerSessionIdentity: null,
incompleteReason: "not_suspended",
notSuspendedRunId: typeof runnerState.runId === "string" ? runnerState.runId : null,
};
}
let providerState: Record<string, unknown>;
@@ -15206,6 +15240,20 @@ async function createRunnerdBackendWithinSessionClaim(
return probe.exitCode === 0 && !probe.timedOut;
};
const moveInterruptedBootstrapSessionAside = async (): Promise<boolean> => {
if (!remoteCommandRunner || !remoteRuntimeRoot || !remoteSessionRoot) return false;
const moved = await remoteCommandRunner.execute({
command: "sh",
args: ["-c",
'set -eu; for directory in "$1" "$2" "$3"; do test -d "$directory" && test ! -L "$directory" || exit 1; done; mv -- "$3" "$4"',
"paperclip-runner-bootstrap-restart", remoteRuntimeRoot, posix.dirname(remoteSessionRoot), remoteSessionRoot,
`${remoteSessionRoot}.bootstrap-restart-${Date.now()}-${randomUUID()}`],
bypassSession: true,
timeoutMs: 30_000,
});
return moved.exitCode === 0 && !moved.timedOut;
};
const recordInPlaceHarnessReuse = async (
providerSessionIdentity: Record<string, unknown>,
startedAtMs = Date.now(),
@@ -15481,7 +15529,13 @@ async function createRunnerdBackendWithinSessionClaim(
!state.runnerState ||
!state.providerSessionIdentity
) {
if (state.incompleteReason !== "unavailable" || backupAvailable ||
const provenRestart = state.incompleteReason === "not_suspended" && !backupAvailable
&& input.restartRecovery?.kind === "bootstrap_incomplete"
&& state.notSuspendedRunId === input.execution.binding.runId
&& hasRemoteBootstrapRestartProof(input.execution.binding.runId)
&& await moveInterruptedBootstrapSessionAside()
&& takeRemoteBootstrapRestartProof(input.execution.binding.runId);
if ((state.incompleteReason !== "unavailable" && !provenRestart) || backupAvailable ||
!(await claimUntouchedRemoteSession())) {
throw new Error(`runner_harness_state_mismatch: resumed_${state.incompleteReason}`);
}