omnirush 0.8.4 → 0.8.5
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.
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Children get OMNIRUSH_PARENT_SESSION=<parent session id> in their
|
|
16
16
|
// environment: a child process uploads nothing itself.
|
|
17
|
+
//
|
|
18
|
+
// A child has no wall-clock limit unless the caller sets one: it runs until
|
|
19
|
+
// it is done. The only automatic stop is an inactivity watchdog (no output
|
|
20
|
+
// at all for CHILD_INACTIVITY_MS, longer while a tool runs). AgentManager
|
|
21
|
+
// runs children blocking or in the background and hands finished
|
|
22
|
+
// background results back for delivery to the parent.
|
|
17
23
|
|
|
18
24
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
19
25
|
import { randomUUID } from "node:crypto";
|
|
@@ -22,8 +28,25 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
|
22
28
|
import { tmpdir } from "node:os";
|
|
23
29
|
import path from "node:path";
|
|
24
30
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Inactivity watchdog default: a child that prints nothing (no model
|
|
33
|
+
* stream, no tool event) for this long is treated as hung and stopped.
|
|
34
|
+
* There is no wall-clock limit by default (a child works as long as it
|
|
35
|
+
* needs); OMNIRUSH_AGENT_INACTIVITY_MINUTES overrides the window and 0
|
|
36
|
+
* turns the watchdog off.
|
|
37
|
+
*/
|
|
38
|
+
export const CHILD_INACTIVITY_MS = 30 * 60_000;
|
|
39
|
+
/** While a child's tool call runs (a build, a polling `sleep`), its silence window is this many times longer. */
|
|
40
|
+
export const TOOL_INACTIVITY_FACTOR = 4;
|
|
41
|
+
|
|
42
|
+
/** The watchdog window from the environment (ms; 0 = off). */
|
|
43
|
+
export function childInactivityMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
44
|
+
const raw = String(env.OMNIRUSH_AGENT_INACTIVITY_MINUTES ?? "").trim().toLowerCase();
|
|
45
|
+
if (!raw) return CHILD_INACTIVITY_MS;
|
|
46
|
+
if (raw === "off" || raw === "0") return 0;
|
|
47
|
+
const minutes = Number(raw);
|
|
48
|
+
return Number.isFinite(minutes) && minutes > 0 ? Math.round(minutes * 60_000) : CHILD_INACTIVITY_MS;
|
|
49
|
+
}
|
|
27
50
|
/** Grace between SIGTERM and SIGKILL on timeout. */
|
|
28
51
|
export const CHILD_KILL_GRACE_MS = 5_000;
|
|
29
52
|
/** Per-child output cap in the structured result (bytes, UTF-8). */
|
|
@@ -133,10 +156,10 @@ export function buildChildArgs(
|
|
|
133
156
|
* Write a role's system prompt to a private temp file and run `body`
|
|
134
157
|
* with its path; the file is cleaned up afterwards either way.
|
|
135
158
|
*/
|
|
136
|
-
export async function withRolePromptFile(
|
|
159
|
+
export async function withRolePromptFile<T>(
|
|
137
160
|
role: AgentRole,
|
|
138
|
-
body: (promptFilePath: string) => Promise<
|
|
139
|
-
): Promise<
|
|
161
|
+
body: (promptFilePath: string) => Promise<T>,
|
|
162
|
+
): Promise<T> {
|
|
140
163
|
let dir: string | null = null;
|
|
141
164
|
try {
|
|
142
165
|
dir = await mkdtemp(path.join(tmpdir(), "omnirush-agent-"));
|
|
@@ -157,7 +180,7 @@ export interface ChildTask {
|
|
|
157
180
|
model?: string;
|
|
158
181
|
}
|
|
159
182
|
|
|
160
|
-
export type ChildStatus = "completed" | "failed" | "timeout" | "cancelled";
|
|
183
|
+
export type ChildStatus = "completed" | "failed" | "timeout" | "stalled" | "cancelled" | "interrupted";
|
|
161
184
|
|
|
162
185
|
export interface ChildResult {
|
|
163
186
|
role: AgentRole;
|
|
@@ -264,8 +287,25 @@ export function parseChildEvents(stdout: string): ParsedChildEvents {
|
|
|
264
287
|
export interface SpawnChildOptions {
|
|
265
288
|
cwd: string;
|
|
266
289
|
parentSessionId: string;
|
|
290
|
+
/**
|
|
291
|
+
* Explicit wall-clock limit (the tool's `timeout_minutes`). Undefined =
|
|
292
|
+
* none: a child runs until it is done, stalls (inactivityMs) or is
|
|
293
|
+
* cancelled.
|
|
294
|
+
*/
|
|
267
295
|
timeoutMs?: number;
|
|
268
|
-
/**
|
|
296
|
+
/**
|
|
297
|
+
* Inactivity watchdog: a child that prints no event for this long is
|
|
298
|
+
* treated as hung and killed (status "stalled"). While one of its tools
|
|
299
|
+
* is running (a long build, a `sleep` poll), the window is
|
|
300
|
+
* TOOL_INACTIVITY_FACTOR times longer. Default: childInactivityMs(env);
|
|
301
|
+
* 0 disables the watchdog.
|
|
302
|
+
*/
|
|
303
|
+
inactivityMs?: number;
|
|
304
|
+
/**
|
|
305
|
+
* Kill the child when this fires (cancellation). An abort whose reason is
|
|
306
|
+
* "interrupted" (the session ended under it) reports status
|
|
307
|
+
* "interrupted"; any other abort reports "cancelled".
|
|
308
|
+
*/
|
|
269
309
|
signal?: AbortSignal;
|
|
270
310
|
/** Grace between SIGTERM and SIGKILL when killing (default CHILD_KILL_GRACE_MS). */
|
|
271
311
|
killGraceMs?: number;
|
|
@@ -273,23 +313,52 @@ export interface SpawnChildOptions {
|
|
|
273
313
|
now?: () => number;
|
|
274
314
|
/** Progress callback: partial output lines as the child runs. */
|
|
275
315
|
onChildStdout?: (role: AgentRole, chunk: string) => void;
|
|
316
|
+
/** Every sign of life from the child (any output), with its event types. */
|
|
317
|
+
onActivity?: (activity: ChildActivity) => void;
|
|
276
318
|
/** The child's session id (a fresh UUID by default). */
|
|
277
319
|
sessionId?: string;
|
|
278
320
|
}
|
|
279
321
|
|
|
322
|
+
export interface ChildActivity {
|
|
323
|
+
at: number;
|
|
324
|
+
/** Assistant messages finished so far. */
|
|
325
|
+
turns: number;
|
|
326
|
+
/** Tool calls running right now. */
|
|
327
|
+
toolsRunning: number;
|
|
328
|
+
}
|
|
329
|
+
|
|
280
330
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
|
|
331
|
+
* The collector reaches the running sub-agents through this global (it must
|
|
332
|
+
* stop them before its last capture; pi runs its session_shutdown handler
|
|
333
|
+
* before the agents extension's).
|
|
334
|
+
*/
|
|
335
|
+
export const AGENTS_HOOK = Symbol.for("omnirush.agents");
|
|
336
|
+
export interface AgentsHook {
|
|
337
|
+
/** Stop every unfinished child (of one session, or all); resolves with them once they exited. */
|
|
338
|
+
interruptAll(parentSessionId?: string): Promise<Array<{ sessionId: string; id: string; role: string }>>;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Reason passed to AbortController.abort() when the session ends under a child. */
|
|
342
|
+
export const INTERRUPTED_REASON = "interrupted";
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Run ONE child agent to completion. No wall-clock limit unless the caller
|
|
346
|
+
* sets timeoutMs; the only automatic kill is the inactivity watchdog (a
|
|
347
|
+
* child silent for the whole window). The child inherits the parent's
|
|
348
|
+
* environment plus OMNIRUSH_PARENT_SESSION. On a kill: SIGTERM, a short
|
|
349
|
+
* grace, then SIGKILL; whatever the child printed so far is kept as the
|
|
350
|
+
* partial result.
|
|
285
351
|
*/
|
|
286
352
|
export async function runChildAgent(
|
|
287
353
|
task: ChildTask,
|
|
288
354
|
options: SpawnChildOptions,
|
|
289
355
|
): Promise<ChildResult> {
|
|
290
|
-
const
|
|
291
|
-
const startedAt =
|
|
292
|
-
const timeoutMs =
|
|
356
|
+
const now = options.now ?? Date.now;
|
|
357
|
+
const startedAt = now();
|
|
358
|
+
const timeoutMs = typeof options.timeoutMs === "number" && options.timeoutMs > 0
|
|
359
|
+
? Math.max(1_000, options.timeoutMs)
|
|
360
|
+
: null;
|
|
361
|
+
const inactivityMs = options.inactivityMs ?? childInactivityMs();
|
|
293
362
|
const spawnImpl = options.spawnImpl ?? nodeSpawn;
|
|
294
363
|
const sessionId = options.sessionId ?? randomUUID();
|
|
295
364
|
|
|
@@ -300,8 +369,14 @@ export async function runChildAgent(
|
|
|
300
369
|
return await new Promise<ChildResult>((resolvePromise) => {
|
|
301
370
|
let stdout = "";
|
|
302
371
|
let stderr = "";
|
|
372
|
+
let pendingLine = "";
|
|
303
373
|
let settled = false;
|
|
304
|
-
let killedFor: "timeout" | "cancelled" | null = null;
|
|
374
|
+
let killedFor: "timeout" | "stalled" | "cancelled" | "interrupted" | null = null;
|
|
375
|
+
let turns = 0;
|
|
376
|
+
const toolsRunning = new Set<string>();
|
|
377
|
+
let lastActivity = now();
|
|
378
|
+
let watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
379
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
305
380
|
|
|
306
381
|
const child = spawnImpl(invocation.command, invocation.args, {
|
|
307
382
|
cwd: options.cwd,
|
|
@@ -316,13 +391,12 @@ export async function runChildAgent(
|
|
|
316
391
|
const finish = (input: ChildResultInput) => {
|
|
317
392
|
if (settled) return;
|
|
318
393
|
settled = true;
|
|
319
|
-
clearTimeout(timer);
|
|
394
|
+
if (timer) clearTimeout(timer);
|
|
395
|
+
if (watchdog) clearTimeout(watchdog);
|
|
320
396
|
if (options.signal && abortHandler) {
|
|
321
397
|
options.signal.removeEventListener("abort", abortHandler);
|
|
322
398
|
}
|
|
323
|
-
resolvePromise(
|
|
324
|
-
buildChildResult(task, input, (options.now ?? Date.now)() - startedAt, sessionId),
|
|
325
|
-
);
|
|
399
|
+
resolvePromise(buildChildResult(task, input, now() - startedAt, sessionId));
|
|
326
400
|
};
|
|
327
401
|
|
|
328
402
|
const killTree = () => {
|
|
@@ -340,32 +414,68 @@ export async function runChildAgent(
|
|
|
340
414
|
}, Math.max(50, options.killGraceMs ?? CHILD_KILL_GRACE_MS)).unref?.();
|
|
341
415
|
};
|
|
342
416
|
|
|
343
|
-
const
|
|
344
|
-
if (settled) return;
|
|
345
|
-
killedFor =
|
|
417
|
+
const kill = (reason: NonNullable<typeof killedFor>) => {
|
|
418
|
+
if (settled || killedFor) return;
|
|
419
|
+
killedFor = reason;
|
|
346
420
|
killTree();
|
|
347
|
-
}
|
|
348
|
-
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// The watchdog re-arms on every sign of life; it fires only after a
|
|
424
|
+
// full window of silence (longer while a tool is running).
|
|
425
|
+
const armWatchdog = () => {
|
|
426
|
+
if (!(inactivityMs > 0) || settled || killedFor) return;
|
|
427
|
+
if (watchdog) clearTimeout(watchdog);
|
|
428
|
+
const window = toolsRunning.size > 0 ? inactivityMs * TOOL_INACTIVITY_FACTOR : inactivityMs;
|
|
429
|
+
watchdog = setTimeout(() => kill("stalled"), window);
|
|
430
|
+
watchdog.unref?.();
|
|
431
|
+
};
|
|
432
|
+
const alive = () => {
|
|
433
|
+
lastActivity = now();
|
|
434
|
+
armWatchdog();
|
|
435
|
+
options.onActivity?.({ at: lastActivity, turns, toolsRunning: toolsRunning.size });
|
|
436
|
+
};
|
|
437
|
+
armWatchdog();
|
|
438
|
+
|
|
439
|
+
if (timeoutMs !== null) {
|
|
440
|
+
timer = setTimeout(() => kill("timeout"), timeoutMs);
|
|
441
|
+
timer.unref?.();
|
|
442
|
+
}
|
|
349
443
|
|
|
350
444
|
const abortHandler = options.signal
|
|
351
|
-
? () =>
|
|
352
|
-
if (settled) return;
|
|
353
|
-
killedFor = "cancelled";
|
|
354
|
-
killTree();
|
|
355
|
-
}
|
|
445
|
+
? () => kill(options.signal?.reason === INTERRUPTED_REASON ? "interrupted" : "cancelled")
|
|
356
446
|
: null;
|
|
357
447
|
if (options.signal && abortHandler) {
|
|
358
448
|
if (options.signal.aborted) abortHandler();
|
|
359
449
|
else options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
360
450
|
}
|
|
361
451
|
|
|
452
|
+
// Tool calls in flight and finished turns, from the event lines.
|
|
453
|
+
const observe = (line: string) => {
|
|
454
|
+
if (!line.includes('"type"')) return;
|
|
455
|
+
let event: any;
|
|
456
|
+
try {
|
|
457
|
+
event = JSON.parse(line);
|
|
458
|
+
} catch {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (event?.type === "tool_execution_start" && typeof event.toolCallId === "string") toolsRunning.add(event.toolCallId);
|
|
462
|
+
else if (event?.type === "tool_execution_end" && typeof event.toolCallId === "string") toolsRunning.delete(event.toolCallId);
|
|
463
|
+
else if (event?.type === "message_end" && event.message?.role === "assistant") turns += 1;
|
|
464
|
+
};
|
|
465
|
+
|
|
362
466
|
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
363
467
|
const text = String(chunk);
|
|
364
468
|
stdout += text;
|
|
469
|
+
const lines = (pendingLine + text).split("\n");
|
|
470
|
+
pendingLine = lines.pop() ?? "";
|
|
471
|
+
for (const line of lines) observe(line);
|
|
472
|
+
alive();
|
|
365
473
|
options.onChildStdout?.(task.role, text);
|
|
366
474
|
});
|
|
367
475
|
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
368
476
|
stderr += String(chunk);
|
|
477
|
+
if (stderr.length > 256 * 1024) stderr = stderr.slice(-64 * 1024);
|
|
478
|
+
alive();
|
|
369
479
|
});
|
|
370
480
|
child.on("error", (error: Error) => {
|
|
371
481
|
finish({
|
|
@@ -376,26 +486,18 @@ export async function runChildAgent(
|
|
|
376
486
|
error: `failed to start: ${error.message}`,
|
|
377
487
|
});
|
|
378
488
|
});
|
|
379
|
-
child.on("close", (code: number | null
|
|
489
|
+
child.on("close", (code: number | null) => {
|
|
380
490
|
const parsed = parseChildEvents(stdout);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (killedFor === "cancelled") {
|
|
392
|
-
finish({
|
|
393
|
-
exitCode: code,
|
|
394
|
-
finalText: parsed.finalText,
|
|
395
|
-
turns: parsed.turns,
|
|
396
|
-
status: "cancelled",
|
|
397
|
-
error: "cancelled by the parent session",
|
|
398
|
-
});
|
|
491
|
+
const partial = parsed.finalText ? " — partial result kept" : "";
|
|
492
|
+
if (killedFor) {
|
|
493
|
+
const error = killedFor === "timeout"
|
|
494
|
+
? `timed out after ${formatMinutes(timeoutMs ?? 0)}${partial}`
|
|
495
|
+
: killedFor === "stalled"
|
|
496
|
+
? `stopped: no activity for ${formatMinutes(toolsRunning.size > 0 ? inactivityMs * TOOL_INACTIVITY_FACTOR : inactivityMs)}${partial}`
|
|
497
|
+
: killedFor === "interrupted"
|
|
498
|
+
? `interrupted: the parent session ended${partial}`
|
|
499
|
+
: `cancelled by the parent session${partial}`;
|
|
500
|
+
finish({ exitCode: code, finalText: parsed.finalText, turns: parsed.turns, status: killedFor, error });
|
|
399
501
|
return;
|
|
400
502
|
}
|
|
401
503
|
finish({
|
|
@@ -405,12 +507,17 @@ export async function runChildAgent(
|
|
|
405
507
|
status: code === 0 ? "completed" : "failed",
|
|
406
508
|
...(code !== 0 && !parsed.finalText && stderr.trim() ? { error: stderr.trim().slice(-500) } : {}),
|
|
407
509
|
});
|
|
408
|
-
void signalName;
|
|
409
510
|
});
|
|
410
511
|
});
|
|
411
512
|
});
|
|
412
513
|
}
|
|
413
514
|
|
|
515
|
+
function formatMinutes(ms: number): string {
|
|
516
|
+
const minutes = ms / 60_000;
|
|
517
|
+
if (minutes >= 1) return `${Math.round(minutes * 10) / 10} min`;
|
|
518
|
+
return `${Math.max(1, Math.round(ms / 1000))} s`;
|
|
519
|
+
}
|
|
520
|
+
|
|
414
521
|
/**
|
|
415
522
|
* Run tasks with a concurrency cap, preserving input order in the
|
|
416
523
|
* results (ports the pi subagent example's mapWithConcurrencyLimit).
|
|
@@ -436,14 +543,366 @@ export async function mapWithConcurrency<TIn, TOut>(
|
|
|
436
543
|
}
|
|
437
544
|
|
|
438
545
|
/** Structured result text for the parent model (one section per child). */
|
|
439
|
-
export function renderChildResults(results: ChildResult[]): string {
|
|
546
|
+
export function renderChildResults(results: ChildResult[], ids?: readonly string[]): string {
|
|
440
547
|
const succeeded = results.filter((result) => result.status === "completed").length;
|
|
441
|
-
const sections = results.map((result) => {
|
|
548
|
+
const sections = results.map((result, index) => {
|
|
442
549
|
const minutes = Math.round((result.durationMs / 60_000) * 10) / 10;
|
|
443
|
-
const header = `### ${result.role}${result.model ? ` [${result.model}]` : ""} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
|
|
550
|
+
const header = `### ${ids?.[index] ? `${ids[index]} ` : ""}${result.role}${result.model ? ` [${result.model}]` : ""} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
|
|
444
551
|
const meta: string[] = [`task: ${result.task}`];
|
|
445
552
|
if (result.error) meta.push(`error: ${result.error}`);
|
|
446
553
|
return `${header}\n${meta.join("\n")}\n\n${result.output || "(no output)"}`;
|
|
447
554
|
});
|
|
448
555
|
return `spawn_agents: ${succeeded}/${results.length} completed\n\n${sections.join("\n\n---\n\n")}`;
|
|
449
556
|
}
|
|
557
|
+
|
|
558
|
+
// --- sub-agent manager: blocking and background dispatch --------------------
|
|
559
|
+
|
|
560
|
+
export type AgentState = "queued" | "running" | ChildStatus;
|
|
561
|
+
|
|
562
|
+
/** One sub-agent of a session, running or finished. */
|
|
563
|
+
export interface AgentRecord {
|
|
564
|
+
/** Short handle the model uses with the agents_* tools ("a1", "a2", ...). */
|
|
565
|
+
id: string;
|
|
566
|
+
/** The spawn_agents call it came from ("b1", ...). */
|
|
567
|
+
batch: string;
|
|
568
|
+
parentSessionId: string;
|
|
569
|
+
/** The child's pi session id (its trace is captured under it). */
|
|
570
|
+
sessionId: string;
|
|
571
|
+
role: AgentRole;
|
|
572
|
+
task: string;
|
|
573
|
+
model?: string;
|
|
574
|
+
/** Dispatched with wait:false: its result comes back as a message. */
|
|
575
|
+
background: boolean;
|
|
576
|
+
/** Background delivery: one message per child, or one per batch. */
|
|
577
|
+
notify: "each" | "batch";
|
|
578
|
+
status: AgentState;
|
|
579
|
+
queuedAt: number;
|
|
580
|
+
startedAt: number | null;
|
|
581
|
+
finishedAt: number | null;
|
|
582
|
+
lastActivityAt: number;
|
|
583
|
+
turns: number;
|
|
584
|
+
toolsRunning: number;
|
|
585
|
+
result: ChildResult | null;
|
|
586
|
+
/** Its result reached the model (tool result or delivered message). */
|
|
587
|
+
delivered: boolean;
|
|
588
|
+
controller: AbortController;
|
|
589
|
+
done: Promise<ChildResult>;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export type AgentRunner = (
|
|
593
|
+
task: ChildTask,
|
|
594
|
+
options: {
|
|
595
|
+
sessionId: string;
|
|
596
|
+
parentSessionId: string;
|
|
597
|
+
signal: AbortSignal;
|
|
598
|
+
onActivity: (activity: ChildActivity) => void;
|
|
599
|
+
/** The call's explicit timeout_minutes, if any. */
|
|
600
|
+
timeoutMs?: number;
|
|
601
|
+
cwd?: string;
|
|
602
|
+
},
|
|
603
|
+
) => Promise<ChildResult>;
|
|
604
|
+
|
|
605
|
+
export interface DispatchOptions {
|
|
606
|
+
background: boolean;
|
|
607
|
+
/** Children running at once (default: all of them). */
|
|
608
|
+
concurrency?: number;
|
|
609
|
+
notify?: "each" | "batch";
|
|
610
|
+
/** Blocking calls: the tool's signal cancels the children. */
|
|
611
|
+
signal?: AbortSignal;
|
|
612
|
+
/** A child settled (progress updates). */
|
|
613
|
+
onSettled?: (record: AgentRecord) => void;
|
|
614
|
+
/** Explicit wall-clock limit per child (none by default). */
|
|
615
|
+
timeoutMs?: number;
|
|
616
|
+
/** Workspace the children run in. */
|
|
617
|
+
cwd?: string;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Tracks every sub-agent of this process per parent session: runs them
|
|
622
|
+
* (through an injected runner), answers status/wait/result/cancel, and
|
|
623
|
+
* queues finished background results for delivery back to the parent
|
|
624
|
+
* (`takeDeliverable`, signalled through `onDeliverable`).
|
|
625
|
+
*/
|
|
626
|
+
export class AgentManager {
|
|
627
|
+
private readonly runner: AgentRunner;
|
|
628
|
+
private readonly now: () => number;
|
|
629
|
+
private readonly onDeliverable: () => void;
|
|
630
|
+
private readonly bySession = new Map<string, AgentRecord[]>();
|
|
631
|
+
private waiters: Array<{ records: AgentRecord[]; resolve: () => void }> = [];
|
|
632
|
+
private changeWaiters: Array<() => void> = [];
|
|
633
|
+
private readonly resolvers = new Map<AgentRecord, (result: ChildResult) => void>();
|
|
634
|
+
private nextAgent = 1;
|
|
635
|
+
private nextBatch = 1;
|
|
636
|
+
|
|
637
|
+
constructor(options: { run: AgentRunner; now?: () => number; onDeliverable?: () => void }) {
|
|
638
|
+
this.runner = options.run;
|
|
639
|
+
this.now = options.now ?? Date.now;
|
|
640
|
+
this.onDeliverable = options.onDeliverable ?? (() => undefined);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
dispatch(
|
|
644
|
+
parentSessionId: string,
|
|
645
|
+
tasks: ChildTask[],
|
|
646
|
+
options: DispatchOptions,
|
|
647
|
+
): { batch: string; records: AgentRecord[]; done: Promise<ChildResult[]> } {
|
|
648
|
+
const batch = `b${this.nextBatch++}`;
|
|
649
|
+
const list = this.bySession.get(parentSessionId) ?? [];
|
|
650
|
+
this.bySession.set(parentSessionId, list);
|
|
651
|
+
const at = this.now();
|
|
652
|
+
const records = tasks.map((task) => {
|
|
653
|
+
let resolveDone!: (result: ChildResult) => void;
|
|
654
|
+
const done = new Promise<ChildResult>((resolve) => { resolveDone = resolve; });
|
|
655
|
+
const record: AgentRecord = {
|
|
656
|
+
id: `a${this.nextAgent++}`,
|
|
657
|
+
batch,
|
|
658
|
+
parentSessionId,
|
|
659
|
+
sessionId: randomUUID(),
|
|
660
|
+
role: task.role,
|
|
661
|
+
task: task.task,
|
|
662
|
+
...(task.model ? { model: task.model } : {}),
|
|
663
|
+
background: options.background,
|
|
664
|
+
notify: options.notify ?? "batch",
|
|
665
|
+
status: "queued",
|
|
666
|
+
queuedAt: at,
|
|
667
|
+
startedAt: null,
|
|
668
|
+
finishedAt: null,
|
|
669
|
+
lastActivityAt: at,
|
|
670
|
+
turns: 0,
|
|
671
|
+
toolsRunning: 0,
|
|
672
|
+
result: null,
|
|
673
|
+
// A blocking call returns its results itself.
|
|
674
|
+
delivered: !options.background,
|
|
675
|
+
controller: new AbortController(),
|
|
676
|
+
done,
|
|
677
|
+
};
|
|
678
|
+
this.resolvers.set(record, resolveDone);
|
|
679
|
+
list.push(record);
|
|
680
|
+
return record;
|
|
681
|
+
});
|
|
682
|
+
if (options.signal) {
|
|
683
|
+
const abort = () => { void this.cancel(records); };
|
|
684
|
+
if (options.signal.aborted) abort();
|
|
685
|
+
else options.signal.addEventListener("abort", abort, { once: true });
|
|
686
|
+
}
|
|
687
|
+
const concurrency = Math.max(1, Math.floor(options.concurrency ?? records.length));
|
|
688
|
+
void mapWithConcurrency(records, concurrency, async (record, index) => {
|
|
689
|
+
// Cancelled while it waited for a slot: it never starts.
|
|
690
|
+
if (!record.result) await this.start(record, tasks[index], options);
|
|
691
|
+
options.onSettled?.(record);
|
|
692
|
+
});
|
|
693
|
+
return { batch, records, done: Promise.all(records.map((record) => record.done)) };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private async start(record: AgentRecord, task: ChildTask, options: DispatchOptions): Promise<void> {
|
|
697
|
+
record.status = "running";
|
|
698
|
+
record.startedAt = this.now();
|
|
699
|
+
record.lastActivityAt = record.startedAt;
|
|
700
|
+
let result: ChildResult;
|
|
701
|
+
try {
|
|
702
|
+
result = await this.runner(task, {
|
|
703
|
+
sessionId: record.sessionId,
|
|
704
|
+
parentSessionId: record.parentSessionId,
|
|
705
|
+
signal: record.controller.signal,
|
|
706
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
707
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
708
|
+
onActivity: (activity) => {
|
|
709
|
+
record.lastActivityAt = activity.at;
|
|
710
|
+
record.turns = activity.turns;
|
|
711
|
+
record.toolsRunning = activity.toolsRunning;
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
} catch (error) {
|
|
715
|
+
result = buildChildResult(task, {
|
|
716
|
+
exitCode: null,
|
|
717
|
+
finalText: "",
|
|
718
|
+
turns: record.turns,
|
|
719
|
+
status: "failed",
|
|
720
|
+
error: error instanceof Error ? error.message : String(error),
|
|
721
|
+
}, this.now() - (record.startedAt ?? this.now()), record.sessionId);
|
|
722
|
+
}
|
|
723
|
+
this.settle(record, result);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private settle(record: AgentRecord, result: ChildResult): void {
|
|
727
|
+
if (record.result) return;
|
|
728
|
+
record.result = result;
|
|
729
|
+
record.status = result.status;
|
|
730
|
+
record.finishedAt = this.now();
|
|
731
|
+
record.turns = result.turns;
|
|
732
|
+
record.toolsRunning = 0;
|
|
733
|
+
this.resolvers.get(record)?.(result);
|
|
734
|
+
this.resolvers.delete(record);
|
|
735
|
+
// Waiters first: what they return is delivered through their tool result.
|
|
736
|
+
const still: typeof this.waiters = [];
|
|
737
|
+
for (const waiter of this.waiters) {
|
|
738
|
+
if (waiter.records.every((candidate) => candidate.result)) {
|
|
739
|
+
waiter.records.forEach((candidate) => { candidate.delivered = true; });
|
|
740
|
+
waiter.resolve();
|
|
741
|
+
} else {
|
|
742
|
+
still.push(waiter);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
this.waiters = still;
|
|
746
|
+
const changed = this.changeWaiters;
|
|
747
|
+
this.changeWaiters = [];
|
|
748
|
+
changed.forEach((resolve) => resolve());
|
|
749
|
+
if (record.background && !record.delivered) this.onDeliverable();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
list(parentSessionId: string): AgentRecord[] {
|
|
753
|
+
return [...(this.bySession.get(parentSessionId) ?? [])];
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Records by id (or all of the session's when `ids` is empty); unknown ids are listed apart. */
|
|
757
|
+
resolve(parentSessionId: string, ids: readonly string[] | undefined): { records: AgentRecord[]; unknown: string[] } {
|
|
758
|
+
const all = this.list(parentSessionId);
|
|
759
|
+
if (!ids || ids.length === 0 || ids.includes("all")) return { records: all, unknown: [] };
|
|
760
|
+
const records: AgentRecord[] = [];
|
|
761
|
+
const unknown: string[] = [];
|
|
762
|
+
for (const raw of ids) {
|
|
763
|
+
const id = String(raw).trim();
|
|
764
|
+
const record = all.find((candidate) => candidate.id === id || candidate.sessionId === id);
|
|
765
|
+
if (record && !records.includes(record)) records.push(record);
|
|
766
|
+
else if (!record) unknown.push(id);
|
|
767
|
+
}
|
|
768
|
+
return { records, unknown };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Children not finished yet (queued or running), of one session or of all. */
|
|
772
|
+
active(parentSessionId?: string): AgentRecord[] {
|
|
773
|
+
const lists = parentSessionId ? [this.list(parentSessionId)] : [...this.bySession.values()];
|
|
774
|
+
return lists.flat().filter((record) => !record.result);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Background results nobody has seen yet, of one session or of all. */
|
|
778
|
+
hasPending(parentSessionId?: string): boolean {
|
|
779
|
+
const lists = parentSessionId ? [this.list(parentSessionId)] : [...this.bySession.values()];
|
|
780
|
+
return lists.flat().some((record) => record.background && !record.delivered);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Finished background results ready to go back to the parent, grouped per
|
|
785
|
+
* delivery (a whole batch, or one child with notify "each"); marked
|
|
786
|
+
* delivered.
|
|
787
|
+
*/
|
|
788
|
+
takeDeliverable(parentSessionId?: string): Array<{ parentSessionId: string; records: AgentRecord[] }> {
|
|
789
|
+
const out: Array<{ parentSessionId: string; records: AgentRecord[] }> = [];
|
|
790
|
+
const sessions = parentSessionId ? [parentSessionId] : [...this.bySession.keys()];
|
|
791
|
+
for (const session of sessions) {
|
|
792
|
+
const records = this.list(session).filter((record) => record.background);
|
|
793
|
+
const batches = new Map<string, AgentRecord[]>();
|
|
794
|
+
for (const record of records) batches.set(record.batch, [...(batches.get(record.batch) ?? []), record]);
|
|
795
|
+
for (const members of batches.values()) {
|
|
796
|
+
const ready = members.filter((record) => record.result && !record.delivered);
|
|
797
|
+
if (ready.length === 0) continue;
|
|
798
|
+
if (members[0].notify === "batch" && members.some((record) => !record.result)) continue;
|
|
799
|
+
ready.forEach((record) => { record.delivered = true; });
|
|
800
|
+
out.push({ parentSessionId: session, records: ready });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Wait until every one of `records` finished, the wait's own limit passed
|
|
808
|
+
* or `signal` fired (the children keep running then). Finished ones are
|
|
809
|
+
* marked delivered: the caller returns them.
|
|
810
|
+
*/
|
|
811
|
+
async wait(records: AgentRecord[], options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<void> {
|
|
812
|
+
if (!records.every((record) => record.result)) {
|
|
813
|
+
await new Promise<void>((resolve) => {
|
|
814
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
815
|
+
const waiter = {
|
|
816
|
+
records,
|
|
817
|
+
resolve: () => {
|
|
818
|
+
if (timer) clearTimeout(timer);
|
|
819
|
+
options.signal?.removeEventListener("abort", stop);
|
|
820
|
+
resolve();
|
|
821
|
+
},
|
|
822
|
+
};
|
|
823
|
+
const stop = () => {
|
|
824
|
+
this.waiters = this.waiters.filter((candidate) => candidate !== waiter);
|
|
825
|
+
waiter.resolve();
|
|
826
|
+
};
|
|
827
|
+
this.waiters.push(waiter);
|
|
828
|
+
if (options.timeoutMs && options.timeoutMs > 0) timer = setTimeout(stop, options.timeoutMs);
|
|
829
|
+
if (options.signal?.aborted) stop();
|
|
830
|
+
else options.signal?.addEventListener("abort", stop, { once: true });
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
records.filter((record) => record.result).forEach((record) => { record.delivered = true; });
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Resolves at the next child that settles. */
|
|
837
|
+
changed(): Promise<void> {
|
|
838
|
+
return new Promise((resolve) => this.changeWaiters.push(resolve));
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Stop children (SIGTERM, then SIGKILL) and wait for them to exit. */
|
|
842
|
+
async cancel(records: AgentRecord[], reason: string = "cancelled", options: { delivered?: boolean } = {}): Promise<void> {
|
|
843
|
+
const live = records.filter((record) => !record.result);
|
|
844
|
+
// The caller hands the results over itself: no delivery message.
|
|
845
|
+
if (options.delivered) live.forEach((record) => { record.delivered = true; });
|
|
846
|
+
for (const record of live) {
|
|
847
|
+
record.controller.abort(reason);
|
|
848
|
+
if (record.startedAt === null) {
|
|
849
|
+
// Still waiting for a slot: settled here, it never starts.
|
|
850
|
+
this.settle(record, buildChildResult(record, {
|
|
851
|
+
exitCode: null,
|
|
852
|
+
finalText: "",
|
|
853
|
+
turns: 0,
|
|
854
|
+
status: reason === INTERRUPTED_REASON ? "interrupted" : "cancelled",
|
|
855
|
+
error: "cancelled before it started",
|
|
856
|
+
}, 0, record.sessionId));
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
await Promise.all(live.map((record) => record.done));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** The session ends: every unfinished child is interrupted; returns them once they exited. */
|
|
863
|
+
async interruptAll(parentSessionId?: string): Promise<AgentRecord[]> {
|
|
864
|
+
const live = this.active(parentSessionId);
|
|
865
|
+
// Nobody is left to read them.
|
|
866
|
+
await this.cancel(live, INTERRUPTED_REASON, { delivered: true });
|
|
867
|
+
return live;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Minutes, rounded for status lines. */
|
|
872
|
+
function minutesOf(ms: number): number {
|
|
873
|
+
return Math.round((ms / 60_000) * 10) / 10;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** One status line per sub-agent (agents_status, /agents). */
|
|
877
|
+
export function renderAgentStatus(records: AgentRecord[], now: number = Date.now()): string {
|
|
878
|
+
if (records.length === 0) return "No sub-agents in this session.";
|
|
879
|
+
const lines = records.map((record) => {
|
|
880
|
+
const since = record.startedAt ?? record.queuedAt;
|
|
881
|
+
const elapsed = minutesOf((record.finishedAt ?? now) - since);
|
|
882
|
+
const bits = [`${record.id}`, `[${record.status}]`, record.role];
|
|
883
|
+
if (record.model) bits.push(`[${record.model}]`);
|
|
884
|
+
bits.push(record.background ? "background" : "blocking");
|
|
885
|
+
const detail: string[] = [`${elapsed} min`];
|
|
886
|
+
if (!record.result && record.startedAt) {
|
|
887
|
+
detail.push(`${record.turns} turns`);
|
|
888
|
+
if (record.toolsRunning > 0) detail.push("tool running");
|
|
889
|
+
detail.push(`last activity ${minutesOf(now - record.lastActivityAt)} min ago`);
|
|
890
|
+
}
|
|
891
|
+
if (record.result && !record.delivered) detail.push("result not yet read");
|
|
892
|
+
const task = record.task.length > 80 ? `${record.task.slice(0, 79)}…` : record.task;
|
|
893
|
+
return `- ${bits.join(" ")} (${detail.join(", ")}) — ${task}`;
|
|
894
|
+
});
|
|
895
|
+
const running = records.filter((record) => !record.result).length;
|
|
896
|
+
return `${records.length} sub-agents, ${running} running:\n${lines.join("\n")}`;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** The message that brings finished background agents back to the parent. */
|
|
900
|
+
export function renderDelivery(records: AgentRecord[]): string {
|
|
901
|
+
const results = records.map((record) => record.result).filter((result): result is ChildResult => Boolean(result));
|
|
902
|
+
const ids = records.map((record) => record.id).join(", ");
|
|
903
|
+
return [
|
|
904
|
+
`Background sub-agents finished (${ids}). Their results follow; continue your work with them.`,
|
|
905
|
+
"",
|
|
906
|
+
renderChildResults(results, records.map((record) => record.id)),
|
|
907
|
+
].join("\n");
|
|
908
|
+
}
|