@cohortapp/agent-sdk 2.16.0 → 2.17.0

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.
@@ -19,7 +19,7 @@
19
19
  * explicitly rejects flock — "no stale lock files if the daemon dies".
20
20
  */
21
21
 
22
- import { promises as fsp } from "fs";
22
+ import { promises as fsp, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
23
23
  import { dirname } from "path";
24
24
 
25
25
  // canonicalizeSlackChannel exists in some installs of scripts/daemon/session-lock.mjs.
@@ -41,6 +41,122 @@ try {
41
41
 
42
42
  const EMPTY_REGISTRY = () => ({ sessions: {}, lru: [] });
43
43
 
44
+ /**
45
+ * Routing keys with a CLI process live on them right now, in THIS daemon.
46
+ *
47
+ * A registry row only becomes "live" when a session CLOSES cleanly, so the
48
+ * registry alone cannot answer "is something already running on this key". It
49
+ * did not have to before: every dispatch minted its own `randomUUID()`, so two
50
+ * concurrent turns in one room were two unrelated sessions. Now they share a
51
+ * key, and `#capital-formation` — 9,187 messages in 21 days, ~38 an hour,
52
+ * TOP-LEVEL, so `agent-daemon.mjs`'s thread lock (which needs a thread id, or a
53
+ * DM) does not cover it — delivers bursts of three and four at a time. Without
54
+ * this set, all of them would resume the same id: N `claude --print
55
+ * --session-id <same>` processes appending one transcript.
56
+ *
57
+ * So: a key that is in flight never RESUMEs. The later turns spawn cold, which
58
+ * is exactly the behaviour being replaced, and continuity resumes on the next
59
+ * turn after the room goes quiet. Process-local by construction — the lease is
60
+ * about OUR child processes, and a stale entry cannot outlive the process that
61
+ * holds it.
62
+ */
63
+ const inFlightKeys = new Set();
64
+
65
+ /** Claim a key for a spawn. Returns false when something already holds it. */
66
+ export function claimSession(key) {
67
+ const k = String(key || "");
68
+ if (!k || inFlightKeys.has(k)) return false;
69
+ inFlightKeys.add(k);
70
+ return true;
71
+ }
72
+
73
+ /** Release a claimed key. Safe to call twice, and for a key never claimed. */
74
+ export function releaseSession(key) {
75
+ inFlightKeys.delete(String(key || ""));
76
+ }
77
+
78
+ /** Is a CLI process live on this key in this daemon? */
79
+ export function isSessionInFlight(key) {
80
+ return inFlightKeys.has(String(key || ""));
81
+ }
82
+
83
+ /** For tests: forget every claim. */
84
+ export function _resetInFlightForTests() {
85
+ inFlightKeys.clear();
86
+ }
87
+
88
+ /**
89
+ * Services whose routing key is `<source>:<channel>[:<thread>]`.
90
+ *
91
+ * Extend by adding a name here, not by editing the key function — a new channel
92
+ * adapter with the same room+thread shape needs nothing else.
93
+ */
94
+ export const CONVERSATION_SOURCES = new Set(["cohort", "telegram", "whatsapp", "orgmail"]);
95
+
96
+ /**
97
+ * Translate a DAEMON-shaped item into the `{source, …}` view {@link routingKey}
98
+ * expects. PURE.
99
+ *
100
+ * Daemon items are `{service, channel_id, thread_id, …}`; the router's key
101
+ * function was specced against `{source, channel, thread_ts, …}` (memo §4.2).
102
+ * This is the seam between them, and it lives HERE rather than in one caller so
103
+ * the responder and the dispatcher cannot key the same conversation two ways —
104
+ * which would give one room two sessions and defeat the whole mechanism.
105
+ *
106
+ * Returns null for an item the router cannot key (a service with no shape, or a
107
+ * missing channel/thread). The caller then mints a fresh id, exactly as before.
108
+ *
109
+ * @param {object} item
110
+ * @returns {object|null}
111
+ */
112
+ export function routerItemFromDaemonItem(item) {
113
+ if (!item || typeof item !== "object") return null;
114
+
115
+ if (item.service === "slack") {
116
+ const channel = item.channel || item.channel_id;
117
+ if (!channel) return null;
118
+ return {
119
+ source: "slack",
120
+ channel,
121
+ thread_ts: item.thread_id || item.thread_ts || null,
122
+ ts: item.ts || item.timestamp || null,
123
+ };
124
+ }
125
+
126
+ if (item.service === "gmail") {
127
+ const tid = item.thread_id || item.threadId;
128
+ if (!tid) return null;
129
+ return { source: "gmail", thread_id: tid };
130
+ }
131
+
132
+ if (item.service === "calendar") {
133
+ const eid = item.event_id || item.eventId;
134
+ if (!eid) return null;
135
+ return { source: "calendar", event_id: eid };
136
+ }
137
+
138
+ // Cohort and the other room+thread services. THIS USED TO RETURN NULL FOR
139
+ // COHORT, so every turn in the agent's own workspace fell back to a fresh
140
+ // pre-minted UUID and started cold — the session router existed and the one
141
+ // surface the owner actually uses never reached it (design §3 R10).
142
+ //
143
+ // `channel_id` ONLY, never `channel`: `item.channel` is the display label
144
+ // (`task/<title>`, `doc/<name>`), and keying a session on a label collides
145
+ // two rooms that happen to share a title. A surface with no room id simply
146
+ // has no session to continue.
147
+ if (CONVERSATION_SOURCES.has(item.service)) {
148
+ const channel = item.channel_id;
149
+ if (!channel) return null;
150
+ return {
151
+ source: item.service,
152
+ channel,
153
+ thread_root_id: item.thread_root_id || item.thread_id || null,
154
+ };
155
+ }
156
+
157
+ return null;
158
+ }
159
+
44
160
  /**
45
161
  * Pure key-derivation function (memo §4.2 table).
46
162
  *
@@ -71,6 +187,25 @@ export function routingKey(item) {
71
187
  return `gmail:${tid}`;
72
188
  }
73
189
 
190
+ // Conversation-shaped services: one ROOM, optionally one thread inside it.
191
+ //
192
+ // `cohort` used to fall through to the throw below, so every Cohort turn was
193
+ // keyed by nothing, routed by nothing, and spawned cold — the agent's own
194
+ // workspace was the one surface with no continuity at all (design §3 R10).
195
+ // telegram / whatsapp / orgmail have exactly the same shape and were in the
196
+ // same position.
197
+ //
198
+ // The room is the key when there is no thread, for the same reason a Slack DM
199
+ // is: a room with no threading IS one conversation. A thread root narrows it,
200
+ // so two threads in a busy channel do not share a session and leak each
201
+ // other's context.
202
+ if (CONVERSATION_SOURCES.has(source)) {
203
+ const channel = item.channel || item.channel_id || "";
204
+ if (!channel) throw new Error(`routingKey: ${source} item missing channel`);
205
+ const thread = item.thread_root_id || item.threadRootId || item.thread_id || item.thread_ts || "";
206
+ return thread ? `${source}:${channel}:${thread}` : `${source}:${channel}`;
207
+ }
208
+
74
209
  if (source === "calendar") {
75
210
  const eid = item.event_id || item.eventId;
76
211
  if (!eid) throw new Error("routingKey: calendar item missing event_id");
@@ -91,6 +226,51 @@ export function routingKey(item) {
91
226
  throw new Error(`routingKey: unknown source "${source}"`);
92
227
  }
93
228
 
229
+ /**
230
+ * THE routing decision, as a pure function of the entry and the clock.
231
+ *
232
+ * Extracted so the async router (responder.mjs) and the SYNCHRONOUS one
233
+ * (dispatcher.mjs, whose spawn path is not async) cannot drift: one rule, two
234
+ * IO shells. Every branch is the memo §4.4 table.
235
+ *
236
+ * @param {object|undefined} entry the registry row for this key
237
+ * @param {{now:number, ttlSeconds:number}} o
238
+ * @returns {{decision:"EPHEMERAL"|"EPHEMERAL_REPLACE"|"RESUME", resumeId:string|null}}
239
+ */
240
+ export function decideRoute(entry, { now, ttlSeconds }) {
241
+ if (!entry) return { decision: "EPHEMERAL", resumeId: null };
242
+ if (now - entry.last_used_at > ttlSeconds * 1000) return { decision: "EPHEMERAL_REPLACE", resumeId: null };
243
+ if (entry.status !== "live") return { decision: "EPHEMERAL_REPLACE", resumeId: null };
244
+ if (entry.last_exit_code !== 0) return { decision: "EPHEMERAL_REPLACE", resumeId: null };
245
+ return { decision: "RESUME", resumeId: entry.claude_session_id };
246
+ }
247
+
248
+ /**
249
+ * Build the registry row for a touched key. Pure — {@link touchEntry} is the
250
+ * only place the row's shape is decided, for both IO shells.
251
+ */
252
+ export function touchEntry(existing, { key, claudeSessionId, daemonSessionId, model, now, ttlSeconds }) {
253
+ return {
254
+ daemon_session_id: daemonSessionId || (existing && existing.daemon_session_id) || null,
255
+ claude_session_id: claudeSessionId ?? (existing && existing.claude_session_id) ?? null,
256
+ key,
257
+ model: model || (existing && existing.model) || null,
258
+ created_at: (existing && existing.created_at) || new Date(now).toISOString(),
259
+ last_used_at: now,
260
+ ttl_seconds: ttlSeconds,
261
+ status: "live",
262
+ last_exit_code: 0,
263
+ };
264
+ }
265
+
266
+ /** Coerce a parsed blob into the registry shape. Shared by both IO shells. */
267
+ function asRegistry(parsed) {
268
+ if (!parsed || typeof parsed !== "object") return EMPTY_REGISTRY();
269
+ if (!parsed.sessions || typeof parsed.sessions !== "object") parsed.sessions = {};
270
+ if (!Array.isArray(parsed.lru)) parsed.lru = [];
271
+ return parsed;
272
+ }
273
+
94
274
  /**
95
275
  * Read the registry from disk. Missing file → empty registry shape.
96
276
  * Corrupted JSON also degrades to empty (memo §6: "Read failure on the
@@ -98,15 +278,17 @@ export function routingKey(item) {
98
278
  */
99
279
  async function readRegistry(path) {
100
280
  try {
101
- const raw = await fsp.readFile(path, "utf-8");
102
- const parsed = JSON.parse(raw);
103
- // Defensive: ensure shape
104
- if (!parsed || typeof parsed !== "object") return EMPTY_REGISTRY();
105
- if (!parsed.sessions || typeof parsed.sessions !== "object") parsed.sessions = {};
106
- if (!Array.isArray(parsed.lru)) parsed.lru = [];
107
- return parsed;
108
- } catch (err) {
109
- if (err && err.code === "ENOENT") return EMPTY_REGISTRY();
281
+ return asRegistry(JSON.parse(await fsp.readFile(path, "utf-8")));
282
+ } catch {
283
+ return EMPTY_REGISTRY();
284
+ }
285
+ }
286
+
287
+ /** The same read, synchronously — {@link createRouterSync} and every re-read. */
288
+ function readRegistrySync(path) {
289
+ try {
290
+ return asRegistry(JSON.parse(readFileSync(path, "utf-8")));
291
+ } catch {
110
292
  return EMPTY_REGISTRY();
111
293
  }
112
294
  }
@@ -151,8 +333,25 @@ export async function createRouter({
151
333
  throw new Error("createRouter: registryPath is required");
152
334
  }
153
335
 
154
- // Eagerly load so first call doesn't race with concurrent writers.
336
+ // RE-READ ON EVERY OPERATION. This used to load the file once and keep the
337
+ // object for the daemon's life, which was safe for exactly as long as this
338
+ // router was the only writer. It is not any more: `dispatcher.mjs` now writes
339
+ // the SAME file through `createRouterSync`, and a cached writer here did two
340
+ // destructive things with it — `touch()` wrote its stale snapshot back, and
341
+ // the LRU sweep at the end of `touch()` DELETED every key absent from that
342
+ // snapshot, so one ordinary quick reply erased the dispatcher's live session
343
+ // rows. Worse in the other direction: this shell would answer RESUME from a
344
+ // cached entry the dispatcher had already recorded as killed.
345
+ //
346
+ // A re-read costs one small JSON parse per spawn, against a process launch.
347
+ // Continuity is not worth a correctness hole, and two shells over one file
348
+ // must agree about what is in it.
155
349
  let registry = await readRegistry(registryPath);
350
+ /** Refresh from disk, so another writer's rows are never overwritten. */
351
+ function refresh() {
352
+ registry = readRegistrySync(registryPath);
353
+ return registry;
354
+ }
156
355
 
157
356
  /**
158
357
  * Decide RESUME / EPHEMERAL / EPHEMERAL_REPLACE for a key (memo §4.4).
@@ -162,24 +361,17 @@ export async function createRouter({
162
361
  * @returns {{ decision: "EPHEMERAL"|"EPHEMERAL_REPLACE"|"RESUME", resumeId: string|null }}
163
362
  */
164
363
  function route(key) {
165
- const entry = registry.sessions[key];
166
- if (!entry) return { decision: "EPHEMERAL", resumeId: null };
167
-
168
- const ttlMs = ttlSeconds * 1000;
169
- if (now() - entry.last_used_at > ttlMs) {
170
- return { decision: "EPHEMERAL_REPLACE", resumeId: null };
171
- }
172
- if (entry.status !== "live") {
173
- return { decision: "EPHEMERAL_REPLACE", resumeId: null };
364
+ // Never hand a running session's id to a second process — see inFlightKeys.
365
+ if (isSessionInFlight(key)) return { decision: "EPHEMERAL", resumeId: null };
366
+ const reg = refresh();
367
+ const entry = reg.sessions[key];
368
+ const out = decideRoute(entry, { now: now(), ttlSeconds });
369
+ if (out.decision === "RESUME") {
370
+ // Touch in-memory; persisted on next touch()/recordExit() write.
371
+ entry.last_used_at = now();
372
+ bumpLru(reg.lru, key);
174
373
  }
175
- if (entry.last_exit_code !== 0) {
176
- return { decision: "EPHEMERAL_REPLACE", resumeId: null };
177
- }
178
-
179
- // Touch in-memory; persisted on next touch()/recordExit() write.
180
- entry.last_used_at = now();
181
- bumpLru(registry.lru, key);
182
- return { decision: "RESUME", resumeId: entry.claude_session_id };
374
+ return out;
183
375
  }
184
376
 
185
377
  /**
@@ -194,20 +386,12 @@ export async function createRouter({
194
386
  */
195
387
  async function touch(key, { claudeSessionId, daemonSessionId, model } = {}) {
196
388
  const ts = now();
197
- const isoNow = new Date(ts).toISOString();
389
+ // Merge onto what is on disk NOW, not onto a snapshot from process start.
390
+ // The LRU sweep below deletes keys, so a stale base does not merely lose an
391
+ // update — it removes another writer's live sessions.
392
+ refresh();
198
393
  const existing = registry.sessions[key];
199
-
200
- const entry = {
201
- daemon_session_id: daemonSessionId || existing?.daemon_session_id || null,
202
- claude_session_id: claudeSessionId ?? existing?.claude_session_id ?? null,
203
- key,
204
- model: model || existing?.model || null,
205
- created_at: existing?.created_at || isoNow,
206
- last_used_at: ts,
207
- ttl_seconds: ttlSeconds,
208
- status: "live",
209
- last_exit_code: 0,
210
- };
394
+ const entry = touchEntry(existing, { key, claudeSessionId, daemonSessionId, model, now: ts, ttlSeconds });
211
395
  registry.sessions[key] = entry;
212
396
  bumpLru(registry.lru, key);
213
397
 
@@ -231,6 +415,7 @@ export async function createRouter({
231
415
  * next route() returns EPHEMERAL_REPLACE.
232
416
  */
233
417
  async function recordExit(key, exitCode) {
418
+ refresh();
234
419
  const entry = registry.sessions[key];
235
420
  if (!entry) return; // No-op — key was never touched.
236
421
  entry.last_exit_code = exitCode;
@@ -245,6 +430,7 @@ export async function createRouter({
245
430
  * Returns the count evicted.
246
431
  */
247
432
  async function evictExpired() {
433
+ refresh();
248
434
  const ttlMs = ttlSeconds * 1000;
249
435
  const cutoff = now() - ttlMs;
250
436
  let evicted = 0;
@@ -267,8 +453,90 @@ export async function createRouter({
267
453
  * the live registry by accident.
268
454
  */
269
455
  function _readForTests() {
270
- return JSON.parse(JSON.stringify(registry));
456
+ return JSON.parse(JSON.stringify(refresh()));
271
457
  }
272
458
 
273
459
  return { route, touch, recordExit, evictExpired, _readForTests };
274
460
  }
461
+
462
+
463
+ /**
464
+ * The SYNCHRONOUS face of the same registry.
465
+ *
466
+ * `dispatcher.mjs#spawnSession` builds its argv and calls `spawn` in one
467
+ * synchronous pass, reached from a synchronous `dispatch()`. Making that path
468
+ * async to await the router would ripple through the queue, the eviction path
469
+ * and every caller — so the router grows a sync shell over the SAME file, the
470
+ * SAME key function and the SAME pure decision ({@link decideRoute}). Two IO
471
+ * shells, one rule; a drift between them would be a bug in one place, not two.
472
+ *
473
+ * The registry is a single small JSON file and these calls happen once per
474
+ * spawn, so the blocking read is nanoseconds against a process launch. Writes
475
+ * keep the temp-file + rename pattern, so a concurrent reader never sees a
476
+ * half-written file.
477
+ *
478
+ * Every method is FAIL-OPEN: an unreadable or corrupt registry routes
479
+ * EPHEMERAL (a cold session, which is exactly today's behaviour), and a failed
480
+ * write is logged by the caller and forgotten. Session continuity is an
481
+ * optimisation; losing it must never cost the work.
482
+ *
483
+ * @param {object} opts same shape as {@link createRouter}
484
+ */
485
+ export function createRouterSync({
486
+ registryPath,
487
+ ttlSeconds = 1800,
488
+ maxLiveSessions = 8,
489
+ now = () => Date.now(),
490
+ } = {}) {
491
+ if (!registryPath) throw new Error("createRouterSync: registryPath is required");
492
+
493
+ // Missing or corrupt: an empty registry, which routes EPHEMERAL. Same posture
494
+ // as the async shell — never throw on the spawn path.
495
+ const read = () => readRegistrySync(registryPath);
496
+
497
+ function write(registry) {
498
+ try {
499
+ mkdirSync(dirname(registryPath), { recursive: true });
500
+ const tmp = `${registryPath}.tmp.${process.pid}.${Date.now()}`;
501
+ writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf-8");
502
+ renameSync(tmp, registryPath);
503
+ return true;
504
+ } catch {
505
+ // A registry we cannot persist costs continuity on the NEXT turn, nothing
506
+ // on this one. The session is already spawning.
507
+ return false;
508
+ }
509
+ }
510
+
511
+ return {
512
+ /** @returns {{decision:string, resumeId:string|null}} */
513
+ route(key) {
514
+ // Never hand a running session's id to a second process — see inFlightKeys.
515
+ if (isSessionInFlight(key)) return { decision: "EPHEMERAL", resumeId: null };
516
+ return decideRoute(read().sessions[key], { now: now(), ttlSeconds });
517
+ },
518
+ touch(key, { claudeSessionId, daemonSessionId, model } = {}) {
519
+ const registry = read();
520
+ registry.sessions[key] = touchEntry(registry.sessions[key], {
521
+ key, claudeSessionId, daemonSessionId, model, now: now(), ttlSeconds,
522
+ });
523
+ bumpLru(registry.lru, key);
524
+ while (registry.lru.length > maxLiveSessions) {
525
+ const evictKey = registry.lru.shift();
526
+ if (evictKey && evictKey !== key) delete registry.sessions[evictKey];
527
+ }
528
+ for (const k of Object.keys(registry.sessions)) {
529
+ if (!registry.lru.includes(k)) delete registry.sessions[k];
530
+ }
531
+ return write(registry);
532
+ },
533
+ recordExit(key, exitCode) {
534
+ const registry = read();
535
+ const entry = registry.sessions[key];
536
+ if (!entry) return false;
537
+ entry.last_exit_code = exitCode;
538
+ if (exitCode !== 0) entry.status = "killed";
539
+ return write(registry);
540
+ },
541
+ };
542
+ }