From 776e900b48dca4b9641f282e58d870c4d6b87110 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:06:12 -0600 Subject: [PATCH] fix(native): retire a finished run's idle Runner that still holds the sandbox Runner port before launching (#20995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../native-runtime/native-session-executor.ts | 36 ++++ ...m-checkpoint-ownership.integration.test.ts | 4 +- ...ox-runner-port-holders.integration.test.ts | 140 +++++++++++++++ .../sandbox-runner-port-holders.ts | 163 ++++++++++++++++++ 4 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 server/src/services/native-runtime/sandbox-runner-port-holders.integration.test.ts create mode 100644 server/src/services/native-runtime/sandbox-runner-port-holders.ts diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index e1e347fed8d..89b14a11a2c 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -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 { + 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[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", diff --git a/server/src/services/native-runtime/native-warm-checkpoint-ownership.integration.test.ts b/server/src/services/native-runtime/native-warm-checkpoint-ownership.integration.test.ts index b19150beafb..042910e3d35 100644 --- a/server/src/services/native-runtime/native-warm-checkpoint-ownership.integration.test.ts +++ b/server/src/services/native-runtime/native-warm-checkpoint-ownership.integration.test.ts @@ -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; diff --git a/server/src/services/native-runtime/sandbox-runner-port-holders.integration.test.ts b/server/src/services/native-runtime/sandbox-runner-port-holders.integration.test.ts new file mode 100644 index 00000000000..7cebee35b59 --- /dev/null +++ b/server/src/services/native-runtime/sandbox-runner-port-holders.integration.test.ts @@ -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(done => probe.listen(0, "127.0.0.1", done)); + const port = (probe.address() as AddressInfo).port; + await new Promise(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((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(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(); + }); +}); diff --git a/server/src/services/native-runtime/sandbox-runner-port-holders.ts b/server/src/services/native-runtime/sandbox-runner-port-holders.ts new file mode 100644 index 00000000000..69361d5dfbf --- /dev/null +++ b/server/src/services/native-runtime/sandbox-runner-port-holders.ts @@ -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 | null { + if (!value) return null; + try { + const parsed: unknown = JSON.parse(value); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : 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 { + 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; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + waitMs?: number; + pollMs?: number; +}): Promise { + 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; +}