UNPKG

@essentialai/cogent-bridge

Version:

Cogent Bridge — cross-agent comms for Claude Code, OpenAI Codex and Slack. Codex users: install with `curl -fsSL https://cogent.tools/install.sh | sh`, then start with `cogent-codex` for real-time peer wake.

261 lines • 12 kB
/** * STALE BRIDGE REAPER — terminate leftover bridges that are too old to stand down by themselves. * * ── WHY THIS EXISTS, AND WHY IT IS THE ONLY OPTION ─────────────────────────────────────── * `wake-ownership.ts` makes a superseded bridge stop waking ON ITS OWN. That heals everything from * 3.23.4 onward — but it is CLIENT code, so a bridge already running 3.23.3 or older never executes * it. Those processes keep waking forever: nothing exits on stdin EOF, the WebSocket is never * unref'd, and their 60s heartbeat keeps `lastSeenAt` fresh so the relay's 30-minute stale sweep can * never reach them. Measured on one dev box: 37 live bridges, versions 3.21.5/3.21.9/3.22.0/3.23.3 * running side by side, some three days old. * * There is no lever on a process running old code except an external one. The operator's users do * not run `pkill`. So: this does. * * ── THE BLAST RADIUS, STATED HONESTLY ──────────────────────────────────────────────────── * A wrong kill takes down a live agent's bridge. That is why the targeting below requires FIVE * independent signals to agree, and why the signal is SIGTERM (the bridge has a handler and shuts * down cleanly) rather than SIGKILL. * * The trade is deliberate and it is not close: * false positive here → a bridge dies, the agent loses cogent tools until restart. VISIBLE and * RECOVERABLE, and loudly logged with the exact reason. * doing nothing → a ghost keeps editing a live repo and committing duplicate work. * INVISIBLE, and not always recoverable. * * ── FIVE SIGNALS, ALL REQUIRED ─────────────────────────────────────────────────────────── * 1. argv is a recognised cogent-bridge ENTRY POINT (not merely a command line mentioning cogent — * that is how a previous harness matched its own `pgrep` pattern and lied). * 2. the process's RESOLVED cwd equals ours exactly (/proc on Linux, lsof on macOS). * 3. it started STRICTLY BEFORE us — a newer bridge is never a leftover, it is the successor. * 4. it is NOT announced in the ownership registry: an announced bridge is 3.23.4+ and will stand * down by itself, so killing it would be gratuitous. * 5. it is neither this process nor any ANCESTOR of it — killing our own parent would take down the * agent session we exist to serve. * * Callers must additionally have registered a peer, so a probe or a half-started process never * reaps anything. * * Opt out with COGENT_REAP_STALE_BRIDGES=false. Default ON, because a default-off self-heal heals * nobody — the same reason the operator gave for rejecting "just tell users to run pkill". */ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { logger } from "../logger.js"; import { listLiveBridges, bridgeDirFor } from "./wake-ownership.js"; /** * Does this command line launch a cogent BRIDGE? * * Deliberately anchored on the entry-point FILE, never on the word "cogent" appearing somewhere. * A substring match would catch this repo's own gate harnesses (`wake-dedup-matrix-test.mjs`, * `slack-roundtrip-test.mjs`) and an editor with the source open — and a harness matching its own * command line is a mistake this codebase has already made twice. */ export function isBridgeCommand(argv) { if (!argv) return false; // Never match a test/harness/script invocation, whatever else it contains. if (/\.test\.|-test\.mjs|scripts\//.test(argv)) return false; return (/\/bridge\/cogent-bridge\.mjs(\s|$)/.test(argv) || // plugin bundle (Claude + codex) /\/cogent-bridge(\s|$)/.test(argv) || // npx / global bin shim /@essentialai\/cogent-bridge(-bundled)?\/dist\/(cli|index)\.js(\s|$)/.test(argv) // npm install ); } /** Best-effort version parsed out of a plugin-cache path, e.g. `/cogent/3.21.5/bridge/...`. */ export function versionFromArgv(argv) { const m = argv.match(/\/cogent\/(\d+\.\d+\.\d+)\//); return m ? m[1] : null; } /** Every running process, from `ps`. Returns [] on any failure — never throws. */ export function listProcesses() { let out; try { // lstart is locale-dependent but Date.parse handles the C locale form ps emits here. out = execFileSync("ps", ["-eo", "pid=,ppid=,lstart=,args="], { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024, timeout: 10_000, }); } catch (err) { logger.debug(`stale-bridge-reaper: ps failed (${err.message})`); return []; } const procs = []; for (const line of out.split("\n")) { // pid ppid <lstart: 5 fields> args... const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.*)$/); if (!m) continue; const started = Date.parse(m[3]); procs.push({ pid: Number(m[1]), ppid: Number(m[2]), startedAt: Number.isFinite(started) ? started : NaN, argv: m[4], }); } return procs; } /** * Resolve a process's working directory, or null when it cannot be determined. * * NULL IS A REFUSAL, NOT A GUESS. If we cannot prove the process is in our cwd we must not touch * it — an unresolvable cwd is the single most likely way this reaper would kill something innocent. */ export function processCwd(pid) { try { if (process.platform === "linux") { return path.resolve(fs.readlinkSync(`/proc/${pid}/cwd`)); } if (process.platform === "darwin") { const out = execFileSync("lsof", ["-a", "-d", "cwd", "-p", String(pid), "-Fn"], { encoding: "utf-8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"], }); const line = out.split("\n").find((l) => l.startsWith("n")); return line ? path.resolve(line.slice(1)) : null; } } catch { /* permission denied, process gone, lsof missing — all mean "cannot prove" */ } return null; } /** * Canonical form of a path for COMPARISON — symlinks resolved. * * 🔴 macOS. `lsof` reports a process's cwd already realpath'd (`/private/var/...`) while * `process.cwd()` keeps the symlinked spelling (`/var/...`), because `/var -> /private/var`. A * `path.resolve`-only comparison therefore NEVER matches on macOS and the reaper is silently inert * — it fails safe, but it heals nobody, which is the whole point of it. Found by running the * platform probe on a real Mac; no Linux test could have shown it. * * Falls back to `path.resolve` when the path no longer exists, so a vanished cwd degrades to a * non-match (a refusal) rather than an exception. */ export function canonicalPath(p) { try { return fs.realpathSync(path.resolve(p)); } catch { return path.resolve(p); } } /** Every ancestor pid of `pid`, walking ppid to init. Cycle-safe. */ export function ancestorsOf(pid, procs) { const byPid = new Map(procs.map((p) => [p.pid, p])); const seen = new Set(); let cur = byPid.get(pid)?.ppid; while (cur && cur > 1 && !seen.has(cur)) { seen.add(cur); cur = byPid.get(cur)?.ppid; } return seen; } /** Pids of every bridge that has ANNOUNCED itself for this cwd (any peerId) — they self-heal. */ function announcedPidsFor(cwd) { const pids = new Set(); try { const base = path.dirname(bridgeDirFor(cwd, "x")); const prefix = path.basename(bridgeDirFor(cwd, "x")).split(".")[0]; // the cwd hash for (const dir of fs.readdirSync(base)) { if (!dir.startsWith(`${prefix}.`)) continue; const peerId = dir.slice(prefix.length + 1); for (const e of listLiveBridges(cwd, peerId)) pids.add(e.pid); } } catch { /* no registry yet — nobody has announced */ } return pids; } /** * Which running processes are leftover bridges for `cwd` that cannot stand down by themselves? * * `procs` is injectable so the decision logic is unit-testable without spawning anything, and * `cwdOf` so a test can supply cwds without /proc. */ export function findStaleBridges(cwd, procs = listProcesses(), opts = {}) { const selfPid = opts.selfPid ?? process.pid; const selfStartedAt = opts.selfStartedAt ?? Date.now() - Math.floor(process.uptime() * 1000); const cwdOf = opts.cwdOf ?? processCwd; const announced = opts.announced ?? announcedPidsFor(cwd); const target = canonicalPath(cwd); const ancestors = ancestorsOf(selfPid, procs); const out = []; for (const p of procs) { if (p.pid === selfPid) continue; // 5. never ourselves if (ancestors.has(p.pid)) continue; // 5. never an ancestor — that is our own agent session if (!isBridgeCommand(p.argv)) continue; // 1. must be a bridge entry point if (announced.has(p.pid)) continue; // 4. announced ⇒ 3.23.4+ ⇒ stands down on its own // 3. must be strictly older. An unparseable start time is NOT "older" — refuse. if (!Number.isFinite(p.startedAt) || p.startedAt >= selfStartedAt) continue; const theirCwd = cwdOf(p.pid); // 2. must PROVABLY share our cwd if (!theirCwd || canonicalPath(theirCwd) !== target) continue; const ver = versionFromArgv(p.argv); out.push({ proc: p, reason: `pid ${p.pid}${ver ? ` (plugin ${ver})` : ""} is a cogent bridge in this exact checkout, ` + `started ${new Date(p.startedAt).toISOString()} — before this process — and has not announced ` + `itself, so it predates self-supersession and would keep waking this session forever`, }); } return out; } /** Is the reaper enabled? Default ON; opt out with a falsey COGENT_REAP_STALE_BRIDGES. */ export function reaperEnabled(env = process.env) { const v = env.COGENT_REAP_STALE_BRIDGES; if (v === undefined || v === "") return true; return !/^(0|false|no|off)$/i.test(v.trim()); } /** * SIGTERM every leftover bridge for this cwd. Returns the candidates acted on. * * Graceful on purpose: the bridge's SIGTERM handler stops auto-relay, disconnects the WebSocket and * exits cleanly, so a mistargeted kill is a clean shutdown rather than a corrupted one. */ export function reapStaleBridges(cwd, opts = {}) { if (!reaperEnabled()) { logger.debug("stale-bridge-reaper: disabled by COGENT_REAP_STALE_BRIDGES"); return []; } let candidates; try { candidates = findStaleBridges(cwd, listProcesses(), opts); } catch (err) { logger.warn(`stale-bridge-reaper: scan failed (${err.message}) — reaping nothing`); return []; } const reaped = []; for (const c of candidates) { // Log BEFORE signalling: if anything about this is wrong, the reason must survive in the log. logger.warn(`Cogent: terminating a stale bridge — ${c.reason}`); try { process.kill(c.proc.pid, "SIGTERM"); reaped.push(c); } catch (err) { logger.warn(`stale-bridge-reaper: could not signal pid ${c.proc.pid}: ${err.message}`); } } if (reaped.length) { logger.warn(`Cogent: reaped ${reaped.length} stale bridge process(es) left behind by an earlier plugin ` + `version. They could not stand down on their own because they predate 3.23.4.`); } return reaped; } //# sourceMappingURL=stale-bridge-reaper.js.map