fix(native): retire a finished run's idle Runner that still holds the sandbox Runner port before launching (#20995)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs execute through a Runner (`paperclip-runnerd`) inside a
remote sandbox. Daytona reaches every Runner through one fixed preview
port (43127), so a sandbox serves one listening Runner at a time.
> - A warm sandbox is reused by the next run of the same task. That run
often opens a new session, so it neither adopts the previous run's warm
Runner nor retires it. Retirement of another session's warm owner works
only through the in-memory warm registry of the server that launched it.
> - After a server restart (or on another server), that registry is
empty. The previous run's idle warm Runner keeps the port, and every new
launch fails with `runner_ingress_bind_conflict` until the bounded retry
runs out.
> - This pull request finds such a holder on the sandbox before launch
and proves from durable records whether it may be stopped. A finished
run's idle Runner is stopped through its verified process identity. Any
other holder sends the run to the fresh-sandbox fallback from #20863 and
is never stopped.
> - The benefit is that a follow-up run after a restart starts on its
reused sandbox instead of colliding with its predecessor and needing
Retry.

## Linked Issues or Issue Description

**What happened?**
Restart-chaos wave `w-20261111T230537-s6402` (Claude through ACPX,
Daytona warm sandboxes, `--ephemeral-server`, K1/K2/K15 with
`--k15-fresh-home`, seed 6402, master, no injected fault) needed Retry
on 4 of 6 tasks. Each failing run's first Runner failed with
`runner_ingress_bind_conflict: failed to bind listener: Address already
in use`. The harness's sandbox captures name the process holding port
43127 at the time. In every case it was the previous, already
`succeeded` run of the same task, started under the server generation
before the restart:

| Task | Failing run | Port 43127 held by | Holder run status |
| --- | --- | --- | --- |
| t01 | b4769dfd | 395b202d (Runner started 23:11:30) | succeeded
23:12:19 |
| t05 | b6d466fd | 0969ad46 (23:11:29) | succeeded 23:12:18 |
| t02 | 4097fc1f | 527a1659 (23:11:28) | succeeded 23:12:17 |
| t03 | 60d821af | 1c06bf75 (23:11:28) | succeeded 23:12:25 |

**Root cause**
Warm owners are tracked only in the process-local `warmNativeSessions`
map in `server/src/services/native-runtime/native-session-executor.ts`.
`claimWarmNativeInstructionCopy`,
`reserveWarmNativeInstructionDirectory` and the same-session admission
path retire a predecessor only when it is in that map. A restarted
server has an empty map. The follow-up run uses a new session, so
`inspectRemoteWarmSessionRecovery` (same session only) does not adopt it
either. The launch then meets the old Runner on the fixed port.
`stopStaleRemoteRunnerBeforeBootstrap` stops only the run's own Runner,
so the same-run retries collide again.

**Expected behavior**
A finished predecessor's idle warm Runner is retired before the next
Runner launches on the same sandbox, wherever it was launched. A holder
that cannot be proven safe to stop is never stopped, and the run moves
to a fresh sandbox.

## What Changed

