@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.
- package/.env.example +5 -2
- package/docs/guides/poller-daemon-setup.md +4 -1
- package/lib/context/budget.mjs +327 -0
- package/lib/context/budget.test.mjs +252 -0
- package/lib/context/history-scope.mjs +138 -0
- package/lib/context/history-scope.test.mjs +79 -0
- package/lib/model-router/economics.mjs +9 -0
- package/lib/model-router/resolve.mjs +6 -0
- package/lib/org/inbound/facts.mjs +4 -2
- package/lib/org/inbound/hydrate.mjs +555 -51
- package/lib/org/inbound/hydrate.test.mjs +456 -1
- package/package.json +3 -1
- package/scripts/daemon/context-compiler.mjs +52 -21
- package/scripts/daemon/context-compiler.test.mjs +106 -0
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
- package/scripts/daemon/dispatcher.mjs +210 -9
- package/scripts/daemon/lib/session-router.mjs +310 -42
- package/scripts/daemon/lib/session-router.test.mjs +260 -1
- package/scripts/daemon/prompt-builder.mjs +97 -12
- package/scripts/daemon/prompt-builder.test.mjs +219 -7
- package/scripts/daemon/responder-history.test.mjs +37 -1
- package/scripts/daemon/responder.mjs +71 -69
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|