/** * ACP bridge child reaper — bridges must die with the daemon (crew#285). * * # The defect * * The engine spawns ACP bridge binaries as direct OS children of this daemon process, * and the only kill handles for them live in the engine's in-memory registry. When the * daemon dies — pkill during a restart, an operator's Ctrl-C, a plain `process.exit` — * that registry dies with it and nothing reaps the bridges: they linger detached until * their own session-idle logic (if any) gets around to exiting. Operators observed * three `claude-agent-acp` processes coexisting while exactly one unit was executing. * * Same defect family as crew#277's cancel-orphans, but for daemon death rather than * run cancellation. * * # The fix, in two halves * * 1. THIS MODULE (daemon side): a central registry of bridge child pids plus an OS * process-table sweep, wired into the daemon's shutdown path. On SIGTERM/SIGINT the * daemon SIGTERMs every bridge child, waits a short grace for them to exit, then * SIGKILLs survivors. On plain `exit` (where no async work is possible) it fires a * synchronous best-effort SIGTERM sweep. * * `register()` exists for any JS-side spawn site (and for tests); the engine-spawned * bridges are found by `discoverBridgeChildren()` — a scan of the OS process table * for DIRECT children of this daemon whose command line names a bridge binary. The * direct-child restriction is the safety rail: another daemon's bridges have a * different parent pid and are never touched. * * 2. `packages/agent-acp-bridges` (bridge side): the bridge treats stdin EOF as the * portable parent-death signal and reaps its own CLI child instead of lingering * until the CLI finishes. See `bridge.mjs`. That half covers our own bridges even * when the daemon dies too hard (SIGKILL) for this module to run at all. * * A third concern joined in crew#340: `kill -9` on a BRIDGE reparents its in-flight * worker CLI (`claude`, `codex`, …) to init — invisible to both halves above — and the * orphan keeps the engine-minted shared worker config home busy, wedging subsequent * `session/new` handshakes until the daemon restarts. The orphan sweeps below (one at * boot, one periodic while the daemon lives) reap those under a conservative triple * gate: ppid == 1, a token-boundary command match, and a cwd inside a run worktree. * * A fourth joined with F-W1-103 (FIX-IT-ALL wave 1): the INTERACTIVE document bridge the pool * spawns per docs root (`npx wicked-interactive serve --root …`, `interactive/bridge-pool.ts`, * detached and unref'd) was outside every sweep — it is not a `*-acp` binary, and its server is * this daemon's GRANDCHILD (an `npm exec` wrapper is the child) — so it outlived every daemon that * started it: two such pairs were found ~20 h after their daemon had exited, on ports nothing * would ever look up again. Now (1) the shutdown sweep reaps the wrapper AND its server child * (npm does not forward a signal to it), and (2) both orphan sweeps reap a ppid-1 interactive tree * whose docs root carries crew's own sidecar (`.wi-serve.crew.json`) naming one of its pids with * an owning daemon that is gone. Adopting a bridge stamps the adopter as its owner, so a bridge in * use by a LIVE daemon — spawner or adopter — is never matched; a bridge nobody recorded (an * operator's own `serve`) is never matched either. Crew-spawned bridges therefore live exactly as * long as a crew daemon owns them: adopt-or-kill, never leak. * * A fifth (crew#806): the orphan sweeps probed each candidate's cwd with a SYNCHRONOUS per-pid * `lsof` inside a timer callback, with no timeout. On a loaded macOS host one `lsof -p ` of a * crashpad handler took over 80 s, and the candidate set included processes that are not ours at * all (`Claude.app`, its crashpad handlers, ChatGPT's "Codex Framework", the `codex app-server * daemon`) — five such probes per 30 s tick, so the tick never finished before the next was due and * the event loop starved: `/health` never answered, a fresh daemon never reached `listen`, SIGTERM * was not honoured (the handler cannot run inside `spawnSync`). Now the sweeps are ASYNC, the cwd of * every candidate is read with ONE batched `lsof` (`/proc` on Linux) under a hard timeout that * SIGKILLs the probe, a tick whose predecessor is still in flight is skipped, a probe that fails or * times out reads as "cwd unknown → do not reap", the boot sweep no longer blocks the boot, and the * candidate match is narrowed to the PROGRAM region of a command line (before its first `-flag` * token, quote-aware), is case-sensitive on POSIX, and a pid is re-checked against a fresh * listing after the probe's await (a recycled pid is nobody's orphan) — a path segment deep in a * `--database=…/Claude/…` flag, or `/Applications/Claude.app/Contents/MacOS/Claude`, is not a * worker CLI. * * # Why a process-table sweep rather than tracked pids alone * * The spawn happens inside the engine (the native actor thread), which reports no pid * back to JS — there is nothing for the daemon to `register()`. The bridges ARE this * process's direct children though, and their command lines name the bridge binaries * (npm `.bin` shims exec `node ...//...`), so a ppid-filtered scan * recovers exactly the set the in-memory kill handles would have covered. The sweep * fails OPEN (returns nothing) when the platform tooling is unavailable: a shutdown * that cannot enumerate children must still shut down. */ import { type CrewSidecar, type LiveBridge } from '../interactive/bridge-pool.js'; /** * The ACP bridge binaries a pre-C5 engine spawned by bare name on PATH. ACP is gone from the seats * (C5) and nothing installs a bridge any more; the list stays so the boot sweep still reaps the * orphans a pre-C5 daemon left running after an upgrade (`bridge-reaper.test.ts` pins it). */ export declare const BRIDGE_BINS: readonly string[]; /** * The worker CLIs those bridges spawn as their OWN children — grandchildren of the daemon. * * `kill -9` on a bridge (crew#340) orphans its in-flight CLI child to init, where the * ppid-filtered direct-child sweep can never see it. The orphan keeps running under the * engine-minted shared worker config home (`~/.wicked-worker/`), which subsequent * `session/new` handshakes contend on — the evidenced "ACP wedged until daemon restart" * failure. These tokens identify exactly those orphans under the SAME conservative triple * gate as orphaned bridges: ppid == 1, a token-boundary command match, AND a cwd inside an * engine run worktree — an operator's own `claude`/`codex` never runs from one of those. */ export declare const WORKER_CLI_BINS: readonly string[]; /** * The interactive document bridge the pool spawns per docs root (F-W1-103): `npx --yes * wicked-interactive@ serve --root `, detached. It runs as an `npm exec` WRAPPER whose * child is the `node …/wicked-interactive serve --root ` that holds the port — the wrapper is * this daemon's child, the server its grandchild, and SIGTERM to the wrapper alone leaves the * server running (npm does not forward it). Every matcher below treats the pair as one tree. */ export declare const INTERACTIVE_BIN = "wicked-interactive"; /** How long a SIGTERM'd bridge gets to exit before the SIGKILL escalation. */ export declare const BRIDGE_KILL_GRACE_MS = 2000; /** * The PROGRAM region of a command line — everything before its first `-flag` token (the whole * line when there is none). The orphan matchers test this region alone (crew#806): an * engine-spawned bridge or worker CLI names its binary THERE (`/x/.bin/claude-agent-acp`, * `node …/wicked-pi.mjs --mode rpc`, `claude -p …`, `sh /x/.bin/pi-acp`, a quoted Windows `.cmd`, * and a path with spaces — POSIX `ps` prints argv unquoted, so no split on whitespace), while * `chrome_crashpad_handler --database=/…/Claude/Crashpad` names ours only inside a flag of a process * that is nobody's worker. A node option before the script (`node --inspect …/claude`) ends the * region early; the engine spawns its bridges and workers with none. */ export declare function programRegionOf(command: string): string; /** Is `command` an interactive bridge `serve` — the npm wrapper or the server itself? */ export declare function isInteractiveServe(command: string): boolean; /** * The `--root ` of an interactive `serve` command line, or null when absent or not absolute. * A quoted value is unquoted; an unquoted one runs to the next `--flag` or the end of the line, so a * root with a space in it survives and the blanks `ps` pads a line with are dropped. */ export declare function rootArgOf(command: string): string | null; /** * A `pid ppid command` process listing → the pids of the DIRECT children of `parentPid` whose * command line names a bridge binary or an interactive `serve`, plus the `serve` children of those * interactive wrappers (this daemon's grandchildren, F-W1-103). Pure, so the parsing is testable * without a real process table. */ export declare function parseBridgeChildren(listing: string, parentPid: number): number[]; /** Hard ceiling on any one process probe (`ps`, `lsof`) the sweeps run (crew#806). A probe past it * is SIGKILLed and reads as "unknown" — the sweeps fail open on it (nothing is reaped on a guess). */ export declare const PROBE_TIMEOUT_MS = 5000; /** * Pids of every live bridge process that is a direct child of `parentPid`. Fails open: * an unreadable process table yields `[]`, never a throw — shutdown must proceed. */ export declare function discoverBridgeChildren(parentPid?: number): number[]; /** * Pids of engine-descended run processes ORPHANED to init (ppid 1): bridges left behind * by a previous daemon generation (#285), AND worker CLIs left behind by a bridge that * died too hard to reap its own child — `kill -9` on the bridge reparents its in-flight * `claude`/`codex`/… to init, where it clings to the shared worker config home until * something reaps it (crew#340). Deliberately conservative — a process owned by another * LIVE daemon (or bridge) still has that parent and is never matched, so neither the boot * sweep nor the periodic sweep can shoot a neighbour's workers (#285, Copilot review: * shutdown-only reaping leaves pre-existing orphans alive forever). */ export declare function parseOrphanedRunProcesses(listing: string): number[]; /** An interactive bridge tree reparented to init: the docs root its command line names and the * pids to reap — the ppid-1 process first (the npm wrapper, or the server itself once its wrapper * is gone), then any `serve` child of it. */ export interface OrphanedInteractiveBridge { root: string; pids: number[]; } /** * Every interactive bridge tree whose parent is init (F-W1-103): a ppid-1 `wicked-interactive … * serve --root ` plus its own `serve` children. Pure — the OWNERSHIP gate (crew's sidecar in * `root` naming one of these pids, with an owner daemon that is gone) is applied by the sweeps, * so a shell-parented or nohup'd operator bridge is parsed here but never reaped there. */ export declare function parseOrphanedInteractiveBridges(listing: string): OrphanedInteractiveBridge[]; /** The cwd contract of every engine-spawned bridge and worker CLI: a run worktree. */ export declare function inRunWorktree(cwd: string | undefined): boolean; /** * The working directory of each of `pids` that has a readable one — ONE batched read for the whole * candidate set (crew#806): `/proc//cwd` on Linux, a single `lsof -a -d cwd -p -Fpn -w` * elsewhere on POSIX (the same shape `api/run-liveness.ts` reads), bounded by {@link PROBE_TIMEOUT_MS} * and SIGKILLed past it. A pid the probe could not answer for is simply absent — the sweeps treat * absence as "cwd unknown → do not reap". Windows has no reading (an empty map): never kill on * uncertainty. A user's own nohup'd `*-acp` also reparents to init but runs from an arbitrary cwd, * so the cwd is the discriminator that keeps the sweeps from shooting it (Copilot review on #300). */ export declare function probeCwds(pids: readonly number[]): Promise>; /** * Boot-time sweep: SIGTERM orphaned bridges from a prior daemon generation AND orphaned * worker CLIs from any bridge that died too hard to reap them — ppid 1 AND cwd inside a * run worktree, so user-started processes are never matched — AND crew-spawned interactive * bridge trees whose owning daemon is gone (ppid 1 AND crew's sidecar names them, F-W1-103). */ export declare function reapOrphansAtBoot(io?: BridgeReaperIo): Promise; /** How often the live daemon re-scans for orphaned run processes. */ export declare const ORPHAN_SWEEP_DEFAULT_MS = 30000; /** The sweep interval: `WICKED_CREW_ORPHAN_SWEEP_SECS` (0 disables), else 30s. */ export declare function orphanSweepMs(raw?: string | undefined): number; /** * One sweep tick. `pendingKill` carries the pids SIGTERMed on a previous tick: one that is * STILL in the table now ignored its SIGTERM for a whole interval and gets SIGKILL — the * same escalation `shutdown()` runs, at sweep-interval granularity. Exported for tests. */ export declare function sweepOrphanedRunProcesses(pendingKill: Set, io?: BridgeReaperIo): Promise<{ terminated: number[]; killed: number[]; }>; /** * Start the periodic orphan sweep. Returns a stop function. The timer is unref'd — it * must never hold an exiting daemon open — and each reap is logged LOUDLY with the pids * and the issue that motivated it, so an operator can connect a recovered wedge to what * was reaped. */ export declare function startOrphanSweep(intervalMs?: number, io?: BridgeReaperIo): () => void; /** Injectable seams so the reaper is testable without signalling real bridges. */ export interface BridgeReaperIo { /** Signal sender; must throw like `process.kill` (ESRCH when the pid is gone). */ kill?: (pid: number, signal: NodeJS.Signals | 0) => void; /** Bridge-child discovery; defaults to the process-table sweep above. */ discover?: (parentPid: number) => number[]; /** Orphan-sweep cwd reader — ONE call per sweep with every candidate pid (crew#806); injectable so * tests avoid a real lsof. Defaults to {@link probeCwds}. */ cwds?: (pids: number[]) => Promise> | Map; /** Orphan-sweep process listing (`pid ppid command` lines); defaults to the real table (async). */ list?: () => string | null | Promise; /** Interactive-orphan gate: crew's sidecar for a docs root (F-W1-103); injectable so tests avoid real files. */ sidecar?: (root: string) => CrewSidecar | null; /** Interactive-orphan gate: the live `.wi-serve.json` for a docs root, so a sidecar is checked * against the bridge INSTANCE and not just its pid (crew#510). Injectable for tests. */ lock?: (root: string) => LiveBridge | null; /** Interactive-orphan gate: is the daemon the sidecar names as owner still THAT daemon — pid AND * start time ({@link ownerAlive}), so a recycled pid reads as "owner gone"? Injectable for tests. */ ownerAlive?: (sidecar: CrewSidecar) => boolean; sleep?: (ms: number) => Promise; graceMs?: number; } /** What `shutdown()` did, for logs and assertions. */ export interface ReapReport { /** Pids that exited within the grace window after SIGTERM. */ terminated: number[]; /** Pids that ignored SIGTERM and were SIGKILLed. */ killed: number[]; /** Pids whose SIGKILL could not be delivered (e.g. EPERM) — still possibly alive. */ undeliverable?: number[]; } /** * The central bridge-child registry plus the shutdown path that empties it. * * A single instance (`bridgeReaper`) is wired into the daemon's shutdown handlers; * tests construct their own with fake IO. */ export declare class BridgeReaper { private readonly tracked; private readonly io; constructor(io?: BridgeReaperIo); /** Track a bridge child pid. Invalid pids (spawn failures yield `undefined`) are ignored. */ register(pid: number | undefined): void; /** Stop tracking a pid — call when the child's `close`/`exit` is observed. */ unregister(pid: number): void; /** Currently tracked pids (registered only; discovery happens at kill time). */ pids(): number[]; /** * Deliver `signal`. For the signal-0 liveness probe, EPERM means "alive but not * ours" and counts as existing; for real signals EPERM means the kill was NOT * delivered and must not be reported as success (Copilot review on #300). */ private signal; /** Registered pids ∪ discovered direct-child bridges, deduplicated. */ private targets; /** * Graceful reap: SIGTERM every target, poll liveness for the grace window, SIGKILL * survivors. Idempotent — dead pids are skipped, and the registry is cleared so a * second invocation (the `exit` sweep after a signal-path shutdown) finds nothing * registered and only re-discovers what actually still lives. */ shutdown(): Promise; /** * Synchronous best-effort sweep for the `exit` event, where no async work (and so no * grace window) is possible. SIGTERM only — a synchronous SIGKILL would deny a bridge * the chance to reap ITS child CLI, recreating the orphan problem one level down. * The bridges' own stdin-EOF watchdog is the backstop for anything that ignores this. */ sweepSync(): void; } /** The daemon-wide reaper the CLI wires into its shutdown handlers. */ export declare const bridgeReaper: BridgeReaper; //# sourceMappingURL=bridge-reaper.d.ts.map