jeo-code 0.12.13 → 0.12.16
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 +55 -1
- package/README.ja.md +33 -9
- package/README.ko.md +33 -9
- package/README.md +33 -9
- package/README.zh.md +33 -9
- package/package.json +4 -2
- package/src/agent/browser-tool.ts +9 -1
- package/src/agent/config-schema.ts +8 -0
- package/src/agent/context-files.ts +19 -0
- package/src/agent/engine.ts +25 -6
- package/src/agent/eval-tool.ts +6 -0
- package/src/agent/goal-verifier.ts +30 -6
- package/src/agent/hooks.ts +52 -4
- package/src/agent/job-registry.ts +110 -18
- package/src/agent/job-tool.ts +43 -9
- package/src/agent/loop-guards.ts +17 -2
- package/src/agent/memory.ts +151 -112
- package/src/agent/monitor-registry.ts +142 -30
- package/src/agent/monitor-tool.ts +34 -6
- package/src/agent/notify/discord-daemon.ts +135 -42
- package/src/agent/notify/session-endpoint.ts +27 -5
- package/src/agent/sandbox/assets.d.ts +4 -0
- package/src/agent/sandbox/authority.ts +358 -0
- package/src/agent/sandbox/bundle.ts +40 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +293 -0
- package/src/agent/sandbox/hands-source.text +14548 -0
- package/src/agent/sandbox/hands-worker.ts +168 -0
- package/src/agent/sandbox/policy.ts +172 -0
- package/src/agent/sandbox/rpc.ts +90 -0
- package/src/agent/sandbox/supervisor.ts +210 -0
- package/src/agent/sandbox/types.ts +77 -0
- package/src/agent/shell-lease.ts +190 -0
- package/src/agent/state.ts +167 -0
- package/src/agent/subagents.ts +4 -3
- package/src/agent/task-tool.ts +8 -0
- package/src/agent/todo-tool.ts +30 -1
- package/src/agent/tool-schemas.ts +13 -0
- package/src/ai/model-discovery.ts +12 -0
- package/src/ai/providers/anthropic.ts +27 -2
- package/src/ai/providers/antigravity.ts +10 -2
- package/src/ai/providers/gemini.ts +20 -16
- package/src/ai/providers/ollama.ts +14 -0
- package/src/ai/providers/openai-responses.ts +22 -5
- package/src/ai/providers/openai.ts +23 -40
- package/src/cli/runner.ts +3 -0
- package/src/commands/approve.ts +37 -13
- package/src/commands/launch/flags.ts +2 -1
- package/src/commands/launch/jobs-slash.ts +1 -1
- package/src/commands/launch.ts +275 -35
- package/src/commands/messenger.ts +107 -39
- package/src/commands/notify.ts +2 -2
- package/src/commands/team.ts +44 -12
- package/src/commands/ultragoal.ts +6 -16
- package/src/messenger/activity.ts +2 -1
- package/src/messenger/config.ts +78 -10
- package/src/messenger/discord-setup.ts +216 -34
- package/src/messenger/discord.ts +37 -43
- package/src/messenger/gateway.ts +85 -37
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +5 -2
- package/src/skills/catalog.ts +207 -0
- package/src/util/retry.ts +12 -0
package/src/messenger/gateway.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MessengerHttpError } from "./http";
|
|
2
|
+
import { DiscordModelSelectionError } from "./model-selection";
|
|
2
3
|
import type {
|
|
3
4
|
MessengerActivityReporter,
|
|
4
5
|
MessengerAdapter,
|
|
@@ -173,7 +174,7 @@ export type MessengerSessionIdentity = readonly [
|
|
|
173
174
|
channel: string,
|
|
174
175
|
thread: OptionalIdentity,
|
|
175
176
|
conversation: OptionalIdentity,
|
|
176
|
-
user:
|
|
177
|
+
user: OptionalIdentity,
|
|
177
178
|
];
|
|
178
179
|
|
|
179
180
|
function optionalIdentity(name: string, value: string | undefined): OptionalIdentity {
|
|
@@ -201,7 +202,7 @@ export function messengerSessionIdentity(message: MessengerMessage): MessengerSe
|
|
|
201
202
|
message.parentChannelId ?? message.channelId,
|
|
202
203
|
optionalIdentity("thread", message.threadId),
|
|
203
204
|
optionalIdentity("conversation", message.conversationId),
|
|
204
|
-
message.senderId,
|
|
205
|
+
message.platform === "discord" && message.threadId !== undefined ? ["absent"] : ["present", message.senderId],
|
|
205
206
|
];
|
|
206
207
|
}
|
|
207
208
|
|
|
@@ -215,9 +216,14 @@ export function messengerSessionRoute(cwd: string, message: MessengerMessage): s
|
|
|
215
216
|
return new Bun.CryptoHasher("sha256").update(JSON.stringify([cwd, messengerConversationKey(message)])).digest("hex");
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Admission lane. A top-level message has no thread yet, so only its raw channel can order it against its
|
|
221
|
+
* neighbours; anything already resolved is ordered by its own conversation. A lane is held only until the request
|
|
222
|
+
* is queued, never while it runs, so it cannot serialize requests that end up in different threads.
|
|
223
|
+
*/
|
|
224
|
+
function admissionKey(message: MessengerMessage, adapter: MessengerAdapter): string {
|
|
219
225
|
if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
|
|
220
|
-
return JSON.stringify([message.platform, message.accountId, message.channelId, message.
|
|
226
|
+
return JSON.stringify([message.platform, message.accountId, message.guildId, message.channelId, message.platform === "discord" ? null : message.senderId]);
|
|
221
227
|
}
|
|
222
228
|
return messengerConversationKey(message);
|
|
223
229
|
}
|
|
@@ -242,12 +248,15 @@ export function isMessengerMessageAllowed(message: MessengerMessage, config: Mes
|
|
|
242
248
|
}
|
|
243
249
|
|
|
244
250
|
interface PendingMessage {
|
|
251
|
+
/** Prepared destination: replies, deliveries and activity target it. */
|
|
245
252
|
message: MessengerMessage;
|
|
246
253
|
adapter: MessengerAdapter;
|
|
247
254
|
signal: AbortSignal;
|
|
248
255
|
resolve: () => void;
|
|
249
|
-
/**
|
|
256
|
+
/** Destination conversation key; requests sharing it never run at the same time. */
|
|
250
257
|
key: string;
|
|
258
|
+
/** Status reactions on the user's original message. */
|
|
259
|
+
status: (state: "processing" | "success" | "failure") => void;
|
|
251
260
|
}
|
|
252
261
|
|
|
253
262
|
export interface MessengerGateway {
|
|
@@ -259,10 +268,13 @@ export interface MessengerGatewayOptions {
|
|
|
259
268
|
/** Minimum spacing between in-place activity edits for one request. */
|
|
260
269
|
activityIntervalMs?: number;
|
|
261
270
|
/**
|
|
262
|
-
* Requests processed at once (default 1). Requests of one
|
|
263
|
-
*
|
|
271
|
+
* Requests processed at once (default 1). Requests of one conversation answer and deliver in arrival order and
|
|
272
|
+
* wait in the queue, not in a slot, behind their predecessor; above 1 the reply implementation must accept
|
|
273
|
+
* concurrent turns for different conversations.
|
|
264
274
|
*/
|
|
265
275
|
concurrency?: number;
|
|
276
|
+
/** Authenticated human controls bypass model queues, including busy sessions. */
|
|
277
|
+
sandboxControl?: (message: MessengerMessage, signal: AbortSignal) => Promise<{ handled: boolean; output: string }>;
|
|
266
278
|
}
|
|
267
279
|
|
|
268
280
|
export function createMessengerGateway(
|
|
@@ -283,22 +295,22 @@ export function createMessengerGateway(
|
|
|
283
295
|
const queue: PendingMessage[] = [];
|
|
284
296
|
const activeKeys = new Set<string>();
|
|
285
297
|
const turns = new Set<Promise<void>>();
|
|
286
|
-
/**
|
|
287
|
-
const
|
|
298
|
+
/** Orders admission (sandbox control, capacity check, destination preparation) per raw lane. */
|
|
299
|
+
const admissionTails = new Map<string, Promise<void>>();
|
|
300
|
+
/** Requests holding a queue slot while the adapter prepares their destination; counted against the queue cap. */
|
|
301
|
+
let preparing = 0;
|
|
288
302
|
const lifetime = new AbortController();
|
|
289
303
|
let running = false;
|
|
290
304
|
let fatalError: MessengerFatalError | undefined;
|
|
291
305
|
let statusInFlight = 0;
|
|
292
306
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
let tail: Promise<void> | undefined;
|
|
299
|
-
let conversation = "";
|
|
307
|
+
function createStatusReporter(
|
|
308
|
+
adapter: MessengerAdapter,
|
|
309
|
+
message: MessengerMessage,
|
|
310
|
+
signal: AbortSignal,
|
|
311
|
+
): (state: "processing" | "success" | "failure") => void {
|
|
300
312
|
let statusChain = Promise.resolve();
|
|
301
|
-
|
|
313
|
+
return state => {
|
|
302
314
|
if (!adapter.status) return;
|
|
303
315
|
statusChain = statusChain.then(async () => {
|
|
304
316
|
if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
|
|
@@ -328,22 +340,20 @@ export function createMessengerGateway(
|
|
|
328
340
|
}
|
|
329
341
|
});
|
|
330
342
|
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function processRequest(pending: PendingMessage): Promise<void> {
|
|
346
|
+
const { message, adapter, signal, resolve, status } = pending;
|
|
347
|
+
let failed = false;
|
|
348
|
+
let activity = NO_ACTIVITY;
|
|
331
349
|
try {
|
|
332
350
|
if (signal.aborted) return;
|
|
333
351
|
status("processing");
|
|
334
|
-
|
|
335
|
-
conversation = messengerConversationKey(destination);
|
|
336
|
-
const previous = conversationTails.get(conversation);
|
|
337
|
-
const done = Promise.withResolvers<void>();
|
|
338
|
-
release = done.resolve;
|
|
339
|
-
tail = done.promise;
|
|
340
|
-
conversationTails.set(conversation, tail);
|
|
341
|
-
await previous;
|
|
342
|
-
activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
|
|
352
|
+
activity = createActivityReporter(adapter, message, signal, activityIntervalMs);
|
|
343
353
|
activity.report("Starting request");
|
|
344
354
|
let text: string;
|
|
345
355
|
try {
|
|
346
|
-
text = await reply(
|
|
356
|
+
text = await reply(message, signal, activity.report);
|
|
347
357
|
} catch (error) {
|
|
348
358
|
failed = true;
|
|
349
359
|
if (error instanceof MessengerFatalError) {
|
|
@@ -353,11 +363,13 @@ export function createMessengerGateway(
|
|
|
353
363
|
return;
|
|
354
364
|
}
|
|
355
365
|
if (signal.aborted) return;
|
|
356
|
-
text =
|
|
366
|
+
text = error instanceof DiscordModelSelectionError
|
|
367
|
+
? "Discord could not verify a model. Ask the operator to configure an explicit Discord model, check provider credentials and live model discovery, then restart the messenger service."
|
|
368
|
+
: "The request could not be completed. Please try again.";
|
|
357
369
|
}
|
|
358
370
|
if (!signal.aborted && text.trim()) {
|
|
359
371
|
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
360
|
-
await adapter.send(
|
|
372
|
+
await adapter.send(message, text, signal);
|
|
361
373
|
}
|
|
362
374
|
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
363
375
|
} catch {
|
|
@@ -365,8 +377,6 @@ export function createMessengerGateway(
|
|
|
365
377
|
if (!signal.aborted) status("failure");
|
|
366
378
|
} finally {
|
|
367
379
|
await activity.close(failed ? "failure" : signal.aborted ? "cancelled" : "success");
|
|
368
|
-
release();
|
|
369
|
-
if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
|
|
370
380
|
resolve();
|
|
371
381
|
}
|
|
372
382
|
}
|
|
@@ -390,21 +400,59 @@ export function createMessengerGateway(
|
|
|
390
400
|
}
|
|
391
401
|
}
|
|
392
402
|
|
|
393
|
-
function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
|
|
403
|
+
async function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
|
|
394
404
|
const adapter = byPlatform.get(message.platform);
|
|
395
405
|
const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
396
406
|
if (effectiveSignal.aborted || !adapter || !isMessengerMessageAllowed(message, config)) return Promise.resolve();
|
|
397
407
|
const id = JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
|
|
398
|
-
if (seen.has(id)
|
|
408
|
+
if (seen.has(id)) return;
|
|
399
409
|
seen.add(id);
|
|
400
410
|
if (seen.size > MAX_SEEN_MESSAGES) {
|
|
401
411
|
const oldest = seen.values().next().value;
|
|
402
412
|
if (oldest !== undefined) seen.delete(oldest);
|
|
403
413
|
}
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
414
|
+
const lane = admissionKey(message, adapter);
|
|
415
|
+
const previous = admissionTails.get(lane) ?? Promise.resolve();
|
|
416
|
+
const ready = Promise.withResolvers<void>();
|
|
417
|
+
const tail = previous.then(() => ready.promise);
|
|
418
|
+
admissionTails.set(lane, tail);
|
|
419
|
+
void tail.then(() => { if (admissionTails.get(lane) === tail) admissionTails.delete(lane); });
|
|
420
|
+
try {
|
|
421
|
+
if (options.sandboxControl) {
|
|
422
|
+
const control = await options.sandboxControl(message, effectiveSignal);
|
|
423
|
+
if (control.handled) {
|
|
424
|
+
if (!effectiveSignal.aborted && control.output.trim()) await adapter.send(message, control.output, effectiveSignal);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
await previous;
|
|
429
|
+
if (effectiveSignal.aborted) return;
|
|
430
|
+
const status = createStatusReporter(adapter, message, effectiveSignal);
|
|
431
|
+
// Capacity is claimed before preparing: a request that cannot be queued must never create a thread.
|
|
432
|
+
if (queue.length + preparing >= MAX_QUEUED_MESSAGES) {
|
|
433
|
+
// Never executed, so forget the id: a redelivery is accepted once capacity returns, replaying nothing.
|
|
434
|
+
seen.delete(id);
|
|
435
|
+
status("failure");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
let destination: MessengerMessage;
|
|
439
|
+
let key: string;
|
|
440
|
+
preparing++;
|
|
441
|
+
try {
|
|
442
|
+
destination = (await adapter.prepare?.(message, effectiveSignal)) ?? message;
|
|
443
|
+
key = messengerConversationKey(destination);
|
|
444
|
+
} catch {
|
|
445
|
+
if (!effectiveSignal.aborted) status("failure");
|
|
446
|
+
return;
|
|
447
|
+
} finally {
|
|
448
|
+
preparing--;
|
|
449
|
+
}
|
|
450
|
+
if (effectiveSignal.aborted) return;
|
|
451
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
452
|
+
queue.push({ message: destination, adapter, signal: effectiveSignal, resolve, key, status });
|
|
453
|
+
schedule();
|
|
454
|
+
return promise;
|
|
455
|
+
} finally { ready.resolve(); }
|
|
408
456
|
}
|
|
409
457
|
|
|
410
458
|
return {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readGlobalConfig, type Config } from "../agent/state";
|
|
2
|
+
import { listProviderModels } from "../ai/model-discovery";
|
|
3
|
+
import { credentialForCall, effectiveCredentialForProvider, qualifyModelId, resolveProvider } from "../ai/model-manager";
|
|
4
|
+
import { resolveModelId } from "../ai/model-registry";
|
|
5
|
+
import { resolveCredential, type AuthProvider, type Credential } from "../auth";
|
|
6
|
+
|
|
7
|
+
export class DiscordModelSelectionError extends Error {
|
|
8
|
+
constructor(cause: unknown) {
|
|
9
|
+
super(cause instanceof Error ? cause.message : "Discord model selection failed", { cause });
|
|
10
|
+
this.name = "DiscordModelSelectionError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Select only from the account's live catalog; never substitute a static model. */
|
|
15
|
+
export async function selectDiscordModel(
|
|
16
|
+
explicit: string | undefined,
|
|
17
|
+
opts: { config?: Config; signal?: AbortSignal; list?: typeof listProviderModels; credential?: typeof resolveCredential } = {},
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
try {
|
|
20
|
+
return await selectDiscordModelFromCatalog(explicit, opts);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (opts.signal?.aborted) throw error;
|
|
23
|
+
throw new DiscordModelSelectionError(error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function selectDiscordModelFromCatalog(
|
|
28
|
+
explicit: string | undefined,
|
|
29
|
+
opts: { config?: Config; signal?: AbortSignal; list?: typeof listProviderModels; credential?: typeof resolveCredential },
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const config = opts.config ?? await readGlobalConfig();
|
|
32
|
+
const requested = explicit?.trim() ? await resolveModelId(explicit.trim(), config) : undefined;
|
|
33
|
+
const provider = resolveProvider(requested || await resolveModelId(config.defaultModel, config));
|
|
34
|
+
if (!requested && provider !== "anthropic" && provider !== "openai" && provider !== "antigravity") {
|
|
35
|
+
throw new Error(`Discord automatic model selection does not support provider ${provider}; configure an explicit model`);
|
|
36
|
+
}
|
|
37
|
+
const result = await (opts.list ?? listProviderModels)(provider, { config, signal: opts.signal, limit: Number.POSITIVE_INFINITY, preferOAuth: true });
|
|
38
|
+
if (opts.signal?.aborted) throw new Error("Messenger request aborted");
|
|
39
|
+
if (!result.ok || result.fallback || result.source === "none" || (result.source === "keyless" && (!requested || (provider !== "ollama" && provider !== "lmstudio"))) || !result.models.length) {
|
|
40
|
+
throw new Error(`Discord cannot verify available models for ${provider}; check login and live model discovery`);
|
|
41
|
+
}
|
|
42
|
+
const canonical = (model: string) => qualifyModelId(model, provider);
|
|
43
|
+
const verified = async (model: string): Promise<string> => {
|
|
44
|
+
const authProvider: AuthProvider | undefined = provider === "anthropic" ? "anthropic" : provider === "openai" ? "openai" : undefined;
|
|
45
|
+
if (authProvider && config.providers[authProvider]) {
|
|
46
|
+
const credential: Credential = await (opts.credential ?? resolveCredential)(authProvider);
|
|
47
|
+
const effective = effectiveCredentialForProvider(authProvider, credential, config, model);
|
|
48
|
+
const actual = credentialForCall(provider, effective, config, provider === "openai" ? config.openaiBaseUrl : undefined);
|
|
49
|
+
if (actual.kind !== result.source) {
|
|
50
|
+
throw new Error(`Discord cannot verify ${provider} model access for the credential used at runtime`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return canonical(model);
|
|
54
|
+
};
|
|
55
|
+
if (requested) {
|
|
56
|
+
const prefix = `${provider}/`;
|
|
57
|
+
const requestedId = requested.startsWith(prefix) ? requested.slice(prefix.length) : requested;
|
|
58
|
+
const match = result.models.find(model => (model.startsWith(prefix) ? model.slice(prefix.length) : model) === requestedId);
|
|
59
|
+
if (!match) throw new Error(`Discord configured model is not available for ${provider}: ${requested}`);
|
|
60
|
+
return verified(match);
|
|
61
|
+
}
|
|
62
|
+
const family = provider === "anthropic" ? /sonnet/i : provider === "openai" ? /terra/i : /gemini.*flash/i;
|
|
63
|
+
const variant = /(?:^|[^a-z0-9])(?:mini|lite|codex|thinking|image)(?=$|[^a-z0-9])/i;
|
|
64
|
+
const models = [...new Set(result.models.filter(model => {
|
|
65
|
+
const name = provider === "antigravity" ? (result.displayNames?.[model] ?? model) : model;
|
|
66
|
+
return family.test(name) && !variant.test(model) && !variant.test(name);
|
|
67
|
+
}))];
|
|
68
|
+
if (!models.length) throw new Error(`Discord found no available ${provider} model in the required family`);
|
|
69
|
+
if (models.length === 1) return verified(models[0]!);
|
|
70
|
+
// Antigravity wire aliases can advertise a different version than their IDs.
|
|
71
|
+
// Only the display name from the same live response establishes recency.
|
|
72
|
+
if (provider === "antigravity" && models.some(model => !result.displayNames?.[model])) {
|
|
73
|
+
throw new Error("Discord cannot determine the newest Antigravity Flash model: live catalog is missing display names; configure an explicit model from the live catalog");
|
|
74
|
+
}
|
|
75
|
+
const ranked = models.map(model => {
|
|
76
|
+
const name = provider === "antigravity" ? result.displayNames![model]! : model;
|
|
77
|
+
const snapshot = name.match(/[-_ ](\d{4}-\d{2}-\d{2}|\d{8})$/);
|
|
78
|
+
const versionName = snapshot ? name.slice(0, -snapshot[0].length) : name;
|
|
79
|
+
const tierName = provider === "antigravity" ? name.match(/\((high|medium|low)\)\s*$/i)?.[1]?.toLowerCase() : undefined;
|
|
80
|
+
const tier = tierName === "high" ? 3 : tierName === "medium" ? 2 : tierName === "low" ? 1 : 0;
|
|
81
|
+
return { model, version: (versionName.match(/\d+/g) ?? []).map(Number), snapshot: Number(snapshot?.[1]?.replaceAll("-", "") ?? 0), tier, ambiguousVersion: provider === "openai" && /terra.*\d/i.test(versionName) };
|
|
82
|
+
});
|
|
83
|
+
if (ranked.some(item => !item.version.length || item.ambiguousVersion)) {
|
|
84
|
+
throw new Error(`Discord cannot determine the newest ${provider} model; configure an explicit model`);
|
|
85
|
+
}
|
|
86
|
+
const compare = (a: number[], b: number[]) => {
|
|
87
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
88
|
+
const difference = (b[i] ?? 0) - (a[i] ?? 0);
|
|
89
|
+
if (difference) return difference;
|
|
90
|
+
}
|
|
91
|
+
return 0;
|
|
92
|
+
};
|
|
93
|
+
ranked.sort((a, b) => compare(a.version, b.version) || b.snapshot - a.snapshot || b.tier - a.tier);
|
|
94
|
+
if (provider === "antigravity" && ranked.some((item, index) => index > 0 && compare(ranked[0]!.version, item.version) === 0 && ranked[0]!.snapshot === item.snapshot && (!ranked[0]!.tier || !item.tier))) {
|
|
95
|
+
throw new Error("Discord cannot distinguish the newest antigravity models; configure an explicit model");
|
|
96
|
+
}
|
|
97
|
+
if (compare(ranked[0]!.version, ranked[1]!.version) === 0 && ranked[0]!.snapshot === ranked[1]!.snapshot && ranked[0]!.tier === ranked[1]!.tier) {
|
|
98
|
+
throw new Error(`Discord cannot distinguish the newest ${provider} models; configure an explicit model`);
|
|
99
|
+
}
|
|
100
|
+
return verified(ranked[0]!.model);
|
|
101
|
+
}
|
|
@@ -6,6 +6,8 @@ interface WorkerProcess {
|
|
|
6
6
|
stderr: ReadableStream<Uint8Array>;
|
|
7
7
|
exited: Promise<number>;
|
|
8
8
|
kill(signal?: number | NodeJS.Signals): void;
|
|
9
|
+
/** Private IPC channel; present only on workers spawned with `ipc`. */
|
|
10
|
+
send?(message: unknown): unknown;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
interface Pending {
|
|
@@ -18,35 +20,80 @@ interface Pending {
|
|
|
18
20
|
|
|
19
21
|
interface Worker {
|
|
20
22
|
child: WorkerProcess;
|
|
23
|
+
exit: Promise<number>;
|
|
24
|
+
policyKey?: string;
|
|
21
25
|
pending?: Pending;
|
|
22
26
|
lastUsed: number;
|
|
23
27
|
dead: boolean;
|
|
28
|
+
forceSent?: boolean;
|
|
24
29
|
exited?: boolean;
|
|
30
|
+
cleanupNonce?: string;
|
|
31
|
+
cleanupGone?: boolean;
|
|
32
|
+
cleanupError?: Error;
|
|
25
33
|
idleTimer?: ReturnType<typeof setTimeout>;
|
|
26
34
|
exitTimer?: ReturnType<typeof setTimeout>;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
/** Resident sessions the pool keeps, and the number of turns it runs at once. */
|
|
38
|
+
export const MESSENGER_SESSION_LIMIT = 16;
|
|
39
|
+
|
|
40
|
+
export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn, limit = MESSENGER_SESSION_LIMIT, idleTimeoutMs = 5 * 60_000, shutdownTimeoutMs = 5_000, options: { requireSandboxCleanup?: boolean } = {}) {
|
|
30
41
|
const workers = new Map<string, Worker>();
|
|
31
42
|
const allWorkers = new Map<Worker, string>();
|
|
32
|
-
const exits = new Map<Promise<number>,
|
|
43
|
+
const exits = new Map<Promise<number>, Worker>();
|
|
33
44
|
const retiring = new Map<string, Promise<number>>();
|
|
45
|
+
const keyTails = new Map<string, Promise<void>>();
|
|
46
|
+
const requireSandboxCleanup = options.requireSandboxCleanup === true;
|
|
47
|
+
const incomplete = (detail: string) => new Error(`REVOKE_INCOMPLETE: ${detail}`);
|
|
34
48
|
|
|
35
49
|
let closed = false;
|
|
36
50
|
const closedSignal = new AbortController();
|
|
37
51
|
|
|
38
52
|
let tick = 0;
|
|
39
53
|
|
|
54
|
+
// Admission: at most `limit` turns hold a worker at once. Later turns wait here in arrival order until a
|
|
55
|
+
// running turn settles, so a busy pool is backpressure on the waiting request, never a failure of it.
|
|
56
|
+
let admitted = 0;
|
|
57
|
+
const waiting: Array<() => void> = [];
|
|
58
|
+
|
|
59
|
+
function admit(signal: AbortSignal): Promise<void> {
|
|
60
|
+
return new Promise<void>((resolve, reject) => {
|
|
61
|
+
const grant = () => { cleanup(); resolve(); };
|
|
62
|
+
const abort = () => {
|
|
63
|
+
const index = waiting.indexOf(grant);
|
|
64
|
+
if (index >= 0) waiting.splice(index, 1);
|
|
65
|
+
cleanup();
|
|
66
|
+
reject(new Error("Messenger request aborted"));
|
|
67
|
+
};
|
|
68
|
+
const cleanup = () => { signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
|
|
69
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
70
|
+
closedSignal.signal.addEventListener("abort", abort, { once: true });
|
|
71
|
+
waiting.push(grant);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Hand the finished turn's slot straight to the oldest waiter, or free it. */
|
|
76
|
+
function release() {
|
|
77
|
+
const next = waiting.shift();
|
|
78
|
+
if (next) next(); else admitted--;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function forceKill(worker: Worker) {
|
|
82
|
+
if (worker.cleanupNonce) return;
|
|
83
|
+
if (worker.forceSent) return;
|
|
84
|
+
try { worker.child.kill("SIGKILL"); worker.forceSent = true; } catch { /* Retry on shutdown if termination failed. */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
40
87
|
function retire(key: string, worker: Worker) {
|
|
41
88
|
if (worker.dead) return;
|
|
42
89
|
worker.dead = true;
|
|
43
90
|
if (workers.get(key) === worker) workers.delete(key);
|
|
44
|
-
allWorkers.delete(worker);
|
|
45
|
-
if (!worker.exited) {
|
|
46
|
-
retiring.set(key, worker.
|
|
47
|
-
void worker.
|
|
48
|
-
() => { if (retiring.get(key) === worker.
|
|
49
|
-
() => {
|
|
91
|
+
if (!worker.cleanupNonce) allWorkers.delete(worker);
|
|
92
|
+
if (!worker.exited || worker.cleanupNonce) {
|
|
93
|
+
retiring.set(key, worker.exit);
|
|
94
|
+
void worker.exit.then(
|
|
95
|
+
() => { if (retiring.get(key) === worker.exit) retiring.delete(key); },
|
|
96
|
+
() => { /* Keep the key blocked when process exit cannot be confirmed. */ },
|
|
50
97
|
);
|
|
51
98
|
}
|
|
52
99
|
if (worker.idleTimer) clearTimeout(worker.idleTimer);
|
|
@@ -55,7 +102,29 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
55
102
|
worker.pending?.reject(new Error("Messenger worker stopped"));
|
|
56
103
|
worker.pending = undefined;
|
|
57
104
|
if (!worker.exited) {
|
|
58
|
-
|
|
105
|
+
if (worker.cleanupNonce) {
|
|
106
|
+
// Protected workers never receive a kill signal: Windows cannot deliver a catchable SIGTERM.
|
|
107
|
+
// Shutdown is a private IPC message; a missing or failing channel is visible, never success.
|
|
108
|
+
const channel = worker.child.send;
|
|
109
|
+
if (typeof channel !== "function") {
|
|
110
|
+
worker.cleanupError = incomplete("worker shutdown channel unavailable");
|
|
111
|
+
} else {
|
|
112
|
+
try {
|
|
113
|
+
const sent = channel.call(worker.child, { type: "jeo_messenger_shutdown", nonce: worker.cleanupNonce });
|
|
114
|
+
if (sent && typeof (sent as PromiseLike<unknown>).then === "function") {
|
|
115
|
+
void (sent as PromiseLike<unknown>).then(undefined, error => { worker.cleanupError = incomplete(`worker shutdown request failed: ${String(error)}`); });
|
|
116
|
+
}
|
|
117
|
+
} catch (error) { worker.cleanupError = incomplete(`worker shutdown request failed: ${String(error)}`); }
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
try { worker.child.kill(); }
|
|
121
|
+
catch (error) { worker.cleanupError = incomplete(`worker termination failed: ${String(error)}`); }
|
|
122
|
+
}
|
|
123
|
+
if (!worker.cleanupNonce) {
|
|
124
|
+
const force = setTimeout(() => forceKill(worker), shutdownTimeoutMs);
|
|
125
|
+
force.unref();
|
|
126
|
+
void worker.exit.then(() => clearTimeout(force), () => clearTimeout(force));
|
|
127
|
+
}
|
|
59
128
|
}
|
|
60
129
|
}
|
|
61
130
|
|
|
@@ -89,16 +158,45 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
89
158
|
} finally { reader.releaseLock(); }
|
|
90
159
|
}
|
|
91
160
|
|
|
92
|
-
function
|
|
93
|
-
|
|
161
|
+
function waitForExit(exit: Promise<number>, signal: AbortSignal, capacity = false): Promise<void> {
|
|
162
|
+
return new Promise<void>((resolve, reject) => {
|
|
163
|
+
const abort = () => { cleanup(); reject(new Error("Messenger request aborted")); };
|
|
164
|
+
const unavailable = () => new Error("Messenger worker capacity unavailable while another session is being cleaned up");
|
|
165
|
+
const timer = setTimeout(() => { cleanup(); reject(capacity ? unavailable() : requireSandboxCleanup ? incomplete("previous worker cleanup is still pending") : new Error("Messenger worker did not exit")); }, shutdownTimeoutMs * 2);
|
|
166
|
+
const cleanup = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
|
|
167
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
168
|
+
closedSignal.signal.addEventListener("abort", abort, { once: true });
|
|
169
|
+
if (signal.aborted || closed) { abort(); return; }
|
|
170
|
+
void exit.then(() => { cleanup(); resolve(); }, error => { cleanup(); reject(capacity ? unavailable() : requireSandboxCleanup ? incomplete(`previous worker cleanup failed: ${String(error)}`) : new Error("Messenger worker exit could not be confirmed")); });
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function start(key: string, command: string[], cwd: string, env: Record<string, string>, signal: AbortSignal, policyKey?: string): Promise<Worker> {
|
|
175
|
+
while ((requireSandboxCleanup ? allWorkers.size : workers.size) >= limit) {
|
|
94
176
|
const idle = [...workers.entries()].filter(([, worker]) => !worker.pending).sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
|
|
95
|
-
if (!idle)
|
|
177
|
+
if (!idle) {
|
|
178
|
+
const retiringWorker = requireSandboxCleanup ? [...allWorkers.keys()].find(worker => worker.dead) : undefined;
|
|
179
|
+
if (!retiringWorker) throw new Error("Messenger worker capacity reached");
|
|
180
|
+
await waitForExit(retiringWorker.exit, signal, true);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
96
183
|
retire(idle[0], idle[1]);
|
|
184
|
+
if (requireSandboxCleanup) await waitForExit(idle[1].exit, signal, true);
|
|
97
185
|
}
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
186
|
+
if (closed || signal.aborted) throw new Error("Messenger request aborted");
|
|
187
|
+
const cleanupNonce = requireSandboxCleanup ? crypto.randomUUID() : undefined;
|
|
188
|
+
const workerEnv = { ...env };
|
|
189
|
+
delete workerEnv.JEO_MESSENGER_SHUTDOWN_NONCE;
|
|
190
|
+
if (cleanupNonce) workerEnv.JEO_MESSENGER_SHUTDOWN_NONCE = cleanupNonce;
|
|
191
|
+
const spawnOptions = { cwd, env: workerEnv, stdin: "pipe", stdout: "pipe", stderr: "pipe", ...(cleanupNonce ? { ipc() {}, serialization: "json" } : {}) };
|
|
192
|
+
const child = spawn(command, spawnOptions as never) as unknown as WorkerProcess;
|
|
193
|
+
const cleanup = Promise.withResolvers<number>();
|
|
194
|
+
const worker: Worker = { child, exit: cleanupNonce ? cleanup.promise : child.exited, cleanupNonce, policyKey, lastUsed: ++tick, dead: false };
|
|
195
|
+
exits.set(worker.exit, worker);
|
|
196
|
+
void worker.exit.then(() => {
|
|
197
|
+
exits.delete(worker.exit);
|
|
198
|
+
if (cleanupNonce) allWorkers.delete(worker);
|
|
199
|
+
}, () => { if (!cleanupNonce) exits.delete(worker.exit); });
|
|
102
200
|
workers.set(key, worker);
|
|
103
201
|
allWorkers.set(worker, key);
|
|
104
202
|
const stdoutDone = readLines(child.stdout, line => {
|
|
@@ -113,7 +211,17 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
113
211
|
finishTurn(key, worker, pending);
|
|
114
212
|
} catch { retire(key, worker); }
|
|
115
213
|
}).catch(() => retire(key, worker));
|
|
116
|
-
|
|
214
|
+
const stderrDone = readLines(child.stderr, line => {
|
|
215
|
+
if (cleanupNonce && line.startsWith("JEO_MESSENGER_SHUTDOWN_ACK ")) {
|
|
216
|
+
try {
|
|
217
|
+
const ack: unknown = JSON.parse(line.slice("JEO_MESSENGER_SHUTDOWN_ACK ".length));
|
|
218
|
+
if (typeof ack === "object" && ack !== null && "nonce" in ack && ack.nonce === cleanupNonce && "gone" in ack) {
|
|
219
|
+
worker.cleanupGone = ack.gone === true;
|
|
220
|
+
worker.cleanupError = worker.cleanupGone ? undefined : incomplete("worker supervisor cleanup failed");
|
|
221
|
+
}
|
|
222
|
+
} catch { /* Unauthenticated or malformed output is never cleanup proof. */ }
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
117
225
|
if (line.startsWith("JEO_MESSENGER_ACTIVITY ")) {
|
|
118
226
|
const activityText = line.slice("JEO_MESSENGER_ACTIVITY ".length).trim();
|
|
119
227
|
if (activityText) worker.pending?.report?.(activityText);
|
|
@@ -121,9 +229,15 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
121
229
|
worker.pending?.report?.("Working on your request");
|
|
122
230
|
}
|
|
123
231
|
}).catch(() => retire(key, worker));
|
|
124
|
-
|
|
125
|
-
|
|
232
|
+
if (cleanupNonce) {
|
|
233
|
+
void Promise.all([child.exited, stderrDone, stdoutDone]).then(([code]) => {
|
|
234
|
+
if (worker.cleanupGone) cleanup.resolve(code);
|
|
235
|
+
else cleanup.reject(worker.cleanupError ?? incomplete("worker exited without supervisor cleanup acknowledgement"));
|
|
236
|
+
}, error => cleanup.reject(incomplete(`worker exit could not be verified: ${String(error)}`)));
|
|
237
|
+
}
|
|
238
|
+
void worker.exit.then(() => {
|
|
126
239
|
worker.exited = true;
|
|
240
|
+
if (worker.dead) return;
|
|
127
241
|
if (workers.get(key) === worker) workers.delete(key);
|
|
128
242
|
worker.exitTimer = setTimeout(() => retire(key, worker), shutdownTimeoutMs);
|
|
129
243
|
worker.exitTimer.unref();
|
|
@@ -134,24 +248,61 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
134
248
|
return worker;
|
|
135
249
|
}
|
|
136
250
|
|
|
137
|
-
|
|
251
|
+
function turn(key: string, command: string[], cwd: string, env: Record<string, string>, text: string, signal: AbortSignal, report?: MessengerActivityReporter, policyKey?: string): Promise<string> {
|
|
252
|
+
if (closed || signal.aborted) return Promise.reject(new Error("Messenger request aborted"));
|
|
253
|
+
const run = () => runTurn(key, command, cwd, env, text, signal, report, policyKey).finally(release);
|
|
254
|
+
const admitRun = () => {
|
|
255
|
+
if (closed || signal.aborted) return Promise.reject(new Error("Messenger request aborted"));
|
|
256
|
+
if (admitted < limit && waiting.length === 0) {
|
|
257
|
+
admitted++;
|
|
258
|
+
return run();
|
|
259
|
+
}
|
|
260
|
+
return admit(signal).then(run);
|
|
261
|
+
};
|
|
262
|
+
// Same-key turns run strictly in arrival order. Queued turns hold no admission slot while they wait,
|
|
263
|
+
// so they never block other sessions; different keys are fully independent.
|
|
264
|
+
const previous = keyTails.get(key);
|
|
265
|
+
const result = previous ? waitForTurn(previous, signal).then(admitRun) : admitRun();
|
|
266
|
+
const settled = result.then(() => undefined, () => undefined);
|
|
267
|
+
// The tail also covers the predecessor, so an aborted queued turn never lets its successor overtake it.
|
|
268
|
+
const tail: Promise<void> = previous ? Promise.all([previous, settled]).then(() => undefined) : settled;
|
|
269
|
+
keyTails.set(key, tail);
|
|
270
|
+
void tail.then(() => { if (keyTails.get(key) === tail) keyTails.delete(key); });
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function waitForTurn(previous: Promise<void>, signal: AbortSignal): Promise<void> {
|
|
275
|
+
return new Promise<void>((resolve, reject) => {
|
|
276
|
+
const abort = () => { cleanup(); reject(new Error("Messenger request aborted")); };
|
|
277
|
+
const cleanup = () => { signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
|
|
278
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
279
|
+
closedSignal.signal.addEventListener("abort", abort, { once: true });
|
|
280
|
+
if (signal.aborted || closed) { abort(); return; }
|
|
281
|
+
void previous.then(() => { cleanup(); resolve(); });
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function runTurn(key: string, command: string[], cwd: string, env: Record<string, string>, text: string, signal: AbortSignal, report?: MessengerActivityReporter, policyKey?: string): Promise<string> {
|
|
138
286
|
if (closed || signal.aborted) throw new Error("Messenger request aborted");
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
});
|
|
287
|
+
for (;;) {
|
|
288
|
+
const existing = workers.get(key);
|
|
289
|
+
if (existing && existing.policyKey !== policyKey) {
|
|
290
|
+
if (existing.pending) throw new Error("Messenger conversation is already active");
|
|
291
|
+
retire(key, existing);
|
|
292
|
+
}
|
|
293
|
+
const previousExit = retiring.get(key);
|
|
294
|
+
if (!previousExit) break;
|
|
295
|
+
await waitForExit(previousExit, signal);
|
|
149
296
|
}
|
|
150
297
|
if (closed || signal.aborted) throw new Error("Messenger request aborted");
|
|
151
298
|
if ([...allWorkers].some(([worker, workerKey]) => workerKey === key && worker.pending)) {
|
|
152
299
|
throw new Error("Messenger conversation is already active");
|
|
153
300
|
}
|
|
154
|
-
const worker = workers.get(key) ?? start(key, command, cwd, env);
|
|
301
|
+
const worker = workers.get(key) ?? await start(key, command, cwd, env, signal, policyKey);
|
|
302
|
+
if (closed || signal.aborted || worker.dead) {
|
|
303
|
+
retire(key, worker);
|
|
304
|
+
throw new Error("Messenger worker stopped");
|
|
305
|
+
}
|
|
155
306
|
if (worker.pending) throw new Error("Messenger conversation is already active");
|
|
156
307
|
if (worker.idleTimer) clearTimeout(worker.idleTimer);
|
|
157
308
|
worker.idleTimer = undefined;
|
|
@@ -179,15 +330,18 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
179
330
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
180
331
|
try {
|
|
181
332
|
return await Promise.race([
|
|
182
|
-
Promise.allSettled([...exits.keys()]).then(
|
|
333
|
+
Promise.allSettled([...exits.keys()]).then(results => {
|
|
334
|
+
const failure = results.find(result => result.status === "rejected");
|
|
335
|
+
if (failure?.status === "rejected" && requireSandboxCleanup) throw incomplete(`worker cleanup failed: ${String(failure.reason)}`);
|
|
336
|
+
return true;
|
|
337
|
+
}),
|
|
183
338
|
new Promise<false>(resolve => { timer = setTimeout(() => resolve(false), shutdownTimeoutMs); }),
|
|
184
339
|
]);
|
|
185
340
|
} finally { if (timer) clearTimeout(timer); }
|
|
186
341
|
};
|
|
187
342
|
if (await waitForExits()) return;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
343
|
+
if (requireSandboxCleanup) throw incomplete("worker cleanup is still pending; cleanup owner retained");
|
|
344
|
+
for (const worker of exits.values()) forceKill(worker);
|
|
191
345
|
if (!(await waitForExits())) throw new Error("Messenger workers did not exit after forced termination");
|
|
192
346
|
}
|
|
193
347
|
|