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.
@@ -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 drain(): Promise<void> {
239
- while (queue.length > 0) {
240
- const pending = queue.shift();
241
- if (!pending) break;
242
- const { message, adapter, signal, resolve } = pending;
243
- let failed = false;
244
- let activity = NO_ACTIVITY;
245
- let statusChain = Promise.resolve();
246
- const status = (state: "processing" | "success" | "failure"): void => {
247
- if (!adapter.status) return;
248
- statusChain = statusChain.then(async () => {
249
- if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
250
- const timeout = new AbortController();
251
- const statusSignal = AbortSignal.any([signal, timeout.signal]);
252
- const stopped = Promise.withResolvers<void>();
253
- const onAbort = (): void => stopped.resolve();
254
- statusSignal.addEventListener("abort", onAbort, { once: true });
255
- statusInFlight++;
256
- const operation = Promise.resolve().then(() => adapter.status!(message, state, statusSignal));
257
- void operation.then(
258
- () => {
259
- statusInFlight--;
260
- },
261
- () => {
262
- statusInFlight--;
263
- },
264
- );
265
- const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
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
- text = await reply(destination, signal, activity.report);
284
- } catch (error) {
285
- failed = true;
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
- // Retire the status line alongside reply delivery rather than delaying the reply on it.
297
- void activity.close();
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
- if (!signal.aborted) status(failed ? "failure" : "success");
304
- } catch {
305
- if (!signal.aborted) {
306
- logger.warn("Messenger reply delivery failed", { platform: message.platform });
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
- await activity.close();
311
- resolve();
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
- if (!workerActive) {
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 draining;
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
+ }