From af78a9c57f3613ce39edea025cc4338692237b67 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Nov 2026 11:46:50 -0600 Subject: [PATCH] fix(runner): name the spawned opencode serve in OpenCode session start The OpenCode server driver spawns `opencode serve` in its own process group but its session.started/resumed carried no process identity. The server's stop proof checks processId from these events, so it never verified that this provider process had exited. Emit its pid, process group and OS start time, and share the runner's local process start reader. Co-Authored-By: Claude Opus 5.5 --- .../opencode/opencode-server-driver.test.ts | 6 +++ .../opencode/opencode-server-driver.ts | 10 +++++ .../src/live/linux-process-start.ts | 45 +++++++++++++++++++ .../src/live/runnerd-codex-transport.ts | 45 +------------------ 4 files changed, 62 insertions(+), 44 deletions(-) diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index fbb618c57d2..8c7b8b945ee 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -1188,6 +1188,12 @@ describe("OpenCodeServerDriver", () => { expect( events.filter((event) => event.eventType === "item.delta"), ).toHaveLength(1); + // The session names the exact spawned `opencode serve`, so cleanup can prove it is gone. + expect(events.find((event) => event.eventType === "session.started")?.payload).toMatchObject({ + processId: spawns[0]!.pid, + processGroupId: spawns[0]!.processGroupId, + processStartedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }); expect( events.find((event) => event.eventType === "item.delta")?.payload.text, ).toBe("done [guide](guide.md)"); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 76f9fd97e1d..fa1451c4caa 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -1,4 +1,5 @@ import { isOpenRouterUrl, OPENROUTER_ATTRIBUTION_HEADERS } from "../openrouter-attribution.js"; +import { readLocalProcessStartedAt } from "../../live/linux-process-start.js"; import { spawn, type ChildProcess } from "node:child_process"; import { resolveCompletionContinuation } from "../completion-continuation.js"; import { createHash, randomBytes } from "node:crypto"; @@ -231,6 +232,8 @@ interface OpenCodeRuntime { version: string; permissionMode: "allow" | "ask" | "deny"; process: ChildProcess; + /** OS identity of the spawned `opencode serve`, so cleanup can prove that exact process is gone. */ + processIdentity: { processId: number; processGroupId: number | null; processStartedAt: string | null } | null; bridge: OpenCodeMcpBridge; trace: ProviderTraceFileSink | null; sensitiveValues: readonly string[]; @@ -624,6 +627,7 @@ class OpenCodeHarnessSession implements HarnessSession { this.#emit(input.snapshot ? "session.resumed" : "session.started", { driverSessionId: input.providerSessionId, providerSessionId: input.providerSessionId, + ...(input.runtime.processIdentity ?? {}), context: { protocolVersion: "http+sse/v1", opencodeVersion: input.runtime.version, @@ -2726,6 +2730,11 @@ async function startRuntime(input: { providerChild.once("spawn", resolve); providerChild.once("error", reject); }); + const processIdentity = child.pid ? { + processId: child.pid, + processGroupId: globalThis.process.platform === "win32" || !isolateProcessGroup ? null : child.pid, + processStartedAt: readLocalProcessStartedAt(child.pid), + } : null; if (child.pid) await input.options.onSpawn?.({ pid: child.pid, @@ -2762,6 +2771,7 @@ async function startRuntime(input: { version, permissionMode: input.options.permissionMode ?? "allow", process: child, + processIdentity, bridge, trace: input.trace, sensitiveValues, diff --git a/packages/paperclip-runner/src/live/linux-process-start.ts b/packages/paperclip-runner/src/live/linux-process-start.ts index 66abe1322dc..ed46e1da3f6 100644 --- a/packages/paperclip-runner/src/live/linux-process-start.ts +++ b/packages/paperclip-runner/src/live/linux-process-start.ts @@ -38,3 +38,48 @@ export function readLinuxProcessStartedAt(pid: number, options: LinuxProcessStar } return new Date(value).toISOString(); } + +/** OS start time of a local process as an ISO string, or null when it cannot be read. */ +export function readLocalProcessStartedAt(pid: number): string | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + try { + if (process.platform === "linux") { + return readLinuxProcessStartedAt(pid); + } + if ( + ["darwin", "freebsd", "openbsd", "aix", "sunos"].includes( + process.platform, + ) + ) { + const raw = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + timeout: 1_500, + windowsHide: true, + }).trim(); + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); + } + if (process.platform === "win32") { + const script = [ + `$target = Get-Process -Id ${pid} -ErrorAction Stop`, + "$target.StartTime.ToUniversalTime().ToString('o')", + ].join("; "); + for (const command of ["powershell.exe", "pwsh.exe"]) { + try { + const raw = execFileSync( + command, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], + { encoding: "utf8", timeout: 1_500, windowsHide: true }, + ).trim(); + const parsed = new Date(raw); + if (!Number.isNaN(parsed.getTime())) return parsed.toISOString(); + } catch { + // Try the other supported PowerShell host. + } + } + } + } catch { + // Process exit and restricted process metadata both produce no fingerprint. + } + return null; +} diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index c5cdae6cb56..e5398ce79ff 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -25,7 +25,7 @@ import { waitForWarmAttachmentReadiness } from "./warm-attachment-readiness.js"; import { resolveCodexCommand } from "../drivers/codex/codex-command.js"; import { isCanonicalProviderEventType } from "../provider-events.js"; import { execFileSync } from "node:child_process"; -import { readLinuxProcessStartedAt } from "./linux-process-start.js"; +import { readLocalProcessStartedAt } from "./linux-process-start.js"; import { createHash, randomUUID } from "node:crypto"; import { appendFileSync, @@ -117,49 +117,6 @@ const RUNNER_BOOTSTRAP_TICKET_TTL_MS = 60_000; const RUNNERD_MAX_OUTBOX_BYTES = 16 * 1024 * 1024; const RUNNERD_P0_RESERVE_BYTES = 1024 * 1024; -function readLocalProcessStartedAt(pid: number): string | null { - if (!Number.isInteger(pid) || pid <= 0) return null; - try { - if (process.platform === "linux") { - return readLinuxProcessStartedAt(pid); - } - if ( - ["darwin", "freebsd", "openbsd", "aix", "sunos"].includes( - process.platform, - ) - ) { - const raw = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { - encoding: "utf8", - timeout: 1_500, - windowsHide: true, - }).trim(); - const parsed = new Date(raw); - return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); - } - if (process.platform === "win32") { - const script = [ - `$target = Get-Process -Id ${pid} -ErrorAction Stop`, - "$target.StartTime.ToUniversalTime().ToString('o')", - ].join("; "); - for (const command of ["powershell.exe", "pwsh.exe"]) { - try { - const raw = execFileSync( - command, - ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], - { encoding: "utf8", timeout: 1_500, windowsHide: true }, - ).trim(); - const parsed = new Date(raw); - if (!Number.isNaN(parsed.getTime())) return parsed.toISOString(); - } catch { - // Try the other supported PowerShell host. - } - } - } - } catch { - // Process exit and restricted process metadata both produce no fingerprint. - } - return null; -} const CONTROL_PLANE_STATE_MAX_BYTES = 256 * 1024 * 1024;