@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
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: 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