- New
`server/src/services/native-runtime/sandbox-runner-port-holders.ts`:
`retireSandboxRunnerPortHolders` lists every other session's Runner on
the sandbox (session roots with a `runner-process.identity`; this run's
own root and moved-aside roots are skipped). It checks each one with the
existing verified process check (`REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT`:
pid, runner id in the command line and, when recorded, the boot
generation). Then, for a live holder:
- Registered in this server's warm registry: it is retired through
`retireWarmPredecessor`.
- Its company-scoped run row is `succeeded` or `cancelled`, for the same
agent, and its Runner state is idle (empty outbox, no pending terminal
delivery, and both saved fences `pendingProviderCleanup` and
`warmTransition` clear): it is stopped through the verified identity
(`TERM`, then `KILL`, each with a bounded wait). An activity log entry
`native.runner_port_holder_retired` is written.
- When the inventory command itself cannot run, the holder state is
unproven, so the run takes the fresh-sandbox fallback.
- Anything else (a live run, a failed or interrupted run that may still
be resumed, an unknown owner, work in the outbox, an unverifiable or
changed identity marker, or a stop that is not confirmed):
`NativePredecessorRetirementUnprovenError`. Before any process start,
the existing #20863 path marks the lease replace-before-reuse and
re-queues the run in the uncharged lane on a fresh sandbox.
- `executePaperclipNativeSessionAttempt` runs this check before
`native.process_start_requested`, and only for a resumed (reused)
sandbox launched through a shell runner without a warm session to reuse
and without a reattach recovery. A created or replacement sandbox cannot
hold another session's Runner, so it adds no command there.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/sandbox-runner-port-holders.integration.test.ts`
(embedded Postgres, real processes): two Runners contend for one real
port. The second bind first fails with `EADDRINUSE`, the same failure as
staging. After `retireSandboxRunnerPortHolders` the finished run's
holder has exited and the bind succeeds, with the activity entry
recorded. The commands run through a confined runner that refuses any
path outside the sandbox workspace base. A holder owned by a live run,
by a finished run with work in its outbox, by a cancelled run with
pending provider cleanup, or by no recorded run is never stopped: the
call raises `NativePredecessorRetirementUnprovenError`
(`runner_port_holder_owner_not_settled` /
`runner_port_holder_unknown_owner`) and the port stays held. This run's
own session, moved-aside roots and exited holders are ignored.
- `native-warm-checkpoint-ownership.integration.test.ts`: its fixture
sandbox runner now answers the inventory command with an empty list
(that sandbox has no other Runner); all 9 cells pass.
- `native-session-executor.test.ts`: 1406 of 1406 pass. Server `tsc
--noEmit` passes.
- Chaos results are in a comment below.

## Risks

- A finished run's idle warm Runner is stopped earlier than its idle
timeout. It held no unsaved work (its run is `succeeded` or `cancelled`
and its outbox is empty), and SIGTERM is the same stop as its idle
close. Only that warm reuse is lost.
- Failed and interrupted holders are not stopped, because their run may
still reattach. Such a run goes to a fresh sandbox instead.
- One extra sandbox command before launch on a reused sandbox for a cold
launch, no wait and no file changes. A warm reuse and a reattach skip
it.
- Not changed: a bind conflict from a holder that is not a Runner (no
identity file) still uses the existing same-run bootstrap budget.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use (shell, file edit, test runs). Default context window,
extended reasoning on.

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

Refs #20863, #20953, #20972, #20992.

🤖 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-11 21:06:12 -06:00
1 parent 7d9973405c
commit 776e900b48
4 files changed
+342 -1

No files matched your search

