jeopi-cli 16.6.13 → 16.6.14
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/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/cli.js +4034 -4034
- package/dist/types/cli/messenger-cli.d.ts +4 -1
- package/dist/types/messenger/gateway.d.ts +7 -0
- package/dist/types/messenger/session-pool.d.ts +38 -0
- package/dist/types/messenger/session-worker.d.ts +49 -0
- package/package.json +12 -12
- package/src/cli/messenger-cli.ts +22 -24
- package/src/cli.ts +13 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/messenger/gateway.ts +130 -77
- package/src/messenger/session-pool.ts +332 -0
- package/src/messenger/session-worker.ts +79 -0
- package/src/messenger/session.ts +2 -4
package/src/messenger/gateway.ts
CHANGED
|
@@ -177,6 +177,24 @@ export function messengerConversationKey(message: MessengerMessage): string {
|
|
|
177
177
|
]);
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/** Stable per-conversation route shared by the persisted session file and its session process. */
|
|
181
|
+
export function messengerSessionRoute(cwd: string, message: MessengerMessage): string {
|
|
182
|
+
return new Bun.CryptoHasher("sha256").update(JSON.stringify([cwd, messengerConversationKey(message)])).digest("hex");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Scheduling heuristic, refined after `prepare`: a top-level server message on a prepare-capable adapter
|
|
187
|
+
* (Discord) starts its own reply thread, so it is never held behind queued work; every other message waits for
|
|
188
|
+
* earlier requests in its conversation. A follow-up arriving in that new thread is ordered behind the request
|
|
189
|
+
* that created it by the prepared-conversation lock in `processRequest`.
|
|
190
|
+
*/
|
|
191
|
+
function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
|
|
192
|
+
if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
|
|
193
|
+
return JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
|
|
194
|
+
}
|
|
195
|
+
return messengerConversationKey(message);
|
|
196
|
+
}
|
|
197
|
+
|
|
180
198
|
export function isMessengerMessageAllowed(message: MessengerMessage, config: MessengerConfig): boolean {
|
|
181
199
|
const platform = config.platforms[message.platform];
|
|
182
200
|
return Boolean(
|
|
@@ -201,6 +219,8 @@ interface PendingMessage {
|
|
|
201
219
|
adapter: MessengerAdapter;
|
|
202
220
|
signal: AbortSignal;
|
|
203
221
|
resolve: () => void;
|
|
222
|
+
/** Requests sharing a key never run at the same time. */
|
|
223
|
+
key: string;
|
|
204
224
|
}
|
|
205
225
|
|
|
206
226
|
export interface MessengerGateway {
|
|
@@ -211,6 +231,11 @@ export interface MessengerGateway {
|
|
|
211
231
|
export interface MessengerGatewayOptions {
|
|
212
232
|
/** Minimum spacing between in-place activity edits for one request. */
|
|
213
233
|
activityIntervalMs?: number;
|
|
234
|
+
/**
|
|
235
|
+
* Requests processed at once (default 1). Requests of one prepared conversation still answer and deliver in
|
|
236
|
+
* order; above 1 the reply implementation must accept concurrent turns for different conversations.
|
|
237
|
+
*/
|
|
238
|
+
concurrency?: number;
|
|
214
239
|
}
|
|
215
240
|
|
|
216
241
|
export function createMessengerGateway(
|
|
@@ -220,6 +245,7 @@ export function createMessengerGateway(
|
|
|
220
245
|
options: MessengerGatewayOptions = {},
|
|
221
246
|
): MessengerGateway {
|
|
222
247
|
const activityIntervalMs = Math.max(0, options.activityIntervalMs ?? ACTIVITY_INTERVAL_MS);
|
|
248
|
+
const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 1));
|
|
223
249
|
const byPlatform = new Map(adapters.map(adapter => [adapter.platform, adapter]));
|
|
224
250
|
if (byPlatform.size !== adapters.length) throw new Error("Duplicate messenger adapter");
|
|
225
251
|
for (const platform of Object.keys(config.platforms)) {
|
|
@@ -228,90 +254,120 @@ export function createMessengerGateway(
|
|
|
228
254
|
}
|
|
229
255
|
const seen = new Set<string>();
|
|
230
256
|
const queue: PendingMessage[] = [];
|
|
257
|
+
const activeKeys = new Set<string>();
|
|
258
|
+
const turns = new Set<Promise<void>>();
|
|
259
|
+
/** Settles when the latest request of a prepared conversation has delivered its reply. */
|
|
260
|
+
const conversationTails = new Map<string, Promise<void>>();
|
|
231
261
|
const lifetime = new AbortController();
|
|
232
|
-
let draining: Promise<void> | undefined;
|
|
233
|
-
let workerActive = false;
|
|
234
262
|
let running = false;
|
|
235
263
|
let fatalError: MessengerFatalError | undefined;
|
|
236
264
|
let statusInFlight = 0;
|
|
237
265
|
|
|
238
|
-
async function
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
await Promise.race([operation, stopped.promise]);
|
|
268
|
-
} catch {
|
|
269
|
-
if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
|
|
270
|
-
} finally {
|
|
271
|
-
clearTimeout(timer);
|
|
272
|
-
statusSignal.removeEventListener("abort", onAbort);
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
};
|
|
276
|
-
try {
|
|
277
|
-
if (signal.aborted) continue;
|
|
278
|
-
status("processing");
|
|
279
|
-
const destination = (await adapter.prepare?.(message, signal)) ?? message;
|
|
280
|
-
activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
|
|
281
|
-
let text: string;
|
|
266
|
+
async function processRequest(pending: PendingMessage): Promise<void> {
|
|
267
|
+
const { message, adapter, signal, resolve } = pending;
|
|
268
|
+
let failed = false;
|
|
269
|
+
let activity = NO_ACTIVITY;
|
|
270
|
+
let release = (): void => {};
|
|
271
|
+
let tail: Promise<void> | undefined;
|
|
272
|
+
let conversation = "";
|
|
273
|
+
let statusChain = Promise.resolve();
|
|
274
|
+
const status = (state: "processing" | "success" | "failure"): void => {
|
|
275
|
+
if (!adapter.status) return;
|
|
276
|
+
statusChain = statusChain.then(async () => {
|
|
277
|
+
if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
|
|
278
|
+
const timeout = new AbortController();
|
|
279
|
+
const statusSignal = AbortSignal.any([signal, timeout.signal]);
|
|
280
|
+
const stopped = Promise.withResolvers<void>();
|
|
281
|
+
const onAbort = (): void => stopped.resolve();
|
|
282
|
+
statusSignal.addEventListener("abort", onAbort, { once: true });
|
|
283
|
+
statusInFlight++;
|
|
284
|
+
const operation = Promise.resolve().then(() => adapter.status!(message, state, statusSignal));
|
|
285
|
+
void operation.then(
|
|
286
|
+
() => {
|
|
287
|
+
statusInFlight--;
|
|
288
|
+
},
|
|
289
|
+
() => {
|
|
290
|
+
statusInFlight--;
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
|
|
282
294
|
try {
|
|
283
|
-
|
|
284
|
-
} catch
|
|
285
|
-
failed
|
|
286
|
-
if (error instanceof MessengerFatalError) {
|
|
287
|
-
fatalError = error;
|
|
288
|
-
status("failure");
|
|
289
|
-
lifetime.abort();
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
if (signal.aborted) continue;
|
|
293
|
-
logger.warn("Messenger agent turn failed", { platform: message.platform });
|
|
294
|
-
text = "The request could not be completed. Please try again.";
|
|
295
|
+
await Promise.race([operation, stopped.promise]);
|
|
296
|
+
} catch {
|
|
297
|
+
if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
|
|
295
298
|
} finally {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
if (!signal.aborted && text.trim()) {
|
|
300
|
-
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
301
|
-
await adapter.send(destination, text, signal);
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
statusSignal.removeEventListener("abort", onAbort);
|
|
302
301
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
if (signal.aborted) return;
|
|
306
|
+
status("processing");
|
|
307
|
+
const destination = (await adapter.prepare?.(message, signal)) ?? message;
|
|
308
|
+
// Answer and deliver in arrival order per prepared conversation, so a follow-up in a new thread can
|
|
309
|
+
// neither overtake nor interleave with the reply of the request that created it.
|
|
310
|
+
conversation = messengerConversationKey(destination);
|
|
311
|
+
const previous = conversationTails.get(conversation);
|
|
312
|
+
const done = Promise.withResolvers<void>();
|
|
313
|
+
release = done.resolve;
|
|
314
|
+
tail = done.promise;
|
|
315
|
+
conversationTails.set(conversation, tail);
|
|
316
|
+
await previous;
|
|
317
|
+
activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
|
|
318
|
+
let text: string;
|
|
319
|
+
try {
|
|
320
|
+
text = await reply(destination, signal, activity.report);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
failed = true;
|
|
323
|
+
if (error instanceof MessengerFatalError) {
|
|
324
|
+
fatalError = error;
|
|
307
325
|
status("failure");
|
|
326
|
+
lifetime.abort();
|
|
327
|
+
return;
|
|
308
328
|
}
|
|
329
|
+
if (signal.aborted) return;
|
|
330
|
+
logger.warn("Messenger agent turn failed", { platform: message.platform });
|
|
331
|
+
text = "The request could not be completed. Please try again.";
|
|
309
332
|
} finally {
|
|
310
|
-
|
|
311
|
-
|
|
333
|
+
// Retire the status line alongside reply delivery rather than delaying the reply on it.
|
|
334
|
+
void activity.close();
|
|
312
335
|
}
|
|
336
|
+
if (!signal.aborted && text.trim()) {
|
|
337
|
+
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
338
|
+
await adapter.send(destination, text, signal);
|
|
339
|
+
}
|
|
340
|
+
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
341
|
+
} catch {
|
|
342
|
+
if (!signal.aborted) {
|
|
343
|
+
logger.warn("Messenger reply delivery failed", { platform: message.platform });
|
|
344
|
+
status("failure");
|
|
345
|
+
}
|
|
346
|
+
} finally {
|
|
347
|
+
await activity.close();
|
|
348
|
+
release();
|
|
349
|
+
if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
|
|
350
|
+
resolve();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Start queued requests in arrival order while capacity allows, skipping conversations already running. */
|
|
355
|
+
function schedule(): void {
|
|
356
|
+
for (let index = 0; index < queue.length && turns.size < concurrency; ) {
|
|
357
|
+
const pending = queue[index]!;
|
|
358
|
+
if (activeKeys.has(pending.key)) {
|
|
359
|
+
index++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
queue.splice(index, 1);
|
|
363
|
+
activeKeys.add(pending.key);
|
|
364
|
+
const turn = processRequest(pending).finally(() => {
|
|
365
|
+
activeKeys.delete(pending.key);
|
|
366
|
+
turns.delete(turn);
|
|
367
|
+
schedule();
|
|
368
|
+
});
|
|
369
|
+
turns.add(turn);
|
|
313
370
|
}
|
|
314
|
-
workerActive = false;
|
|
315
371
|
}
|
|
316
372
|
|
|
317
373
|
function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
|
|
@@ -326,11 +382,8 @@ export function createMessengerGateway(
|
|
|
326
382
|
if (oldest !== undefined) seen.delete(oldest);
|
|
327
383
|
}
|
|
328
384
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
329
|
-
queue.push({ message, adapter, signal: effectiveSignal, resolve });
|
|
330
|
-
|
|
331
|
-
workerActive = true;
|
|
332
|
-
draining = drain();
|
|
333
|
-
}
|
|
385
|
+
queue.push({ message, adapter, signal: effectiveSignal, resolve, key: schedulingKey(message, adapter) });
|
|
386
|
+
schedule();
|
|
334
387
|
return promise;
|
|
335
388
|
}
|
|
336
389
|
|
|
@@ -362,7 +415,7 @@ export function createMessengerGateway(
|
|
|
362
415
|
} finally {
|
|
363
416
|
lifetime.abort();
|
|
364
417
|
await Promise.allSettled(starts);
|
|
365
|
-
await
|
|
418
|
+
while (turns.size > 0) await Promise.allSettled(turns);
|
|
366
419
|
signal.removeEventListener("abort", abort);
|
|
367
420
|
}
|
|
368
421
|
},
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
createWorkerHandle,
|
|
4
|
+
createWorkerSubprocess,
|
|
5
|
+
resolveWorkerSpawnCmd,
|
|
6
|
+
SMOKE_TEST_TIMEOUT_MS,
|
|
7
|
+
smokeTestWorker,
|
|
8
|
+
type WorkerHandle,
|
|
9
|
+
workerEnvFromParent,
|
|
10
|
+
} from "../subprocess/worker-client";
|
|
11
|
+
import { messengerSessionRoute } from "./gateway";
|
|
12
|
+
import type { MessengerSessionInbound, MessengerSessionOutbound } from "./session-worker";
|
|
13
|
+
import type { MessengerActivityReporter, MessengerConfig, MessengerMessage, MessengerReply } from "./types";
|
|
14
|
+
|
|
15
|
+
/** Hidden CLI selector that re-enters jeopi as one conversation's session process (dispatched in `cli.ts`). */
|
|
16
|
+
export const MESSENGER_SESSION_WORKER_ARG = "__omp_worker_messenger_session";
|
|
17
|
+
|
|
18
|
+
const MAX_SESSION_PROCESSES = 8;
|
|
19
|
+
const SESSION_IDLE_MS = 10 * 60_000;
|
|
20
|
+
/** Bound on a session process flushing and disposing its session before it is killed. */
|
|
21
|
+
const SESSION_CLOSE_TIMEOUT_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
/** One conversation's jeopi session process, as the pool sees it. */
|
|
24
|
+
export interface MessengerSessionProcess {
|
|
25
|
+
send(message: MessengerSessionInbound): void;
|
|
26
|
+
onMessage(handler: (message: MessengerSessionOutbound) => void): () => void;
|
|
27
|
+
/** Settles when the process has exited for any reason. */
|
|
28
|
+
exited: Promise<unknown>;
|
|
29
|
+
kill(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Session processes start without the configured platform token variables. Not OS isolation: tools run as the same user. */
|
|
33
|
+
function sessionEnv(config: MessengerConfig): Record<string, string> {
|
|
34
|
+
const secrets = new Set<string>();
|
|
35
|
+
for (const platform of Object.values(config.platforms)) {
|
|
36
|
+
if (!platform) continue;
|
|
37
|
+
secrets.add(platform.tokenEnv);
|
|
38
|
+
if (platform.appTokenEnv) secrets.add(platform.appTokenEnv);
|
|
39
|
+
}
|
|
40
|
+
return Object.fromEntries(Object.entries(workerEnvFromParent()).filter(([key]) => !secrets.has(key)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function spawnSessionHandle(
|
|
44
|
+
env: Record<string, string>,
|
|
45
|
+
): WorkerHandle<MessengerSessionInbound, MessengerSessionOutbound> & { exited: Promise<unknown> } {
|
|
46
|
+
const spawned = createWorkerSubprocess<MessengerSessionOutbound>({
|
|
47
|
+
spawnCommand: resolveWorkerSpawnCmd(MESSENGER_SESSION_WORKER_ARG),
|
|
48
|
+
env,
|
|
49
|
+
exitLabel: "messenger session process",
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
...createWorkerHandle<MessengerSessionInbound, MessengerSessionOutbound>(spawned, message =>
|
|
53
|
+
spawned.proc.send(message),
|
|
54
|
+
),
|
|
55
|
+
exited: spawned.proc.exited,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Spawn a real session process (the pool's default). */
|
|
60
|
+
export function spawnMessengerSessionProcess(config: MessengerConfig): MessengerSessionProcess {
|
|
61
|
+
const handle = spawnSessionHandle(sessionEnv(config));
|
|
62
|
+
return {
|
|
63
|
+
send: handle.send,
|
|
64
|
+
onMessage: handler => handle.onMessage(handler),
|
|
65
|
+
exited: handle.exited,
|
|
66
|
+
kill: () => void handle.terminate(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `jeopi --smoke-test` probe: the session process loads its module graph and answers over IPC. */
|
|
71
|
+
export async function smokeTestMessengerSessionWorker({
|
|
72
|
+
timeoutMs = SMOKE_TEST_TIMEOUT_MS,
|
|
73
|
+
}: {
|
|
74
|
+
timeoutMs?: number;
|
|
75
|
+
} = {}): Promise<void> {
|
|
76
|
+
await smokeTestWorker(spawnSessionHandle(workerEnvFromParent()), "messenger session process", timeoutMs);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MessengerSessionPoolOptions {
|
|
80
|
+
/** Resident session processes; at the limit a new conversation retires the least recently used idle one. */
|
|
81
|
+
maxProcesses?: number;
|
|
82
|
+
/** Idle time after which a conversation's process exits; its session file keeps the history. */
|
|
83
|
+
idleMs?: number;
|
|
84
|
+
/** Test seam: start one conversation's session process. */
|
|
85
|
+
spawn?: (config: MessengerConfig) => MessengerSessionProcess;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface MessengerSessionPool {
|
|
89
|
+
reply: MessengerReply;
|
|
90
|
+
close(): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface LiveSession {
|
|
94
|
+
process: MessengerSessionProcess;
|
|
95
|
+
/** Rejects the in-flight turn when the process dies under it. */
|
|
96
|
+
failTurn?: (error: Error) => void;
|
|
97
|
+
lastUsed: number;
|
|
98
|
+
/** Its capacity slot was returned; set once. */
|
|
99
|
+
released: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface Lane {
|
|
103
|
+
route: string;
|
|
104
|
+
/** Settles after this conversation's latest queued job: jobs run strictly one after another. */
|
|
105
|
+
tail: Promise<unknown>;
|
|
106
|
+
/** Turns queued or running; idle retirement waits for zero. */
|
|
107
|
+
turns: number;
|
|
108
|
+
live?: LiveSession;
|
|
109
|
+
idle?: NodeJS.Timeout;
|
|
110
|
+
/** Already chosen to free a slot, so concurrent starts pick different victims. */
|
|
111
|
+
evicting?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Give every conversation (a Discord thread or DM, a Telegram chat or topic, a Slack thread) its own jeopi
|
|
116
|
+
* session process: the first message starts it, later turns reuse it, and it exits after idling; the next
|
|
117
|
+
* message resumes the persisted session in a fresh process. Turns of one conversation run strictly in order
|
|
118
|
+
* (including retirement), while different conversations run concurrently. At most `maxProcesses` processes
|
|
119
|
+
* are resident: a new conversation waits for the least recently used idle one to retire, or for a slot.
|
|
120
|
+
*/
|
|
121
|
+
export function createMessengerSessionPool(
|
|
122
|
+
config: MessengerConfig,
|
|
123
|
+
options: MessengerSessionPoolOptions = {},
|
|
124
|
+
): MessengerSessionPool {
|
|
125
|
+
const maxProcesses = Math.max(1, options.maxProcesses ?? MAX_SESSION_PROCESSES);
|
|
126
|
+
const idleMs = Math.max(0, options.idleMs ?? SESSION_IDLE_MS);
|
|
127
|
+
const spawn = options.spawn ?? spawnMessengerSessionProcess;
|
|
128
|
+
const cwd = path.resolve(config.cwd);
|
|
129
|
+
const lanes = new Map<string, Lane>();
|
|
130
|
+
const waiters = new Set<() => void>();
|
|
131
|
+
let resident = 0;
|
|
132
|
+
let nextId = 0;
|
|
133
|
+
let closed = false;
|
|
134
|
+
let closing: Promise<void> | undefined;
|
|
135
|
+
|
|
136
|
+
const enqueue = <T>(lane: Lane, job: () => Promise<T>): Promise<T> => {
|
|
137
|
+
const run = lane.tail.then(job);
|
|
138
|
+
lane.tail = run.catch(() => {});
|
|
139
|
+
return run;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** A slot freed or a conversation went idle: let waiting starts re-check capacity. */
|
|
143
|
+
const capacityChanged = (): void => {
|
|
144
|
+
for (const wake of [...waiters]) wake();
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const release = (live: LiveSession): void => {
|
|
148
|
+
if (live.released) return;
|
|
149
|
+
live.released = true;
|
|
150
|
+
resident--;
|
|
151
|
+
capacityChanged();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Let the process flush and dispose its session, then kill it; the session file stays for resumption. */
|
|
155
|
+
const retire = async (lane: Lane): Promise<void> => {
|
|
156
|
+
const live = lane.live;
|
|
157
|
+
if (!live) return;
|
|
158
|
+
lane.live = undefined;
|
|
159
|
+
const id = String(++nextId);
|
|
160
|
+
const acknowledged = Promise.withResolvers<void>();
|
|
161
|
+
const unsubscribe = live.process.onMessage(event => {
|
|
162
|
+
if (event.type === "closed" && event.id === id) acknowledged.resolve();
|
|
163
|
+
});
|
|
164
|
+
const timer = setTimeout(acknowledged.resolve, SESSION_CLOSE_TIMEOUT_MS);
|
|
165
|
+
try {
|
|
166
|
+
live.process.send({ type: "close", id });
|
|
167
|
+
await Promise.race([acknowledged.promise, live.process.exited]);
|
|
168
|
+
} catch {
|
|
169
|
+
// The process is already gone; killing it below is still safe.
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
unsubscribe();
|
|
173
|
+
live.process.kill();
|
|
174
|
+
release(live);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const retireIfIdle = async (lane: Lane): Promise<void> => {
|
|
179
|
+
try {
|
|
180
|
+
if (lane.turns > 0) return;
|
|
181
|
+
await retire(lane);
|
|
182
|
+
if (lane.turns === 0 && !lane.live && lanes.get(lane.route) === lane) {
|
|
183
|
+
clearTimeout(lane.idle);
|
|
184
|
+
lanes.delete(lane.route);
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
lane.evicting = false;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const leastRecentlyUsedIdle = (): Lane | undefined => {
|
|
192
|
+
let victim: Lane | undefined;
|
|
193
|
+
let oldest = Number.POSITIVE_INFINITY;
|
|
194
|
+
for (const lane of lanes.values()) {
|
|
195
|
+
if (!lane.live || lane.turns > 0 || lane.evicting || lane.live.lastUsed >= oldest) continue;
|
|
196
|
+
oldest = lane.live.lastUsed;
|
|
197
|
+
victim = lane;
|
|
198
|
+
}
|
|
199
|
+
return victim;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const waitForCapacity = async (signal: AbortSignal): Promise<void> => {
|
|
203
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
204
|
+
const wake = (): void => resolve();
|
|
205
|
+
waiters.add(wake);
|
|
206
|
+
signal.addEventListener("abort", wake, { once: true });
|
|
207
|
+
try {
|
|
208
|
+
await promise;
|
|
209
|
+
} finally {
|
|
210
|
+
waiters.delete(wake);
|
|
211
|
+
signal.removeEventListener("abort", wake);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** Start a conversation's process without exceeding `maxProcesses`; the check and spawn are atomic. */
|
|
216
|
+
const start = async (lane: Lane, signal: AbortSignal): Promise<LiveSession> => {
|
|
217
|
+
for (;;) {
|
|
218
|
+
if (closed || signal.aborted) throw new Error("Messenger session unavailable");
|
|
219
|
+
if (resident < maxProcesses) break;
|
|
220
|
+
const victim = leastRecentlyUsedIdle();
|
|
221
|
+
if (victim) {
|
|
222
|
+
victim.evicting = true;
|
|
223
|
+
await enqueue(victim, () => retireIfIdle(victim));
|
|
224
|
+
} else {
|
|
225
|
+
await waitForCapacity(signal);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const child = spawn(config);
|
|
229
|
+
resident++;
|
|
230
|
+
const live: LiveSession = { process: child, lastUsed: Date.now(), released: false };
|
|
231
|
+
lane.live = live;
|
|
232
|
+
const onExit = (): void => {
|
|
233
|
+
live.failTurn?.(new Error("Messenger session process exited"));
|
|
234
|
+
if (lane.live === live) lane.live = undefined;
|
|
235
|
+
release(live);
|
|
236
|
+
};
|
|
237
|
+
void child.exited.then(onExit, onExit);
|
|
238
|
+
child.send({ type: "init", id: String(++nextId), config });
|
|
239
|
+
return live;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const runTurn = async (
|
|
243
|
+
lane: Lane,
|
|
244
|
+
message: MessengerMessage,
|
|
245
|
+
signal: AbortSignal,
|
|
246
|
+
onActivity: MessengerActivityReporter | undefined,
|
|
247
|
+
): Promise<string> => {
|
|
248
|
+
if (closed || signal.aborted) throw new Error("Messenger session unavailable");
|
|
249
|
+
const id = String(++nextId);
|
|
250
|
+
const result = Promise.withResolvers<string>();
|
|
251
|
+
// Abort, IPC events and process exit may each settle it, possibly after the turn already failed elsewhere.
|
|
252
|
+
result.promise.catch(() => {});
|
|
253
|
+
let live = lane.live;
|
|
254
|
+
let unsubscribe: (() => void) | undefined;
|
|
255
|
+
const onAbort = (): void => {
|
|
256
|
+
try {
|
|
257
|
+
live?.process.send({ type: "abort", id });
|
|
258
|
+
} catch {
|
|
259
|
+
// Already gone: retirement below reaps it.
|
|
260
|
+
}
|
|
261
|
+
result.reject(new Error("Messenger request aborted"));
|
|
262
|
+
};
|
|
263
|
+
try {
|
|
264
|
+
live ??= await start(lane, signal);
|
|
265
|
+
const current = live;
|
|
266
|
+
current.failTurn = result.reject;
|
|
267
|
+
unsubscribe = current.process.onMessage(event => {
|
|
268
|
+
if (event.id !== id) return;
|
|
269
|
+
if (event.type === "activity") onActivity?.(event.text);
|
|
270
|
+
else if (event.type === "reply") result.resolve(event.text);
|
|
271
|
+
else if (event.type === "failed") result.reject(new Error("Messenger session turn failed"));
|
|
272
|
+
});
|
|
273
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
274
|
+
if (signal.aborted) onAbort();
|
|
275
|
+
try {
|
|
276
|
+
current.process.send({ type: "turn", id, message });
|
|
277
|
+
} catch (error) {
|
|
278
|
+
result.reject(error);
|
|
279
|
+
}
|
|
280
|
+
return await result.promise;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
// A failed, aborted or crashed turn may leave its session unusable: the next turn resumes the
|
|
283
|
+
// persisted session in a fresh process, after this one has flushed.
|
|
284
|
+
await retire(lane);
|
|
285
|
+
throw error;
|
|
286
|
+
} finally {
|
|
287
|
+
signal.removeEventListener("abort", onAbort);
|
|
288
|
+
unsubscribe?.();
|
|
289
|
+
if (live) {
|
|
290
|
+
live.failTurn = undefined;
|
|
291
|
+
live.lastUsed = Date.now();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const reply: MessengerReply = async (message, signal, onActivity) => {
|
|
297
|
+
if (closed || signal.aborted) throw new Error("Messenger session unavailable");
|
|
298
|
+
const route = messengerSessionRoute(cwd, message);
|
|
299
|
+
let lane = lanes.get(route);
|
|
300
|
+
if (!lane) {
|
|
301
|
+
lane = { route, tail: Promise.resolve(), turns: 0 };
|
|
302
|
+
lanes.set(route, lane);
|
|
303
|
+
}
|
|
304
|
+
const current = lane;
|
|
305
|
+
clearTimeout(current.idle);
|
|
306
|
+
current.turns++;
|
|
307
|
+
try {
|
|
308
|
+
return await enqueue(current, () => runTurn(current, message, signal, onActivity));
|
|
309
|
+
} finally {
|
|
310
|
+
current.turns--;
|
|
311
|
+
if (current.turns === 0 && !closed) {
|
|
312
|
+
current.idle = setTimeout(() => void enqueue(current, () => retireIfIdle(current)), idleMs);
|
|
313
|
+
current.idle.unref?.();
|
|
314
|
+
capacityChanged();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const close = (): Promise<void> => {
|
|
320
|
+
closing ??= (async () => {
|
|
321
|
+
closed = true;
|
|
322
|
+
capacityChanged();
|
|
323
|
+
const all = [...lanes.values()];
|
|
324
|
+
lanes.clear();
|
|
325
|
+
for (const lane of all) clearTimeout(lane.idle);
|
|
326
|
+
await Promise.allSettled(all.map(retire));
|
|
327
|
+
})();
|
|
328
|
+
return closing;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return { reply, close };
|
|
332
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { logger } from "jeopi-utils";
|
|
2
|
+
import { createMessengerSessionBridge, type MessengerSessionBridge } from "./session";
|
|
3
|
+
import type { MessengerConfig, MessengerMessage } from "./types";
|
|
4
|
+
|
|
5
|
+
/** Gateway pool → session process. */
|
|
6
|
+
export type MessengerSessionInbound =
|
|
7
|
+
| { type: "ping"; id: string }
|
|
8
|
+
| { type: "init"; id: string; config: MessengerConfig }
|
|
9
|
+
| { type: "turn"; id: string; message: MessengerMessage }
|
|
10
|
+
| { type: "abort"; id: string }
|
|
11
|
+
| { type: "close"; id: string };
|
|
12
|
+
|
|
13
|
+
/** Session process → gateway pool. Failures carry no detail: tool output and provider errors stay here. */
|
|
14
|
+
export type MessengerSessionOutbound =
|
|
15
|
+
| { type: "pong"; id: string }
|
|
16
|
+
| { type: "activity"; id: string; text: string }
|
|
17
|
+
| { type: "reply"; id: string; text: string }
|
|
18
|
+
| { type: "failed"; id: string }
|
|
19
|
+
| { type: "closed"; id: string };
|
|
20
|
+
|
|
21
|
+
export interface MessengerSessionTransport {
|
|
22
|
+
send(message: MessengerSessionOutbound): void;
|
|
23
|
+
onMessage(handler: (message: MessengerSessionInbound) => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Serve one conversation's isolated AgentSession inside its own session process. The pool serializes turns, so
|
|
28
|
+
* the bridge sees one turn at a time and keeps its tool isolation and persisted history unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export function startMessengerSessionWorker(
|
|
31
|
+
transport: MessengerSessionTransport,
|
|
32
|
+
createBridge: typeof createMessengerSessionBridge = createMessengerSessionBridge,
|
|
33
|
+
): void {
|
|
34
|
+
let bridge: MessengerSessionBridge | undefined;
|
|
35
|
+
const turns = new Map<string, AbortController>();
|
|
36
|
+
transport.onMessage(message => {
|
|
37
|
+
switch (message.type) {
|
|
38
|
+
case "ping":
|
|
39
|
+
transport.send({ type: "pong", id: message.id });
|
|
40
|
+
return;
|
|
41
|
+
case "init":
|
|
42
|
+
if (bridge) return;
|
|
43
|
+
try {
|
|
44
|
+
bridge = createBridge(message.config);
|
|
45
|
+
} catch {
|
|
46
|
+
logger.warn("Messenger session process could not create its session");
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
case "turn": {
|
|
50
|
+
const current = bridge;
|
|
51
|
+
if (!current) {
|
|
52
|
+
transport.send({ type: "failed", id: message.id });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
turns.set(message.id, controller);
|
|
57
|
+
void current
|
|
58
|
+
.reply(message.message, controller.signal, text =>
|
|
59
|
+
transport.send({ type: "activity", id: message.id, text }),
|
|
60
|
+
)
|
|
61
|
+
.then(
|
|
62
|
+
text => transport.send({ type: "reply", id: message.id, text }),
|
|
63
|
+
() => transport.send({ type: "failed", id: message.id }),
|
|
64
|
+
)
|
|
65
|
+
.finally(() => turns.delete(message.id));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
case "abort":
|
|
69
|
+
turns.get(message.id)?.abort();
|
|
70
|
+
return;
|
|
71
|
+
case "close":
|
|
72
|
+
for (const controller of turns.values()) controller.abort();
|
|
73
|
+
void (bridge?.close() ?? Promise.resolve())
|
|
74
|
+
.catch(() => logger.warn("Messenger session process shutdown failed"))
|
|
75
|
+
.finally(() => transport.send({ type: "closed", id: message.id }));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|