@dreb/coding-agent 2.71.0 → 2.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/core/agent-session.d.ts +37 -9
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +226 -149
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/thinking.d.ts +2 -0
- package/dist/core/thinking.d.ts.map +1 -1
- package/dist/core/thinking.js +20 -14
- package/dist/core/thinking.js.map +1 -1
- package/dist/core/tools/index.d.ts +1 -1
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +1 -1
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/subagent.d.ts +77 -6
- package/dist/core/tools/subagent.d.ts.map +1 -1
- package/dist/core/tools/subagent.js +312 -64
- package/dist/core/tools/subagent.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +6 -8
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +9 -5
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +6 -0
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +9 -0
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +7 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +11 -2
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/docs/agent-models.md +2 -2
- package/docs/dashboard.md +46 -3
- package/docs/json.md +2 -2
- package/docs/models.md +1 -1
- package/docs/providers.md +26 -0
- package/docs/rpc.md +25 -6
- package/docs/settings.md +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync, } from "node:fs";
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { basename, join, resolve } from "node:path";
|
|
6
6
|
import { completeSimple } from "@dreb/ai";
|
|
@@ -238,6 +238,13 @@ export function handleChildJsonlLine(line, sinks) {
|
|
|
238
238
|
sinks.onProgress(`${sinks.toolNameRef.current} done`);
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* How long a gracefully aborted (RPC `abort`) child may take to finish its run and
|
|
243
|
+
* emit `agent_end` before it is terminated with SIGTERM.
|
|
244
|
+
*/
|
|
245
|
+
export const SUBAGENT_ABORT_GRACE_MS = 5000;
|
|
246
|
+
/** Delay between SIGTERM and the SIGKILL backstop for a child that is still alive. */
|
|
247
|
+
export const SUBAGENT_SIGKILL_BACKSTOP_MS = 5000;
|
|
241
248
|
async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentProvider, sessionDir, parentSessionFile, onChildEvent, thinkingOverride, onControlAvailable) {
|
|
242
249
|
const drebBin = findDrebBinary();
|
|
243
250
|
log.debug(`[subagent] spawn: agent=${agentConfig.name} cwd=${cwd}`);
|
|
@@ -325,6 +332,7 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
|
|
|
325
332
|
}
|
|
326
333
|
let settled = false;
|
|
327
334
|
let killTimer = null;
|
|
335
|
+
let abortGraceTimer = null;
|
|
328
336
|
const collectedMessages = [];
|
|
329
337
|
const stderrChunks = [];
|
|
330
338
|
let stderrSize = 0;
|
|
@@ -335,6 +343,8 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
|
|
|
335
343
|
let resolvedThinking;
|
|
336
344
|
let rpcRequestId = 0;
|
|
337
345
|
let rpcCompleted = false;
|
|
346
|
+
/** Set once a graceful RPC `abort` has been sent for a user stop. */
|
|
347
|
+
let gracefulAbortRequested = false;
|
|
338
348
|
let controlError;
|
|
339
349
|
const pendingRpc = new Map();
|
|
340
350
|
const sendRpc = (command) => {
|
|
@@ -353,16 +363,11 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
|
|
|
353
363
|
getState: async () => sendRpc({ type: "get_state" }),
|
|
354
364
|
}
|
|
355
365
|
: undefined;
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
const
|
|
361
|
-
if (!controlError)
|
|
362
|
-
controlError = err;
|
|
363
|
-
for (const request of pendingRpc.values())
|
|
364
|
-
request.reject(err);
|
|
365
|
-
pendingRpc.clear();
|
|
366
|
+
// Escalating termination: SIGTERM now, SIGKILL if the process is still alive
|
|
367
|
+
// after the backstop delay. Liveness is judged by exitCode/signalCode —
|
|
368
|
+
// `proc.killed` only reports that a signal was *sent*, so it is already true
|
|
369
|
+
// after the SIGTERM and cannot gate the SIGKILL escalation.
|
|
370
|
+
const forceKill = () => {
|
|
366
371
|
try {
|
|
367
372
|
proc.kill("SIGTERM");
|
|
368
373
|
}
|
|
@@ -370,14 +375,41 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
|
|
|
370
375
|
/* process already exited */
|
|
371
376
|
}
|
|
372
377
|
killTimer ??= setTimeout(() => {
|
|
378
|
+
if (proc.exitCode !== null || proc.signalCode !== null)
|
|
379
|
+
return;
|
|
373
380
|
try {
|
|
374
|
-
|
|
375
|
-
proc.kill("SIGKILL");
|
|
381
|
+
proc.kill("SIGKILL");
|
|
376
382
|
}
|
|
377
383
|
catch {
|
|
378
384
|
/* process already exited */
|
|
379
385
|
}
|
|
380
|
-
},
|
|
386
|
+
}, SUBAGENT_SIGKILL_BACKSTOP_MS);
|
|
387
|
+
};
|
|
388
|
+
// Terminally fail a controlled child: remember the first failure, reject all
|
|
389
|
+
// in-flight control requests, and stop the process (with the same SIGKILL
|
|
390
|
+
// backstop as the abort path) so the close handler settles instead of the
|
|
391
|
+
// child idling in RPC mode forever.
|
|
392
|
+
const failControlledChild = (err) => {
|
|
393
|
+
if (!controlError)
|
|
394
|
+
controlError = err;
|
|
395
|
+
for (const request of pendingRpc.values())
|
|
396
|
+
request.reject(err);
|
|
397
|
+
pendingRpc.clear();
|
|
398
|
+
forceKill();
|
|
399
|
+
};
|
|
400
|
+
// Ask a controlled child to abort its own run (graceful stop). If the request cannot
|
|
401
|
+
// be delivered or the child refuses it, terminate the child instead.
|
|
402
|
+
const requestGracefulAbort = () => {
|
|
403
|
+
if (!proc.stdin?.writable) {
|
|
404
|
+
forceKill();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
sendRpc({ type: "abort" }).catch((err) => {
|
|
408
|
+
if (settled)
|
|
409
|
+
return;
|
|
410
|
+
log.warn(`[subagent] graceful abort failed (agent=${agentConfig.name}): ${err instanceof Error ? err.message : String(err)}; terminating child`);
|
|
411
|
+
forceKill();
|
|
412
|
+
});
|
|
381
413
|
};
|
|
382
414
|
proc.stdin?.on("error", (err) => {
|
|
383
415
|
log.warn(`[subagent] stdin stream error (agent=${agentConfig.name}): ${err.message}`);
|
|
@@ -405,7 +437,17 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
|
|
|
405
437
|
const message = JSON.parse(line);
|
|
406
438
|
if (message?.type === "agent_end" && !rpcCompleted) {
|
|
407
439
|
rpcCompleted = true;
|
|
408
|
-
setImmediate(
|
|
440
|
+
setImmediate(forceKill);
|
|
441
|
+
}
|
|
442
|
+
if (message?.type === "agent_start" && gracefulAbortRequested && !rpcCompleted) {
|
|
443
|
+
// The child handles commands concurrently. An abort that arrived while
|
|
444
|
+
// its initial prompt was still in its pre-run preamble (API-key
|
|
445
|
+
// resolution, input/before-start hooks) was acknowledged without
|
|
446
|
+
// aborting anything, because no run existed yet. Abort the run that
|
|
447
|
+
// has now started, before it can execute tools the user just stopped.
|
|
448
|
+
// Otherwise it would keep working until the grace period expires, and
|
|
449
|
+
// SIGTERM would orphan any tool process tree it had started.
|
|
450
|
+
requestGracefulAbort();
|
|
409
451
|
}
|
|
410
452
|
if (message?.type === "response" && typeof message.id === "string") {
|
|
411
453
|
const pending = pendingRpc.get(message.id);
|
|
@@ -455,23 +497,22 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
|
|
|
455
497
|
failControlledChild(error);
|
|
456
498
|
});
|
|
457
499
|
}
|
|
458
|
-
// Handle abort signal (
|
|
500
|
+
// Handle abort signal (user stop / stop-all). A controlled child is stopped
|
|
501
|
+
// gracefully first: the RPC `abort` makes the child abort its own run, so its
|
|
502
|
+
// tools unwind — notably the bash tool kills its detached process group on its
|
|
503
|
+
// abort signal, which a bare SIGTERM to this child would orphan. The child's
|
|
504
|
+
// resulting agent_end triggers the normal SIGTERM above; a run that starts only
|
|
505
|
+
// after the abort was handled is aborted again on its agent_start. If the abort
|
|
506
|
+
// cannot be delivered or the child does not finish within the grace period,
|
|
507
|
+
// escalate to SIGTERM and then the SIGKILL backstop.
|
|
459
508
|
const onAbort = () => {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
catch {
|
|
464
|
-
/* process already exited */
|
|
509
|
+
if (!controlClient || rpcCompleted || controlError || !proc.stdin?.writable) {
|
|
510
|
+
forceKill();
|
|
511
|
+
return;
|
|
465
512
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
proc.kill("SIGKILL");
|
|
470
|
-
}
|
|
471
|
-
catch {
|
|
472
|
-
/* process already exited */
|
|
473
|
-
}
|
|
474
|
-
}, 5000);
|
|
513
|
+
gracefulAbortRequested = true;
|
|
514
|
+
abortGraceTimer ??= setTimeout(forceKill, SUBAGENT_ABORT_GRACE_MS);
|
|
515
|
+
requestGracefulAbort();
|
|
475
516
|
};
|
|
476
517
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
477
518
|
proc.on("error", (err) => {
|
|
@@ -484,6 +525,8 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
|
|
|
484
525
|
pendingRpc.clear();
|
|
485
526
|
if (killTimer)
|
|
486
527
|
clearTimeout(killTimer);
|
|
528
|
+
if (abortGraceTimer)
|
|
529
|
+
clearTimeout(abortGraceTimer);
|
|
487
530
|
signal?.removeEventListener("abort", onAbort);
|
|
488
531
|
rejectPromise(new Error(`Subagent process error: ${err.message}`));
|
|
489
532
|
});
|
|
@@ -497,6 +540,8 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
|
|
|
497
540
|
pendingRpc.clear();
|
|
498
541
|
if (killTimer)
|
|
499
542
|
clearTimeout(killTimer);
|
|
543
|
+
if (abortGraceTimer)
|
|
544
|
+
clearTimeout(abortGraceTimer);
|
|
500
545
|
signal?.removeEventListener("abort", onAbort);
|
|
501
546
|
const exitCode = rpcCompleted ? 0 : (code ?? 1);
|
|
502
547
|
const stderr = stderrChunks.join("");
|
|
@@ -928,10 +973,6 @@ export function formatModelFallbackSummary(skippedModels, proposalModel, finalMo
|
|
|
928
973
|
: `using "${finalModel ?? proposalModel ?? "unknown"}".`;
|
|
929
974
|
return `[MODEL FALLBACK: skipped ${skippedModels.length} unavailable model(s); ${routeSummary}]\n${skipped}`;
|
|
930
975
|
}
|
|
931
|
-
export function prependModelFallbackSummary(output, skippedModels, proposalModel, finalModel) {
|
|
932
|
-
const fallbackSummary = formatModelFallbackSummary(skippedModels, proposalModel, finalModel);
|
|
933
|
-
return fallbackSummary ? `${fallbackSummary}\n\n${output}` : output;
|
|
934
|
-
}
|
|
935
976
|
function formatSkippedModelFailureDetails(skippedModels) {
|
|
936
977
|
if (skippedModels.length === 0)
|
|
937
978
|
return undefined;
|
|
@@ -939,6 +980,13 @@ function formatSkippedModelFailureDetails(skippedModels) {
|
|
|
939
980
|
}
|
|
940
981
|
const MAX_PARALLEL_TASKS = 8;
|
|
941
982
|
const MAX_TASK_LENGTH = 32_768; // 32 KB — prevent E2BIG from oversized argv
|
|
983
|
+
/** Thrown by {@link SubagentConcurrencyGate.acquire} when its signal aborts before a slot is granted. */
|
|
984
|
+
export class SubagentSlotWaitAbortedError extends Error {
|
|
985
|
+
constructor() {
|
|
986
|
+
super("Cancelled while waiting for a subagent concurrency slot.");
|
|
987
|
+
this.name = "SubagentSlotWaitAbortedError";
|
|
988
|
+
}
|
|
989
|
+
}
|
|
942
990
|
/**
|
|
943
991
|
* Create a concurrency gate limited to `maxConcurrent` simultaneous holders. The count lives in
|
|
944
992
|
* this closure, so a single gate instance shared across tool rebuilds keeps counting in-flight
|
|
@@ -951,16 +999,27 @@ export function createSubagentConcurrencyGate(maxConcurrent) {
|
|
|
951
999
|
let running = 0;
|
|
952
1000
|
const waiters = [];
|
|
953
1001
|
return {
|
|
954
|
-
acquire() {
|
|
1002
|
+
acquire(signal) {
|
|
1003
|
+
if (signal?.aborted)
|
|
1004
|
+
return Promise.reject(new SubagentSlotWaitAbortedError());
|
|
955
1005
|
if (running < maxConcurrent) {
|
|
956
1006
|
running++;
|
|
957
1007
|
return Promise.resolve();
|
|
958
1008
|
}
|
|
959
|
-
return new Promise((resolve) => {
|
|
960
|
-
|
|
1009
|
+
return new Promise((resolve, reject) => {
|
|
1010
|
+
const grant = () => {
|
|
1011
|
+
signal?.removeEventListener("abort", onAbort);
|
|
961
1012
|
running++;
|
|
962
1013
|
resolve();
|
|
963
|
-
}
|
|
1014
|
+
};
|
|
1015
|
+
const onAbort = () => {
|
|
1016
|
+
const index = waiters.indexOf(grant);
|
|
1017
|
+
if (index !== -1)
|
|
1018
|
+
waiters.splice(index, 1);
|
|
1019
|
+
reject(new SubagentSlotWaitAbortedError());
|
|
1020
|
+
};
|
|
1021
|
+
waiters.push(grant);
|
|
1022
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
964
1023
|
});
|
|
965
1024
|
},
|
|
966
1025
|
release() {
|
|
@@ -1272,11 +1331,19 @@ singleModelMode) {
|
|
|
1272
1331
|
onProgress?.(`Running ${name} agent${usedModel ? ` (${usedModel})` : ""}...`);
|
|
1273
1332
|
const result = await spawnSubagent(effectiveConfig, task, cwd, signal, onProgress, resolvedProvider, sessionDir, parentSessionFile, onChildEvent, finalThinking, onControlAvailable);
|
|
1274
1333
|
const finalSelectedModel = result.model ?? (usedModel ? canonicalModelRef(resolvedProvider, usedModel) : undefined);
|
|
1275
|
-
|
|
1334
|
+
// Spawn-time routing notes are system text, not the child's words: keep them out of
|
|
1335
|
+
// `output` so the parent sees them outside the agent-output block (and so a chain's
|
|
1336
|
+
// `{previous}` carries only the previous agent's output).
|
|
1337
|
+
const notes = [];
|
|
1276
1338
|
if (warning) {
|
|
1277
1339
|
const warningContext = arbitrationEnabled ? `Proposal resolution: ${warning}` : warning;
|
|
1278
|
-
|
|
1340
|
+
notes.push(`[WARNING: ${warningContext}]`);
|
|
1279
1341
|
}
|
|
1342
|
+
const fallbackSummary = formatModelFallbackSummary(skippedModels, arbitrationEnabled ? proposalSelectedModel : finalSelectedModel, arbitrationEnabled ? finalSelectedModel : undefined);
|
|
1343
|
+
if (fallbackSummary)
|
|
1344
|
+
notes.push(fallbackSummary);
|
|
1345
|
+
if (notes.length > 0)
|
|
1346
|
+
result.notes = notes;
|
|
1280
1347
|
return result;
|
|
1281
1348
|
}
|
|
1282
1349
|
async function executeChain(agents, chain, defaultCwd, signal, onProgress, parentProvider, registry, sessionBaseDir, defaultAgent, defaultModel, defaultThinking, parentModel, getAgentModelsForAgentFn, parentSessionFile, onChildEvent, arbitration, onControlAvailable,
|
|
@@ -1540,6 +1607,11 @@ function findFirstUserMessageSummary(sessionFile) {
|
|
|
1540
1607
|
}
|
|
1541
1608
|
return undefined;
|
|
1542
1609
|
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Infer a finished child's status from the last assistant message in its session log.
|
|
1612
|
+
* `aborted` only occurs when the parent gracefully cancelled the child (user stop), so it
|
|
1613
|
+
* maps to `cancelled`; `error` maps to `failed`.
|
|
1614
|
+
*/
|
|
1543
1615
|
function inferCompletedSessionStatus(sessionFile) {
|
|
1544
1616
|
const tail = readFileTail(sessionFile, TAIL_READ_BYTES);
|
|
1545
1617
|
if (!tail)
|
|
@@ -1554,10 +1626,88 @@ function inferCompletedSessionStatus(sessionFile) {
|
|
|
1554
1626
|
if (entry.message.role !== "assistant")
|
|
1555
1627
|
continue;
|
|
1556
1628
|
const stopReason = entry.message.stopReason;
|
|
1557
|
-
|
|
1629
|
+
if (stopReason === "aborted")
|
|
1630
|
+
return "cancelled";
|
|
1631
|
+
return stopReason === "error" ? "failed" : "completed";
|
|
1558
1632
|
}
|
|
1559
1633
|
return "completed";
|
|
1560
1634
|
}
|
|
1635
|
+
/**
|
|
1636
|
+
* File in a background agent's session directory recording that the user stopped it.
|
|
1637
|
+
* A stop doesn't always leave a trailing `aborted` message in the child's log: a chain
|
|
1638
|
+
* stopped between steps, a child terminated after the grace period, and an agent stopped
|
|
1639
|
+
* while queued or before spawn (which has no log at all). The marker is the authoritative
|
|
1640
|
+
* record that rehydration checks first.
|
|
1641
|
+
*/
|
|
1642
|
+
export const SUBAGENT_CANCELLED_MARKER_FILE = "cancelled.json";
|
|
1643
|
+
/**
|
|
1644
|
+
* Record a user stop in the agent's session directory (created if the agent never
|
|
1645
|
+
* spawned). Agents without a session directory or parent session file can't be
|
|
1646
|
+
* rehydrated, so nothing is written for them. A write failure doesn't block the stop
|
|
1647
|
+
* itself. It is logged as an error, because the agent may show as completed or be
|
|
1648
|
+
* missing after a reload.
|
|
1649
|
+
*/
|
|
1650
|
+
function writeCancellationMarker(info) {
|
|
1651
|
+
if (!info.sessionDir || !info.parentSessionFile)
|
|
1652
|
+
return;
|
|
1653
|
+
const marker = {
|
|
1654
|
+
type: "subagent_cancelled",
|
|
1655
|
+
version: 1,
|
|
1656
|
+
cancelledAt: new Date().toISOString(),
|
|
1657
|
+
parentSession: info.parentSessionFile,
|
|
1658
|
+
agentType: info.agentType,
|
|
1659
|
+
taskSummary: info.taskSummary,
|
|
1660
|
+
startedAt: new Date(info.startedAt).toISOString(),
|
|
1661
|
+
...(info.cwd ? { cwd: info.cwd } : {}),
|
|
1662
|
+
};
|
|
1663
|
+
try {
|
|
1664
|
+
mkdirSync(info.sessionDir, { recursive: true });
|
|
1665
|
+
writeFileSync(join(info.sessionDir, SUBAGENT_CANCELLED_MARKER_FILE), `${JSON.stringify(marker)}\n`);
|
|
1666
|
+
}
|
|
1667
|
+
catch (err) {
|
|
1668
|
+
log.error(`[subagent] Failed to record the cancellation of background agent ${info.agentId} in ${info.sessionDir}: ${err instanceof Error ? err.message : String(err)}. After a reload it may show as completed or be missing.`);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
function isCancellationMarker(value) {
|
|
1672
|
+
return (isRecord(value) &&
|
|
1673
|
+
value.type === "subagent_cancelled" &&
|
|
1674
|
+
typeof value.parentSession === "string" &&
|
|
1675
|
+
typeof value.agentType === "string" &&
|
|
1676
|
+
value.agentType.trim() !== "" &&
|
|
1677
|
+
typeof value.taskSummary === "string" &&
|
|
1678
|
+
typeof value.startedAt === "string" &&
|
|
1679
|
+
Number.isFinite(Date.parse(value.startedAt)) &&
|
|
1680
|
+
(value.cwd === undefined || typeof value.cwd === "string"));
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Read an agent's cancellation marker. Returns `undefined` when there is none, the parsed
|
|
1684
|
+
* marker when valid, and `"malformed"` (after a warning) when the file exists but can't be
|
|
1685
|
+
* used. A malformed marker still proves the stop happened, but can't identify the parent.
|
|
1686
|
+
* Filesystem errors other than absence propagate to the caller's recovery policy.
|
|
1687
|
+
*/
|
|
1688
|
+
function readCancellationMarker(sessionDir) {
|
|
1689
|
+
const markerPath = join(sessionDir, SUBAGENT_CANCELLED_MARKER_FILE);
|
|
1690
|
+
let raw;
|
|
1691
|
+
try {
|
|
1692
|
+
raw = readFileSync(markerPath, "utf8");
|
|
1693
|
+
}
|
|
1694
|
+
catch (err) {
|
|
1695
|
+
if (isMissingFilesystemError(err))
|
|
1696
|
+
return undefined;
|
|
1697
|
+
throw err;
|
|
1698
|
+
}
|
|
1699
|
+
let parsed;
|
|
1700
|
+
try {
|
|
1701
|
+
parsed = JSON.parse(raw);
|
|
1702
|
+
}
|
|
1703
|
+
catch {
|
|
1704
|
+
parsed = undefined;
|
|
1705
|
+
}
|
|
1706
|
+
if (isCancellationMarker(parsed))
|
|
1707
|
+
return parsed;
|
|
1708
|
+
log.warn(`[subagent] ignoring malformed cancellation marker ${markerPath}; the agent is still reported as cancelled`);
|
|
1709
|
+
return "malformed";
|
|
1710
|
+
}
|
|
1561
1711
|
function parseStartedAt(header, sessionFile) {
|
|
1562
1712
|
if (typeof header.timestamp === "string") {
|
|
1563
1713
|
const timestamp = Date.parse(header.timestamp);
|
|
@@ -1575,21 +1725,38 @@ function parseStartedAt(header, sessionFile) {
|
|
|
1575
1725
|
}
|
|
1576
1726
|
function hasRegisteredSession(sessionDir, sessionFile) {
|
|
1577
1727
|
const comparableSessionDir = canonicalExistingPath(sessionDir);
|
|
1578
|
-
const comparableSessionFile = canonicalExistingPath(sessionFile);
|
|
1728
|
+
const comparableSessionFile = sessionFile ? canonicalExistingPath(sessionFile) : undefined;
|
|
1579
1729
|
for (const agent of backgroundAgentRegistry.values()) {
|
|
1580
1730
|
if (agent.sessionDir && canonicalExistingPath(agent.sessionDir) === comparableSessionDir)
|
|
1581
1731
|
return true;
|
|
1582
|
-
if (
|
|
1732
|
+
if (comparableSessionFile &&
|
|
1733
|
+
agent.sessionFile &&
|
|
1734
|
+
canonicalExistingPath(agent.sessionFile) === comparableSessionFile) {
|
|
1583
1735
|
return true;
|
|
1736
|
+
}
|
|
1584
1737
|
}
|
|
1585
1738
|
return false;
|
|
1586
1739
|
}
|
|
1740
|
+
/** A registry ID for a rehydrated agent, derived from its session directory name and kept unique. */
|
|
1741
|
+
function nextRehydratedAgentId(sessionDirName) {
|
|
1742
|
+
const baseAgentId = `${REHYDRATED_AGENT_ID_PREFIX}${sessionDirName}`;
|
|
1743
|
+
let agentId = baseAgentId;
|
|
1744
|
+
let suffix = 2;
|
|
1745
|
+
while (backgroundAgentRegistry.has(agentId)) {
|
|
1746
|
+
agentId = `${baseAgentId}-${suffix++}`;
|
|
1747
|
+
}
|
|
1748
|
+
return agentId;
|
|
1749
|
+
}
|
|
1587
1750
|
/**
|
|
1588
1751
|
* Recover completed background subagents after a dashboard/RPC process resumes
|
|
1589
1752
|
* an existing parent session. Live background-agent state is an in-memory
|
|
1590
1753
|
* registry, while child sessions are durable JSONL files under the subagent
|
|
1591
1754
|
* sessions directory.
|
|
1592
1755
|
*
|
|
1756
|
+
* A user stop's cancellation marker ({@link SUBAGENT_CANCELLED_MARKER_FILE}) takes
|
|
1757
|
+
* precedence over status inferred from the log. An agent stopped before it spawned has
|
|
1758
|
+
* only the marker, and is recovered from it.
|
|
1759
|
+
*
|
|
1593
1760
|
* The first root is authoritative: absence races and malformed JSON are skipped,
|
|
1594
1761
|
* while operational filesystem failures throw. Later roots are compatibility
|
|
1595
1762
|
* stores, so their operational filesystem failures are warned and skipped.
|
|
@@ -1640,8 +1807,29 @@ export function rehydrateBackgroundAgentsFromDisk(parentSessionFile, subagentSes
|
|
|
1640
1807
|
continue;
|
|
1641
1808
|
seenSessionDirs.add(canonicalSessionDir);
|
|
1642
1809
|
const sessionFiles = discoverSessionFiles(sessionDir, entry.name, { throwOnOperationalError: true });
|
|
1643
|
-
|
|
1810
|
+
const cancellationMarker = readCancellationMarker(sessionDir);
|
|
1811
|
+
if (sessionFiles.length === 0) {
|
|
1812
|
+
// A user stop while the agent was queued or before it spawned leaves no log,
|
|
1813
|
+
// only the cancellation marker.
|
|
1814
|
+
if (cancellationMarker === undefined || cancellationMarker === "malformed")
|
|
1815
|
+
continue;
|
|
1816
|
+
if (!parentSessionMatches(cancellationMarker.parentSession, parentSessionFile))
|
|
1817
|
+
continue;
|
|
1818
|
+
if (hasRegisteredSession(sessionDir))
|
|
1819
|
+
continue;
|
|
1820
|
+
const agentId = nextRehydratedAgentId(entry.name);
|
|
1821
|
+
backgroundAgentRegistry.set(agentId, {
|
|
1822
|
+
agentId,
|
|
1823
|
+
agentType: cancellationMarker.agentType,
|
|
1824
|
+
taskSummary: cancellationMarker.taskSummary,
|
|
1825
|
+
startedAt: Date.parse(cancellationMarker.startedAt),
|
|
1826
|
+
status: "cancelled",
|
|
1827
|
+
sessionDir,
|
|
1828
|
+
cwd: cancellationMarker.cwd,
|
|
1829
|
+
});
|
|
1830
|
+
registered++;
|
|
1644
1831
|
continue;
|
|
1832
|
+
}
|
|
1645
1833
|
let sessionFile;
|
|
1646
1834
|
let header;
|
|
1647
1835
|
for (const candidateFile of sessionFiles) {
|
|
@@ -1660,12 +1848,7 @@ export function rehydrateBackgroundAgentsFromDisk(parentSessionFile, subagentSes
|
|
|
1660
1848
|
}
|
|
1661
1849
|
if (!sessionFile || !header || hasRegisteredSession(sessionDir, sessionFile))
|
|
1662
1850
|
continue;
|
|
1663
|
-
const
|
|
1664
|
-
let agentId = baseAgentId;
|
|
1665
|
-
let suffix = 2;
|
|
1666
|
-
while (backgroundAgentRegistry.has(agentId)) {
|
|
1667
|
-
agentId = `${baseAgentId}-${suffix++}`;
|
|
1668
|
-
}
|
|
1851
|
+
const agentId = nextRehydratedAgentId(entry.name);
|
|
1669
1852
|
const statusFile = sessionFiles[sessionFiles.length - 1] ?? sessionFile;
|
|
1670
1853
|
const agentType = typeof header.agentType === "string" && header.agentType.trim() ? header.agentType : "agent";
|
|
1671
1854
|
const taskSummary = findFirstUserMessageSummary(sessionFile) ?? `${agentType} (${entry.name})`;
|
|
@@ -1674,7 +1857,9 @@ export function rehydrateBackgroundAgentsFromDisk(parentSessionFile, subagentSes
|
|
|
1674
1857
|
agentType,
|
|
1675
1858
|
taskSummary,
|
|
1676
1859
|
startedAt: parseStartedAt(header, sessionFile),
|
|
1677
|
-
|
|
1860
|
+
// The marker is authoritative: a stop between chain steps or a terminated child
|
|
1861
|
+
// doesn't leave a trailing `aborted` message for inference to find.
|
|
1862
|
+
status: cancellationMarker !== undefined ? "cancelled" : inferCompletedSessionStatus(statusFile),
|
|
1678
1863
|
sessionDir,
|
|
1679
1864
|
sessionFile,
|
|
1680
1865
|
cwd: typeof header.cwd === "string" ? header.cwd : undefined,
|
|
@@ -1725,14 +1910,49 @@ export async function getBackgroundAgentPendingSteering(agentId) {
|
|
|
1725
1910
|
const [state, pending] = await Promise.all([client.getState(), client.getPendingMessages()]);
|
|
1726
1911
|
return { steeringMode: state.steeringMode, pending };
|
|
1727
1912
|
}
|
|
1728
|
-
/**
|
|
1913
|
+
/**
|
|
1914
|
+
* Mark one registry entry cancelled, record the stop on disk, and abort its
|
|
1915
|
+
* controller. The status is set before aborting so anything reacting
|
|
1916
|
+
* synchronously to the abort (and the lifecycle, which skips status updates once
|
|
1917
|
+
* its signal is aborted) observes `cancelled` rather than `running`. The
|
|
1918
|
+
* controller's abort listeners perform the graceful child shutdown (RPC abort
|
|
1919
|
+
* first, then SIGTERM/SIGKILL backstops).
|
|
1920
|
+
*/
|
|
1921
|
+
function cancelBackgroundAgentEntry(agentId, controller) {
|
|
1922
|
+
const entry = backgroundAgentRegistry.get(agentId);
|
|
1923
|
+
if (entry && entry.status === "running") {
|
|
1924
|
+
entry.status = "cancelled";
|
|
1925
|
+
writeCancellationMarker(entry);
|
|
1926
|
+
}
|
|
1927
|
+
backgroundAbortControllers.delete(agentId);
|
|
1928
|
+
backgroundControlClients.delete(agentId);
|
|
1929
|
+
controller.abort();
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Cancel exactly one running background agent (user stop). Siblings, the parent
|
|
1933
|
+
* session, and their control channels are untouched. Works for agents that are
|
|
1934
|
+
* executing, queued for a concurrency slot, or between chain steps; a chain is
|
|
1935
|
+
* stopped as a whole. Completion is reported asynchronously through the normal
|
|
1936
|
+
* background-complete path with `cancelled = true`.
|
|
1937
|
+
*
|
|
1938
|
+
* Throws for unknown agents and agents that are no longer running (completed,
|
|
1939
|
+
* failed, cancelled, or rehydrated from disk) — never a silent no-op.
|
|
1940
|
+
*/
|
|
1941
|
+
export function abortBackgroundAgent(agentId) {
|
|
1942
|
+
const info = backgroundAgentRegistry.get(agentId);
|
|
1943
|
+
if (!info)
|
|
1944
|
+
throw new Error(`Unknown background agent "${agentId}".`);
|
|
1945
|
+
if (info.status !== "running")
|
|
1946
|
+
throw new Error(`Background agent "${agentId}" is no longer running.`);
|
|
1947
|
+
const controller = backgroundAbortControllers.get(agentId);
|
|
1948
|
+
if (!controller)
|
|
1949
|
+
throw new Error(`Background agent "${agentId}" has no abort controller; it cannot be stopped.`);
|
|
1950
|
+
cancelBackgroundAgentEntry(agentId, controller);
|
|
1951
|
+
}
|
|
1952
|
+
/** Cancel all running background agents (Esc / parent stop). */
|
|
1729
1953
|
export function abortBackgroundAgents() {
|
|
1730
|
-
for (const [id, controller] of backgroundAbortControllers) {
|
|
1731
|
-
controller
|
|
1732
|
-
const entry = backgroundAgentRegistry.get(id);
|
|
1733
|
-
if (entry && entry.status === "running") {
|
|
1734
|
-
entry.status = "failed";
|
|
1735
|
-
}
|
|
1954
|
+
for (const [id, controller] of [...backgroundAbortControllers]) {
|
|
1955
|
+
cancelBackgroundAgentEntry(id, controller);
|
|
1736
1956
|
}
|
|
1737
1957
|
backgroundAbortControllers.clear();
|
|
1738
1958
|
backgroundControlClients.clear();
|
|
@@ -1860,7 +2080,22 @@ function formatSubagentResult(result, options, theme, showImages) {
|
|
|
1860
2080
|
}
|
|
1861
2081
|
return text;
|
|
1862
2082
|
}
|
|
1863
|
-
|
|
2083
|
+
/**
|
|
2084
|
+
* Wrap a subagent's own output in labelled delimiter lines so it can't be mistaken for
|
|
2085
|
+
* the system lines around it (execution metadata, cancellation/error notes, session log,
|
|
2086
|
+
* still-running list). `partial` marks output from a run that did not finish cleanly
|
|
2087
|
+
* (cancelled, failed, or truncated). Blank lines around the delimiters keep them as
|
|
2088
|
+
* separate paragraphs when the text is rendered as Markdown (dashboard, Telegram).
|
|
2089
|
+
*/
|
|
2090
|
+
export function formatAgentOutputBlock(output, partial) {
|
|
2091
|
+
return `--- agent output${partial ? " (partial)" : ""} ---\n\n${output}\n\n--- end of agent output ---`;
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Render one subagent result (used for each chain step). The agent's output is wrapped by
|
|
2095
|
+
* `formatAgentOutputBlock`; `cancelled` marks a step that was stopped by the user, whose
|
|
2096
|
+
* output is therefore partial.
|
|
2097
|
+
*/
|
|
2098
|
+
export function formatSingleResult(result, cancelled = false) {
|
|
1864
2099
|
const metadata = [
|
|
1865
2100
|
result.model ? `model: ${result.model}` : undefined,
|
|
1866
2101
|
result.thinking ? `thinking: ${result.thinking}` : undefined,
|
|
@@ -1878,8 +2113,12 @@ export function formatSingleResult(result) {
|
|
|
1878
2113
|
// Clean exit but an error was surfaced (e.g. truncation at the token limit).
|
|
1879
2114
|
text += `**Error**: ${result.errorMessage}\n`;
|
|
1880
2115
|
}
|
|
2116
|
+
if (result.notes && result.notes.length > 0) {
|
|
2117
|
+
text += `\n${result.notes.join("\n\n")}\n`;
|
|
2118
|
+
}
|
|
1881
2119
|
if (result.output) {
|
|
1882
|
-
|
|
2120
|
+
const partial = cancelled || result.exitCode !== 0 || !!result.errorMessage;
|
|
2121
|
+
text += `\n${formatAgentOutputBlock(result.output, partial)}`;
|
|
1883
2122
|
}
|
|
1884
2123
|
else if (result.exitCode === 0 && !result.errorMessage) {
|
|
1885
2124
|
text += "\n(No output)";
|
|
@@ -1914,7 +2153,7 @@ export function createSubagentToolDefinition(cwd, options) {
|
|
|
1914
2153
|
// supplied (external SDK callers, tests) the tool owns a fresh per-instance gate, which
|
|
1915
2154
|
// still keeps separately embedded sessions from coupling through module-global state.
|
|
1916
2155
|
const concurrencyGate = options?.concurrencyGate ?? createSubagentConcurrencyGate(maxConcurrentSubagents);
|
|
1917
|
-
const acquireBackgroundSlot = () => concurrencyGate.acquire();
|
|
2156
|
+
const acquireBackgroundSlot = (signal) => concurrencyGate.acquire(signal);
|
|
1918
2157
|
const releaseBackgroundSlot = () => concurrencyGate.release();
|
|
1919
2158
|
// Discover agents at definition time to build the prompt guidelines.
|
|
1920
2159
|
// This is cheap (reads .md files) and the same call happens on every execute().
|
|
@@ -2010,6 +2249,7 @@ export function createSubagentToolDefinition(cwd, options) {
|
|
|
2010
2249
|
status: "running",
|
|
2011
2250
|
sessionDir,
|
|
2012
2251
|
cwd: agentCwd,
|
|
2252
|
+
parentSessionFile: getParentSessionFile(),
|
|
2013
2253
|
});
|
|
2014
2254
|
backgroundAbortControllers.set(agentId, bgAbort);
|
|
2015
2255
|
onBackgroundStart?.(agentId, agentName, taskSummary, sessionDir);
|
|
@@ -2051,8 +2291,12 @@ export function createSubagentToolDefinition(cwd, options) {
|
|
|
2051
2291
|
}
|
|
2052
2292
|
};
|
|
2053
2293
|
const run = async () => {
|
|
2054
|
-
|
|
2294
|
+
// A stop while queued for a slot settles immediately (the gate rejects
|
|
2295
|
+
// without taking a slot), so only release a slot this run acquired.
|
|
2296
|
+
let slotAcquired = false;
|
|
2055
2297
|
try {
|
|
2298
|
+
await acquireBackgroundSlot(bgSignal);
|
|
2299
|
+
slotAcquired = true;
|
|
2056
2300
|
const result = await runFn(bgSignal, onChildEvent, onArbitrationRecord, onControlAvailable);
|
|
2057
2301
|
const entry = backgroundAgentRegistry.get(agentId);
|
|
2058
2302
|
if (entry && !bgSignal.aborted)
|
|
@@ -2078,7 +2322,8 @@ export function createSubagentToolDefinition(cwd, options) {
|
|
|
2078
2322
|
}
|
|
2079
2323
|
finally {
|
|
2080
2324
|
backgroundControlClients.delete(agentId);
|
|
2081
|
-
|
|
2325
|
+
if (slotAcquired)
|
|
2326
|
+
releaseBackgroundSlot();
|
|
2082
2327
|
}
|
|
2083
2328
|
};
|
|
2084
2329
|
run().catch((err) => {
|
|
@@ -2191,8 +2436,11 @@ export function createSubagentToolDefinition(cwd, options) {
|
|
|
2191
2436
|
getAgentModelsForAgent,
|
|
2192
2437
|
}
|
|
2193
2438
|
: undefined, onControlAvailable, getParentSessionId(), getSingleModelMode);
|
|
2439
|
+
// A user stop ends the chain at its current (last) step, so only that step's
|
|
2440
|
+
// output can be cut short.
|
|
2441
|
+
const stoppedAtLastStep = signal.aborted;
|
|
2194
2442
|
const resultText = results
|
|
2195
|
-
.map((r, i) => `### Step ${i + 1}\n${formatSingleResult(r)}`)
|
|
2443
|
+
.map((r, i) => `### Step ${i + 1}\n${formatSingleResult(r, stoppedAtLastStep && i === results.length - 1)}`)
|
|
2196
2444
|
.join("\n\n---\n\n");
|
|
2197
2445
|
const failed = results.filter((r) => r.exitCode !== 0);
|
|
2198
2446
|
// Per-step session logs are already embedded in resultText via formatSingleResult
|