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.

257 lines • 12.1 kB
/** * 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