@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
JavaScript
/**
* 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