@@ -59,6 +59,7 @@ import { environmentTaskOperationSchema, environmentTaskResultSchema } from "@pa
import { preparePluginRunnerCredentials } from "./plugin-runner-credentials.js";
import { createPluginRunnerTaskOwner, verifyPluginRunnerTaskReattachment } from "./plugin-runner-task.js";
import { findNativePredecessorRetirementUnproven, NATIVE_PREDECESSOR_RETIREMENT_UNPROVEN_CODE, NativePredecessorRetirementUnprovenError } from "./predecessor-retirement.js";
import { retireSandboxRunnerPortHolders } from "./sandbox-runner-port-holders.js";
import { NativeExecutionContext } from "./native-execution-context.js";
import { NativeStartupCleanupPendingError, clearNativeStartupContext } from "./native-startup-cleanup.js";
import { readUnreadSteeredMessages } from "./unread-steered-messages.js";
@@ -819,6 +820,25 @@ function sandboxProviderLeaseId(target?: AdapterExecutionTarget | null): string
* reports the typed pre-provider failure that sends the next attempt to a
* fresh sandbox instead of failing the task.
*/
async function retireInProcessWarmOwnerOnSandbox(runId: string, providerLeaseId: string | null): Promise<boolean> {
for (const [scope, entry] of warmNativeSessions) {
if (entry.runId !== runId || entry.released || (providerLeaseId && entry.providerLeaseId !== providerLeaseId)) continue;
if (entry.busy || entry.preparingRunId) {
throw new NativePredecessorRetirementUnprovenError(runId, providerLeaseId, new Error("runner_port_holder_busy"));
}
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
entry.idleTimer = null;
entry.busy = true;
entry.closeOnReleaseReason = "warm Runner held the sandbox Runner port for another session";
try {
await retireWarmPredecessor(entry, entry.closeOnReleaseReason);
forgetWarmNativeSession(scope, entry);
} finally { entry.busy = false; }
return true;
}
return false;
}
async function retireWarmPredecessor(entry: WarmNativeSession, reason: string,
options?: Parameters<typeof closeWarmNativeSession>[2]) {
// Chaos cell SR1U: a sandbox owner whose stop stays unconfirmed even when
@@ -12045,6 +12065,22 @@ async function executePaperclipNativeSessionAttempt(
remoteCleanupLease = await readRemoteCleanupLease();
return true;
};
const portTarget = input.runnerExecutionTarget;
if (input.useRunnerd && !input.backend && !existingWarmSession && portTarget?.kind === "remote" && portTarget.transport === "sandbox"
&& portTarget.runner && !portTarget.runnerTask && portTarget.sandboxLeaseAcquisition?.outcome === "resumed"
&& input.restartRecovery?.kind !== "reattach_remote_runner"
&& input.restartRecovery?.kind !== "reattach_existing_runner") {
const runtimeRoot = posix.join(portTarget.remoteCwd, ".paperclip-runtime", "paperclip-runner");
const providerLeaseId = portTarget.sandboxLeaseAcquisition?.providerLeaseId ?? null;
const retirePortHolders = () => retireSandboxRunnerPortHolders({
db: input.db, companyId: input.execution.binding.companyId, agentId: input.execution.binding.agentId,
runId: input.execution.binding.runId, runner: portTarget.runner!, runtimeRoot,
ownSessionRoot: posix.join(runtimeRoot, "sessions", createHash("sha256").update(nativeSessionKey(input.execution)).digest("hex")),
providerLeaseId, adoptedRunId: remoteWarmRecovery?.identity.runId ?? null,
retireInProcess: holderRunId => retireInProcessWarmOwnerOnSandbox(holderRunId, providerLeaseId), onLog: input.onLog,
});
await trace.measure("runner.port_holders.retire", () => executionContext ? executionContext.wait(retirePortHolders) : retirePortHolders());
}
nativeSessionExecuteStartedAtMs = Date.now();
native = await trace.measure(
"native.session.execute",
@@ -148,7 +148,9 @@ describeEmbeddedPostgres("managed warm checkpoint ownership", () => {
sandboxLeaseAcquisition: { outcome: acquisition, providerLeaseId },
runner: { execute: async (command: { args?: string[] }) => command.args?.[1] === "uname -s; uname -m"
? { exitCode: 0, timedOut: false, stdout: `${process.platform === "darwin" ? "Darwin" : "Linux"}\n${process.arch === "arm64" ? "arm64" : "x86_64"}\n`, stderr: "" }
: { exitCode: 1, timedOut: false, stdout: "", stderr: "" }, syncIn: async () => {} } } as never,
: command.args?.[2] === "paperclip-runner-port-holders"
? { exitCode: 0, timedOut: false, stdout: "", stderr: "" }
: { exitCode: 1, timedOut: false, stdout: "", stderr: "" }, syncIn: async () => {} } } as never,
instructionWorkingCopy: { runId: fixture.runId, root, checkpointWarm: async () => {
if (instructionErrorWithDetach) { enteredClose(); await closeGate; throw initiatingError; }
if (scenario === "instruction_error") throw initiatingError;
@@ -0,0 +1,140 @@
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createServer, type AddressInfo } from "node:net";
import { join } from "node:path";
import { and, eq } from "drizzle-orm";
import { afterEach, expect, it } from "vitest";
import { activityLog, heartbeatRuns } from "@paperclipai/db";
import { describeEmbeddedPostgres, nativeRestartFixture } from "../../__tests__/helpers/native-restart.js";
import { NativePredecessorRetirementUnprovenError } from "./predecessor-retirement.js";
import { retireSandboxRunnerPortHolders } from "./sandbox-runner-port-holders.js";
const HOLDER_SCRIPT = 'require("node:net").createServer().listen(Number(process.argv[1]), "127.0.0.1", () => process.stdout.write("ready\\n"));';
describeEmbeddedPostgres("a sandbox Runner port held by another session's Runner", () => {
const environment = nativeRestartFixture();
const holders: ChildProcess[] = [];
afterEach(() => { for (const holder of holders.splice(0)) if (holder.exitCode === null) holder.kill("SIGKILL"); });
const confinedCommands: string[][] = [];
const confinedRunner = (base: string) => ({ execute: async (request: { command: string; args?: string[] }) => {
const operands = (request.args ?? []).slice(2);
confinedCommands.push(operands);
if (operands.some(operand => operand.startsWith("/") && !operand.startsWith(`${base}/`))) {
return { exitCode: 126, timedOut: false, stdout: "", stderr: "outside sandbox" };
}
const result = spawnSync(request.command, request.args ?? [], { encoding: "utf8", timeout: 20_000, cwd: base });
return { exitCode: result.status, timedOut: result.error !== undefined, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
} });
async function freePort() {
const probe = createServer();
await new Promise<void>(done => probe.listen(0, "127.0.0.1", done));
const port = (probe.address() as AddressInfo).port;
await new Promise<void>(done => probe.close(() => done()));
return port;
}
async function bind(port: number): Promise<"bound" | string> {
return new Promise(done => {
const contender = createServer();
contender.once("error", (error: NodeJS.ErrnoException) => done(error.code ?? "error"));
contender.listen(port, "127.0.0.1", () => contender.close(() => done("bound")));
});
}
async function startHolder(input: { runtimeRoot: string; session: string; runId: string; port: number;
lifecycle?: string; outbox?: readonly unknown[]; pendingProviderCleanup?: unknown }) {
const runnerInstanceId = `runner-${randomUUID()}`;
const holder = spawn(process.execPath, ["-e", HOLDER_SCRIPT, String(input.port), "--runner-id", runnerInstanceId],
{ stdio: ["ignore", "pipe", "ignore"] });
holders.push(holder);
await new Promise<void>((done, fail) => {
holder.stdout!.once("data", () => done());
holder.once("exit", code => fail(new Error(`holder exited ${code}`)));
});
const directory = join(input.runtimeRoot, "sessions", input.session, "runner");
await mkdir(directory, { recursive: true });
await writeFile(join(directory, "runner-process.identity"),
`holdernonce\n${holder.pid}\n${new Date().toISOString()}\n${runnerInstanceId}\n${await processFingerprint(holder.pid!)}`);
await writeFile(join(directory, "runner-state.json"), JSON.stringify({ runnerInstanceId, runId: input.runId,
lifecycle: input.lifecycle ?? "ready", outbox: input.outbox ?? [], pendingTerminalDelivery: null,
pendingProviderCleanup: input.pendingProviderCleanup ?? null, warmTransition: null }));
return holder;
}
async function processFingerprint(pid: number) {
if (process.platform !== "linux") return "";
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
const ticks = stat.slice(stat.lastIndexOf(") ") + 2).trim().split(/\s+/)[19];
const boot = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim();
return `linux:${boot}:${ticks}\n`;
}
async function exited(holder: ChildProcess) {
if (holder.exitCode !== null || holder.signalCode !== null) return true;
return new Promise<boolean>(done => {
const timer = setTimeout(() => done(false), 5_000);
holder.once("exit", () => { clearTimeout(timer); done(true); });
});
}
async function scenario(label: string, holderStatus: "succeeded" | "running" | "cancelled" | null, state: { lifecycle?: string; outbox?: readonly unknown[]; pendingProviderCleanup?: unknown } = {}) {
const predecessor = await environment.seedRun(`PORT-HOLDER-PREV-${label}`);
const current = await environment.seedRun(`PORT-HOLDER-NEXT-${label}`, predecessor.db);
if (holderStatus) await predecessor.db.update(heartbeatRuns).set({ status: holderStatus }).where(eq(heartbeatRuns.id, predecessor.runId));
const base = join(environment.current.runtimeRoot, `sandbox-${label}`);
const runtimeRoot = join(base, ".paperclip-runtime", "paperclip-runner");
const port = await freePort();
const holder = await startHolder({ runtimeRoot, session: "a".repeat(64), runId: holderStatus ? predecessor.runId : randomUUID(), port, ...state });
const retire = () => retireSandboxRunnerPortHolders({ db: current.db, companyId: environment.current.companyId,
agentId: environment.current.agentId, runId: current.runId, runner: confinedRunner(base) as never, runtimeRoot,
ownSessionRoot: join(runtimeRoot, "sessions", "b".repeat(64)), providerLeaseId: "sandbox-1",
retireInProcess: async () => false, waitMs: 3_000, pollMs: 100 });
return { predecessor, current, holder, port, retire };
}
it("stops a finished run's idle Runner that holds the port, so the next Runner binds", async () => {
const { predecessor, current, holder, port, retire } = await scenario("finished", "succeeded");
expect(await bind(port)).toBe("EADDRINUSE");
await expect(retire()).resolves.toEqual([predecessor.runId]);
expect(await exited(holder)).toBe(true);
expect(await bind(port)).toBe("bound");
expect(confinedCommands.flat().filter(operand => operand.startsWith("/")).every(operand => operand.startsWith(`${environment.current.runtimeRoot}/sandbox-finished/`))).toBe(true);
const [activity] = await current.db.select().from(activityLog).where(and(
eq(activityLog.runId, current.runId), eq(activityLog.action, "native.runner_port_holder_retired")));
expect(activity?.details).toMatchObject({ holderRunId: predecessor.runId, holderStatus: "succeeded", providerLeaseId: "sandbox-1" });
});
it.each([
["a live run", "running", {}, "runner_port_holder_owner_not_settled"],
["a run with work in its outbox", "succeeded", { outbox: [{ seq: 1 }] }, "runner_port_holder_owner_not_settled"],
["a run whose provider cleanup is pending", "cancelled", { pendingProviderCleanup: { reason: "fixture" } }, "runner_port_holder_owner_not_settled"],
["no recorded run", null, {}, "runner_port_holder_unknown_owner"],
] as const)("never stops a holder owned by %s and sends the run to a fresh sandbox", async (_name, status, state, reason) => {
const { holder, port, retire } = await scenario(`kept-${reason}-${status}`, status, state);
const failure = await retire().then(() => null, (error: unknown) => error);
expect(failure).toBeInstanceOf(NativePredecessorRetirementUnprovenError);
expect(((failure as Error).cause as Error).message).toBe(reason);
expect(holder.exitCode).toBeNull();
expect(await bind(port)).toBe("EADDRINUSE");
});
it("ignores this run's own session, moved-aside roots and holders that already exited", async () => {
const current = await environment.seedRun("PORT-HOLDER-OWN");
const base = join(environment.current.runtimeRoot, "sandbox-own");
const runtimeRoot = join(base, ".paperclip-runtime", "paperclip-runner");
const own = await startHolder({ runtimeRoot, session: "b".repeat(64), runId: current.runId, port: await freePort() });
const aside = await startHolder({ runtimeRoot, session: `${"c".repeat(64)}.bootstrap-restart-1`, runId: randomUUID(), port: await freePort() });
const gone = await startHolder({ runtimeRoot, session: "d".repeat(64), runId: randomUUID(), port: await freePort() });
gone.kill("SIGKILL");
expect(await exited(gone)).toBe(true);
await expect(retireSandboxRunnerPortHolders({ db: current.db, companyId: environment.current.companyId,
agentId: environment.current.agentId, runId: current.runId, runner: confinedRunner(base) as never, runtimeRoot,
ownSessionRoot: join(runtimeRoot, "sessions", "b".repeat(64)), providerLeaseId: "sandbox-1",
retireInProcess: async () => false })).resolves.toEqual([]);
expect(own.exitCode).toBeNull();
expect(aside.exitCode).toBeNull();
});
});
@@ -0,0 +1,163 @@
import { and, eq } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
import { persistActivity } from "../activity-log.js";
import { NativePredecessorRetirementUnprovenError } from "./predecessor-retirement.js";
import { REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT } from "./remote-runner-recovery-diagnostics.js";
export const SANDBOX_RUNNER_HOLDER_LIST_SCRIPT = [
"set -eu",
'root=$1; own=$2',
'test -d "$root/sessions" && test ! -L "$root/sessions" || exit 0',
'for session in "$root"/sessions/*; do',
' test -d "$session" && test ! -L "$session" || continue',
' case "${session##*/}" in *.*) continue;; esac',
' test "$session" = "$own" && continue',
' identity="$session/runner/runner-process.identity"; state="$session/runner/runner-state.json"',
' test -f "$identity" && test ! -L "$identity" || continue',
' encoded_state=""',
' if test -f "$state" && test ! -L "$state"; then encoded_state=$(base64 < "$state" | tr -d "\\n"); fi',
' printf "%s\\t%s\\t%s\\n" "$identity" "$(base64 < "$identity" | tr -d "\\n")" "$encoded_state"',
"done",
].join("\n");
const RETIRABLE_HOLDER_RUN_STATUSES = new Set(["succeeded", "cancelled"]);
const RUN_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export type SandboxRunnerHolder = {
identityPath: string;
nonce: string;
pid: number;
runnerInstanceId: string;
processStartFingerprint: string | null;
runId: string | null;
idle: boolean;
};
function decode(value: string | undefined): string | null {
if (!value) return null;
try { return Buffer.from(value, "base64").toString("utf8"); } catch { return null; }
}
function parseObject(value: string | null): Record<string, unknown> | null {
if (!value) return null;
try {
const parsed: unknown = JSON.parse(value);
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
} catch { return null; }
}
export function parseSandboxRunnerHolders(stdout: string): SandboxRunnerHolder[] {
const holders: SandboxRunnerHolder[] = [];
for (const line of stdout.split("\n")) {
const [identityPath, encodedIdentity, encodedState] = line.split("\t");
const identity = decode(encodedIdentity)?.trim().split("\n");
if (!identityPath || !identity) continue;
const [nonce, rawPid, startedAt, runnerInstanceId, fingerprint] = identity;
const pid = Number(rawPid);
if (!nonce || !/^[a-zA-Z0-9_-]+$/.test(nonce) || !Number.isSafeInteger(pid) || pid <= 0 || !startedAt
|| !runnerInstanceId || (fingerprint !== undefined && !/^linux:[0-9a-f-]+:[0-9]+$/.test(fingerprint))) continue;
const state = parseObject(decode(encodedState));
const runId = typeof state?.runId === "string" && state.runnerInstanceId === runnerInstanceId ? state.runId : null;
const idle = state !== null && Array.isArray(state.outbox) && state.outbox.length === 0
&& state.pendingTerminalDelivery === null && state.pendingProviderCleanup == null && state.warmTransition == null;
holders.push({ identityPath, nonce, pid, runnerInstanceId, processStartFingerprint: fingerprint ?? null, runId, idle });
}
return holders;
}
type HolderProbe = "alive" | "gone" | "unproven";
async function probeHolder(runner: CommandManagedRuntimeRunner, holder: SandboxRunnerHolder,
signal?: "TERM" | "KILL"): Promise<HolderProbe> {
const result = await runner.execute({
command: "sh",
args: ["-c", REMOTE_RECOVERY_PROCESS_CHECK_SCRIPT + (signal ? '; kill -"$6" "$pid"' : ""),
"paperclip-runner-port-holder-check", holder.identityPath, holder.nonce, holder.runnerInstanceId, String(holder.pid),
holder.processStartFingerprint ?? "", ...(signal ? [signal] : [])],
bypassSession: true,
timeoutMs: 10_000,
}).catch(() => null);
if (!result || result.timedOut) return "unproven";
if (result.exitCode === 0) return "alive";
if (result.exitCode === 45 || result.exitCode === 47) return "gone";
return "unproven";
}
async function waitUntilGone(runner: CommandManagedRuntimeRunner, holder: SandboxRunnerHolder, waitMs: number, pollMs: number) {
const deadline = Date.now() + waitMs;
for (;;) {
const probe = await probeHolder(runner, holder);
if (probe !== "alive") return probe;
if (Date.now() >= deadline) return "alive" as const;
await new Promise(done => setTimeout(done, pollMs));
}
}
export async function retireSandboxRunnerPortHolders(input: {
db: Db;
companyId: string;
agentId: string;
runId: string;
runner: CommandManagedRuntimeRunner;
runtimeRoot: string;
ownSessionRoot: string;
providerLeaseId: string | null;
adoptedRunId?: string | null;
retireInProcess: (holderRunId: string) => Promise<boolean>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
waitMs?: number;
pollMs?: number;
}): Promise<string[]> {
const listed = await input.runner.execute({
command: "sh",
args: ["-c", SANDBOX_RUNNER_HOLDER_LIST_SCRIPT, "paperclip-runner-port-holders", input.runtimeRoot, input.ownSessionRoot],
bypassSession: true,
timeoutMs: 15_000,
}).catch(() => null);
if (!listed || listed.exitCode !== 0 || listed.timedOut) {
throw new NativePredecessorRetirementUnprovenError(null, input.providerLeaseId,
new Error(`runner_port_holder_inventory_unavailable: exit=${listed?.exitCode ?? "none"} timedOut=${listed?.timedOut ?? false}`));
}
const retired: string[] = [];
for (const holder of parseSandboxRunnerHolders(listed.stdout)) {
if (holder.runId === input.runId || (holder.runId !== null && holder.runId === input.adoptedRunId)) continue;
const initial = await probeHolder(input.runner, holder);
if (initial === "gone") continue;
const unproven = (reason: string) => new NativePredecessorRetirementUnprovenError(holder.runId, input.providerLeaseId,
new Error(`runner_port_holder_${reason}`));
if (initial === "unproven") throw unproven("unverifiable");
if (holder.runId && await input.retireInProcess(holder.runId)) {
if (await waitUntilGone(input.runner, holder, input.waitMs ?? 10_000, input.pollMs ?? 500) !== "gone") throw unproven("still_running");
retired.push(holder.runId);
continue;
}
if (!holder.runId || !RUN_ID_PATTERN.test(holder.runId)) throw unproven("unknown_owner");
const [owner] = await input.db.select({ status: heartbeatRuns.status, agentId: heartbeatRuns.agentId }).from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, holder.runId))).limit(1);
if (!owner) throw unproven("unknown_owner");
if (!RETIRABLE_HOLDER_RUN_STATUSES.has(owner.status) || owner.agentId !== input.agentId || !holder.idle) {
throw unproven("owner_not_settled");
}
let outcome: "stopped" | "killed" = "stopped";
let probe = await probeHolder(input.runner, holder, "TERM");
if (probe === "alive") probe = await waitUntilGone(input.runner, holder, input.waitMs ?? 10_000, input.pollMs ?? 500);
if (probe === "alive") {
outcome = "killed";
probe = await probeHolder(input.runner, holder, "KILL");
if (probe === "alive") probe = await waitUntilGone(input.runner, holder, input.waitMs ?? 10_000, input.pollMs ?? 500);
}
if (probe !== "gone") throw unproven("stop_unconfirmed");
await persistActivity(input.db, {
companyId: input.companyId, actorType: "system", actorId: "native-runtime", agentId: input.agentId,
action: "native.runner_port_holder_retired", entityType: "heartbeat_run", entityId: input.runId, runId: input.runId,
details: { holderRunId: holder.runId, holderRunnerInstanceId: holder.runnerInstanceId, holderStatus: owner.status, outcome,
providerLeaseId: input.providerLeaseId },
});
await input.onLog?.("stdout",
`[paperclip-runner] Stopped the finished run ${holder.runId}'s idle Runner that still held this sandbox's Runner port (${outcome}).\n`)
.catch(() => undefined);
retired.push(holder.runId);
}
return retired;
}