// Defense-in-depth orphan reaper — mirrors rs/src/reaper.rs (see it for the full // rationale). Records each running agent's (wrapper pid, agent pgid) so a later // sweep kills the recorded process group of any agent whose wrapper died WITHOUT // running its own group cleanup (SIGKILL by an OOM killer / oxmgr force-restart / // a panic). It targets the recorded pgid of a CONFIRMED-DEAD wrapper — never // ppid==1 — so it is container-safe and never touches an unrelated process. import { appendFile, mkdir, readdir, readFile, rename, unlink, writeFile } from "fs/promises"; import path from "path"; import { agentYesHome } from "./agentYesHome.ts"; const registryPath = () => path.join(agentYesHome(), "reaper.jsonl"); function isAlive(pid: number): boolean { if (pid <= 1) return false; try { process.kill(pid, 0); // signal 0 probes existence without affecting the target return true; } catch (e) { return (e as NodeJS.ErrnoException).code === "EPERM"; // exists, owned by another user } } /** The recorded process-group id for a wrapper pid (newest entry wins), or null * if unknown. Lets a force-kill target the agent's whole group, not just its * wrapper pid — same registry the orphan sweep uses. */ export async function pgidForWrapper(wpid: number): Promise { if (!wpid || wpid <= 1) return null; let content: string; try { content = await readFile(registryPath(), "utf8"); } catch { return null; } let pgid: number | null = null; for (const line of content.split("\n")) { const t = line.trim(); if (!t) continue; try { const e = JSON.parse(t); if (e.wpid === wpid && typeof e.pgid === "number" && e.pgid > 1) pgid = e.pgid; } catch { // skip malformed } } return pgid; } /** Record this wrapper + its agent's process group for later sweeping. */ export async function register(wrapperPid: number, pgid: number): Promise { if (pgid <= 1) return; // never persist a group we'd refuse to signal try { await mkdir(agentYesHome(), { recursive: true }); await appendFile(registryPath(), JSON.stringify({ wpid: wrapperPid, pgid }) + "\n"); } catch { // best-effort } } /** SIGKILL the recorded group of every agent whose wrapper has exited, and * rewrite the registry keeping only still-running agents. Best-effort. */ export async function sweep(): Promise { // Independent of the reaper registry (an agent may leak an activity marker // without ever being registered), so prune first — before the no-registry // early return below. await pruneStaleActivityMarkers(); let content: string; try { content = await readFile(registryPath(), "utf8"); } catch { return; // no registry yet } const keep: string[] = []; for (const line of content.split("\n")) { const t = line.trim(); if (!t) continue; let entry: { wpid?: unknown; pgid?: unknown }; try { entry = JSON.parse(t); } catch { continue; // drop malformed lines } if (typeof entry.wpid !== "number" || typeof entry.pgid !== "number") continue; if (isAlive(entry.wpid)) { keep.push(t); // agent still running — keep watching it continue; } // Wrapper gone — reap its recorded group. The pgid outlives the leader, so // this catches descendants already reparented to PID 1. The `> 1` guard is // critical: process.kill(-1) would signal every process the user owns. if (process.platform !== "win32" && entry.pgid > 1) { try { process.kill(-entry.pgid, "SIGKILL"); } catch { // ESRCH = nothing left alive in that group } } } try { const tmp = registryPath() + ".tmp"; await writeFile(tmp, keep.join("\n")); await rename(tmp, registryPath()); } catch { // best-effort } } /** Prune typing-activity markers (`activity/.stdin`) left by hard-killed * agents whose own exit cleanup never ran. Independent of the reaper registry * above, so it catches every dead pid. A leaked marker is harmless (it ages out * of the typing window and stopped agents never render the chip); this just * keeps the dir from accumulating dead entries. Mirrors rs/src/fifo.rs * `prune_stale_activity_markers`. Best-effort. */ async function pruneStaleActivityMarkers(): Promise { const dir = path.join(agentYesHome(), "activity"); let names: string[]; try { names = await readdir(dir); } catch { return; // no activity dir yet } await Promise.all( names.map(async (name) => { if (!name.endsWith(".stdin")) return; const pid = Number(name.slice(0, -".stdin".length)); if (!Number.isInteger(pid) || isAlive(pid)) return; try { await unlink(path.join(dir, name)); } catch { // raced with another sweep / the agent's own cleanup } }), ); }