brl-subagent 2.3.7 → 2.3.9
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/AGENT.md +21 -9
- package/README.md +50 -10
- package/package.json +5 -5
- package/presets/test-engineer.md +2 -0
- package/src/git.ts +15 -0
- package/src/history.ts +25 -1
- package/src/index.ts +981 -551
- package/src/notify-completion.ts +7 -13
- package/src/params.ts +31 -6
- package/src/prelude.ts +206 -0
- package/src/prompt.ts +1 -1
- package/src/router.ts +26 -5
- package/src/runner.ts +7 -1
- package/src/session-manager.ts +21 -31
- package/src/state.ts +42 -2
- package/src/tui.ts +1 -1
- package/src/types.ts +32 -0
- package/src/unit-run.ts +4 -0
- package/src/validate.ts +111 -10
- package/templates/code-review.md +1 -1
- package/templates/debug-issue.md +2 -1
- package/templates/write-tests.md +1 -1
package/src/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
mergeWorkBranch,
|
|
66
66
|
} from "./git";
|
|
67
67
|
import { preflightCheck } from "./preflight";
|
|
68
|
+
import { gateSessionCost, rejectApprovalAlwaysInBackground, runPreTaskValidation, validateDelegationTargets, formatValidationWarnings } from "./prelude";
|
|
68
69
|
import { loadBuiltinPresets, loadCustomPresets, getAllPresets, writePresetFile, formatPresetRestriction, formatToolRestriction } from "./presets";
|
|
69
70
|
import { modelIsAvailable } from "./model-availability";
|
|
70
71
|
import { validatePreTask, diagnoseFailure } from "./validate";
|
|
@@ -399,6 +400,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
399
400
|
};
|
|
400
401
|
}
|
|
401
402
|
|
|
403
|
+
// Per-unit display name for validation prefixes: the label when present,
|
|
404
|
+
// else the task truncated to 60 chars. Shared by the foreground parallel
|
|
405
|
+
// pre-pass and the background fan-out pre-pass (issue #220) so a unit
|
|
406
|
+
// renders identically in either shape.
|
|
407
|
+
const displayTaskName = (merged: ReturnType<typeof mergeSubTaskParams>): string =>
|
|
408
|
+
merged.label ??
|
|
409
|
+
(merged.task.length > 60 ? `${merged.task.slice(0, 57)}...` : merged.task);
|
|
410
|
+
|
|
402
411
|
// -------------------------------------------------------------------
|
|
403
412
|
// P1: runChainMode
|
|
404
413
|
// -------------------------------------------------------------------
|
|
@@ -416,32 +425,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
416
425
|
const chainModeStartedAt = new Date().toISOString();
|
|
417
426
|
|
|
418
427
|
// R5: Check session cost limit before spawning
|
|
419
|
-
const
|
|
420
|
-
state
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
currentTotal,
|
|
428
|
-
estimatedCost: perTaskEstimate * chainSteps.length,
|
|
429
|
-
limit,
|
|
430
|
-
});
|
|
431
|
-
return {
|
|
432
|
-
content: [
|
|
433
|
-
{
|
|
434
|
-
type: "text" as const,
|
|
435
|
-
text:
|
|
436
|
-
`Cannot delegate: session cost limit reached ` +
|
|
437
|
-
`($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
|
|
438
|
-
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
439
|
-
},
|
|
440
|
-
],
|
|
441
|
-
details: undefined,
|
|
442
|
-
isError: true,
|
|
443
|
-
};
|
|
444
|
-
}
|
|
428
|
+
const costError = gateSessionCost({
|
|
429
|
+
state,
|
|
430
|
+
ctx,
|
|
431
|
+
log,
|
|
432
|
+
units: chainSteps.length,
|
|
433
|
+
label: "Chain delegation",
|
|
434
|
+
});
|
|
435
|
+
if (costError) return costError;
|
|
445
436
|
|
|
446
437
|
// Reject delegation if recursion depth exceeds configured max
|
|
447
438
|
const currentDepth = getCurrentDepth();
|
|
@@ -521,7 +512,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
521
512
|
// #32). Chain top-level tasks are empty (modeCount forbids task+chain), so
|
|
522
513
|
// keyword warnings skip on empty text and only the hard outputFile-vs-write
|
|
523
514
|
// conflict applies (issue #34). The mode-level outputFile on globalParams is
|
|
524
|
-
// the one validated;
|
|
515
|
+
// the one validated here; every step's own cwd/outputFile/tools are
|
|
516
|
+
// validated by the per-step pre-pass below (issue #222).
|
|
525
517
|
const validation = validatePreTask({
|
|
526
518
|
task: globalParams.task,
|
|
527
519
|
toolOptions: globalParams.toolOptions,
|
|
@@ -542,6 +534,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
542
534
|
};
|
|
543
535
|
}
|
|
544
536
|
|
|
537
|
+
// Per-step pre-pass (issue #222): resolve + validate EVERY step before
|
|
538
|
+
// any spawn, so a single bad step rejects the chain with nothing
|
|
539
|
+
// started — the same all-before-any-spawn semantics the background
|
|
540
|
+
// fan-out and the parallel `tasks` shape use. Chain steps are fully
|
|
541
|
+
// known up front and a step's cwd/outputFile never depend on a previous
|
|
542
|
+
// step's output (only its TASK text consumes {previous}), so the pass
|
|
543
|
+
// can run up front — and must, because discovering step 5's bad cwd
|
|
544
|
+
// after four steps already ran would waste their whole budget.
|
|
545
|
+
const stepCwds: string[] = [];
|
|
546
|
+
const stepWarnings: string[] = [];
|
|
547
|
+
for (let i = 0; i < chainSteps.length; i++) {
|
|
548
|
+
const merged = mergeSubTaskParams(globalParams, chainSteps[i]);
|
|
549
|
+
const stepPrefix = `Step ${i + 1} ("${displayTaskName(merged)}")`;
|
|
550
|
+
|
|
551
|
+
const stepTargets = validateDelegationTargets({
|
|
552
|
+
ctx,
|
|
553
|
+
effectiveCwd: merged.effectiveCwd,
|
|
554
|
+
outputFile: merged.outputFile,
|
|
555
|
+
prefix: stepPrefix,
|
|
556
|
+
});
|
|
557
|
+
if (!stepTargets.ok) return stepTargets.error;
|
|
558
|
+
stepCwds.push(stepTargets.cwd);
|
|
559
|
+
|
|
560
|
+
const stepValidation = runPreTaskValidation({
|
|
561
|
+
log,
|
|
562
|
+
label: "Chain",
|
|
563
|
+
prefix: stepPrefix,
|
|
564
|
+
logContext: { step: i + 1 },
|
|
565
|
+
preTask: {
|
|
566
|
+
task: merged.task,
|
|
567
|
+
toolOptions: merged.toolOptions,
|
|
568
|
+
thinkingLevel: merged.thinkingLevel,
|
|
569
|
+
gitMode: globalParams.resolvedGitMode,
|
|
570
|
+
outputFile: merged.outputFile,
|
|
571
|
+
force: params.force as boolean | undefined,
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
if (stepValidation.error) return stepValidation.error;
|
|
575
|
+
for (const w of stepValidation.warnings) stepWarnings.push(`${stepPrefix}: ${w}`);
|
|
576
|
+
}
|
|
577
|
+
|
|
545
578
|
// Resolve model once (per-call top-level model override beats preset)
|
|
546
579
|
const modelResult = resolveSubagentModel(ctx, globalParams.resolvedPreset, params.model as string | undefined);
|
|
547
580
|
if (!modelResult.ok) return modelResult.error;
|
|
@@ -706,7 +739,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
706
739
|
let result: SubagentResult;
|
|
707
740
|
try {
|
|
708
741
|
result = await runSubagent(
|
|
709
|
-
|
|
742
|
+
// Issue #222: this step's OWN validated cwd (the pre-pass
|
|
743
|
+
// resolved it against ctx.cwd) — the mode-level
|
|
744
|
+
// resolvedCwd is the fallback for steps that declare none.
|
|
745
|
+
stepCwds[i],
|
|
710
746
|
subagentPrompt,
|
|
711
747
|
stepModel,
|
|
712
748
|
merged.thinkingLevel,
|
|
@@ -724,7 +760,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
724
760
|
// run entry as failed — crashOutput comes from the live entry's
|
|
725
761
|
// last streamed output (the crash result itself has no
|
|
726
762
|
// messages), and errorCategory is classified onto originalParams.
|
|
727
|
-
finalizeUnitRunCrash(state, pi, run, err, "Chain step",
|
|
763
|
+
finalizeUnitRunCrash(state, pi, run, err, "Chain step", stepCwds[i], log, {
|
|
728
764
|
step: i + 1,
|
|
729
765
|
});
|
|
730
766
|
}
|
|
@@ -869,9 +905,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
869
905
|
};
|
|
870
906
|
state.persistRun(pi, chainAggregateRun);
|
|
871
907
|
|
|
908
|
+
// Per-step validation warnings ride the result, prefixed with their
|
|
909
|
+
// step (issue #222), after the JSON payload — empty renders nothing so
|
|
910
|
+
// warning-free results stay byte-identical.
|
|
872
911
|
return {
|
|
873
912
|
content: [
|
|
874
|
-
{
|
|
913
|
+
{
|
|
914
|
+
type: "text" as const,
|
|
915
|
+
text: JSON.stringify(chainDetails, null, 2) + formatValidationWarnings(stepWarnings),
|
|
916
|
+
},
|
|
875
917
|
],
|
|
876
918
|
details: chainDetails,
|
|
877
919
|
};
|
|
@@ -903,32 +945,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
903
945
|
const taskList = params.tasks as SubTaskParams[];
|
|
904
946
|
|
|
905
947
|
// R5: Check session cost limit before spawning
|
|
906
|
-
const
|
|
907
|
-
state
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
currentTotal,
|
|
915
|
-
estimatedCost: perTaskEstimate * taskList.length,
|
|
916
|
-
limit,
|
|
917
|
-
});
|
|
918
|
-
return {
|
|
919
|
-
content: [
|
|
920
|
-
{
|
|
921
|
-
type: "text" as const,
|
|
922
|
-
text:
|
|
923
|
-
`Cannot delegate: session cost limit reached ` +
|
|
924
|
-
`($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
|
|
925
|
-
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
926
|
-
},
|
|
927
|
-
],
|
|
928
|
-
details: undefined,
|
|
929
|
-
isError: true,
|
|
930
|
-
};
|
|
931
|
-
}
|
|
948
|
+
const costError = gateSessionCost({
|
|
949
|
+
state,
|
|
950
|
+
ctx,
|
|
951
|
+
log,
|
|
952
|
+
units: taskList.length,
|
|
953
|
+
label: "Parallel delegation",
|
|
954
|
+
});
|
|
955
|
+
if (costError) return costError;
|
|
932
956
|
|
|
933
957
|
// Reject delegation if recursion depth exceeds configured max
|
|
934
958
|
const currentDepth = getCurrentDepth();
|
|
@@ -975,17 +999,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
975
999
|
);
|
|
976
1000
|
|
|
977
1001
|
// Validate CWD once
|
|
978
|
-
const
|
|
979
|
-
if (!
|
|
980
|
-
|
|
981
|
-
content: [
|
|
982
|
-
{ type: "text" as const, text: `Invalid cwd: ${cwdResult.error}` },
|
|
983
|
-
],
|
|
984
|
-
details: undefined,
|
|
985
|
-
isError: true,
|
|
986
|
-
};
|
|
987
|
-
}
|
|
988
|
-
const resolvedCwd = cwdResult.value;
|
|
1002
|
+
const targets = validateDelegationTargets({ ctx, effectiveCwd: globalParams.effectiveCwd });
|
|
1003
|
+
if (!targets.ok) return targets.error;
|
|
1004
|
+
const resolvedCwd = targets.cwd;
|
|
989
1005
|
|
|
990
1006
|
// Pre-flight checks — fail fast before consuming resources
|
|
991
1007
|
const pfResult = preflightCheck(resolvedCwd);
|
|
@@ -1008,26 +1024,64 @@ export default function (pi: ExtensionAPI) {
|
|
|
1008
1024
|
// #32). Parallel top-level tasks are empty (modeCount forbids task+tasks),
|
|
1009
1025
|
// so keyword warnings skip on empty text and only the hard
|
|
1010
1026
|
// outputFile-vs-write conflict applies (issue #34). The mode-level
|
|
1011
|
-
// outputFile on globalParams is the one validated;
|
|
1012
|
-
// are
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1027
|
+
// outputFile on globalParams is the one validated here; every task's own
|
|
1028
|
+
// cwd/outputFile/tools are validated by the per-task pre-pass below
|
|
1029
|
+
// (issue #222).
|
|
1030
|
+
const preTask = runPreTaskValidation({
|
|
1031
|
+
log,
|
|
1032
|
+
label: "Parallel",
|
|
1033
|
+
preTask: {
|
|
1034
|
+
task: globalParams.task,
|
|
1035
|
+
toolOptions: globalParams.toolOptions,
|
|
1036
|
+
thinkingLevel: globalParams.thinkingLevel,
|
|
1037
|
+
gitMode: globalParams.resolvedGitMode,
|
|
1038
|
+
outputFile: globalParams.outputFile,
|
|
1039
|
+
},
|
|
1019
1040
|
});
|
|
1020
|
-
if (
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1041
|
+
if (preTask.error) return preTask.error;
|
|
1042
|
+
|
|
1043
|
+
// Per-task pre-pass (issue #220, extended by #222): validate EVERY task
|
|
1044
|
+
// before any spawn, so a single bad task rejects the batch with nothing
|
|
1045
|
+
// started (`force` degrades capability errors to warnings) — the same
|
|
1046
|
+
// semantics runBackgroundFanOut applies. Each task's OWN cwd is resolved
|
|
1047
|
+
// and validated here (with its outputFile, resolved against that cwd) and
|
|
1048
|
+
// the resolved value is what that task spawns in — issue #222 closed the
|
|
1049
|
+
// gap where a per-task `cwd` was merged but the spawn still used the
|
|
1050
|
+
// mode-level cwd. Per-task warnings are prefixed with their unit and
|
|
1051
|
+
// collected here to ride the final result. The mode-entry validation
|
|
1052
|
+
// above stays for the mode-level outputFile (issue #34).
|
|
1053
|
+
const taskWarnings: string[] = [];
|
|
1054
|
+
// Parallel index → the task's own validated cwd (issue #222).
|
|
1055
|
+
const taskCwds: string[] = [];
|
|
1056
|
+
for (let i = 0; i < taskList.length; i++) {
|
|
1057
|
+
const merged = mergeSubTaskParams(globalParams, taskList[i]);
|
|
1058
|
+
const taskPrefix = `Task ${i + 1} ("${displayTaskName(merged)}")`;
|
|
1059
|
+
|
|
1060
|
+
const taskTargets = validateDelegationTargets({
|
|
1061
|
+
ctx,
|
|
1062
|
+
effectiveCwd: merged.effectiveCwd,
|
|
1063
|
+
outputFile: merged.outputFile,
|
|
1064
|
+
prefix: taskPrefix,
|
|
1065
|
+
});
|
|
1066
|
+
if (!taskTargets.ok) return taskTargets.error;
|
|
1067
|
+
taskCwds.push(taskTargets.cwd);
|
|
1068
|
+
|
|
1069
|
+
const unitValidation = runPreTaskValidation({
|
|
1070
|
+
log,
|
|
1071
|
+
label: "Parallel",
|
|
1072
|
+
prefix: taskPrefix,
|
|
1073
|
+
logContext: { task: i + 1 },
|
|
1074
|
+
preTask: {
|
|
1075
|
+
task: merged.task,
|
|
1076
|
+
toolOptions: merged.toolOptions,
|
|
1077
|
+
thinkingLevel: merged.thinkingLevel,
|
|
1078
|
+
gitMode: globalParams.resolvedGitMode,
|
|
1079
|
+
outputFile: merged.outputFile,
|
|
1080
|
+
force: params.force as boolean | undefined,
|
|
1081
|
+
},
|
|
1082
|
+
});
|
|
1083
|
+
if (unitValidation.error) return unitValidation.error;
|
|
1084
|
+
for (const w of unitValidation.warnings) taskWarnings.push(`${taskPrefix}: ${w}`);
|
|
1031
1085
|
}
|
|
1032
1086
|
|
|
1033
1087
|
// Resolve model once (per-call top-level model override beats preset)
|
|
@@ -1078,6 +1132,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1078
1132
|
const runTask = async (
|
|
1079
1133
|
index: number,
|
|
1080
1134
|
merged: ReturnType<typeof mergeSubTaskParams>,
|
|
1135
|
+
unitCwd: string,
|
|
1081
1136
|
): Promise<boolean> => {
|
|
1082
1137
|
|
|
1083
1138
|
// C3: Resolve this step's model override (step.model > global resolved model)
|
|
@@ -1154,7 +1209,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1154
1209
|
// the live ghost (grace logic treats a 'running' record as live).
|
|
1155
1210
|
try {
|
|
1156
1211
|
const result = await runSubagent(
|
|
1157
|
-
|
|
1212
|
+
// Issue #222: this task's OWN validated cwd.
|
|
1213
|
+
unitCwd,
|
|
1158
1214
|
subagentPrompt,
|
|
1159
1215
|
stepModel,
|
|
1160
1216
|
merged.thinkingLevel,
|
|
@@ -1197,7 +1253,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1197
1253
|
// the live drill-in loops forever. finalizeLiveSubagent is
|
|
1198
1254
|
// idempotent; the rethrow keeps Promise.allSettled semantics (the
|
|
1199
1255
|
// caller's finally still releases the concurrency slot).
|
|
1200
|
-
finalizeUnitRunCrash(state, pi, run, err, "Parallel subtask",
|
|
1256
|
+
finalizeUnitRunCrash(state, pi, run, err, "Parallel subtask", unitCwd, log, {
|
|
1201
1257
|
index,
|
|
1202
1258
|
});
|
|
1203
1259
|
}
|
|
@@ -1275,7 +1331,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1275
1331
|
}
|
|
1276
1332
|
let taskSuccess = false;
|
|
1277
1333
|
try {
|
|
1278
|
-
taskSuccess = await runTask(index, merged);
|
|
1334
|
+
taskSuccess = await runTask(index, merged, taskCwds[index]);
|
|
1279
1335
|
} finally {
|
|
1280
1336
|
// Issue #137: release with the unit's real success — the status
|
|
1281
1337
|
// bar's "N failed" count reflects the true outcome.
|
|
@@ -1332,9 +1388,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1332
1388
|
totalCost,
|
|
1333
1389
|
});
|
|
1334
1390
|
|
|
1391
|
+
// C: per-task validation warnings ride the result, prefixed with their
|
|
1392
|
+
// task (issue #220), after the JSON payload — empty renders nothing so
|
|
1393
|
+
// warning-free results stay byte-identical.
|
|
1335
1394
|
return {
|
|
1336
1395
|
content: [
|
|
1337
|
-
{
|
|
1396
|
+
{
|
|
1397
|
+
type: "text" as const,
|
|
1398
|
+
text: JSON.stringify(parallelDetails, null, 2) + formatValidationWarnings(taskWarnings),
|
|
1399
|
+
},
|
|
1338
1400
|
],
|
|
1339
1401
|
details: parallelDetails,
|
|
1340
1402
|
};
|
|
@@ -1372,32 +1434,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1372
1434
|
}
|
|
1373
1435
|
|
|
1374
1436
|
// R5: Check session cost limit before spawning
|
|
1375
|
-
const
|
|
1376
|
-
state
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
currentTotal,
|
|
1384
|
-
estimatedCost: perTaskEstimate * graphTasks.length,
|
|
1385
|
-
limit,
|
|
1386
|
-
});
|
|
1387
|
-
return {
|
|
1388
|
-
content: [
|
|
1389
|
-
{
|
|
1390
|
-
type: "text" as const,
|
|
1391
|
-
text:
|
|
1392
|
-
`Cannot delegate: session cost limit reached ` +
|
|
1393
|
-
`($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
|
|
1394
|
-
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
1395
|
-
},
|
|
1396
|
-
],
|
|
1397
|
-
details: undefined,
|
|
1398
|
-
isError: true,
|
|
1399
|
-
};
|
|
1400
|
-
}
|
|
1437
|
+
const costError = gateSessionCost({
|
|
1438
|
+
state,
|
|
1439
|
+
ctx,
|
|
1440
|
+
log,
|
|
1441
|
+
units: graphTasks.length,
|
|
1442
|
+
label: "Graph delegation",
|
|
1443
|
+
});
|
|
1444
|
+
if (costError) return costError;
|
|
1401
1445
|
|
|
1402
1446
|
// Reject delegation if recursion depth exceeds configured max
|
|
1403
1447
|
const currentDepth = getCurrentDepth();
|
|
@@ -1478,8 +1522,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1478
1522
|
// #32). Graph top-level tasks are empty (modeCount forbids task+graph),
|
|
1479
1523
|
// so keyword warnings skip on empty text and only the hard
|
|
1480
1524
|
// outputFile-vs-write conflict applies (issue #34). The mode-level
|
|
1481
|
-
// outputFile on globalParams is the one validated;
|
|
1482
|
-
// are
|
|
1525
|
+
// outputFile on globalParams is the one validated here; every node's own
|
|
1526
|
+
// cwd/outputFile/tools are validated by the per-node pre-pass below
|
|
1527
|
+
// (issue #222).
|
|
1483
1528
|
const validation = validatePreTask({
|
|
1484
1529
|
task: globalParams.task,
|
|
1485
1530
|
toolOptions: globalParams.toolOptions,
|
|
@@ -1555,6 +1600,73 @@ export default function (pi: ExtensionAPI) {
|
|
|
1555
1600
|
}
|
|
1556
1601
|
const waves = sortResult.waves;
|
|
1557
1602
|
|
|
1603
|
+
// Per-node pre-pass (issue #222): resolve + validate EVERY node before
|
|
1604
|
+
// any spawn, so a single bad node rejects the whole graph with nothing
|
|
1605
|
+
// started — the same all-before-any-spawn semantics the background
|
|
1606
|
+
// fan-out, the parallel `tasks` shape and the chain use. Each node's OWN
|
|
1607
|
+
// cwd is resolved and validated here (with its outputFile, resolved
|
|
1608
|
+
// against that cwd) and the resolved value is what that node spawns in.
|
|
1609
|
+
// The pre-pass runs on the DECLARED task text, before {id} substitution
|
|
1610
|
+
// (the substituted text is only known mid-dispatch, once a dependency's
|
|
1611
|
+
// output exists) — the declared text is validated with the placeholder
|
|
1612
|
+
// TOKENS stripped (a dependency whose id happens to be a blocking
|
|
1613
|
+
// keyword must not make `{<id>}` itself trip the keyword rules), while
|
|
1614
|
+
// the SUBSTITUTED content stays unvalidated by design. The host's own
|
|
1615
|
+
// wording is still validated, and refusing to validate up front would
|
|
1616
|
+
// mean a bad node in the LAST wave is discovered after every earlier
|
|
1617
|
+
// wave already spent its budget.
|
|
1618
|
+
const nodeCwds = new Map<string, string>();
|
|
1619
|
+
const nodeWarnings: string[] = [];
|
|
1620
|
+
for (const graphTask of graphTasks) {
|
|
1621
|
+
const merged = mergeSubTaskParams(globalParams, {
|
|
1622
|
+
task: graphTask.task,
|
|
1623
|
+
label: graphTask.label,
|
|
1624
|
+
model: graphTask.model,
|
|
1625
|
+
thinkingLevel: graphTask.thinkingLevel,
|
|
1626
|
+
priority: graphTask.priority,
|
|
1627
|
+
cwd: graphTask.cwd,
|
|
1628
|
+
timeout: graphTask.timeout,
|
|
1629
|
+
outputFile: graphTask.outputFile,
|
|
1630
|
+
tools: graphTask.tools,
|
|
1631
|
+
excludeTools: graphTask.excludeTools,
|
|
1632
|
+
noBuiltinTools: graphTask.noBuiltinTools,
|
|
1633
|
+
systemPrompt: graphTask.systemPrompt,
|
|
1634
|
+
inheritSystemPrompt: graphTask.inheritSystemPrompt,
|
|
1635
|
+
});
|
|
1636
|
+
const nodePrefix = `Node "${graphTask.id}"`;
|
|
1637
|
+
|
|
1638
|
+
const nodeTargets = validateDelegationTargets({
|
|
1639
|
+
ctx,
|
|
1640
|
+
effectiveCwd: merged.effectiveCwd,
|
|
1641
|
+
outputFile: merged.outputFile,
|
|
1642
|
+
prefix: nodePrefix,
|
|
1643
|
+
});
|
|
1644
|
+
if (!nodeTargets.ok) return nodeTargets.error;
|
|
1645
|
+
nodeCwds.set(graphTask.id, nodeTargets.cwd);
|
|
1646
|
+
|
|
1647
|
+
const nodeValidation = runPreTaskValidation({
|
|
1648
|
+
log,
|
|
1649
|
+
label: "Graph",
|
|
1650
|
+
prefix: nodePrefix,
|
|
1651
|
+
logContext: { id: graphTask.id },
|
|
1652
|
+
preTask: {
|
|
1653
|
+
// Placeholder TOKENS stripped (issue #222 review): the rules
|
|
1654
|
+
// read the declared string, so a `{<id>}` whose dependency id
|
|
1655
|
+
// is a blocking keyword would block on a token, not on text
|
|
1656
|
+
// the host wrote. The declared wording around it is unchanged
|
|
1657
|
+
// and still validated.
|
|
1658
|
+
task: merged.task.replace(GRAPH_OUTPUT_PLACEHOLDER_RE, " "),
|
|
1659
|
+
toolOptions: merged.toolOptions,
|
|
1660
|
+
thinkingLevel: merged.thinkingLevel,
|
|
1661
|
+
gitMode: globalParams.resolvedGitMode,
|
|
1662
|
+
outputFile: merged.outputFile,
|
|
1663
|
+
force: params.force as boolean | undefined,
|
|
1664
|
+
},
|
|
1665
|
+
});
|
|
1666
|
+
if (nodeValidation.error) return nodeValidation.error;
|
|
1667
|
+
for (const w of nodeValidation.warnings) nodeWarnings.push(`${nodePrefix}: ${w}`);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1558
1670
|
// Build base prompt once
|
|
1559
1671
|
const basePrompt = ctx.getSystemPrompt();
|
|
1560
1672
|
|
|
@@ -1626,6 +1738,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1626
1738
|
};
|
|
1627
1739
|
|
|
1628
1740
|
const merged = mergeSubTaskParams(globalParams, subTaskParams);
|
|
1741
|
+
// Issue #222: the node's OWN validated cwd (pre-pass);
|
|
1742
|
+
// resolvedCwd (the mode-level cwd) is the guaranteed
|
|
1743
|
+
// fallback, so a node that declares none still resolves.
|
|
1744
|
+
const nodeCwd = nodeCwds.get(graphTask.id) ?? resolvedCwd;
|
|
1629
1745
|
|
|
1630
1746
|
// C3: Resolve this step's model override (step.model > global resolved model)
|
|
1631
1747
|
const stepModel = resolveStepModel(ctx, merged.model, subagentModel);
|
|
@@ -1709,7 +1825,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1709
1825
|
const stepOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
|
|
1710
1826
|
|
|
1711
1827
|
const result = await runSubagent(
|
|
1712
|
-
|
|
1828
|
+
nodeCwd,
|
|
1713
1829
|
subagentPrompt,
|
|
1714
1830
|
stepModel,
|
|
1715
1831
|
merged.thinkingLevel,
|
|
@@ -1760,7 +1876,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1760
1876
|
// finalizeLiveSubagent is idempotent; the rethrow keeps
|
|
1761
1877
|
// Promise.allSettled semantics (a failing node must NOT cancel
|
|
1762
1878
|
// sibling nodes in the wave).
|
|
1763
|
-
finalizeUnitRunCrash(state, pi, run, err, "Graph node",
|
|
1879
|
+
finalizeUnitRunCrash(state, pi, run, err, "Graph node", nodeCwd, log, {
|
|
1764
1880
|
id: graphTask.id,
|
|
1765
1881
|
});
|
|
1766
1882
|
} finally {
|
|
@@ -1857,9 +1973,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1857
1973
|
};
|
|
1858
1974
|
state.persistRun(pi, graphAggregateRun);
|
|
1859
1975
|
|
|
1976
|
+
// Per-node validation warnings ride the result, prefixed with their
|
|
1977
|
+
// node id (issue #222), after the JSON payload — empty renders
|
|
1978
|
+
// nothing so warning-free results stay byte-identical.
|
|
1860
1979
|
return {
|
|
1861
1980
|
content: [
|
|
1862
|
-
{
|
|
1981
|
+
{
|
|
1982
|
+
type: "text" as const,
|
|
1983
|
+
text: JSON.stringify(graphDetails, null, 2) + formatValidationWarnings(nodeWarnings),
|
|
1984
|
+
},
|
|
1863
1985
|
],
|
|
1864
1986
|
details: graphDetails,
|
|
1865
1987
|
};
|
|
@@ -1878,6 +2000,666 @@ export default function (pi: ExtensionAPI) {
|
|
|
1878
2000
|
}
|
|
1879
2001
|
}
|
|
1880
2002
|
|
|
2003
|
+
// -------------------------------------------------------------------
|
|
2004
|
+
// runBackgroundFanOut — background fan-out for tasks mode.
|
|
2005
|
+
// Resolves + validates the WHOLE batch up front (no spawn on any
|
|
2006
|
+
// failure), then spawns the tasks sequentially, in task order, through the
|
|
2007
|
+
// shared startBackgroundAgent tail, and returns immediately with their ids.
|
|
2008
|
+
// Foreground-only gates (preflight, circuit breaker, recursion depth) are
|
|
2009
|
+
// deliberately skipped — the single-background path skips them too.
|
|
2010
|
+
// -------------------------------------------------------------------
|
|
2011
|
+
|
|
2012
|
+
async function runBackgroundFanOut(
|
|
2013
|
+
pi: ExtensionAPI,
|
|
2014
|
+
ctx: ExtensionContext,
|
|
2015
|
+
params: Record<string, unknown>,
|
|
2016
|
+
signal: AbortSignal | undefined,
|
|
2017
|
+
): Promise<ToolResult<SubagentResult | undefined>> {
|
|
2018
|
+
const taskList = params.tasks as SubTaskParams[];
|
|
2019
|
+
|
|
2020
|
+
// Session cost limit gates the WHOLE batch before any spawn — same
|
|
2021
|
+
// estimate/limit logic as runParallelMode, scaled by task count.
|
|
2022
|
+
const costError = gateSessionCost({
|
|
2023
|
+
state,
|
|
2024
|
+
ctx,
|
|
2025
|
+
log,
|
|
2026
|
+
units: taskList.length,
|
|
2027
|
+
label: "Background fan-out",
|
|
2028
|
+
});
|
|
2029
|
+
if (costError) return costError;
|
|
2030
|
+
|
|
2031
|
+
// Resolve global params once — same call shape as runParallelMode.
|
|
2032
|
+
const globalParams = resolveSubagentParams(
|
|
2033
|
+
params as {
|
|
2034
|
+
task: string;
|
|
2035
|
+
label?: string;
|
|
2036
|
+
preset?: string;
|
|
2037
|
+
systemPrompt?: string;
|
|
2038
|
+
inheritSystemPrompt?: boolean;
|
|
2039
|
+
thinkingLevel?: string;
|
|
2040
|
+
outputFile?: string;
|
|
2041
|
+
timeout?: number;
|
|
2042
|
+
cwd?: string;
|
|
2043
|
+
tools?: string[];
|
|
2044
|
+
excludeTools?: string[];
|
|
2045
|
+
noBuiltinTools?: boolean;
|
|
2046
|
+
gitMode?: string;
|
|
2047
|
+
},
|
|
2048
|
+
state,
|
|
2049
|
+
ctx,
|
|
2050
|
+
log,
|
|
2051
|
+
);
|
|
2052
|
+
|
|
2053
|
+
// Resolve the global model once (per-call model beats preset/config).
|
|
2054
|
+
const globalModelResult = resolveSubagentModel(
|
|
2055
|
+
ctx,
|
|
2056
|
+
globalParams.resolvedPreset,
|
|
2057
|
+
params.model as string | undefined,
|
|
2058
|
+
);
|
|
2059
|
+
if (!globalModelResult.ok) return globalModelResult.error;
|
|
2060
|
+
const globalModel = globalModelResult.model;
|
|
2061
|
+
|
|
2062
|
+
// Same rejection as the single-background path: approvalMode 'always'
|
|
2063
|
+
// cannot work in background — there is no interactive dialog to approve
|
|
2064
|
+
// the diff, for any of the N agents.
|
|
2065
|
+
const approvalError = rejectApprovalAlwaysInBackground(globalParams.resolvedApprovalMode);
|
|
2066
|
+
if (approvalError) return approvalError;
|
|
2067
|
+
// W5 parity with the single-background path: 'writes' silently
|
|
2068
|
+
// auto-approves in background — warn once so the caller knows none of
|
|
2069
|
+
// the N agents' diffs will be gated on approval.
|
|
2070
|
+
if (globalParams.resolvedApprovalMode === 'writes') {
|
|
2071
|
+
log.warn("Background fan-out spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
|
|
2072
|
+
tasks: taskList.length,
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// gitMode 'branch' is rejected for the whole batch: the per-repository
|
|
2077
|
+
// git lock is awaited inside the first spawn and held until that agent
|
|
2078
|
+
// settles, so same-repository branch-mode spawns would serialize and
|
|
2079
|
+
// block this call for the whole chain. The rule is blanket (not
|
|
2080
|
+
// per-repository-checked) because the lock is keyed by the resolved
|
|
2081
|
+
// repository root: units in different directories of one repo share the
|
|
2082
|
+
// working tree and contend for the same lock.
|
|
2083
|
+
if (globalParams.resolvedGitMode === 'branch') {
|
|
2084
|
+
return {
|
|
2085
|
+
content: [{ type: "text" as const, text:
|
|
2086
|
+
`Cannot start ${taskList.length} background agents with gitMode 'branch': the per-repository git lock ` +
|
|
2087
|
+
`is awaited by the first spawn and held until that agent settles, so same-repository branch-mode ` +
|
|
2088
|
+
`spawns would serialize and block this call for the whole chain. Background fan-out therefore ` +
|
|
2089
|
+
`rejects gitMode 'branch' for all tasks in this version. Use gitMode 'none' for the batch, or ` +
|
|
2090
|
+
`dispatch each unit as a separate single-task background call.`
|
|
2091
|
+
}],
|
|
2092
|
+
details: undefined,
|
|
2093
|
+
isError: true,
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
// Per-task pre-pass: resolve + validate EVERYTHING before any spawn, so a
|
|
2098
|
+
// single bad task rejects the whole batch with nothing started.
|
|
2099
|
+
type FanOutTask = {
|
|
2100
|
+
merged: ReturnType<typeof mergeSubTaskParams>;
|
|
2101
|
+
model: { provider: string; id: string };
|
|
2102
|
+
cwd: string;
|
|
2103
|
+
outputFile: string | undefined;
|
|
2104
|
+
};
|
|
2105
|
+
|
|
2106
|
+
const fanOutTasks: FanOutTask[] = [];
|
|
2107
|
+
// C: per-task warnings collect here to ride the immediate spawn result.
|
|
2108
|
+
const taskWarnings: string[] = [];
|
|
2109
|
+
for (let i = 0; i < taskList.length; i++) {
|
|
2110
|
+
const merged = mergeSubTaskParams(globalParams, taskList[i]);
|
|
2111
|
+
const name = displayTaskName(merged);
|
|
2112
|
+
const taskPrefix = `Task ${i + 1} ("${name}")`;
|
|
2113
|
+
// Per-task model override wins; the global model is the fallback.
|
|
2114
|
+
const stepModel = resolveStepModel(ctx, merged.model, globalModel);
|
|
2115
|
+
|
|
2116
|
+
const targets = validateDelegationTargets({
|
|
2117
|
+
ctx,
|
|
2118
|
+
effectiveCwd: merged.effectiveCwd,
|
|
2119
|
+
outputFile: merged.outputFile,
|
|
2120
|
+
prefix: taskPrefix,
|
|
2121
|
+
});
|
|
2122
|
+
if (!targets.ok) return targets.error;
|
|
2123
|
+
|
|
2124
|
+
const preTask = runPreTaskValidation({
|
|
2125
|
+
log,
|
|
2126
|
+
label: "Background fan-out",
|
|
2127
|
+
prefix: taskPrefix,
|
|
2128
|
+
logContext: { task: i + 1 },
|
|
2129
|
+
preTask: {
|
|
2130
|
+
task: merged.task,
|
|
2131
|
+
toolOptions: merged.toolOptions,
|
|
2132
|
+
thinkingLevel: merged.thinkingLevel,
|
|
2133
|
+
gitMode: globalParams.resolvedGitMode,
|
|
2134
|
+
outputFile: merged.outputFile,
|
|
2135
|
+
force: params.force as boolean | undefined,
|
|
2136
|
+
},
|
|
2137
|
+
});
|
|
2138
|
+
if (preTask.error) return preTask.error;
|
|
2139
|
+
for (const w of preTask.warnings) taskWarnings.push(`${taskPrefix}: ${w}`);
|
|
2140
|
+
|
|
2141
|
+
fanOutTasks.push({ merged, model: stepModel, cwd: targets.cwd, outputFile: targets.outputFile });
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// Spawn pass — sequential, in task order, through the shared tail.
|
|
2145
|
+
const started: Array<{ id: string; label?: string }> = [];
|
|
2146
|
+
const startedIdsText = (): string => started.map((s) => s.id).join(", ");
|
|
2147
|
+
|
|
2148
|
+
for (let i = 0; i < fanOutTasks.length; i++) {
|
|
2149
|
+
if (signal?.aborted) {
|
|
2150
|
+
if (started.length === 0) {
|
|
2151
|
+
return {
|
|
2152
|
+
content: [{ type: "text" as const, text: `Background fan-out cancelled before any agent started.` }],
|
|
2153
|
+
details: undefined,
|
|
2154
|
+
isError: true,
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
return {
|
|
2158
|
+
content: [{ type: "text" as const, text:
|
|
2159
|
+
`Background fan-out cancelled after starting ${started.length} of ${fanOutTasks.length} agents ` +
|
|
2160
|
+
`(${startedIdsText()}) — they are detached and will still wake you.`
|
|
2161
|
+
}],
|
|
2162
|
+
details: undefined,
|
|
2163
|
+
isError: true,
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
const { merged, model, cwd, outputFile } = fanOutTasks[i];
|
|
2168
|
+
const name = displayTaskName(merged);
|
|
2169
|
+
const subagentPrompt = buildSubagentPrompt(
|
|
2170
|
+
ctx.getSystemPrompt(),
|
|
2171
|
+
merged.inheritSP,
|
|
2172
|
+
merged.customSP,
|
|
2173
|
+
outputFile,
|
|
2174
|
+
merged.toolOptions?.tools,
|
|
2175
|
+
globalParams.resolvedPreset?.promptGuideline,
|
|
2176
|
+
);
|
|
2177
|
+
const unitPriority = merged.priority ?? (params.priority as string | undefined);
|
|
2178
|
+
|
|
2179
|
+
let agent: BackgroundAgent;
|
|
2180
|
+
try {
|
|
2181
|
+
agent = await startBackgroundAgent(pi, ctx, {
|
|
2182
|
+
task: merged.task,
|
|
2183
|
+
type: (params.preset as string | undefined) || 'general-purpose',
|
|
2184
|
+
description: merged.label,
|
|
2185
|
+
model: `${model.provider}/${model.id}`,
|
|
2186
|
+
thinkingLevel: merged.thinkingLevel,
|
|
2187
|
+
priority: unitPriority,
|
|
2188
|
+
systemPrompt: subagentPrompt,
|
|
2189
|
+
cwd,
|
|
2190
|
+
toolOptions: merged.toolOptions,
|
|
2191
|
+
timeout: merged.timeout,
|
|
2192
|
+
gitMode: globalParams.resolvedGitMode,
|
|
2193
|
+
// Retry parity with createUnitRun: snapshot this unit's resolved
|
|
2194
|
+
// values so a retry restores them. The execution-shape fields
|
|
2195
|
+
// (background/gitMode/approvalMode/force) are deliberately NOT
|
|
2196
|
+
// passed here — a fan-out unit is spec'd to retry as a single
|
|
2197
|
+
// run, so it must not inherit this spawn's background-ness.
|
|
2198
|
+
// Same omission in unit-run.ts's createUnitRun.
|
|
2199
|
+
originalParams: snapshotOriginalParams({
|
|
2200
|
+
systemPrompt: merged.customSP,
|
|
2201
|
+
inheritSystemPrompt: merged.inheritSP,
|
|
2202
|
+
model: merged.model,
|
|
2203
|
+
thinkingLevel: merged.thinkingLevel,
|
|
2204
|
+
priority: unitPriority,
|
|
2205
|
+
outputFile: merged.outputFile,
|
|
2206
|
+
timeout: merged.timeout,
|
|
2207
|
+
cwd: merged.effectiveCwd,
|
|
2208
|
+
tools: merged.toolOptions?.tools,
|
|
2209
|
+
excludeTools: merged.toolOptions?.excludeTools,
|
|
2210
|
+
noBuiltinTools: merged.toolOptions?.noBuiltinTools,
|
|
2211
|
+
preset: globalParams.resolvedPreset?.name,
|
|
2212
|
+
}),
|
|
2213
|
+
sanitizeCwd: merged.effectiveCwd,
|
|
2214
|
+
});
|
|
2215
|
+
} catch (err) {
|
|
2216
|
+
const message = sanitizeErrorMessage(
|
|
2217
|
+
err instanceof Error ? err.message : String(err),
|
|
2218
|
+
merged.effectiveCwd,
|
|
2219
|
+
);
|
|
2220
|
+
log.error("Background fan-out spawn failed", { task: i + 1, error: message });
|
|
2221
|
+
const alreadyStarted = started.length > 0
|
|
2222
|
+
? `\n\nAlready started: ${startedIdsText()} — they are detached and will still wake you.`
|
|
2223
|
+
: "";
|
|
2224
|
+
return {
|
|
2225
|
+
content: [{ type: "text" as const, text:
|
|
2226
|
+
`Failed to spawn background agent for Task ${i + 1} ("${name}"): ${message}` + alreadyStarted
|
|
2227
|
+
}],
|
|
2228
|
+
details: undefined,
|
|
2229
|
+
isError: true,
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
started.push({ id: agent.id, label: merged.label });
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// Text-only result: ids in task order + the wake hint. C: per-task
|
|
2236
|
+
// validation warnings ride the immediate SPAWN result (prefixed with
|
|
2237
|
+
// their task) so the conductor can stop/re-dispatch in the same turn.
|
|
2238
|
+
const lines = started
|
|
2239
|
+
.map((s, i) => `${i + 1}. ${s.label ? `"${s.label}" ` : ""}— ${s.id}`)
|
|
2240
|
+
.join("\n");
|
|
2241
|
+
return {
|
|
2242
|
+
content: [{ type: "text" as const, text:
|
|
2243
|
+
`Background agents started: ${started.length}\n\n` +
|
|
2244
|
+
`${lines}\n\n` +
|
|
2245
|
+
`You'll be woken with a completion message as each finishes; use get_subagent_result({ agent_id }) for retrieval and stall checks.` +
|
|
2246
|
+
formatValidationWarnings(taskWarnings)
|
|
2247
|
+
}],
|
|
2248
|
+
details: undefined,
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// -------------------------------------------------------------------
|
|
2253
|
+
// startBackgroundAgent — the shared background-spawn tail.
|
|
2254
|
+
// Used by BOTH spawnBackgroundRun (single task) and runBackgroundFanOut
|
|
2255
|
+
// (tasks fan-out): the session-manager import, the spawn call, live-monitor
|
|
2256
|
+
// registration, footer counters, the 2s progress poller and the hard-cap
|
|
2257
|
+
// timer live here exactly once. Callers keep their own prelude, checks,
|
|
2258
|
+
// validations and result formatting. THROWS on spawn failure — callers
|
|
2259
|
+
// translate the error into their own result shape.
|
|
2260
|
+
// -------------------------------------------------------------------
|
|
2261
|
+
|
|
2262
|
+
async function startBackgroundAgent(
|
|
2263
|
+
pi: ExtensionAPI,
|
|
2264
|
+
ctx: ExtensionContext,
|
|
2265
|
+
spawn: {
|
|
2266
|
+
task: string;
|
|
2267
|
+
type?: string;
|
|
2268
|
+
description?: string;
|
|
2269
|
+
model?: string;
|
|
2270
|
+
thinkingLevel: ThinkingLevel;
|
|
2271
|
+
priority?: string;
|
|
2272
|
+
systemPrompt: string;
|
|
2273
|
+
cwd: string;
|
|
2274
|
+
toolOptions?: SubagentToolOptions;
|
|
2275
|
+
timeout?: number;
|
|
2276
|
+
gitMode: GitMode;
|
|
2277
|
+
originalParams?: SubagentRun["originalParams"];
|
|
2278
|
+
/** cwd used to redact paths from crash notifications. */
|
|
2279
|
+
sanitizeCwd: string;
|
|
2280
|
+
},
|
|
2281
|
+
): Promise<BackgroundAgent> {
|
|
2282
|
+
const { spawnBackgroundSession, setAgentFinalOutput, extractFinalOutput, updateAgentStatus } = await import('./session-manager');
|
|
2283
|
+
// Issue #31 (PR #76 review, DRY): extract final output from a
|
|
2284
|
+
// possibly-released ref — the poller may observe a terminal agent
|
|
2285
|
+
// whose ref was already nulled by the settlement path.
|
|
2286
|
+
const extractAgentFinalOutput = (a: BackgroundAgent): string =>
|
|
2287
|
+
extractFinalOutput(a._sessionRef ?? { messages: [] });
|
|
2288
|
+
|
|
2289
|
+
const agent = await spawnBackgroundSession(pi, ctx, {
|
|
2290
|
+
task: spawn.task,
|
|
2291
|
+
type: spawn.type,
|
|
2292
|
+
description: spawn.description,
|
|
2293
|
+
model: spawn.model,
|
|
2294
|
+
thinkingLevel: spawn.thinkingLevel,
|
|
2295
|
+
// Issue #114: per-unit priority rides the spawn so the background
|
|
2296
|
+
// run entry + drill-in header carry it like foreground runs.
|
|
2297
|
+
priority: spawn.priority,
|
|
2298
|
+
systemPrompt: spawn.systemPrompt,
|
|
2299
|
+
cwd: spawn.cwd,
|
|
2300
|
+
toolOptions: spawn.toolOptions,
|
|
2301
|
+
timeout: spawn.timeout,
|
|
2302
|
+
gitMode: spawn.gitMode,
|
|
2303
|
+
// Issue #98/#108: snapshotOriginalParams is the single source of truth
|
|
2304
|
+
// for the caller params a later retry restores.
|
|
2305
|
+
originalParams: spawn.originalParams,
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
// Register for live monitor
|
|
2309
|
+
state.registerLiveSubagent(agent.id, {
|
|
2310
|
+
id: agent.id,
|
|
2311
|
+
label: agent.description,
|
|
2312
|
+
task: agent.task,
|
|
2313
|
+
model: agent.model,
|
|
2314
|
+
thinkingLevel: agent.thinkingLevel,
|
|
2315
|
+
priority: agent.priority,
|
|
2316
|
+
startedAt: agent.startedAt,
|
|
2317
|
+
ctx,
|
|
2318
|
+
});
|
|
2319
|
+
|
|
2320
|
+
// Update footer counters
|
|
2321
|
+
state.activeSubagents++;
|
|
2322
|
+
updateProgressStatus(state, ctx);
|
|
2323
|
+
|
|
2324
|
+
let completed = false;
|
|
2325
|
+
|
|
2326
|
+
// Poll for live progress
|
|
2327
|
+
const pollInterval = setInterval(() => {
|
|
2328
|
+
try {
|
|
2329
|
+
if (completed) return;
|
|
2330
|
+
|
|
2331
|
+
const session = agent._sessionRef;
|
|
2332
|
+
// Issue #31: a nulled ref on a TERMINAL agent is expected (the
|
|
2333
|
+
// settlement path releases the ref) — fall through to finalize
|
|
2334
|
+
// below instead of treating it as a crash. A nulled ref on a
|
|
2335
|
+
// live agent is still a crash.
|
|
2336
|
+
if (!session && !agent.completedAt) {
|
|
2337
|
+
// Session ref not available — session may have crashed.
|
|
2338
|
+
// Defensive: the ref is assigned synchronously before the poller starts
|
|
2339
|
+
// and is never nulled, so this is mostly unreachable — but keep the
|
|
2340
|
+
// bookkeeping safe regardless.
|
|
2341
|
+
completed = true;
|
|
2342
|
+
clearInterval(pollInterval);
|
|
2343
|
+
clearTimeout(hardCapHandle);
|
|
2344
|
+
// Capture whatever output exists (may be empty — fine).
|
|
2345
|
+
setAgentFinalOutput(agent.id, state.subagentSessions.get(agent.id)?.liveOutput ?? '');
|
|
2346
|
+
// Gate the counter on the finalize claim: if the
|
|
2347
|
+
// stale sweep already finalized this entry, the
|
|
2348
|
+
// decrement happened there (PR #71 review).
|
|
2349
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2350
|
+
state.activeSubagents--;
|
|
2351
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2352
|
+
}
|
|
2353
|
+
state.failedSubagents++;
|
|
2354
|
+
updateProgressStatus(state, ctx);
|
|
2355
|
+
pi.sendMessage({
|
|
2356
|
+
customType: "subagent-notification",
|
|
2357
|
+
content: `Background agent "${agent.description}" crashed.`,
|
|
2358
|
+
display: true,
|
|
2359
|
+
details: { agentId: agent.id }
|
|
2360
|
+
}, { deliverAs: "followUp" });
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// While running, update the live monitor.
|
|
2365
|
+
// Once completedAt is set, skip straight to finalize below.
|
|
2366
|
+
// Issue #31: past the guard above, the only nulled-ref path left is a
|
|
2367
|
+
// TERMINAL agent (settlement releases the ref), so a live agent always
|
|
2368
|
+
// has its session — the guard already returned on the live-nulled-ref
|
|
2369
|
+
// crash path. The compiler can't correlate the two checks, so assert it.
|
|
2370
|
+
if (!agent.completedAt) {
|
|
2371
|
+
const finalOutput = extractFinalOutput(session!);
|
|
2372
|
+
|
|
2373
|
+
try {
|
|
2374
|
+
const stats = session!.getSessionStats();
|
|
2375
|
+
state.updateLiveSubagent(agent.id, finalOutput, stats.tokens.input, stats.tokens.output);
|
|
2376
|
+
} catch {
|
|
2377
|
+
state.updateLiveSubagent(agent.id, finalOutput, 0, 0);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// Authoritative completion: the prompt promise settled — status and
|
|
2382
|
+
// completedAt are set by .then()/.catch() in spawnBackgroundSession.
|
|
2383
|
+
// Do NOT rely on !session.isStreaming: it is false during prompt()
|
|
2384
|
+
// preflight (auth check, model resolution) and for failed starts.
|
|
2385
|
+
if (agent.completedAt) {
|
|
2386
|
+
completed = true;
|
|
2387
|
+
clearInterval(pollInterval);
|
|
2388
|
+
clearTimeout(hardCapHandle);
|
|
2389
|
+
|
|
2390
|
+
// Issue #31: prefer the output already captured at settlement;
|
|
2391
|
+
// the ref may already be nulled on terminal agents, so never
|
|
2392
|
+
// overwrite with empty and stay null-safe.
|
|
2393
|
+
const finalOutput = agent.finalOutput ?? extractAgentFinalOutput(agent);
|
|
2394
|
+
setAgentFinalOutput(agent.id, finalOutput);
|
|
2395
|
+
|
|
2396
|
+
// Gate the counter on the finalize claim: if the
|
|
2397
|
+
// stale sweep already finalized this entry, the
|
|
2398
|
+
// decrement happened there (PR #71 review).
|
|
2399
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2400
|
+
state.activeSubagents--;
|
|
2401
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
if (agent.status === 'failed') {
|
|
2405
|
+
state.failedSubagents++;
|
|
2406
|
+
updateProgressStatus(state, ctx);
|
|
2407
|
+
pi.sendMessage({
|
|
2408
|
+
customType: "subagent-notification",
|
|
2409
|
+
content: `Background agent "${agent.description}" failed.`,
|
|
2410
|
+
display: true,
|
|
2411
|
+
details: { agentId: agent.id }
|
|
2412
|
+
}, { deliverAs: "followUp" });
|
|
2413
|
+
} else if (agent.status === 'stopped') {
|
|
2414
|
+
// User-initiated stop (stop_subagent) or deadline abort
|
|
2415
|
+
// (timeout/hard cap) — not a failure.
|
|
2416
|
+
updateProgressStatus(state, ctx);
|
|
2417
|
+
} else {
|
|
2418
|
+
state.completedSubagents++;
|
|
2419
|
+
state.unseenSubagents++;
|
|
2420
|
+
updateProgressStatus(state, ctx);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
} catch (err) {
|
|
2424
|
+
if (!completed) {
|
|
2425
|
+
completed = true;
|
|
2426
|
+
clearInterval(pollInterval);
|
|
2427
|
+
clearTimeout(hardCapHandle);
|
|
2428
|
+
try {
|
|
2429
|
+
setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
|
|
2430
|
+
} catch { /* ignore */ }
|
|
2431
|
+
// Gate the counter on the finalize claim: if the
|
|
2432
|
+
// stale sweep already finalized this entry, the
|
|
2433
|
+
// decrement happened there (PR #71 review).
|
|
2434
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2435
|
+
state.activeSubagents--;
|
|
2436
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2437
|
+
}
|
|
2438
|
+
state.failedSubagents++;
|
|
2439
|
+
updateProgressStatus(state, ctx);
|
|
2440
|
+
pi.sendMessage({
|
|
2441
|
+
customType: "subagent-notification",
|
|
2442
|
+
content: `Background agent "${agent.description}" crashed: ${sanitizeErrorMessage((err as Error).message, spawn.sanitizeCwd)}`,
|
|
2443
|
+
display: true,
|
|
2444
|
+
details: { agentId: agent.id }
|
|
2445
|
+
}, { deliverAs: "followUp" });
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
}, 2000);
|
|
2449
|
+
|
|
2450
|
+
// Hard cap: stop polling AND abort the session after the deadline.
|
|
2451
|
+
// W2 (issue #28): previously this only stopped the poller — the pi
|
|
2452
|
+
// session kept running forever (orphaned). Now it pre-sets
|
|
2453
|
+
// 'stopped' (so the .then keeps it, per W1) and aborts the
|
|
2454
|
+
// session. The cap honors a shorter per-agent timeout.
|
|
2455
|
+
const hardCapMs = Math.min(spawn.timeout ?? 30 * 60 * 1000, 30 * 60 * 1000);
|
|
2456
|
+
const hardCapHandle = setTimeout(() => {
|
|
2457
|
+
if (!completed && !agent.completedAt) {
|
|
2458
|
+
try {
|
|
2459
|
+
completed = true;
|
|
2460
|
+
clearInterval(pollInterval);
|
|
2461
|
+
// Pre-set stopped BEFORE aborting so the .then in
|
|
2462
|
+
// spawnBackgroundSession keeps the stopped state.
|
|
2463
|
+
updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
|
|
2464
|
+
agent._sessionRef?.abort().catch(() => {
|
|
2465
|
+
// Abort may reject if the session is mid-dispose;
|
|
2466
|
+
// the status flip above is already recorded.
|
|
2467
|
+
});
|
|
2468
|
+
// Capture whatever final output exists in the session
|
|
2469
|
+
const hardCapSession = agent._sessionRef;
|
|
2470
|
+
const hardCapFinalOutput = hardCapSession ? extractFinalOutput(hardCapSession) : '';
|
|
2471
|
+
setAgentFinalOutput(agent.id, hardCapFinalOutput);
|
|
2472
|
+
// Gate the counter on the finalize claim: if the
|
|
2473
|
+
// stale sweep already finalized this entry, the
|
|
2474
|
+
// decrement happened there (PR #71 review).
|
|
2475
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2476
|
+
state.activeSubagents--;
|
|
2477
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2478
|
+
}
|
|
2479
|
+
// m6: deadline abort is a stop, not a completion — mirror the
|
|
2480
|
+
// W3/poller stopped path (no completedSubagents increment).
|
|
2481
|
+
updateProgressStatus(state, ctx);
|
|
2482
|
+
} catch (err) {
|
|
2483
|
+
// Defensive: never let the hard-cap timer throw uncaught — that would
|
|
2484
|
+
// skip finalizeLiveSubagent, the counter decrement, and the notification.
|
|
2485
|
+
// Do all fallible work first (output capture), then mutate counters,
|
|
2486
|
+
// then notify — a throw mid-path can't double-fire mutations.
|
|
2487
|
+
completed = true;
|
|
2488
|
+
clearInterval(pollInterval);
|
|
2489
|
+
// m3: the try may have thrown BEFORE updateAgentStatus ran (e.g.
|
|
2490
|
+
// persistAgent fs failure) — leave the record terminal so
|
|
2491
|
+
// get_subagent_result doesn't report 'running' and the W3 timer
|
|
2492
|
+
// guard (!agent.completedAt) can't re-fire later.
|
|
2493
|
+
try {
|
|
2494
|
+
updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
|
|
2495
|
+
} catch { /* ignore */ }
|
|
2496
|
+
try {
|
|
2497
|
+
setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
|
|
2498
|
+
} catch { /* ignore */ }
|
|
2499
|
+
// Gate the counter on the finalize claim: if the
|
|
2500
|
+
// stale sweep already finalized this entry, the
|
|
2501
|
+
// decrement happened there (PR #71 review).
|
|
2502
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2503
|
+
state.activeSubagents--;
|
|
2504
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2505
|
+
}
|
|
2506
|
+
state.failedSubagents++;
|
|
2507
|
+
updateProgressStatus(state, ctx);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
}, hardCapMs);
|
|
2511
|
+
|
|
2512
|
+
log.info("Background agent spawned", { agentId: agent.id, task: spawn.task, model: agent.model });
|
|
2513
|
+
|
|
2514
|
+
return agent;
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// -------------------------------------------------------------------
|
|
2518
|
+
// spawnBackgroundRun — Phase 6.5 background spawn, extracted verbatim
|
|
2519
|
+
// from execute() (#198 phase 1) so a later phase can fan out N runs.
|
|
2520
|
+
// -------------------------------------------------------------------
|
|
2521
|
+
|
|
2522
|
+
async function spawnBackgroundRun(
|
|
2523
|
+
pi: ExtensionAPI,
|
|
2524
|
+
ctx: ExtensionContext,
|
|
2525
|
+
params: Omit<Parameters<typeof resolveSubagentParams>[0], "task"> & {
|
|
2526
|
+
task?: string;
|
|
2527
|
+
model?: string;
|
|
2528
|
+
priority?: string;
|
|
2529
|
+
},
|
|
2530
|
+
singleTask: string,
|
|
2531
|
+
): Promise<ToolResult<undefined>> {
|
|
2532
|
+
// Phase 6.5: Background execution — spawn session and return ID immediately.
|
|
2533
|
+
// Resolve the preset and its model BEFORE the background branch so the
|
|
2534
|
+
// preset's model (and system prompt) are honored in background mode.
|
|
2535
|
+
const bgResolved = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
|
|
2536
|
+
const {
|
|
2537
|
+
resolvedPreset: bgResolvedPreset,
|
|
2538
|
+
autoRoutedPreset: bgAutoRoutedPreset,
|
|
2539
|
+
autoRouteKeyword: bgAutoRouteKeyword,
|
|
2540
|
+
} = bgResolved;
|
|
2541
|
+
const bgModelResult = resolveSubagentModel(ctx, bgResolvedPreset, params.model);
|
|
2542
|
+
const bgModel = bgModelResult.ok
|
|
2543
|
+
? `${bgModelResult.model.provider}/${bgModelResult.model.id}`
|
|
2544
|
+
: undefined;
|
|
2545
|
+
|
|
2546
|
+
try {
|
|
2547
|
+
// W5 (issue #28): approvalMode 'always' cannot work in background —
|
|
2548
|
+
// there is no interactive dialog to approve the diff. Reject loudly
|
|
2549
|
+
// instead of silently running unattended with write access.
|
|
2550
|
+
const approvalError = rejectApprovalAlwaysInBackground(bgResolved.resolvedApprovalMode);
|
|
2551
|
+
if (approvalError) return approvalError;
|
|
2552
|
+
// W5: 'writes' in background silently auto-approves — warn once so the
|
|
2553
|
+
// caller knows the diff will NOT be gated on approval.
|
|
2554
|
+
if (bgResolved.resolvedApprovalMode === 'writes') {
|
|
2555
|
+
log.warn("Background agent spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
|
|
2556
|
+
task: params.task,
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// W6 (issue #28): session cost limit must gate background spawns too —
|
|
2561
|
+
// the R5 check after the background branch never runs for them.
|
|
2562
|
+
// Same estimate/limit logic as single mode.
|
|
2563
|
+
const costError = gateSessionCost({
|
|
2564
|
+
state,
|
|
2565
|
+
ctx,
|
|
2566
|
+
log,
|
|
2567
|
+
units: 1,
|
|
2568
|
+
label: "Background delegation",
|
|
2569
|
+
});
|
|
2570
|
+
if (costError) return costError;
|
|
2571
|
+
|
|
2572
|
+
// F1: Validate cwd + outputFile the same way foreground single mode does —
|
|
2573
|
+
// an unvalidated outputFile would reach the prompt and could steer the
|
|
2574
|
+
// background agent's write tool outside the project root.
|
|
2575
|
+
const targets = validateDelegationTargets({
|
|
2576
|
+
ctx,
|
|
2577
|
+
effectiveCwd: bgResolved.effectiveCwd,
|
|
2578
|
+
outputFile: bgResolved.outputFile,
|
|
2579
|
+
});
|
|
2580
|
+
if (!targets.ok) return targets.error;
|
|
2581
|
+
const bgResolvedOutputFile = targets.outputFile;
|
|
2582
|
+
|
|
2583
|
+
// C: H1 validation for background mode — reject capability blocks and
|
|
2584
|
+
// outputFile-vs-write conflicts before spawning (same as single mode;
|
|
2585
|
+
// `force` honors the same override foreground does).
|
|
2586
|
+
const bgPreTask = runPreTaskValidation({
|
|
2587
|
+
log,
|
|
2588
|
+
label: "Background",
|
|
2589
|
+
preTask: {
|
|
2590
|
+
task: singleTask,
|
|
2591
|
+
toolOptions: bgResolved.toolOptions,
|
|
2592
|
+
thinkingLevel: bgResolved.thinkingLevel,
|
|
2593
|
+
gitMode: bgResolved.resolvedGitMode,
|
|
2594
|
+
outputFile: bgResolvedOutputFile,
|
|
2595
|
+
force: params.force,
|
|
2596
|
+
},
|
|
2597
|
+
});
|
|
2598
|
+
if (bgPreTask.error) return bgPreTask.error;
|
|
2599
|
+
|
|
2600
|
+
// Build the full prompt the same way foreground single mode does:
|
|
2601
|
+
// base prompt (optionally inherited) + custom prompt + preset guidance.
|
|
2602
|
+
const bgPrompt = buildSubagentPrompt(
|
|
2603
|
+
ctx.getSystemPrompt(),
|
|
2604
|
+
bgResolved.inheritSP,
|
|
2605
|
+
bgResolved.customSP,
|
|
2606
|
+
bgResolvedOutputFile,
|
|
2607
|
+
bgResolved.toolOptions?.tools,
|
|
2608
|
+
bgResolvedPreset?.promptGuideline,
|
|
2609
|
+
);
|
|
2610
|
+
|
|
2611
|
+
const agent = await startBackgroundAgent(pi, ctx, {
|
|
2612
|
+
task: singleTask,
|
|
2613
|
+
type: params.preset || 'general-purpose',
|
|
2614
|
+
description: params.label,
|
|
2615
|
+
model: bgModel,
|
|
2616
|
+
thinkingLevel: bgResolved.thinkingLevel,
|
|
2617
|
+
priority: params.priority,
|
|
2618
|
+
systemPrompt: bgPrompt,
|
|
2619
|
+
cwd: targets.cwd,
|
|
2620
|
+
toolOptions: bgResolved.toolOptions,
|
|
2621
|
+
timeout: bgResolved.timeout,
|
|
2622
|
+
gitMode: bgResolved.resolvedGitMode,
|
|
2623
|
+
originalParams: snapshotOriginalParams(params),
|
|
2624
|
+
sanitizeCwd: bgResolved.effectiveCwd,
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
// B2: Surface auto-route decisions in background spawn result too —
|
|
2628
|
+
// preset name + matched-keyword evidence + restriction, derived from
|
|
2629
|
+
// the RESOLVED toolOptions (reflects per-call overrides).
|
|
2630
|
+
const bgAutoRouteNote = bgAutoRoutedPreset
|
|
2631
|
+
? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}'${bgAutoRouteKeyword ? ` (matched keyword '${bgAutoRouteKeyword}')` : ""} — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
|
|
2632
|
+
: "";
|
|
2633
|
+
|
|
2634
|
+
return {
|
|
2635
|
+
content: [{
|
|
2636
|
+
type: "text" as const,
|
|
2637
|
+
text: `Background agent started: ${agent.id}\n\n` +
|
|
2638
|
+
`Description: ${agent.description}\n` +
|
|
2639
|
+
`Task: ${agent.task}\n` +
|
|
2640
|
+
`Status: ${agent.status}\n\n` +
|
|
2641
|
+
`You'll be woken with a completion message when it finishes; use get_subagent_result({ agent_id: "${agent.id}" }) for retrieval and stall checks.` +
|
|
2642
|
+
bgAutoRouteNote +
|
|
2643
|
+
// C: warnings ride the immediate SPAWN result so the conductor
|
|
2644
|
+
// can stop/re-dispatch in the same turn.
|
|
2645
|
+
formatValidationWarnings(bgPreTask.warnings),
|
|
2646
|
+
}],
|
|
2647
|
+
details: undefined,
|
|
2648
|
+
};
|
|
2649
|
+
} catch (err) {
|
|
2650
|
+
const message = sanitizeErrorMessage(
|
|
2651
|
+
err instanceof Error ? err.message : String(err),
|
|
2652
|
+
bgResolved.effectiveCwd,
|
|
2653
|
+
);
|
|
2654
|
+
log.error("Failed to spawn background agent", { error: message });
|
|
2655
|
+
return {
|
|
2656
|
+
content: [{ type: "text" as const, text: `Failed to spawn background agent: ${message}` }],
|
|
2657
|
+
details: undefined,
|
|
2658
|
+
isError: true,
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
|
|
1881
2663
|
// -------------------------------------------------------------------
|
|
1882
2664
|
// /brl-subagent command
|
|
1883
2665
|
// -------------------------------------------------------------------
|
|
@@ -1973,14 +2755,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1973
2755
|
"You can customize per-call via inheritSystemPrompt and systemPrompt: set inheritSystemPrompt: false to save context, provide a systemPrompt for custom instructions, or use both to add instructions on top of inheritance.",
|
|
1974
2756
|
"Set thinkingLevel per call to match task complexity. The level is capped at the user's configured maximum. Map tasks to levels using this heuristic: off = file listing, grep, simple read. minimal = file diff, syntax check, find-and-replace. low = refactoring, test generation, documentation. medium = default — code review, debugging, moderate analysis. high = security audit, architecture review, complex debugging. xhigh = multi-step causal reasoning, research, novel problem solving. Default to 'off' or 'minimal' for trivial tasks — do not waste the user's budget.",
|
|
1975
2757
|
"Use outputFile to have the subagent write full findings to disk and return only a structured summary — saves context tokens for large investigations.",
|
|
1976
|
-
"Set timeout (in ms) to limit how long a subagent can run. Useful for tasks that might hang or get stuck.",
|
|
2758
|
+
"Set timeout (in ms) to limit how long a subagent can run. Useful for tasks that might hang or get stuck. Background runs are additionally hard-capped at 30 minutes, so a larger value is silently shortened.",
|
|
1977
2759
|
"Set cwd to override the subagent's working directory. Defaults to the current project directory.",
|
|
1978
2760
|
"Set label to give the subagent a human-readable name (e.g., 'security-audit' or 'docs-review'). Labels appear in the status bar and tool call display.",
|
|
1979
2761
|
`Use preset to apply a delegation configuration (built-in or custom via /brl-subagent preset). Preset values are defaults — explicit parameters override them. IMPORTANT: some presets restrict tools — e.g. outputFile requires the subagent's write tool, which security-auditor and code-reviewer exclude. Built-in presets: ${presetRestrictionSummary}. Custom presets are NOT listed here — inspect them via /brl-subagent preset before combining with outputFile or tool-dependent work. When combining a preset with outputFile or tool-dependent work, verify the preset allows the required tools.`,
|
|
1980
2762
|
buildTemplateGuideline(templateSummary),
|
|
1981
|
-
"To retry a failed subagent, pass its run ID as retryRunId. The
|
|
2763
|
+
"To retry a failed subagent, pass its run ID as retryRunId. The retry restores the recorded params - including background, gitMode, approvalMode, force - with explicit values on this call winning. Multi-step runs degrade to a single task. See the retryRunId description for the full field list.",
|
|
1982
2764
|
"Set retryOnTimeout: true to automatically retry a subagent that times out. Only retries once — the second timeout is treated as a final failure.",
|
|
1983
|
-
"Set background: true to run the subagent in the background without blocking. The tool returns immediately with an agent ID. Background runs wake the conductor with a structured completion message when they finish — do not poll: polling is only correct when completion notifications are disabled (completionNotify \"off\"); one status check as a stall check is legitimate.",
|
|
2765
|
+
"Set background: true to run the subagent in the background without blocking. The tool returns immediately with an agent ID. With tasks, background fans out: every task starts as its own background agent, the call returns one ID per task in task order, and the conductor is woken once per agent as each finishes; chain and graph cannot be combined with background (rejected). Background runs wake the conductor with a structured completion message when they finish — do not poll: polling is only correct when completion notifications are disabled (completionNotify \"off\"); one status check as a stall check is legitimate.",
|
|
1984
2766
|
"",
|
|
1985
2767
|
"## Conductor Guardrails",
|
|
1986
2768
|
"",
|
|
@@ -1989,9 +2771,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1989
2771
|
"2. **Thinking level**: Match thinking level to task complexity: off/minimal for trivial tasks (file listing, grep), low for refactoring/docs, medium for code review/debugging, high for security audits/complex debugging, xhigh for multi-step reasoning/novel problems.",
|
|
1990
2772
|
"3. **Git mode**: Use gitMode='branch' for tasks that create commits or PRs. Use gitMode='none' for read-only tasks.",
|
|
1991
2773
|
"4. **Tools**: Verify the subagent has the tools it needs. If the task writes files, ensure write and edit are not excluded. If the task runs commands, ensure bash is not excluded.",
|
|
1992
|
-
"5. **Timeout**: Set timeout based on task complexity. Simple: 30s. Medium: 60s. Complex: 120s+. xhigh thinking: at least 120s.",
|
|
2774
|
+
"5. **Timeout**: Set timeout based on task complexity. Simple: 30s. Medium: 60s. Complex: 120s+. xhigh thinking: at least 120s. Background runs are hard-capped at 30 minutes regardless of the value set.",
|
|
1993
2775
|
"",
|
|
1994
|
-
"These guardrails prevent common misconfigurations. The extension also validates configuration before spawning (H1): tool warnings are
|
|
2776
|
+
"These guardrails prevent common misconfigurations. The extension also validates configuration before spawning (H1): tool warnings are surfaced in the returned result, but two mismatches are HARD errors that reject the delegation — outputFile with the write tool excluded, and a capability-critical task/toolset mismatch (a run/execute/test/compile/benchmark task without bash, or an exploration task with none of find/ls/grep/bash; pass force: true to override the capability class). Getting it right the first time is faster and more efficient.",
|
|
1995
2777
|
"",
|
|
1996
2778
|
"Before delegating, evaluate existing presets to find the best match for the task: tech-writer (documentation), code-reviewer (code review), security-auditor (security analysis), test-engineer (test writing), debugger (debugging), refactorer (refactoring), data-analyst (data analysis), rapid-prototyper (quick prototypes). Use the preset parameter to apply the best match. If no preset fits, use dev-agent for general development tasks.",
|
|
1997
2779
|
"The autoRoutePreset() function can automatically select the best preset based on task keywords. Consider using it for preset selection.",
|
|
@@ -2046,7 +2828,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2046
2828
|
Type.Number({
|
|
2047
2829
|
description:
|
|
2048
2830
|
"Maximum time in milliseconds the subagent is allowed to run. " +
|
|
2049
|
-
"If exceeded, the subagent is killed and an error is returned."
|
|
2831
|
+
"If exceeded, the subagent is killed and an error is returned. " +
|
|
2832
|
+
"Background runs are additionally hard-capped at 30 minutes, so a larger value is silently shortened there.",
|
|
2050
2833
|
}),
|
|
2051
2834
|
),
|
|
2052
2835
|
cwd: Type.Optional(
|
|
@@ -2102,9 +2885,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
2102
2885
|
Type.String({
|
|
2103
2886
|
description:
|
|
2104
2887
|
"ID of a previously failed subagent run to retry. " +
|
|
2105
|
-
"The
|
|
2106
|
-
"
|
|
2107
|
-
"
|
|
2888
|
+
"The retry rebuilds the parameter object from a fixed field set, falling back to the " +
|
|
2889
|
+
"original run's recorded values, with explicit values on this call winning: task, label, " +
|
|
2890
|
+
"model, preset, systemPrompt, inheritSystemPrompt, thinkingLevel, priority, outputFile, " +
|
|
2891
|
+
"timeout, cwd, tools, excludeTools, noBuiltinTools, background, gitMode, approvalMode, force. " +
|
|
2892
|
+
"The execution-shape fields ARE restored: a retried background run stays background (with its " +
|
|
2893
|
+
"completion wake), and a retried branch-mode run keeps its work branch, its approvalMode " +
|
|
2894
|
+
"gating and its force override. Qualify that by the effective gitMode - when neither the record " +
|
|
2895
|
+
"nor this call carries one, the configured default applies, which may still be 'branch'. " +
|
|
2896
|
+
"Not restored: chain/tasks/graph and params, so a retried multi-step run silently degrades to a " +
|
|
2897
|
+
"single task - re-issue it fresh. retryOnTimeout is explicit-only: never restored, honoured when passed. " +
|
|
2898
|
+
"template is asymmetric: the original's template is NOT restored, but a template passed on the " +
|
|
2899
|
+
"retry call DOES take effect (it is resolved before the retry merge). " +
|
|
2900
|
+
"Fan-out units (parallel/chain/graph origin) record no background, so retrying one is a single " +
|
|
2901
|
+
"foreground run - pass background: true for a background retry. " +
|
|
2902
|
+
"Only works with runs that ended in failure (exitCode != 0, timeout, error, or abort).",
|
|
2108
2903
|
}),
|
|
2109
2904
|
),
|
|
2110
2905
|
gitMode: Type.Optional(
|
|
@@ -2129,12 +2924,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
2129
2924
|
"always (ask every time). Default is user config (/brl-subagent approval).",
|
|
2130
2925
|
}),
|
|
2131
2926
|
),
|
|
2927
|
+
force: Type.Optional(
|
|
2928
|
+
Type.Boolean({
|
|
2929
|
+
description:
|
|
2930
|
+
"Override for capability pre-flight errors (default false). A dispatch is blocked when the task clearly needs a capability the resolved toolset lacks — " +
|
|
2931
|
+
"a run/execute/test/compile/benchmark task with no bash, or an exploration task (search/grep/find/list/locate/glob) with none of find/ls/grep/bash. " +
|
|
2932
|
+
"Set force: true to dispatch anyway: the mismatch is then delivered as a warning in the result instead of rejecting the call. " +
|
|
2933
|
+
"Warnings are surfaced either way. force never suppresses outputFile-without-write conflicts.",
|
|
2934
|
+
}),
|
|
2935
|
+
),
|
|
2132
2936
|
background: Type.Optional(
|
|
2133
2937
|
Type.Boolean({
|
|
2134
2938
|
description:
|
|
2135
2939
|
"Run the subagent in the background without blocking the conductor. " +
|
|
2136
2940
|
"When true, the tool returns immediately with an agent ID. " +
|
|
2137
2941
|
"The conductor is woken with a completion message; use get_subagent_result for post-wake retrieval and stall checks. " +
|
|
2942
|
+
"Supports a single task or the tasks array (a fan-out that starts one background agent per task; with tasks the call returns one ID per task and the conductor is woken once per agent as each finishes); combining background with chain or graph is rejected. " +
|
|
2138
2943
|
"Default: false (blocking mode).",
|
|
2139
2944
|
}),
|
|
2140
2945
|
),
|
|
@@ -2193,7 +2998,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2193
2998
|
})),
|
|
2194
2999
|
graph: Type.Optional(Type.Array(Type.Object({
|
|
2195
3000
|
id: Type.String({ description: "Unique identifier for this task node" }),
|
|
2196
|
-
task: Type.String({ description: "Task description. Use {
|
|
3001
|
+
task: Type.String({ description: "Task description. Use {<nodeId>} to reference output from another task (the referenced node's own id, which must be a word-character id - letters, digits, underscore; ids like 'step-1' or 'node.a' are not matched and the braces are left as literal text)." }),
|
|
2197
3002
|
label: Type.Optional(Type.String({})),
|
|
2198
3003
|
model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
|
|
2199
3004
|
dependsOn: Type.Optional(Type.Array(Type.String({}), { description: "IDs of tasks that must complete before this one starts" })),
|
|
@@ -2242,6 +3047,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2242
3047
|
retryRunId?: string;
|
|
2243
3048
|
retryOnTimeout?: boolean;
|
|
2244
3049
|
background?: boolean;
|
|
3050
|
+
force?: boolean;
|
|
2245
3051
|
gitMode?: string;
|
|
2246
3052
|
priority?: string;
|
|
2247
3053
|
chain?: Array<{
|
|
@@ -2396,7 +3202,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2396
3202
|
// resolving templates first keeps a combined template + retryRunId call
|
|
2397
3203
|
// from silently disabling template resolution (issue #175 review).
|
|
2398
3204
|
if (params.retryRunId) {
|
|
2399
|
-
const runEntry = state.
|
|
3205
|
+
const runEntry = state.findSpawnRunById(ctx, params.retryRunId);
|
|
2400
3206
|
if (runEntry) {
|
|
2401
3207
|
params = resolveRetryParams(params, runEntry);
|
|
2402
3208
|
} else {
|
|
@@ -2456,6 +3262,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
2456
3262
|
};
|
|
2457
3263
|
}
|
|
2458
3264
|
|
|
3265
|
+
// background: true with chain/graph was silently ignored — the batch ran
|
|
3266
|
+
// foreground (blocking until every unit finished) while the caller believed
|
|
3267
|
+
// it was background. Both remain rejected loudly at dispatch validation;
|
|
3268
|
+
// tasks (parallel) fans out into independent background agents instead.
|
|
3269
|
+
if (params.background && (isChain || isGraph)) {
|
|
3270
|
+
const batchMode = isChain ? "chain" : "graph";
|
|
3271
|
+
return {
|
|
3272
|
+
content: [
|
|
3273
|
+
{
|
|
3274
|
+
type: "text" as const,
|
|
3275
|
+
text: `background: true is not supported with ${batchMode} — batch modes run foreground and would block until every unit finishes. Remove background to run it foreground, or start each unit as its own single-task background call and sequence the dependencies yourself.`,
|
|
3276
|
+
},
|
|
3277
|
+
],
|
|
3278
|
+
details: undefined,
|
|
3279
|
+
isError: true,
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
|
|
2459
3283
|
if (isChain) {
|
|
2460
3284
|
if (params.chain!.length > MAX_CHAIN_STEPS) {
|
|
2461
3285
|
return {
|
|
@@ -2485,6 +3309,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2485
3309
|
isError: true,
|
|
2486
3310
|
};
|
|
2487
3311
|
}
|
|
3312
|
+
if (params.background) return runBackgroundFanOut(pi, ctx, params, signal);
|
|
2488
3313
|
return runParallelMode(params, signal, onUpdate, ctx);
|
|
2489
3314
|
}
|
|
2490
3315
|
|
|
@@ -2497,420 +3322,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
2497
3322
|
// boolean-array filter — assert, mirroring params.chain!/params.tasks! above.
|
|
2498
3323
|
const singleTask = params.task!; // non-empty (sanitizer guarantees; isSingle confirms)
|
|
2499
3324
|
|
|
2500
|
-
// Phase 6.5: Background execution — spawn session and return ID immediately.
|
|
2501
|
-
// Resolve the preset and its model BEFORE the background branch so the
|
|
2502
|
-
// preset's model (and system prompt) are honored in background mode.
|
|
2503
|
-
const bgResolved = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
|
|
2504
|
-
const { resolvedPreset: bgResolvedPreset, autoRoutedPreset: bgAutoRoutedPreset } = bgResolved;
|
|
2505
|
-
const bgModelResult = resolveSubagentModel(ctx, bgResolvedPreset, params.model);
|
|
2506
|
-
const bgModel = bgModelResult.ok
|
|
2507
|
-
? `${bgModelResult.model.provider}/${bgModelResult.model.id}`
|
|
2508
|
-
: undefined;
|
|
2509
|
-
|
|
2510
3325
|
if (params.background) {
|
|
2511
|
-
|
|
2512
|
-
// Issue #31 (PR #76 review, DRY): extract final output from a
|
|
2513
|
-
// possibly-released ref — the poller may observe a terminal agent
|
|
2514
|
-
// whose ref was already nulled by the settlement path.
|
|
2515
|
-
const extractAgentFinalOutput = (a: BackgroundAgent): string =>
|
|
2516
|
-
extractFinalOutput(a._sessionRef ?? { messages: [] });
|
|
2517
|
-
|
|
2518
|
-
try {
|
|
2519
|
-
// W5 (issue #28): approvalMode 'always' cannot work in background —
|
|
2520
|
-
// there is no interactive dialog to approve the diff. Reject loudly
|
|
2521
|
-
// instead of silently running unattended with write access.
|
|
2522
|
-
if (bgResolved.resolvedApprovalMode === 'always') {
|
|
2523
|
-
return {
|
|
2524
|
-
content: [{ type: "text" as const, text:
|
|
2525
|
-
`Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
|
|
2526
|
-
`and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
|
|
2527
|
-
}],
|
|
2528
|
-
details: undefined,
|
|
2529
|
-
isError: true,
|
|
2530
|
-
};
|
|
2531
|
-
}
|
|
2532
|
-
// W5: 'writes' in background silently auto-approves — warn once so the
|
|
2533
|
-
// caller knows the diff will NOT be gated on approval.
|
|
2534
|
-
if (bgResolved.resolvedApprovalMode === 'writes') {
|
|
2535
|
-
log.warn("Background agent spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
|
|
2536
|
-
task: params.task,
|
|
2537
|
-
});
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
// W6 (issue #28): session cost limit must gate background spawns too —
|
|
2541
|
-
// the R5 check after the background branch never runs for them.
|
|
2542
|
-
// Same estimate/limit logic as single mode.
|
|
2543
|
-
const bgPerTaskEstimate = state.config.perTaskCostEstimate > 0
|
|
2544
|
-
? state.config.perTaskCostEstimate
|
|
2545
|
-
: 0.05;
|
|
2546
|
-
if (state.checkCostLimit(bgPerTaskEstimate, ctx)) {
|
|
2547
|
-
const bgLimit = state.config.sessionCostLimit;
|
|
2548
|
-
const bgCurrentTotal = state.getSessionTotalCost(ctx);
|
|
2549
|
-
log.warn("Background delegation rejected: session cost limit reached", {
|
|
2550
|
-
currentTotal: bgCurrentTotal,
|
|
2551
|
-
estimatedCost: bgPerTaskEstimate,
|
|
2552
|
-
limit: bgLimit,
|
|
2553
|
-
});
|
|
2554
|
-
return {
|
|
2555
|
-
content: [
|
|
2556
|
-
{
|
|
2557
|
-
type: "text" as const,
|
|
2558
|
-
text:
|
|
2559
|
-
`Cannot delegate: session cost limit reached ` +
|
|
2560
|
-
`($${bgCurrentTotal.toFixed(4)} spent of $${bgLimit.toFixed(2)} limit). ` +
|
|
2561
|
-
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
2562
|
-
},
|
|
2563
|
-
],
|
|
2564
|
-
details: undefined,
|
|
2565
|
-
isError: true,
|
|
2566
|
-
};
|
|
2567
|
-
}
|
|
2568
|
-
|
|
2569
|
-
// F1: Validate cwd + outputFile the same way foreground single mode does —
|
|
2570
|
-
// an unvalidated outputFile would reach the prompt and could steer the
|
|
2571
|
-
// background agent's write tool outside the project root.
|
|
2572
|
-
const bgCwdResult = validateCwd(bgResolved.effectiveCwd, ctx.cwd);
|
|
2573
|
-
if (!bgCwdResult.ok) {
|
|
2574
|
-
return {
|
|
2575
|
-
content: [{ type: "text" as const, text: `Invalid cwd: ${bgCwdResult.error}` }],
|
|
2576
|
-
details: undefined,
|
|
2577
|
-
isError: true,
|
|
2578
|
-
};
|
|
2579
|
-
}
|
|
2580
|
-
let bgResolvedOutputFile: string | undefined;
|
|
2581
|
-
if (bgResolved.outputFile) {
|
|
2582
|
-
const ofResult = validateOutputFile(bgResolved.outputFile, bgCwdResult.value);
|
|
2583
|
-
if (!ofResult.ok) {
|
|
2584
|
-
return {
|
|
2585
|
-
content: [
|
|
2586
|
-
{ type: "text" as const, text: `Invalid outputFile: ${ofResult.error}` },
|
|
2587
|
-
],
|
|
2588
|
-
details: undefined,
|
|
2589
|
-
isError: true,
|
|
2590
|
-
};
|
|
2591
|
-
}
|
|
2592
|
-
bgResolvedOutputFile = ofResult.value;
|
|
2593
|
-
}
|
|
2594
|
-
|
|
2595
|
-
// C: H1 validation for background mode — reject outputFile-vs-write
|
|
2596
|
-
// conflicts before spawning (loud failure, same as single mode).
|
|
2597
|
-
const bgValidation = validatePreTask({
|
|
2598
|
-
task: singleTask,
|
|
2599
|
-
toolOptions: bgResolved.toolOptions,
|
|
2600
|
-
thinkingLevel: bgResolved.thinkingLevel,
|
|
2601
|
-
gitMode: bgResolved.resolvedGitMode,
|
|
2602
|
-
outputFile: bgResolvedOutputFile,
|
|
2603
|
-
});
|
|
2604
|
-
if (bgValidation.warnings.length > 0) {
|
|
2605
|
-
log.warn("Background pre-task validation warnings", { warnings: bgValidation.warnings });
|
|
2606
|
-
}
|
|
2607
|
-
if (!bgValidation.valid) {
|
|
2608
|
-
const errText = bgValidation.errors.join("; ");
|
|
2609
|
-
log.warn("Background pre-task validation failed", { errors: bgValidation.errors });
|
|
2610
|
-
return {
|
|
2611
|
-
content: [{ type: "text" as const, text: errText }],
|
|
2612
|
-
details: undefined,
|
|
2613
|
-
isError: true,
|
|
2614
|
-
};
|
|
2615
|
-
}
|
|
2616
|
-
|
|
2617
|
-
// Build the full prompt the same way foreground single mode does:
|
|
2618
|
-
// base prompt (optionally inherited) + custom prompt + preset guidance.
|
|
2619
|
-
const bgPrompt = buildSubagentPrompt(
|
|
2620
|
-
ctx.getSystemPrompt(),
|
|
2621
|
-
bgResolved.inheritSP,
|
|
2622
|
-
bgResolved.customSP,
|
|
2623
|
-
bgResolvedOutputFile,
|
|
2624
|
-
bgResolved.toolOptions?.tools,
|
|
2625
|
-
bgResolvedPreset?.promptGuideline,
|
|
2626
|
-
);
|
|
2627
|
-
|
|
2628
|
-
const agent = await spawnBackgroundSession(pi, ctx, {
|
|
2629
|
-
task: singleTask,
|
|
2630
|
-
type: params.preset || 'general-purpose',
|
|
2631
|
-
description: params.label,
|
|
2632
|
-
model: bgModel,
|
|
2633
|
-
thinkingLevel: bgResolved.thinkingLevel,
|
|
2634
|
-
// Issue #114: per-unit priority rides the spawn so the background
|
|
2635
|
-
// run entry + drill-in header carry it like foreground runs.
|
|
2636
|
-
priority: params.priority,
|
|
2637
|
-
systemPrompt: bgPrompt,
|
|
2638
|
-
cwd: bgCwdResult.value,
|
|
2639
|
-
toolOptions: bgResolved.toolOptions,
|
|
2640
|
-
timeout: bgResolved.timeout,
|
|
2641
|
-
gitMode: bgResolved.resolvedGitMode,
|
|
2642
|
-
// Issue #98: snapshot the caller's raw params on the run entry so a
|
|
2643
|
-
// retry of this background run restores them — single source of truth
|
|
2644
|
-
// snapshotOriginalParams (issue #108: no more duplicated 11-field literal).
|
|
2645
|
-
originalParams: snapshotOriginalParams(params),
|
|
2646
|
-
});
|
|
2647
|
-
|
|
2648
|
-
// Register for live monitor
|
|
2649
|
-
state.registerLiveSubagent(agent.id, {
|
|
2650
|
-
id: agent.id,
|
|
2651
|
-
label: agent.description,
|
|
2652
|
-
task: agent.task,
|
|
2653
|
-
model: agent.model,
|
|
2654
|
-
thinkingLevel: agent.thinkingLevel,
|
|
2655
|
-
priority: agent.priority,
|
|
2656
|
-
startedAt: agent.startedAt,
|
|
2657
|
-
ctx,
|
|
2658
|
-
});
|
|
2659
|
-
|
|
2660
|
-
// Update footer counters
|
|
2661
|
-
state.activeSubagents++;
|
|
2662
|
-
updateProgressStatus(state, ctx);
|
|
2663
|
-
|
|
2664
|
-
let completed = false;
|
|
2665
|
-
|
|
2666
|
-
// Poll for live progress
|
|
2667
|
-
const pollInterval = setInterval(() => {
|
|
2668
|
-
try {
|
|
2669
|
-
if (completed) return;
|
|
2670
|
-
|
|
2671
|
-
const session = agent._sessionRef;
|
|
2672
|
-
// Issue #31: a nulled ref on a TERMINAL agent is expected (the
|
|
2673
|
-
// settlement path releases the ref) — fall through to finalize
|
|
2674
|
-
// below instead of treating it as a crash. A nulled ref on a
|
|
2675
|
-
// live agent is still a crash.
|
|
2676
|
-
if (!session && !agent.completedAt) {
|
|
2677
|
-
// Session ref not available — session may have crashed.
|
|
2678
|
-
// Defensive: the ref is assigned synchronously before the poller starts
|
|
2679
|
-
// and is never nulled, so this is mostly unreachable — but keep the
|
|
2680
|
-
// bookkeeping safe regardless.
|
|
2681
|
-
completed = true;
|
|
2682
|
-
clearInterval(pollInterval);
|
|
2683
|
-
clearTimeout(hardCapHandle);
|
|
2684
|
-
// Capture whatever output exists (may be empty — fine).
|
|
2685
|
-
setAgentFinalOutput(agent.id, state.subagentSessions.get(agent.id)?.liveOutput ?? '');
|
|
2686
|
-
// Gate the counter on the finalize claim: if the
|
|
2687
|
-
// stale sweep already finalized this entry, the
|
|
2688
|
-
// decrement happened there (PR #71 review).
|
|
2689
|
-
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2690
|
-
state.activeSubagents--;
|
|
2691
|
-
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2692
|
-
}
|
|
2693
|
-
state.failedSubagents++;
|
|
2694
|
-
updateProgressStatus(state, ctx);
|
|
2695
|
-
pi.sendMessage({
|
|
2696
|
-
customType: "subagent-notification",
|
|
2697
|
-
content: `Background agent "${agent.description}" crashed.`,
|
|
2698
|
-
display: true,
|
|
2699
|
-
details: { agentId: agent.id }
|
|
2700
|
-
}, { deliverAs: "followUp" });
|
|
2701
|
-
return;
|
|
2702
|
-
}
|
|
2703
|
-
|
|
2704
|
-
// While running, update the live monitor.
|
|
2705
|
-
// Once completedAt is set, skip straight to finalize below.
|
|
2706
|
-
// Issue #31: past the guard above, the only nulled-ref path left is a
|
|
2707
|
-
// TERMINAL agent (settlement releases the ref), so a live agent always
|
|
2708
|
-
// has its session — the guard already returned on the live-nulled-ref
|
|
2709
|
-
// crash path. The compiler can't correlate the two checks, so assert it.
|
|
2710
|
-
if (!agent.completedAt) {
|
|
2711
|
-
const finalOutput = extractFinalOutput(session!);
|
|
2712
|
-
|
|
2713
|
-
try {
|
|
2714
|
-
const stats = session!.getSessionStats();
|
|
2715
|
-
state.updateLiveSubagent(agent.id, finalOutput, stats.tokens.input, stats.tokens.output);
|
|
2716
|
-
} catch {
|
|
2717
|
-
state.updateLiveSubagent(agent.id, finalOutput, 0, 0);
|
|
2718
|
-
}
|
|
2719
|
-
}
|
|
2720
|
-
|
|
2721
|
-
// Authoritative completion: the prompt promise settled — status and
|
|
2722
|
-
// completedAt are set by .then()/.catch() in spawnBackgroundSession.
|
|
2723
|
-
// Do NOT rely on !session.isStreaming: it is false during prompt()
|
|
2724
|
-
// preflight (auth check, model resolution) and for failed starts.
|
|
2725
|
-
if (agent.completedAt) {
|
|
2726
|
-
completed = true;
|
|
2727
|
-
clearInterval(pollInterval);
|
|
2728
|
-
clearTimeout(hardCapHandle);
|
|
2729
|
-
|
|
2730
|
-
// Issue #31: prefer the output already captured at settlement;
|
|
2731
|
-
// the ref may already be nulled on terminal agents, so never
|
|
2732
|
-
// overwrite with empty and stay null-safe.
|
|
2733
|
-
const finalOutput = agent.finalOutput ?? extractAgentFinalOutput(agent);
|
|
2734
|
-
setAgentFinalOutput(agent.id, finalOutput);
|
|
2735
|
-
|
|
2736
|
-
// Gate the counter on the finalize claim: if the
|
|
2737
|
-
// stale sweep already finalized this entry, the
|
|
2738
|
-
// decrement happened there (PR #71 review).
|
|
2739
|
-
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2740
|
-
state.activeSubagents--;
|
|
2741
|
-
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2742
|
-
}
|
|
2743
|
-
|
|
2744
|
-
if (agent.status === 'failed') {
|
|
2745
|
-
state.failedSubagents++;
|
|
2746
|
-
updateProgressStatus(state, ctx);
|
|
2747
|
-
pi.sendMessage({
|
|
2748
|
-
customType: "subagent-notification",
|
|
2749
|
-
content: `Background agent "${agent.description}" failed.`,
|
|
2750
|
-
display: true,
|
|
2751
|
-
details: { agentId: agent.id }
|
|
2752
|
-
}, { deliverAs: "followUp" });
|
|
2753
|
-
} else if (agent.status === 'stopped') {
|
|
2754
|
-
// User-initiated stop (stop_subagent) or deadline abort
|
|
2755
|
-
// (timeout/hard cap) — not a failure.
|
|
2756
|
-
updateProgressStatus(state, ctx);
|
|
2757
|
-
} else {
|
|
2758
|
-
state.completedSubagents++;
|
|
2759
|
-
state.unseenSubagents++;
|
|
2760
|
-
updateProgressStatus(state, ctx);
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
} catch (err) {
|
|
2764
|
-
if (!completed) {
|
|
2765
|
-
completed = true;
|
|
2766
|
-
clearInterval(pollInterval);
|
|
2767
|
-
clearTimeout(hardCapHandle);
|
|
2768
|
-
try {
|
|
2769
|
-
setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
|
|
2770
|
-
} catch { /* ignore */ }
|
|
2771
|
-
// Gate the counter on the finalize claim: if the
|
|
2772
|
-
// stale sweep already finalized this entry, the
|
|
2773
|
-
// decrement happened there (PR #71 review).
|
|
2774
|
-
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2775
|
-
state.activeSubagents--;
|
|
2776
|
-
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2777
|
-
}
|
|
2778
|
-
state.failedSubagents++;
|
|
2779
|
-
updateProgressStatus(state, ctx);
|
|
2780
|
-
pi.sendMessage({
|
|
2781
|
-
customType: "subagent-notification",
|
|
2782
|
-
content: `Background agent "${agent.description}" crashed: ${sanitizeErrorMessage((err as Error).message, bgResolved.effectiveCwd)}`,
|
|
2783
|
-
display: true,
|
|
2784
|
-
details: { agentId: agent.id }
|
|
2785
|
-
}, { deliverAs: "followUp" });
|
|
2786
|
-
}
|
|
2787
|
-
}
|
|
2788
|
-
}, 2000);
|
|
2789
|
-
|
|
2790
|
-
// Hard cap: stop polling AND abort the session after the deadline.
|
|
2791
|
-
// W2 (issue #28): previously this only stopped the poller — the pi
|
|
2792
|
-
// session kept running forever (orphaned). Now it pre-sets
|
|
2793
|
-
// 'stopped' (so the .then keeps it, per W1) and aborts the
|
|
2794
|
-
// session. The cap honors a shorter per-agent timeout.
|
|
2795
|
-
const hardCapMs = Math.min(bgResolved.timeout ?? 30 * 60 * 1000, 30 * 60 * 1000);
|
|
2796
|
-
const hardCapHandle = setTimeout(() => {
|
|
2797
|
-
if (!completed && !agent.completedAt) {
|
|
2798
|
-
try {
|
|
2799
|
-
completed = true;
|
|
2800
|
-
clearInterval(pollInterval);
|
|
2801
|
-
// Pre-set stopped BEFORE aborting so the .then in
|
|
2802
|
-
// spawnBackgroundSession keeps the stopped state.
|
|
2803
|
-
updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
|
|
2804
|
-
agent._sessionRef?.abort().catch(() => {
|
|
2805
|
-
// Abort may reject if the session is mid-dispose;
|
|
2806
|
-
// the status flip above is already recorded.
|
|
2807
|
-
});
|
|
2808
|
-
// Capture whatever final output exists in the session
|
|
2809
|
-
const hardCapSession = agent._sessionRef;
|
|
2810
|
-
const hardCapFinalOutput = hardCapSession ? extractFinalOutput(hardCapSession) : '';
|
|
2811
|
-
setAgentFinalOutput(agent.id, hardCapFinalOutput);
|
|
2812
|
-
// Gate the counter on the finalize claim: if the
|
|
2813
|
-
// stale sweep already finalized this entry, the
|
|
2814
|
-
// decrement happened there (PR #71 review).
|
|
2815
|
-
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2816
|
-
state.activeSubagents--;
|
|
2817
|
-
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2818
|
-
}
|
|
2819
|
-
// m6: deadline abort is a stop, not a completion — mirror the
|
|
2820
|
-
// W3/poller stopped path (no completedSubagents increment).
|
|
2821
|
-
updateProgressStatus(state, ctx);
|
|
2822
|
-
} catch (err) {
|
|
2823
|
-
// Defensive: never let the hard-cap timer throw uncaught — that would
|
|
2824
|
-
// skip finalizeLiveSubagent, the counter decrement, and the notification.
|
|
2825
|
-
// Do all fallible work first (output capture), then mutate counters,
|
|
2826
|
-
// then notify — a throw mid-path can't double-fire mutations.
|
|
2827
|
-
completed = true;
|
|
2828
|
-
clearInterval(pollInterval);
|
|
2829
|
-
// m3: the try may have thrown BEFORE updateAgentStatus ran (e.g.
|
|
2830
|
-
// persistAgent fs failure) — leave the record terminal so
|
|
2831
|
-
// get_subagent_result doesn't report 'running' and the W3 timer
|
|
2832
|
-
// guard (!agent.completedAt) can't re-fire later.
|
|
2833
|
-
try {
|
|
2834
|
-
updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
|
|
2835
|
-
} catch { /* ignore */ }
|
|
2836
|
-
try {
|
|
2837
|
-
setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
|
|
2838
|
-
} catch { /* ignore */ }
|
|
2839
|
-
// Gate the counter on the finalize claim: if the
|
|
2840
|
-
// stale sweep already finalized this entry, the
|
|
2841
|
-
// decrement happened there (PR #71 review).
|
|
2842
|
-
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2843
|
-
state.activeSubagents--;
|
|
2844
|
-
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2845
|
-
}
|
|
2846
|
-
state.failedSubagents++;
|
|
2847
|
-
updateProgressStatus(state, ctx);
|
|
2848
|
-
}
|
|
2849
|
-
}
|
|
2850
|
-
}, hardCapMs);
|
|
2851
|
-
|
|
2852
|
-
log.info("Background agent spawned", { agentId: agent.id, task: params.task, model: agent.model });
|
|
2853
|
-
|
|
2854
|
-
// B2: Surface auto-route decisions in background spawn result too.
|
|
2855
|
-
// Derive the restriction from the RESOLVED toolOptions (which reflects
|
|
2856
|
-
// per-call overrides) rather than the preset's declared values.
|
|
2857
|
-
const bgAutoRouteNote = bgAutoRoutedPreset
|
|
2858
|
-
? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}' — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
|
|
2859
|
-
: "";
|
|
2860
|
-
|
|
2861
|
-
return {
|
|
2862
|
-
content: [{
|
|
2863
|
-
type: "text" as const,
|
|
2864
|
-
text: `Background agent started: ${agent.id}\n\n` +
|
|
2865
|
-
`Description: ${agent.description}\n` +
|
|
2866
|
-
`Task: ${agent.task}\n` +
|
|
2867
|
-
`Status: ${agent.status}\n\n` +
|
|
2868
|
-
`You'll be woken with a completion message when it finishes; use get_subagent_result({ agent_id: "${agent.id}" }) for retrieval and stall checks.` +
|
|
2869
|
-
bgAutoRouteNote,
|
|
2870
|
-
}],
|
|
2871
|
-
details: undefined,
|
|
2872
|
-
};
|
|
2873
|
-
} catch (err) {
|
|
2874
|
-
const message = sanitizeErrorMessage(
|
|
2875
|
-
err instanceof Error ? err.message : String(err),
|
|
2876
|
-
bgResolved.effectiveCwd,
|
|
2877
|
-
);
|
|
2878
|
-
log.error("Failed to spawn background agent", { error: message });
|
|
2879
|
-
return {
|
|
2880
|
-
content: [{ type: "text" as const, text: `Failed to spawn background agent: ${message}` }],
|
|
2881
|
-
details: undefined,
|
|
2882
|
-
isError: true,
|
|
2883
|
-
};
|
|
2884
|
-
}
|
|
3326
|
+
return spawnBackgroundRun(pi, ctx, params, singleTask);
|
|
2885
3327
|
}
|
|
2886
3328
|
|
|
2887
3329
|
// R5: Check session cost limit before spawning
|
|
2888
3330
|
// Use a default per-task estimate of $0.05 if no perTaskCostEstimate is set
|
|
2889
|
-
const
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
estimatedCost: perTaskEstimate,
|
|
2898
|
-
limit,
|
|
2899
|
-
});
|
|
2900
|
-
return {
|
|
2901
|
-
content: [
|
|
2902
|
-
{
|
|
2903
|
-
type: "text" as const,
|
|
2904
|
-
text:
|
|
2905
|
-
`Cannot delegate: session cost limit reached ` +
|
|
2906
|
-
`($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
|
|
2907
|
-
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
2908
|
-
},
|
|
2909
|
-
],
|
|
2910
|
-
details: undefined,
|
|
2911
|
-
isError: true,
|
|
2912
|
-
};
|
|
2913
|
-
}
|
|
3331
|
+
const costError = gateSessionCost({
|
|
3332
|
+
state,
|
|
3333
|
+
ctx,
|
|
3334
|
+
log,
|
|
3335
|
+
units: 1,
|
|
3336
|
+
label: "Subagent delegation",
|
|
3337
|
+
});
|
|
3338
|
+
if (costError) return costError;
|
|
2914
3339
|
|
|
2915
3340
|
// Reject delegation if recursion depth exceeds configured max.
|
|
2916
3341
|
// This prevents subagents from spawning infinite sub-subagents while
|
|
@@ -2950,6 +3375,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2950
3375
|
resolvedApprovalMode,
|
|
2951
3376
|
resolvedPreset,
|
|
2952
3377
|
autoRoutedPreset,
|
|
3378
|
+
autoRouteKeyword,
|
|
2953
3379
|
} = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
|
|
2954
3380
|
|
|
2955
3381
|
// F1: Validate CWD
|
|
@@ -2991,12 +3417,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
2991
3417
|
}
|
|
2992
3418
|
|
|
2993
3419
|
// H1: Pre-task validation — deterministic check that tools/thinking match task
|
|
3420
|
+
// (`force` degrades capability errors to warnings — B2's override).
|
|
2994
3421
|
const validation = validatePreTask({
|
|
2995
3422
|
task,
|
|
2996
3423
|
toolOptions,
|
|
2997
3424
|
thinkingLevel,
|
|
2998
3425
|
gitMode: resolvedGitMode,
|
|
2999
3426
|
outputFile: resolvedOutputFile,
|
|
3427
|
+
force: params.force,
|
|
3000
3428
|
});
|
|
3001
3429
|
if (validation.warnings.length > 0) {
|
|
3002
3430
|
log.warn("Pre-task validation warnings", { warnings: validation.warnings });
|
|
@@ -3013,6 +3441,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3013
3441
|
isError: true,
|
|
3014
3442
|
};
|
|
3015
3443
|
}
|
|
3444
|
+
// C: surface warnings in the returned result (not just the log) —
|
|
3445
|
+
// shared renderer so foreground/background wording stays identical.
|
|
3446
|
+
const warningsNote = formatValidationWarnings(validation.warnings);
|
|
3016
3447
|
|
|
3017
3448
|
// Resolve model (per-call top-level model override beats preset)
|
|
3018
3449
|
const modelResult = resolveSubagentModel(ctx, resolvedPreset, params.model);
|
|
@@ -3400,6 +3831,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3400
3831
|
if (suggestions.length > 0) {
|
|
3401
3832
|
finalMsg += "\n\nSuggestions:\n" + suggestions.map((s) => `- ${s}`).join("\n");
|
|
3402
3833
|
}
|
|
3834
|
+
// C: warnings ride the returned result too — a failed run is
|
|
3835
|
+
// exactly when the conductor most needs the mismatch signal.
|
|
3836
|
+
finalMsg += warningsNote;
|
|
3403
3837
|
|
|
3404
3838
|
completeTranscript(runId, 'failed');
|
|
3405
3839
|
|
|
@@ -3426,16 +3860,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
3426
3860
|
state.recordSuccess();
|
|
3427
3861
|
|
|
3428
3862
|
// B2: Surface auto-route decisions in the tool result so the conductor
|
|
3429
|
-
// sees
|
|
3430
|
-
// restriction from the RESOLVED
|
|
3431
|
-
// overrides) rather than the preset's
|
|
3863
|
+
// sees WHICH preset was auto-selected, the KEYWORD evidence for it,
|
|
3864
|
+
// and what it restricts. Derive the restriction from the RESOLVED
|
|
3865
|
+
// toolOptions (reflects per-call overrides) rather than the preset's
|
|
3866
|
+
// declared values.
|
|
3432
3867
|
const autoRouteNote = autoRoutedPreset
|
|
3433
|
-
? `\n\n[auto-routed to preset '${autoRoutedPreset.name}' — ${toolOptions ? formatToolRestriction(toolOptions) : formatPresetRestriction(autoRoutedPreset)}]`
|
|
3868
|
+
? `\n\n[auto-routed to preset '${autoRoutedPreset.name}'${autoRouteKeyword ? ` (matched keyword '${autoRouteKeyword}')` : ""} — ${toolOptions ? formatToolRestriction(toolOptions) : formatPresetRestriction(autoRoutedPreset)}]`
|
|
3434
3869
|
: "";
|
|
3435
3870
|
|
|
3436
3871
|
return {
|
|
3437
3872
|
content: [
|
|
3438
|
-
{ type: "text" as const, text: (finalOutput || "(no output)") + autoRouteNote },
|
|
3873
|
+
{ type: "text" as const, text: (finalOutput || "(no output)") + autoRouteNote + warningsNote },
|
|
3439
3874
|
],
|
|
3440
3875
|
details: result,
|
|
3441
3876
|
};
|
|
@@ -3515,11 +3950,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3515
3950
|
agent_id: Type.String({
|
|
3516
3951
|
description: "The agent ID returned by delegate_task when background=true",
|
|
3517
3952
|
}),
|
|
3518
|
-
wait: Type.Optional(
|
|
3519
|
-
Type.Boolean({
|
|
3520
|
-
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
3521
|
-
}),
|
|
3522
|
-
),
|
|
3523
3953
|
verbose: Type.Optional(
|
|
3524
3954
|
Type.Boolean({
|
|
3525
3955
|
description: "If true, include the full conversation log. Default: false.",
|