@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.
257 lines • 12.1 kB
JavaScript
/**
* CROSS-PROCESS WAKE LOCK — never two agent turns running at once in ONE CHECKOUT.
*
* ── THE DEFECT THIS FIXES, AND HOW IT DIFFERS FROM wake-claim.ts ─────────────────────────
* `wake-claim.ts` excludes on the MESSAGE id: it stops one message, fanned by the relay to N
* sockets of the same peer, from being answered N times. That is a real defect and it is fixed.
*
* It cannot help here. Measured on a real user (peer `marvin-coder`, 2026-09-05): the SAME peer
* was registered on the FREE relay at plugin v3.20.0 AND on the TEAM relay at v3.23.1 — same cwd,
* same thread id, two live bridge processes. Reachable because credentials are keyed on cwd ALONE
* (`~/.cogent/credentials/<sha256(cwd)>.json`, credential-store.ts:64-71); the endpoint is not part
* of the key, so a second join overwrites the file while the first process keeps running from its
* in-memory token and live WebSocket. Nothing on disk then records that the first relay is in play,
* which is why nothing reaps it.
*
* Two relays mint two DIFFERENT message ids, so both per-message claims succeed and both processes
* resume the SAME session concurrently. `scripts/wake-dedup-matrix-test.mjs --topology two-relay`
* reproduces it offline against two local relays: two wakes, one session id, 4001ms of overlap.
*
* ── WHY THE KEY IS THE CWD, NOT THE SESSION ──────────────────────────────────────────────
* The first cut of this keyed on (cwd, sessionId). That is too narrow. `pinnedSessionId` is
* per-process in-memory state (auto-relay.ts:338) and `decideResolution` returns whatever pin it
* was handed whenever that pin still validates, so two bridge processes in one checkout can hold
* DIFFERENT pins — they would take different locks and overlap anyway.
*
* More importantly the damage is a property of the WORKTREE, not of the session: two `claude`
* processes editing one checkout corrupt it whether or not they resume the same session. So the
* key is the cwd. The session id is recorded in the lease for diagnostics only.
*
* `wake-claim.ts` explicitly rejected a per-cwd lock — "a per-cwd lock would ... LOSE messages,
* the loser would silently drop a message it was the only one free to handle". That objection is
* about DROP semantics and does not apply here: this lock WAITS. Same key, opposite conclusion.
*
* Blast radius is deliberately small: with a single bridge process the lock is never contended, so
* nothing about normal operation changes. Contention only arises in the topology that IS the bug.
*
* ── WHY IT WAITS INSTEAD OF DROPPING ─────────────────────────────────────────────────────
* Two relays carry two genuinely distinct questions; answering both is CORRECT. What is never
* correct is running both turns at once against one worktree. So a loser waits for the holder and
* then proceeds, which serialises the turns without losing a message. Dropping would trade a
* corruption bug for a silence bug, and silence is the worse failure mode in this codebase.
*
* ── FAILS OPEN, DELIBERATELY ─────────────────────────────────────────────────────────────
* Every uncertain path — unreadable lock dir, corrupt holder record, a holder that outlives the
* wait budget — resolves to "go ahead and wake". A lock that can mute an agent for good is worse
* than the overlap it prevents. That is the 3.21.5 lesson: a fix whose blast radius is "auto-wake
* stops working" is not a safe fix, however correct it looks.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getConfig } from "../config.js";
import { logger } from "../logger.js";
/** Hard ceiling on how long one lease may exclude others. Mirrors wake-claim's CLAIM_MAX_MS. */
export const WAKE_LOCK_MAX_MS = 1_800_000; // 30 min
/** Multiplier over COGENT_TIMEOUT_MS covering primary + rotated-session retry, as wake-claim does. */
const LEASE_TIMEOUT_FACTOR = 3;
/** How often a waiter re-checks. Short enough to be invisible, long enough not to spin. */
const POLL_MS = 250;
/** MUST match wake-claim / wake-inflight / credential-store — one cwd, one key, everywhere. */
function hashCwd(cwd) {
return crypto.createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16);
}
/** `~/.cogent/wake-locks/<sha256(cwd)[..16]>.json` — ONE checkout, ONE turn. */
export function wakeLockPath(cwd = process.cwd()) {
return path.join(os.homedir(), ".cogent", "wake-locks", `${hashCwd(cwd)}.json`);
}
function leaseTtlMs() {
let timeout = 300_000;
try {
const c = getConfig()?.COGENT_TIMEOUT_MS;
if (typeof c === "number" && Number.isFinite(c) && c > 0)
timeout = c;
}
catch {
/* config not loaded — documented default */
}
return Math.min(timeout * LEASE_TIMEOUT_FACTOR, WAKE_LOCK_MAX_MS);
}
/** Default budget for waiting out another process's turn: one full turn. */
function defaultMaxWaitMs() {
try {
const c = getConfig()?.COGENT_TIMEOUT_MS;
if (typeof c === "number" && Number.isFinite(c) && c > 0)
return c;
}
catch {
/* config not loaded */
}
return 300_000;
}
/**
* How many nested acquisitions this process currently holds.
*
* 🔴 WHY RE-ENTRANCY IS NOT OPTIONAL. `_injectOnly` is called from handleIncoming (auto-relay.ts:811),
* OUTSIDE the `processing` lock, so it can run while a wake in THIS SAME process holds the lease.
* Without this counter the inner acquire would treat the self-pid record as stale, steal it, and
* then delete it on release — leaving the outer wake running with no lease and letting a second
* process in. That is the hole this lock exists to close, reopened one level down.
*/
let heldDepth = 0;
/**
* Is the recorded holder still a live process?
*
* A record bearing OUR pid counts as dead ONLY when heldDepth is 0 — i.e. we are not actually
* holding it, so the file is a leak from an earlier turn. While we do hold it, re-entrancy is
* handled before this is ever consulted.
*/
function holderAlive(pid) {
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
return false;
if (pid === process.pid)
return heldDepth > 0;
try {
process.kill(pid, 0); // signal 0 = existence check only
return true;
}
catch (err) {
// EPERM means it EXISTS but belongs to another user — alive for our purposes.
return err?.code === "EPERM";
}
}
/** Read the current holder, or null when absent/corrupt (both mean "free"). */
function readHolder(p) {
try {
const held = JSON.parse(fs.readFileSync(p, "utf-8"));
if (!held || typeof held.expiresAt !== "number")
return null;
return held;
}
catch {
return null;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Become the single process running an agent turn in `cwd`, waiting out any other holder.
*
* `sessionId` is recorded in the lease for diagnostics; it is NOT part of the key (see the header).
*
* Never returns `acquired: false` today — every path either takes the lease or fails open — but the
* field is explicit so a caller reads the intent rather than assuming it.
*/
export async function acquireWakeLock(cwd = process.cwd(), sessionId = "unknown", opts = {}) {
const p = wakeLockPath(cwd);
const maxWaitMs = opts.maxWaitMs ?? defaultMaxWaitMs();
const started = Date.now();
let contended = false;
// Re-entrant: this process already holds the checkout, so nothing may block and nothing may be
// written. Only the OUTERMOST release removes the file.
if (heldDepth > 0) {
heldDepth++;
let released = false;
return {
acquired: true,
waitedMs: 0,
contended: false,
release: () => {
if (released)
return;
released = true;
heldDepth--;
},
};
}
const lease = () => {
heldDepth++;
let released = false;
return {
acquired: true,
waitedMs: Date.now() - started,
contended,
release: () => {
if (released)
return;
released = true;
heldDepth--;
if (heldDepth === 0)
releaseWakeLock(cwd);
},
};
};
/** Fail open: the caller wakes, and nothing is left behind that could mute the next turn. */
const failOpen = (why) => {
logger.warn(`wake-lock: ${why} — waking anyway (fail-open)`);
return { acquired: true, waitedMs: Date.now() - started, contended, release: () => { } };
};
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
}
catch (err) {
return failOpen(`cannot create lock dir (${err.message})`);
}
for (;;) {
const now = Date.now();
const body = JSON.stringify({
pid: process.pid,
sessionId,
startedAt: now,
expiresAt: now + leaseTtlMs(),
});
try {
fs.writeFileSync(p, body, { flag: "wx" }); // atomic: exactly one winner
if (contended) {
logger.info(`Auto-relay: waited ${Date.now() - started}ms for another bridge process to finish its ` +
`turn in this checkout before resuming — turns are serialised, not overlapping.`);
}
return lease();
}
catch (err) {
if (err?.code !== "EEXIST") {
return failOpen(`cannot write lock (${err.message})`);
}
}
// Held. Take it over when the holder is gone, expired, or unreadable — a lease that outlives
// its holder would mute this agent until the writer-stamped expiry.
const held = readHolder(p);
if (!held || held.expiresAt <= now || !holderAlive(held.pid)) {
try {
fs.writeFileSync(p, body); // steal
}
catch (err) {
return failOpen(`cannot steal abandoned lock (${err.message})`);
}
logger.debug(`wake-lock: took over an abandoned lease in ${cwd}`);
return lease();
}
if (!contended) {
contended = true;
logger.info(`Auto-relay: pid ${held.pid} is already running an agent turn in this checkout ` +
`(session ${held.sessionId}) — waiting for it rather than running a second resume over ` +
`the same worktree. Two bridge processes are live for this cwd; if that is unexpected, ` +
`check for a stale one left behind by a plugin update.`);
}
if (Date.now() - started >= maxWaitMs) {
// Budget spent and the holder is STILL alive. Proceeding risks the overlap this lock
// exists to prevent; refusing risks silence. Silence is worse — but say so loudly.
return failOpen(`pid ${held.pid} still holds this checkout after ${Date.now() - started}ms`);
}
await sleep(POLL_MS);
}
}
/** Test-only: reset the in-process nesting counter. Production code must never call this. */
export function __resetWakeLockDepthForTests() {
heldDepth = 0;
}
/** Release the lease. Best-effort; an orphan expires or is stolen when its holder dies. */
export function releaseWakeLock(cwd = process.cwd()) {
try {
fs.unlinkSync(wakeLockPath(cwd));
}
catch {
/* already gone, or never ours */
}
}
//# sourceMappingURL=wake-lock.js.map