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.

315 lines • 15.3 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: their code does not exit on stdin EOF (index.ts does * since 2026-10-13; the code already running out there does not), 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. * Both start times come from the SAME `ps` listing (see findStaleBridges). * 4. it is NOT in the ownership registry: a bridge there is 3.23.4+ and stands down by itself. A * 3.25.7+ bridge is in it from process start (wake-ownership PROCESS_MARK), registered or not. * 4b. ...and its version is READ and is older than 3.23.4. "Unannounced ⇒ old" is false: a * 3.23.4–3.25.6 bridge that has not registered (presence standby/deferred) never announced, holds * no peer and cannot wake. Killing it took down marvin's live session bridge (3.25.6, 2026-10-13) * under a log line claiming it "predates 3.23.4". 🔴 An UNREADABLE version is not evidence of an * old one (a global-install shim, review r4 problem 5): it is spared. The cost, accepted: a * pre-3.23.4 ghost whose version cannot be read is no longer reaped — every such process is at * least five weeks old (3.23.4 shipped 2026-09-06) and a wrong kill takes down a live agent. * 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 { listLiveBridgesInFolder } 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; } /** The first version that announces itself and stands down on its own (wake-ownership.ts). */ export const SELF_SUPERSESSION_VERSION = "3.23.4"; /** `a >= b` for plain x.y.z versions; a pre-release/build suffix is ignored. */ export function versionAtLeast(a, b) { const pa = a.split(/[-+]/)[0].split(".").map(Number); const pb = b.split(/[-+]/)[0].split(".").map(Number); for (let i = 0; i < 3; i++) { const x = pa[i] ?? 0; const y = pb[i] ?? 0; if (x !== y) return x > y; } return true; } /** * The bridge's version: from the plugin-cache path, else from the package.json beside a versionless * npm/npx entry point (`.../node_modules/.bin/cogent-bridge`, `.../@essentialai/cogent-bridge/dist/cli.js`). * Null when neither is readable — and null is never a reason to kill (signal 4b). * * A package.json can be newer than the process reading it describes (an unpinned npx re-resolves * into the SAME `_npx/<hash>` folder, measured 2026-10-01): a ghost then reads as current and is * spared, the same outcome as an unreadable version. */ export function bridgeVersion(argv, readFile = (p) => fs.readFileSync(p, "utf-8")) { const fromPath = versionFromArgv(argv); if (fromPath) return fromPath; const candidates = []; const pkg = argv.match(/(\S*\/@essentialai\/cogent-bridge(?:-bundled)?)\/dist\/(?:cli|index)\.js(?:\s|$)/); if (pkg) candidates.push(path.join(pkg[1], "package.json")); const bin = argv.match(/(\S*\/node_modules)\/\.bin\/cogent-bridge(?:\s|$)/); if (bin) { for (const name of ["cogent-bridge", "cogent-bridge-bundled"]) { candidates.push(path.join(bin[1], "@essentialai", name, "package.json")); } } for (const file of candidates) { try { const pj = JSON.parse(readFile(file)); if (typeof pj.name === "string" && pj.name.startsWith("@essentialai/cogent-bridge") && typeof pj.version === "string" && /^\d+\.\d+\.\d+/.test(pj.version)) { return pj.version; } } catch { /* missing or unreadable — try the next one */ } } return 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; } /** * 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. * * 🔴 "OLDER THAN US" COMPARES LIKE WITH LIKE: our own start time is read from the SAME `ps` listing * as theirs. `ps` reports whole seconds rounded down twice (boot time, then ticks since boot) — * measured up to 1.8 s behind the clock on Linux — so against our own millisecond clock a bridge * started up to two seconds AFTER us looked older and was selected (stale-bridge-reap-test, red on * main). If we are not in the listing, order cannot be proven and nothing is reaped. */ export function findStaleBridges(cwd, procs = listProcesses(), opts = {}) { const versionOf = opts.versionOf ?? ((argv) => bridgeVersion(argv)); const selfPid = opts.selfPid ?? process.pid; const selfStartedAt = opts.selfStartedAt ?? procs.find((p) => p.pid === selfPid)?.startedAt ?? NaN; if (!Number.isFinite(selfStartedAt)) return []; const cwdOf = opts.cwdOf ?? processCwd; const announced = opts.announced ?? new Set(listLiveBridgesInFolder(cwd).map((e) => e.pid)); 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. in the registry ⇒ 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; // 4b. only on positive evidence of an old version: unreadable, or 3.23.4+ ⇒ refuse. const ver = versionOf(p.argv); if (!ver || versionAtLeast(ver, SELF_SUPERSESSION_VERSION)) continue; out.push({ proc: p, reason: `pid ${p.pid} (version ${ver}) is a cogent bridge in this exact checkout, started ` + `${new Date(p.startedAt).toISOString()} — before this process — and is not in the bridge ` + `registry; version ${ver} predates ${SELF_SUPERSESSION_VERSION} 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) for this checkout, each at a version ` + `before ${SELF_SUPERSESSION_VERSION}: they could not stand down on their own.`); } return reaped; } //# sourceMappingURL=stale-bridge-reaper.js.map