import { spawn } from "node:child_process"; import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; import { getCrewEnv } from "../config/env-vars.ts"; import { appendEventAsync } from "../state/event-log/event-log.ts"; import type { TeamRunManifest } from "../state/types.ts"; import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts"; import { sanitizeEnvSecrets } from "../utils/env-filter.ts"; import { logInternalError } from "../utils/internal-error.ts"; import { packageRoot } from "../utils/paths.ts"; import { redactSecretString } from "../utils/redaction.ts"; import { registerWorker, unregisterWorker } from "./orphan-worker-registry.ts"; import { PEER_DEP_DIR_ENV, resolvePeerDepDir } from "./peer-dep.ts"; export type FileExists = (filePath: string) => boolean; const requireFromHere = createRequire(import.meta.url); // Node introduced --experimental-strip-types in v22.6.0 const STRIP_TYPES_MIN_MAJOR = 22; const STRIP_TYPES_MIN_MINOR = 6; export type LoaderSpec = { kind: "jiti"; path: string } | { kind: "strip-types" }; type LoaderInput = LoaderSpec | string | false | undefined; function jitiRegisterPathFromPackageJson(packageJsonPath: string): string { return path.join(path.dirname(packageJsonPath), "lib", "jiti-register.mjs"); } export function resolveJitiRegisterPath(pkgRoot: string | undefined, exists: FileExists = fs.existsSync): string | undefined { const effectiveRoot = pkgRoot ?? packageRoot(); // Walk upward from effectiveRoot looking for node_modules/jiti/lib/jiti-register.mjs let current = path.resolve(effectiveRoot); const root = path.parse(current).root; while (true) { const candidate = path.join(current, "node_modules", "jiti", "lib", "jiti-register.mjs"); if (exists(candidate)) return candidate; if (current === root) break; const parent = path.dirname(current); if (parent === current) break; current = parent; } // Fallback: require resolution (handles global installs or isolated stores) try { const pkgPath = requireFromHere.resolve("jiti/package.json"); const candidates = [ jitiRegisterPathFromPackageJson(pkgPath), path.join(path.dirname(pkgPath), "register.mjs"), path.join(path.dirname(pkgPath), "dist", "register.mjs"), ]; for (const c of candidates) if (exists(c)) return c; } catch (error) { // R17-B4 (LOW): fall-through is benign (undefined → next loader // strategy), but keep the require.resolve root cause visible under // PI_TEAMS_DEBUG instead of a bare catch. logInternalError("async-runner.jiti-resolve", error, "require.resolve('jiti/package.json') fallback failed", "debug"); } return undefined; } export function nodeSupportsStripTypes(version = process.version): boolean { const match = /^v?(\d+)\.(\d+)/.exec(version); if (!match) return false; const major = Number(match[1]); const minor = Number(match[2]); if (major > STRIP_TYPES_MIN_MAJOR) return true; if (major === STRIP_TYPES_MIN_MAJOR && minor >= STRIP_TYPES_MIN_MINOR) return true; return false; } export interface ResolveLoaderOptions { packageRoot?: string; exists?: FileExists; nodeVersion?: string; } export function resolveTypeScriptLoader(opts: ResolveLoaderOptions = {}): LoaderSpec | undefined { const jitiPath = resolveJitiRegisterPath(opts.packageRoot, opts.exists); if (jitiPath) return { kind: "jiti", path: jitiPath }; if (nodeSupportsStripTypes(opts.nodeVersion)) return { kind: "strip-types" }; return undefined; } function normalizeLoaderInput(input: LoaderInput): LoaderSpec | undefined { if (input === undefined || input === null || input === false || input === "") return undefined; if (typeof input === "string") return { kind: "jiti", path: input }; return input; } function buildLoaderUnavailableMessage(searchedFrom: string): string { return [ "pi-crew background runner cannot start: jiti loader not found and Node --experimental-strip-types fallback unavailable.", ` - Searched for node_modules/jiti walking upward from: ${searchedFrom}`, ` - Node --experimental-strip-types requires >= 22.6 (current: ${process.version})`, " - Fix: run 'npm install' in the pi-crew directory, reinstall via 'pi install npm:pi-crew', or upgrade Node.js to >= 22.6.", ].join("\n"); } export function getBackgroundRunnerCommand( runnerPath: string, cwd: string, runId: string, loaderInput: LoaderInput = resolveTypeScriptLoader(), /** * Directory to write the V8 fatal-error report into. Defaults to * path.dirname(runnerPath). Pass the run stateRoot so the report lands * next to background.log for easy diagnosis. */ reportDirectory?: string, ): { args: string[]; loader: "jiti" | "strip-types" } { const loader = normalizeLoaderInput(loaderInput); if (!loader) throw new Error(buildLoaderUnavailableMessage(packageRoot())); // Limit V8 heap to 512MB for the background runner to avoid triggering the // Linux OOM killer. The runner itself is lightweight — it delegates work to // child Pi processes — so 512MB is generous. Without this limit, Node.js // defaults to ~1.5GB on 64-bit systems, which combined with jiti compilation // and child processes can exhaust system memory. const memoryLimit = "--max-old-space-size=512"; // V8 diagnostic report on fatal error. A native heap-OOM abort or segfault // bypasses the JS process.on('exit') handler and console overrides // entirely — only a V8 report file survives such crashes. This is ON by // default precisely so silent runner deaths (like the explore→code-review // transition crash) leave a native stack/heap/environment trace. Users who // don't want report files can opt out with PI_CREW_BG_REPORT_ON_FATAL=0. const reportOn = !(getCrewEnv("PI_CREW_BG_REPORT_ON_FATAL") === "0" || getCrewEnv("PI_TEAMS_BG_REPORT_ON_FATAL") === "0"); const reportDir = reportDirectory ?? path.dirname(runnerPath); const reportFlags = reportOn ? ["--report-on-fatalerror", "--report-compact", `--report-directory=${reportDir}`] : []; if (loader.kind === "jiti") { return { args: [ memoryLimit, ...reportFlags, "--trace-uncaught", "--import", pathToFileURL(loader.path).href, runnerPath, "--cwd", cwd, "--run-id", runId, ], loader: "jiti", }; } return { args: [memoryLimit, ...reportFlags, "--experimental-strip-types", runnerPath, "--cwd", cwd, "--run-id", runId], loader: "strip-types", }; } export interface SpawnBackgroundTeamRunResult { pid?: number; logPath: string; } /** * Env vars explicitly forwarded to the detached background runner. * * Provider API keys (MINIMAX/OPENAI/ANTHROPIC/...) are INTENTIONALLY OMITTED * (security review M1): the background runner only spawns child Pi workers, * which read keys from the Pi config file (not env). Passing keys via env * leaks them into V8 fatal-error reports (--report-on-fatalerror writes the * `environmentVariables` section unredacted). Matches child-pi.ts policy. * Exported so the invariant is unit-testable (test/unit/async-runner.test.ts). */ export const BACKGROUND_RUNNER_ENV_ALLOWLIST: string[] = [ // Essential non-secret vars "PATH", "HOME", "USER", "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "LC_MONETARY", "LC_NUMERIC", "LC_TIME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", // Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts). ...WINDOWS_ESSENTIAL_ENV_VARS, "NVM_BIN", "NVM_DIR", "NVM_INC", "NODE_PATH", "NODE_DISABLE_COLORS", "NODE_EXTRA_CA_CERTS", "NPM_CONFIG_REGISTRY", "NPM_CONFIG_USERCONFIG", "NPM_CONFIG_GLOBALCONFIG", // PI_CREW_PARENT_PID is needed for parent-guard (liveness check). "PI_CREW_DEPTH", "PI_CREW_MAX_DEPTH", "PI_CREW_INHERIT_PROJECT_CONTEXT", "PI_CREW_INHERIT_SKILLS", "PI_CREW_PARENT_PID", "PI_TEAMS_DEPTH", "PI_TEAMS_MAX_DEPTH", "PI_TEAMS_INHERIT_PROJECT_CONTEXT", "PI_TEAMS_INHERIT_SKILLS", "PI_TEAMS_PI_BIN", "PI_TEAMS_MOCK_CHILD_PI", "PI_CREW_ALLOW_MOCK", // Entry guard for the detached runner main() (background-runner.ts skips main() // on plain imports — the inline async test seam lazy-imports it). "PI_CREW_BACKGROUND_RUNNER_ENTRY", // Phase 1.5: worker-thread atomic writer opt-in (RFC 15). "PI_CREW_WORKER_ATOMIC_WRITER", "PI_TEAMS_WORKER_ATOMIC_WRITER", // Phase 1.5 #1: verification env sanitization opt-out (RFC 13 §6). "PI_CREW_VERIFICATION_SANITIZE_ENV", "PI_TEAMS_VERIFICATION_SANITIZE_ENV", "PI_CREW_VERIFICATION_PRESERVE_ENV", "PI_TEAMS_VERIFICATION_PRESERVE_ENV", // Phase 1.5 #2: verification git-worktree sandbox opt-in (RFC 16). "PI_CREW_VERIFICATION_WORKTREE", "PI_TEAMS_VERIFICATION_WORKTREE", // Phase 1.5 #3: V8 diagnostic report on fatal error (RFC 17 — investigation). "PI_CREW_BG_REPORT_ON_FATAL", "PI_TEAMS_BG_REPORT_ON_FATAL", // MuxSurface: multiplexer env the detached runner needs to DETECT and // REACH the host's tmux/herdr — surface follows env + config for async runs // too (2026-08-30 Finding 2: without these, every async run gates to no-mux // and stays headless). None of these are secret-named (sanitizeEnvSecrets // would reject them from the allowlist otherwise). "TMUX", "TMUX_PANE", "TMUX_TMPDIR", "HERDR_ENV", "HERDR_SESSION", "HERDR_PANE_ID", "HERDR_SOCKET_PATH", "HERDR_WORKSPACE_ID", "HERDR_PING_TIMEOUT_MS", // Model-selection control vars (Finding #1, full battery 2026-09-29): the // detached runner resolves subagent model routing from env (PI_CREW_MODEL → // resolveDefaultSubagentModel; PI_CREW_MODEL_FALLBACK_ORDER → // resolveModelFallbackPolicy) for manifests without a persisted modelContext // (older manifests) and as a user override channel. Stripping them made // every async run silently fall back to the DEFAULT chain (live: 3 parallel // runs died on minimax/MiniMax-M3 402 while the parent ran zai/glm-5.3). // PI_CREW_MODEL_REQUIRE_CREDENTIALS is NOT here — see forwardModelControlEnv. "PI_CREW_MODEL", "PI_CREW_MODEL_FALLBACK_ORDER", ]; /** * Stamp the async telemetry marker onto the background runner's env. * (MuxSurface async policy, 2026-08-30): `PI_CREW_ASYNC_RUN` is NOT a surface * gate — surface follows env + `runtime.surface.*` config, same as sync runs. * The marker stays for telemetry (`SurfaceGateEnvSnapshot.asyncRun`). Pane * attach from a detached process is safe: parent-guard (PI_CREW_PARENT_PID) * still reaps panes when the host dies. * * Exported for the unit test asserting the invariant. */ export function buildBackgroundRunnerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return { ...env, PI_CREW_ASYNC_RUN: "1" }; } /** * Model-selection control vars that CANNOT live on * {@link BACKGROUND_RUNNER_ENV_ALLOWLIST}: isSecretKey() matches the * "_CREDENTIALS" suffix of PI_CREW_MODEL_REQUIRE_CREDENTIALS (keyword * "credential"), and sanitizeEnvSecrets THROWS on secret-looking allowlist * entries that are absent from the spawning env — so allowlisting it would * break EVERY async spawn whenever the flag is unset. It is a declared * control flag (config/env-vars.ts, read by resolveModelFallbackPolicy), * never a secret, so we forward it explicitly when set instead. * Exported pure so tests pin the contract (async-runner.test.ts). */ export const MODEL_CONTROL_ENV_EXPLICIT: readonly string[] = ["PI_CREW_MODEL_REQUIRE_CREDENTIALS"]; /** Copy the explicitly-forwarded model control vars (set ones only). */ export function forwardModelControlEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const forwarded: NodeJS.ProcessEnv = {}; for (const key of MODEL_CONTROL_ENV_EXPLICIT) { const value = env[key]; if (value !== undefined) forwarded[key] = value; } return forwarded; } /** F4 v2: the ONE line written to the background-runner's stdin carrying * PER-TASK compound broker tokens (see stdin-handshake.ts for why compound — * ADR-0 item 6 rejects bare-runId tokens for wait.*). Kept pure + exported so * tests pin the WRITER format against the READER parser — a drift between * the two silently breaks coordination for every async run. */ export function buildBrokerStdinLine(runId: string, socketPath: string, tasks: Record): string { return `${JSON.stringify({ v: 2, runId, socketPath, tasks })}\n`; } /** * IN-PROCESS ASYNC TEST SEAM (kills two classes of test pain): * 1. Windows Defender first-spawn stall — CI ground truth (run 36093514388 * attempt 3): the detached background-runner spawn stalled >300s with the * task left "queued" while the event loop stayed healthy. No second * process to come up = no per-file-hash AV scan inside a test deadline. * 2. Orphan-tmpdir leak — a detached runner survives the test's rmSync and * keeps writing run state into a deleted tree (source of the ~3.4k * pi-crew-* debris, 2026-09-26 triage). In-process, the run dies with the * test process. * * Gated by BOTH PI_CREW_TEST_ASYNC_INLINE=1 AND the existing test-fixture * gate PI_CREW_ALLOW_MOCK=1 (defense-in-depth: production never sets either). * The inline host reuses background-runner's executeBackgroundRun core — * same code path as the detached runner, no drift. */ export function isInlineAsyncTestSeamActive(): boolean { return getCrewEnv("PI_CREW_TEST_ASYNC_INLINE") === "1" && getCrewEnv("PI_CREW_ALLOW_MOCK") === "1"; } async function runInlineBackgroundTeamRun(manifest: TeamRunManifest, logPath: string): Promise { const log = (message: string): void => { try { fs.appendFileSync(logPath, `${message}\n`, "utf-8"); } catch { /* best-effort */ } }; log(`[pi-crew] inline test seam: executing run ${manifest.runId} in-process (no detached runner) pid=${process.pid}`); await appendEventAsync(manifest.eventsPath, { type: "async.spawned", runId: manifest.runId, data: { pid: process.pid, logPath, inline: true }, }); // Fire-and-forget — spawn() semantics: the caller must NOT await the run. // Catch-all: a late rejection after the test ended must not become an // unhandledRejection that fails the whole file. void (async () => { try { // LAZY: defer the core import — background-runner must not load in the // normal spawn path (and its module-scope main() is entry-guarded). const { executeBackgroundRun } = await import("./background-runner.ts"); // LAZY: defer the core import — background-runner must not load in the normal spawn path const { loadRunManifestById } = await import("../state/stores/state-store.ts"); // LAZY: state-store pulls the whole stores chain const { withRunLockSync } = await import("../state/coordination/locks.ts"); // LAZY: coordination locks only needed on the inline path const loaded = withRunLockSync(manifest, () => loadRunManifestById(manifest.cwd, manifest.runId), { staleMs: 30_000 }); const effectiveManifest = loaded?.manifest ?? manifest; const tasks = loaded?.tasks ?? []; await executeBackgroundRun({ manifest: effectiveManifest, tasks }, { log: (message) => log(`[inline] ${message}`) }); } catch (error) { const message = error instanceof Error ? error.message : String(error); log(`[pi-crew] inline test seam run ${manifest.runId} FAILED: ${message}`); try { const { loadRunManifestByIdAsync: reload, updateRunStatus } = await import("../state/stores/state-store.ts"); // LAZY: failure-path imports only const fresh = (await reload(manifest.cwd, manifest.runId))?.manifest; if (fresh) updateRunStatus(fresh, "failed", `inline test seam: ${message}`); else await appendEventAsync(manifest.eventsPath, { type: "async.failed", runId: manifest.runId, message: `inline test seam: ${message}`, }); if (fresh) await appendEventAsync(fresh.eventsPath, { type: "async.failed", runId: fresh.runId, message: `inline test seam: ${message}`, }); } catch { /* best-effort */ } } })(); return { pid: process.pid, logPath }; } export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise { if (isInlineAsyncTestSeamActive()) { const logPath = path.join(manifest.stateRoot, "background.log"); fs.mkdirSync(manifest.stateRoot, { recursive: true }); return runInlineBackgroundTeamRun(manifest, logPath); } // FIX (2026-07-02, perf review F-critical): use packageRoot() instead of // import.meta.url-relative path. The previous path.resolve walks // /background-runner.ts, which is correct in src/ but BROKEN // in the bundle: esbuild's __esm helper does not preserve per-module // import.meta.url, so the resolve lands at /background-runner.ts // (missing `src/runtime/`). The background-runner then ENOENTs at spawn // and 3 explorer agents failed during the verification run. packageRoot() // walks up to find pi-crew's package.json and works correctly from both // src/ and dist/ entry points. Mirrors the fix in pi-args.ts:10 (commit // 0dd93e0). const runnerPath = path.join(packageRoot(), "src", "runtime", "background-runner.ts"); const logPath = path.join(manifest.stateRoot, "background.log"); fs.mkdirSync(manifest.stateRoot, { recursive: true }); // SECURITY FIX: Use sanitizeEnvSecrets with same allow-list as child-pi.ts // to prevent leaking all env vars (including secrets) to detached background runner. // Previously, destructuring only removed PI_CREW_PARENT_PID but kept everything else. const filteredEnv = sanitizeEnvSecrets(process.env, { allowList: BACKGROUND_RUNNER_ENV_ALLOWLIST, }); // FIX: removed delete workarounds — with explicit allowlist, these vars // are no longer auto-leaked. Matches child-pi.ts. // FIX (split-scope install): pass the resolved peer-dep dir to the child so // it can resolve @earendil-works/pi-coding-agent WITHOUT the ~200ms // `npm root -g` probe. No-op when pi-crew and pi are co-located. See // src/runtime/peer-dep.ts. const peerDepDir = resolvePeerDepDir(); const childEnv = buildBackgroundRunnerEnv({ ...(peerDepDir ? { ...filteredEnv, [PEER_DEP_DIR_ENV]: peerDepDir } : filteredEnv), PI_CREW_BACKGROUND_RUNNER_ENTRY: "1", // Finding #1: model control vars that cannot ride the allowlist (see // forwardModelControlEnv). Copied from the ORIGINAL env, post-sanitize. ...forwardModelControlEnv(process.env), }); const loader = resolveTypeScriptLoader(); if (!loader) { const message = buildLoaderUnavailableMessage(packageRoot()); // FIX-08: use async event append to avoid sleepSync event-loop blocking. await appendEventAsync(manifest.eventsPath, { type: "async.failed", runId: manifest.runId, message, }); throw new Error(message); } // Pass manifest.stateRoot as report-directory so V8 fatal reports land // next to background.log (same dir) for easy post-mortem diagnosis. const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId, loader, manifest.stateRoot); fs.appendFileSync(logPath, `[pi-crew] background loader=${command.loader}\n`, "utf-8"); // Spawn the background runner as a fully detached process with its own session. // BUG #17 FIX: setsid:true + detached:true creates a process that: // 1. Has its own session (SID = PID) — immune to terminal/SIGTERM signals // 2. Is detached (unref'd) — parent exit doesn't affect it // 3. Has its own process group (PGID = PID) — process group kills don't reach it // // IMPORTANT: session_shutdown handlers must NOT kill async runners. // See register.ts cleanupRuntime — the kill loop was commented out. // Type assertion for setsid is necessary because Node.js types don't include it // in SpawnOptions on all platforms, but it's supported on Unix systems. // Use explicit cast through unknown to satisfy TypeScript's strict type checking. const spawnOpts = { cwd: manifest.cwd, detached: true, setsid: true, // F4 (2026-09-12 live battery): stdin is a PIPE — broker credentials // travel heap → pipe → heap right after spawn (see below). Previously // "ignore", which (together with the env allowlist and the missing // runner-side issuer) left EVERY async worker broker-less: ask/message // fell back to "proceed with best judgment" silently. stdio: ["pipe", "pipe", "pipe"], env: childEnv, windowsHide: true, } as unknown as Parameters[2]; const child = spawn(process.execPath, command.args, spawnOpts); // F4: hand the runner a per-run broker credential over stdin. The env // route is CLOSED BY DESIGN — BACKGROUND_RUNNER_ENV_ALLOWLIST cannot carry // PI_CREW_BROKER_TOKEN (secret-suffixed names are rejected by the // sanitizeEnvSecrets validator, and a PI_CREW_BROKER_* glob is flagged // isDangerousGlob). The token never touches disk (invariant, // lifecycle-handlers.ts:990) and dies with the parent session (heap-only // registry). issuer(runId) without taskId mints the LEGACY per-run token, // which the registry accepts for any task of the run via its bare-runId // fallback (crew-broker-tokens.ts get()). Best-effort: any failure here // leaves the runner creds-less (= previous behavior), never throws. try { let line: string; // LAZY: broker issuer only when a handshake payload needs minting. const { getActiveBrokerIssuer } = await import("./broker/broker-issuer.ts"); const issuer = getActiveBrokerIssuer(); if (issuer) { // F4 v2: pre-mint a COMPOUND (runId+taskId) token for every task of the // run — wait.* rejects bare-runId tokens (ADR-0 item 6), so the legacy // issuer(runId) shortcut left every park forbidden. Tasks are persisted // BEFORE dispatch (tasksPath exists on the manifest); dynamic workflows // that plan tasks inside the runner get no creds (follow-up: broker-side // mint RPC). // LAZY: state-store pulls the whole stores chain into the async runner. const { loadRunManifestByIdAsync } = await import("../state/stores/state-store.ts"); const loaded = await loadRunManifestByIdAsync(manifest.cwd, manifest.runId); const runTasks = loaded?.tasks ?? []; const tasks: Record = {}; let socketPath: string | undefined; for (const task of runTasks) { if (!task?.id) continue; const creds = await issuer(manifest.runId, task.id); if (creds) { tasks[task.id] = creds.token; socketPath ??= creds.socketPath; } } if (socketPath && Object.keys(tasks).length > 0) { line = buildBrokerStdinLine(manifest.runId, socketPath, tasks); child.stdin?.write(line); } else { child.stdin?.write("\n"); } } else { child.stdin?.write("\n"); } } catch { /* best-effort: runner proceeds creds-less */ } try { child.stdin?.end(); } catch { /* EPIPE: runner already died — the spawn error handling below owns that */ } // Round 27 (BUG 3) history: the piped stdout/stderr were previously destroyed // immediately to avoid a pipe-buffer deadlock (child writes >64KB with nobody // draining → hang). BUT destroying stderr ALSO swallowed native crash // messages: a V8 heap-OOM abort() or segfault writes its diagnostic directly // to the process stderr fd, which was the (now-destroyed) pipe read-end, so // the bytes vanished — making runner deaths completely silent. This caused // the explore→code-review transition crash to leave zero trace. // // FIX: keep stdout destroyed (unused — runner redirects its own console to // a file), but DRAIN stderr asynchronously into background.log with a // "[child stderr]" prefix. Buffer is capped to avoid unbounded memory if a // noisy child streams megabytes; excess is dropped with a truncation marker. child.stdout?.destroy(); const STDERR_CAPTURE_LIMIT = 256 * 1024; const stderrChunks: Buffer[] = []; let stderrLen = 0; let stderrTruncated = false; const flushStderr = (): void => { if (stderrChunks.length === 0) return; let body: string; try { body = Buffer.concat(stderrChunks).toString("utf-8"); } catch { stderrChunks.length = 0; return; } stderrChunks.length = 0; try { // FIND-14: route child stderr through redactSecretString before writing // to the log so API keys / bearer tokens / inline secrets emitted by the // child are scrubbed. Without this, a child crash trace containing // `Authorization: Bearer ...` or a stack trace with `MINIMAX_API_KEY=...` // would land in background.log unredacted and ship to disk (and to the // V8 fatal-error report which writes environmentVariables unredacted). const redacted = redactSecretString(body); fs.appendFileSync(logPath, `[child stderr] ${redacted}${redacted.endsWith("\n") ? "" : "\n"}`, "utf-8"); } catch { /* best-effort */ } }; child.stderr?.on("data", (chunk: Buffer) => { if (stderrLen + chunk.length > STDERR_CAPTURE_LIMIT) { if (!stderrTruncated) { stderrTruncated = true; try { fs.appendFileSync(logPath, `[child stderr truncated at ${STDERR_CAPTURE_LIMIT} bytes]\n`, "utf-8"); } catch { /* best-effort */ } } return; } stderrChunks.push(chunk); stderrLen += chunk.length; }); child.stderr?.on("end", flushStderr); child.stderr?.on("close", flushStderr); child.on("error", (error: Error) => { logInternalError("async-runner.spawn", error, `pid=${child.pid ?? "unknown"}`); }); child.unref(); // Track this worker in the orphan registry so it can be killed on // session_start of a future session if the parent pi process is killed. if (child.pid) { registerWorker( child.pid, manifest.ownerSessionId ?? "unknown", manifest.runId, process.pid, // parentPid — used by cleanup to verify session is dead ); // Best-effort: unregister when child exits. Background-runner writes // the marker file before it dies, so we unregister on the next // cleanup tick. The child "exit" event may fire after unref() when // stdio is piped; register handler defensively. if (child.pid) { child.on("exit", () => { try { unregisterWorker(child.pid!); } catch { /* best-effort */ } }); } } return { pid: child.pid, logPath }; }