@essentialai/cogent-bridge 3.23.2 → 3.23.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * CROSS-PROCESS SESSION WAKE LOCK — never two `claude --resume <S>` at once in one checkout.
3
+ *
4
+ * ── THE DEFECT THIS FIXES, AND HOW IT DIFFERS FROM wake-claim.ts ─────────────────────────
5
+ * `wake-claim.ts` excludes on the MESSAGE id: it stops one message, fanned by the relay to N
6
+ * sockets of the same peer, from being answered N times. That is a real defect and it is fixed.
7
+ *
8
+ * It cannot help here. Measured on a real user (peer `marvin-coder`, 2026-09-05): the SAME peer
9
+ * was registered on the FREE relay at plugin v3.20.0 AND on the TEAM relay at v3.23.1 — same cwd,
10
+ * same thread id, two live bridge processes. Reachable because credentials are keyed on cwd ALONE
11
+ * (`~/.cogent/credentials/<sha256(cwd)>.json`, credential-store.ts:64-71); the endpoint is not part
12
+ * of the key, so a second join overwrites the file while the first process keeps running from its
13
+ * in-memory token and live WebSocket. Nothing on disk then records that the first relay is in play,
14
+ * which is why nothing reaps it.
15
+ *
16
+ * Two relays mint two DIFFERENT message ids, so both per-message claims succeed and both processes
17
+ * resume the SAME session concurrently. `scripts/wake-dedup-matrix-test.mjs --topology two-relay`
18
+ * reproduces it offline against two local relays: two wakes, one session id, 4001ms of overlap.
19
+ *
20
+ * So the exclusion has to key on the RESUME TARGET — (cwd, sessionId) — not on the message.
21
+ *
22
+ * ── WHY IT WAITS INSTEAD OF DROPPING ─────────────────────────────────────────────────────
23
+ * Two relays carry two genuinely distinct questions; answering both is CORRECT. What is never
24
+ * correct is running both turns at once against one worktree. So a loser waits for the holder and
25
+ * then proceeds, which serialises the turns without losing a message. Dropping would trade a
26
+ * corruption bug for a silence bug, and silence is the worse failure mode in this codebase.
27
+ *
28
+ * ── FAILS OPEN, DELIBERATELY ─────────────────────────────────────────────────────────────
29
+ * Every uncertain path — unreadable lock dir, corrupt holder record, a holder that outlives the
30
+ * wait budget — resolves to "go ahead and wake". A lock that can mute an agent for good is worse
31
+ * than the overlap it prevents. That is the 3.21.5 lesson: a fix whose blast radius is "auto-wake
32
+ * stops working" is not a safe fix, however correct it looks.
33
+ */
34
+ import crypto from "node:crypto";
35
+ import fs from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+ import { getConfig } from "../config.js";
39
+ import { logger } from "../logger.js";
40
+ /** Hard ceiling on how long one lease may exclude others. Mirrors wake-claim's CLAIM_MAX_MS. */
41
+ export const SESSION_WAKE_MAX_MS = 1_800_000; // 30 min
42
+ /** Multiplier over COGENT_TIMEOUT_MS covering primary + rotated-session retry, as wake-claim does. */
43
+ const LEASE_TIMEOUT_FACTOR = 3;
44
+ /** How often a waiter re-checks. Short enough to be invisible, long enough not to spin. */
45
+ const POLL_MS = 250;
46
+ /** MUST match wake-claim / wake-inflight / credential-store — one cwd, one key, everywhere. */
47
+ function hashCwd(cwd) {
48
+ return crypto.createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16);
49
+ }
50
+ const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]/g, "_");
51
+ /** `~/.cogent/wake-sessions/<sha256(cwd)[..16]>.<sessionId>.json` */
52
+ export function sessionWakeLockPath(sessionId, cwd = process.cwd()) {
53
+ return path.join(os.homedir(), ".cogent", "wake-sessions", `${hashCwd(cwd)}.${safe(sessionId)}.json`);
54
+ }
55
+ function leaseTtlMs() {
56
+ let timeout = 300_000;
57
+ try {
58
+ const c = getConfig()?.COGENT_TIMEOUT_MS;
59
+ if (typeof c === "number" && Number.isFinite(c) && c > 0)
60
+ timeout = c;
61
+ }
62
+ catch {
63
+ /* config not loaded — documented default */
64
+ }
65
+ return Math.min(timeout * LEASE_TIMEOUT_FACTOR, SESSION_WAKE_MAX_MS);
66
+ }
67
+ /** Default budget for waiting out another process's turn: one full turn. */
68
+ function defaultMaxWaitMs() {
69
+ try {
70
+ const c = getConfig()?.COGENT_TIMEOUT_MS;
71
+ if (typeof c === "number" && Number.isFinite(c) && c > 0)
72
+ return c;
73
+ }
74
+ catch {
75
+ /* config not loaded */
76
+ }
77
+ return 300_000;
78
+ }
79
+ /**
80
+ * Is the recorded holder still a live process?
81
+ *
82
+ * Our OWN pid counts as DEAD here, unlike wake-claim: in-process wakes are already serialised by
83
+ * AutoRelayService's `processing` flag, so a lease still bearing this pid is a leak from an earlier
84
+ * turn, never a concurrent one. Blocking on ourselves would deadlock the agent for the full budget.
85
+ */
86
+ function holderAlive(pid) {
87
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
88
+ return false;
89
+ if (pid === process.pid)
90
+ return false;
91
+ try {
92
+ process.kill(pid, 0); // signal 0 = existence check only
93
+ return true;
94
+ }
95
+ catch (err) {
96
+ // EPERM means it EXISTS but belongs to another user — alive for our purposes.
97
+ return err?.code === "EPERM";
98
+ }
99
+ }
100
+ /** Read the current holder, or null when absent/corrupt (both mean "free"). */
101
+ function readHolder(p) {
102
+ try {
103
+ const held = JSON.parse(fs.readFileSync(p, "utf-8"));
104
+ if (!held || typeof held.expiresAt !== "number")
105
+ return null;
106
+ return held;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
113
+ /**
114
+ * Become the single process resuming `sessionId` in `cwd`, waiting out any other holder.
115
+ *
116
+ * Never returns `acquired: false` today — every path either takes the lease or fails open — but the
117
+ * field is explicit so a caller reads the intent rather than assuming it.
118
+ */
119
+ export async function acquireSessionWake(sessionId, cwd = process.cwd(), opts = {}) {
120
+ const p = sessionWakeLockPath(sessionId, cwd);
121
+ const maxWaitMs = opts.maxWaitMs ?? defaultMaxWaitMs();
122
+ const started = Date.now();
123
+ let contended = false;
124
+ const lease = () => ({
125
+ acquired: true,
126
+ waitedMs: Date.now() - started,
127
+ contended,
128
+ release: () => releaseSessionWake(sessionId, cwd),
129
+ });
130
+ /** Fail open: the caller wakes, and nothing is left behind that could mute the next turn. */
131
+ const failOpen = (why) => {
132
+ logger.warn(`session-wake-lock: ${why} — waking anyway (fail-open)`);
133
+ return { acquired: true, waitedMs: Date.now() - started, contended, release: () => { } };
134
+ };
135
+ try {
136
+ fs.mkdirSync(path.dirname(p), { recursive: true });
137
+ }
138
+ catch (err) {
139
+ return failOpen(`cannot create lock dir (${err.message})`);
140
+ }
141
+ for (;;) {
142
+ const now = Date.now();
143
+ const body = JSON.stringify({
144
+ pid: process.pid,
145
+ sessionId,
146
+ startedAt: now,
147
+ expiresAt: now + leaseTtlMs(),
148
+ });
149
+ try {
150
+ fs.writeFileSync(p, body, { flag: "wx" }); // atomic: exactly one winner
151
+ if (contended) {
152
+ logger.info(`Auto-relay: waited ${Date.now() - started}ms for another bridge process to finish its ` +
153
+ `turn on session ${sessionId} before resuming — turns are now serialised, not overlapping.`);
154
+ }
155
+ return lease();
156
+ }
157
+ catch (err) {
158
+ if (err?.code !== "EEXIST") {
159
+ return failOpen(`cannot write lock (${err.message})`);
160
+ }
161
+ }
162
+ // Held. Take it over when the holder is gone, expired, or unreadable — a lease that outlives
163
+ // its holder would mute this agent until the writer-stamped expiry.
164
+ const held = readHolder(p);
165
+ if (!held || held.expiresAt <= now || !holderAlive(held.pid)) {
166
+ try {
167
+ fs.writeFileSync(p, body); // steal
168
+ }
169
+ catch (err) {
170
+ return failOpen(`cannot steal abandoned lock (${err.message})`);
171
+ }
172
+ logger.debug(`session-wake-lock: took over an abandoned lease for session ${sessionId}`);
173
+ return lease();
174
+ }
175
+ if (!contended) {
176
+ contended = true;
177
+ logger.info(`Auto-relay: pid ${held.pid} is already resuming session ${sessionId} in this checkout — ` +
178
+ `waiting for it rather than running a second '--resume' over the same worktree. ` +
179
+ `Two bridge processes are registered for this cwd (check for a stale one).`);
180
+ }
181
+ if (Date.now() - started >= maxWaitMs) {
182
+ // Budget spent and the holder is STILL alive. Proceeding risks the overlap this lock
183
+ // exists to prevent; refusing risks silence. Silence is worse — but say so loudly.
184
+ return failOpen(`pid ${held.pid} still holds session ${sessionId} after ${Date.now() - started}ms`);
185
+ }
186
+ await sleep(POLL_MS);
187
+ }
188
+ }
189
+ /** Release the lease. Best-effort; an orphan expires or is stolen when its holder dies. */
190
+ export function releaseSessionWake(sessionId, cwd = process.cwd()) {
191
+ try {
192
+ fs.unlinkSync(sessionWakeLockPath(sessionId, cwd));
193
+ }
194
+ catch {
195
+ /* already gone, or never ours */
196
+ }
197
+ }
198
+ //# sourceMappingURL=session-wake-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-wake-lock.js","sourceRoot":"","sources":["../../src/services/session-wake-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEtC,gGAAgG;AAChG,MAAM,CAAC,MAAM,mBAAmB,GAAG,SAAS,CAAC,CAAC,SAAS;AAEvD,sGAAsG;AACtG,MAAM,oBAAoB,GAAG,CAAC,CAAC;AAE/B,2FAA2F;AAC3F,MAAM,OAAO,GAAG,GAAG,CAAC;AASpB,+FAA+F;AAC/F,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAC1F,CAAC;AACD,MAAM,IAAI,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC;AAE/E,qEAAqE;AACrE,MAAM,UAAU,mBAAmB,CAAC,SAAiB,EAAE,MAAc,OAAO,CAAC,GAAG,EAAE;IAChF,OAAO,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,eAAe,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;AACxG,CAAC;AAED,SAAS,UAAU;IACjB,IAAI,OAAO,GAAG,OAAO,CAAC;IACtB,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,SAAS,EAAE,EAAE,iBAAiB,CAAC;QACzC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC;YAAE,OAAO,GAAG,CAAC,CAAC;IACxE,CAAC;IAAC,MAAM,CAAC;QACP,4CAA4C;IAC9C,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,OAAO,GAAG,oBAAoB,EAAE,mBAAmB,CAAC,CAAC;AACvE,CAAC;AAED,4EAA4E;AAC5E,SAAS,gBAAgB;IACvB,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,SAAS,EAAE,EAAE,iBAAiB,CAAC;QACzC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC;YAAE,OAAO,CAAC,CAAC;IACrE,CAAC;IAAC,MAAM,CAAC;QACP,uBAAuB;IACzB,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;GAMG;AACH,SAAS,WAAW,CAAC,GAAY;IAC/B,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IAChF,IAAI,GAAG,KAAK,OAAO,CAAC,GAAG;QAAE,OAAO,KAAK,CAAC;IACtC,IAAI,CAAC;QACH,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,kCAAkC;QACxD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,8EAA8E;QAC9E,OAAQ,GAA6B,EAAE,IAAI,KAAK,OAAO,CAAC;IAC1D,CAAC;AACH,CAAC;AAED,+EAA+E;AAC/E,SAAS,UAAU,CAAC,CAAS;IAC3B,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,CAAc,CAAC;QAClE,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,CAAC,SAAS,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QAC7D,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAaD,MAAM,KAAK,GAAG,CAAC,EAAU,EAAiB,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;AAEnF;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,SAAiB,EACjB,MAAc,OAAO,CAAC,GAAG,EAAE,EAC3B,OAA+B,EAAE;IAEjC,MAAM,CAAC,GAAG,mBAAmB,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IAC9C,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,gBAAgB,EAAE,CAAC;IACvD,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC3B,IAAI,SAAS,GAAG,KAAK,CAAC;IAEtB,MAAM,KAAK,GAAG,GAAqB,EAAE,CAAC,CAAC;QACrC,QAAQ,EAAE,IAAI;QACd,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO;QAC9B,SAAS;QACT,OAAO,EAAE,GAAG,EAAE,CAAC,kBAAkB,CAAC,SAAS,EAAE,GAAG,CAAC;KAClD,CAAC,CAAC;IACH,6FAA6F;IAC7F,MAAM,QAAQ,GAAG,CAAC,GAAW,EAAoB,EAAE;QACjD,MAAM,CAAC,IAAI,CAAC,sBAAsB,GAAG,8BAA8B,CAAC,CAAC;QACrE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,EAAE,GAAE,CAAC,EAAE,CAAC;IAC1F,CAAC,CAAC;IAEF,IAAI,CAAC;QACH,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACrD,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,QAAQ,CAAC,2BAA4B,GAAa,CAAC,OAAO,GAAG,CAAC,CAAC;IACxE,CAAC;IAED,SAAS,CAAC;QACR,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC;YAC1B,GAAG,EAAE,OAAO,CAAC,GAAG;YAChB,SAAS;YACT,SAAS,EAAE,GAAG;YACd,SAAS,EAAE,GAAG,GAAG,UAAU,EAAE;SACV,CAAC,CAAC;QAEvB,IAAI,CAAC;YACH,EAAE,CAAC,aAAa,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,6BAA6B;YACxE,IAAI,SAAS,EAAE,CAAC;gBACd,MAAM,CAAC,IAAI,CACT,sBAAsB,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,8CAA8C;oBACtF,mBAAmB,SAAS,+DAA+D,CAC9F,CAAC;YACJ,CAAC;YACD,OAAO,KAAK,EAAE,CAAC;QACjB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAK,GAA6B,EAAE,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACtD,OAAO,QAAQ,CAAC,sBAAuB,GAAa,CAAC,OAAO,GAAG,CAAC,CAAC;YACnE,CAAC;QACH,CAAC;QAED,6FAA6F;QAC7F,oEAAoE;QACpE,MAAM,IAAI,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC;QAC3B,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,SAAS,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YAC7D,IAAI,CAAC;gBACH,EAAE,CAAC,aAAa,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,QAAQ;YACrC,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,OAAO,QAAQ,CAAC,gCAAiC,GAAa,CAAC,OAAO,GAAG,CAAC,CAAC;YAC7E,CAAC;YACD,MAAM,CAAC,KAAK,CAAC,+DAA+D,SAAS,EAAE,CAAC,CAAC;YACzF,OAAO,KAAK,EAAE,CAAC;QACjB,CAAC;QAED,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,SAAS,GAAG,IAAI,CAAC;YACjB,MAAM,CAAC,IAAI,CACT,mBAAmB,IAAI,CAAC,GAAG,gCAAgC,SAAS,sBAAsB;gBACxF,iFAAiF;gBACjF,2EAA2E,CAC9E,CAAC;QACJ,CAAC;QACD,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,IAAI,SAAS,EAAE,CAAC;YACtC,qFAAqF;YACrF,mFAAmF;YACnF,OAAO,QAAQ,CACb,OAAO,IAAI,CAAC,GAAG,wBAAwB,SAAS,UAAU,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,IAAI,CACnF,CAAC;QACJ,CAAC;QACD,MAAM,KAAK,CAAC,OAAO,CAAC,CAAC;IACvB,CAAC;AACH,CAAC;AAED,2FAA2F;AAC3F,MAAM,UAAU,kBAAkB,CAAC,SAAiB,EAAE,MAAc,OAAO,CAAC,GAAG,EAAE;IAC/E,IAAI,CAAC;QACH,EAAE,CAAC,UAAU,CAAC,mBAAmB,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC,CAAC;IACrD,CAAC;IAAC,MAAM,CAAC;QACP,iCAAiC;IACnC,CAAC;AACH,CAAC"}
@@ -0,0 +1,14 @@
1
+ /** Hard ceiling on how long one claim may suppress other processes. Mirrors INFLIGHT_MAX_MS. */
2
+ export declare const CLAIM_MAX_MS = 1800000;
3
+ /** `~/.cogent/wake-claims/<sha256(cwd)[..16]>.<peerId>.<messageId>.json` */
4
+ export declare function wakeClaimPath(peerId: string, messageId: string, cwd?: string): string;
5
+ /**
6
+ * Try to become the single process that wakes for this message.
7
+ *
8
+ * Returns true when this process owns the wake (or when claiming was impossible — see FAILS OPEN).
9
+ * Returns false ONLY when another LIVE, UNEXPIRED process demonstrably holds it.
10
+ */
11
+ export declare function claimWake(peerId: string, messageId: string, cwd?: string): boolean;
12
+ /** Release the claim. Best-effort; an orphan expires or is stolen when its holder dies. */
13
+ export declare function releaseWake(peerId: string, messageId: string, cwd?: string): void;
14
+ //# sourceMappingURL=wake-claim.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wake-claim.d.ts","sourceRoot":"","sources":["../../src/services/wake-claim.ts"],"names":[],"mappings":"AA4CA,gGAAgG;AAChG,eAAO,MAAM,YAAY,UAAY,CAAC;AAiBtC,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,GAAE,MAAsB,GAAG,MAAM,CAOpG;AA0BD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,GAAE,MAAsB,GAAG,OAAO,CAqCjG;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,GAAE,MAAsB,GAAG,IAAI,CAMhG"}
@@ -0,0 +1,139 @@
1
+ /**
2
+ * CROSS-PROCESS WAKE EXCLUSION — one wake per message, however many bridges are listening.
3
+ *
4
+ * ── THE DEFECT (peer marvin-coder, 2026-09-05) ──────────────────────────────────────────
5
+ * Four commits with identical titles on main, plus another session's note: "the duplicate-work
6
+ * prediction came true — five identical commits". Mechanism, read out of the relay source rather
7
+ * than inferred: `ConnectionManager` holds `sessionId -> peerId -> PeerConnection[]` — an ARRAY —
8
+ * and `sendToPeer` iterates it, sending to EVERY connection. That array is deliberate (a reconnect
9
+ * keeps the old socket briefly), but it means N bridge processes registered as the SAME peer each
10
+ * receive the SAME message. Each consults its own in-memory queue — empty in each, because the
11
+ * queue is per-process — and each runs `claude --resume <the same threadId>` in the same cwd.
12
+ * N copies, same work, same files.
13
+ *
14
+ * The existing wake-inflight marker cannot help: rail B only WRITES it (auto-relay.ts:931) as a
15
+ * signal to rail C, and never reads it back to decide whether to wake.
16
+ *
17
+ * ── WHY A FILE, AND WHY `wx` ─────────────────────────────────────────────────────────────
18
+ * The competing writers are separate OS processes, so the exclusion must live outside all of them.
19
+ * `writeFileSync(..., { flag: "wx" })` is an atomic exclusive create: exactly one caller can win,
20
+ * with no read-modify-write window. That is the same primitive peer-registry already uses for
21
+ * local locks, including its stale-holder detection via `process.kill(pid, 0)`.
22
+ *
23
+ * Homedir-keyed, never COGENT_STATE_PATH: the competing processes may be launched with different
24
+ * env (the documented trap for COGENT_CHECK_ON_STOP), and an exclusion the other process cannot
25
+ * see is not an exclusion.
26
+ *
27
+ * ── PER MESSAGE, NOT PER CWD ─────────────────────────────────────────────────────────────
28
+ * A per-cwd lock would prevent more collisions, and would also LOSE messages: both processes
29
+ * receive every message, so the loser of a cwd-wide lock would silently drop a message it was the
30
+ * only one free to handle. Per-message dedups the actual defect (the same message delivered N
31
+ * times) while letting two processes legitimately split two different messages.
32
+ *
33
+ * ── FAILS OPEN, DELIBERATELY ─────────────────────────────────────────────────────────────
34
+ * If a claim cannot be written or read, we WAKE. Duplicated work is visible and recoverable; a
35
+ * silently dropped message is neither, and silence is the worst failure mode in this codebase.
36
+ * This trades the fix's guarantee for the product's guarantee, on purpose.
37
+ */
38
+ import crypto from "node:crypto";
39
+ import fs from "node:fs";
40
+ import os from "node:os";
41
+ import path from "node:path";
42
+ import { getConfig } from "../config.js";
43
+ import { logger } from "../logger.js";
44
+ /** Hard ceiling on how long one claim may suppress other processes. Mirrors INFLIGHT_MAX_MS. */
45
+ export const CLAIM_MAX_MS = 1_800_000; // 30 min
46
+ /** Multiplier over COGENT_TIMEOUT_MS covering primary + rotated-session retry, as wake-inflight does. */
47
+ const CLAIM_TIMEOUT_FACTOR = 3;
48
+ /** MUST match wake-inflight.ts hashCwd / credential-store — one cwd, one key, everywhere. */
49
+ function hashCwd(cwd) {
50
+ return crypto.createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16);
51
+ }
52
+ const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]/g, "_");
53
+ /** `~/.cogent/wake-claims/<sha256(cwd)[..16]>.<peerId>.<messageId>.json` */
54
+ export function wakeClaimPath(peerId, messageId, cwd = process.cwd()) {
55
+ return path.join(os.homedir(), ".cogent", "wake-claims", `${hashCwd(cwd)}.${safe(peerId)}.${safe(messageId)}.json`);
56
+ }
57
+ function ttlMs() {
58
+ let timeout = 300_000;
59
+ try {
60
+ const c = getConfig()?.COGENT_TIMEOUT_MS;
61
+ if (typeof c === "number" && Number.isFinite(c) && c > 0)
62
+ timeout = c;
63
+ }
64
+ catch {
65
+ /* config not loaded — documented default */
66
+ }
67
+ return Math.min(timeout * CLAIM_TIMEOUT_FACTOR, CLAIM_MAX_MS);
68
+ }
69
+ /** Is the recorded holder still a live process? Unknown/odd values count as DEAD (steal). */
70
+ function holderAlive(pid) {
71
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
72
+ return false;
73
+ if (pid === process.pid)
74
+ return true;
75
+ try {
76
+ process.kill(pid, 0); // signal 0 = existence check only
77
+ return true;
78
+ }
79
+ catch (err) {
80
+ // EPERM means it EXISTS but belongs to another user — alive for our purposes.
81
+ return err?.code === "EPERM";
82
+ }
83
+ }
84
+ /**
85
+ * Try to become the single process that wakes for this message.
86
+ *
87
+ * Returns true when this process owns the wake (or when claiming was impossible — see FAILS OPEN).
88
+ * Returns false ONLY when another LIVE, UNEXPIRED process demonstrably holds it.
89
+ */
90
+ export function claimWake(peerId, messageId, cwd = process.cwd()) {
91
+ const p = wakeClaimPath(peerId, messageId, cwd);
92
+ const now = Date.now();
93
+ const body = () => JSON.stringify({ pid: process.pid, startedAt: now, expiresAt: now + ttlMs() });
94
+ try {
95
+ fs.mkdirSync(path.dirname(p), { recursive: true });
96
+ try {
97
+ fs.writeFileSync(p, body(), { flag: "wx" }); // atomic: exactly one winner
98
+ return true;
99
+ }
100
+ catch (err) {
101
+ if (err?.code !== "EEXIST")
102
+ throw err;
103
+ }
104
+ // Someone holds it. Take it over ONLY if they are gone or their claim has expired — a claim
105
+ // that outlives its holder would mute this peer for good, which is worse than duplicating.
106
+ let held = null;
107
+ try {
108
+ held = JSON.parse(fs.readFileSync(p, "utf-8"));
109
+ }
110
+ catch {
111
+ held = null; // corrupt → treat as abandoned
112
+ }
113
+ const expired = !held || typeof held.expiresAt !== "number" || held.expiresAt <= now;
114
+ if (!held || expired || !holderAlive(held.pid)) {
115
+ fs.writeFileSync(p, body()); // steal
116
+ logger.debug(`wake-claim: took over an abandoned claim for ${messageId}`);
117
+ return true;
118
+ }
119
+ logger.info(`Auto-relay: SKIPPING wake for message ${messageId} — pid ${held.pid} in this cwd already has it. ` +
120
+ `Another bridge process is registered as "${peerId}"; without this both would resume the same ` +
121
+ `session and duplicate the work.`);
122
+ return false;
123
+ }
124
+ catch (err) {
125
+ // FAIL OPEN. Never lose a message to a broken lock.
126
+ logger.warn(`wake-claim: could not claim ${messageId} (${err.message}) — waking anyway`);
127
+ return true;
128
+ }
129
+ }
130
+ /** Release the claim. Best-effort; an orphan expires or is stolen when its holder dies. */
131
+ export function releaseWake(peerId, messageId, cwd = process.cwd()) {
132
+ try {
133
+ fs.unlinkSync(wakeClaimPath(peerId, messageId, cwd));
134
+ }
135
+ catch {
136
+ /* already gone, or never ours */
137
+ }
138
+ }
139
+ //# sourceMappingURL=wake-claim.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wake-claim.js","sourceRoot":"","sources":["../../src/services/wake-claim.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ /** Hard ceiling on how long one lease may exclude others. Mirrors wake-claim's CLAIM_MAX_MS. */
2
+ export declare const WAKE_LOCK_MAX_MS = 1800000;
3
+ /** `~/.cogent/wake-locks/<sha256(cwd)[..16]>.json` — ONE checkout, ONE turn. */
4
+ export declare function wakeLockPath(cwd?: string): string;
5
+ export interface WakeLease {
6
+ /** True whenever the caller may wake — including every fail-open path. */
7
+ acquired: boolean;
8
+ /** Milliseconds spent waiting for another process. 0 when uncontended. */
9
+ waitedMs: number;
10
+ /** True when another live process held the lease and we had to wait for it. */
11
+ contended: boolean;
12
+ /** Idempotent. Safe to call even when the lease was never really taken. */
13
+ release: () => void;
14
+ }
15
+ /**
16
+ * Become the single process running an agent turn in `cwd`, waiting out any other holder.
17
+ *
18
+ * `sessionId` is recorded in the lease for diagnostics; it is NOT part of the key (see the header).
19
+ *
20
+ * Never returns `acquired: false` today — every path either takes the lease or fails open — but the
21
+ * field is explicit so a caller reads the intent rather than assuming it.
22
+ */
23
+ export declare function acquireWakeLock(cwd?: string, sessionId?: string, opts?: {
24
+ maxWaitMs?: number;
25
+ }): Promise<WakeLease>;
26
+ /** Test-only: reset the in-process nesting counter. Production code must never call this. */
27
+ export declare function __resetWakeLockDepthForTests(): void;
28
+ /** Release the lease. Best-effort; an orphan expires or is stolen when its holder dies. */
29
+ export declare function releaseWakeLock(cwd?: string): void;
30
+ //# sourceMappingURL=wake-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wake-lock.d.ts","sourceRoot":"","sources":["../../src/services/wake-lock.ts"],"names":[],"mappings":"AAuDA,gGAAgG;AAChG,eAAO,MAAM,gBAAgB,UAAY,CAAC;AAmB1C,gFAAgF;AAChF,wBAAgB,YAAY,CAAC,GAAG,GAAE,MAAsB,GAAG,MAAM,CAEhE;AAiED,MAAM,WAAW,SAAS;IACxB,0EAA0E;IAC1E,QAAQ,EAAE,OAAO,CAAC;IAClB,0EAA0E;IAC1E,QAAQ,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,SAAS,EAAE,OAAO,CAAC;IACnB,2EAA2E;IAC3E,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAID;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,GAAG,GAAE,MAAsB,EAC3B,SAAS,SAAY,EACrB,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAChC,OAAO,CAAC,SAAS,CAAC,CAyGpB;AAED,6FAA6F;AAC7F,wBAAgB,4BAA4B,IAAI,IAAI,CAEnD;AAED,2FAA2F;AAC3F,wBAAgB,eAAe,CAAC,GAAG,GAAE,MAAsB,GAAG,IAAI,CAMjE"}