@aragon-agent/cli 0.6.17 → 0.6.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -51
- package/README.md +48 -2
- package/dist/agent/controller.js +13 -0
- package/dist/agent/headless.js +7 -0
- package/dist/commands/builtins.js +18 -3
- package/dist/commands/index-build.d.ts +39 -0
- package/dist/commands/index-build.js +61 -0
- package/dist/config/cli-commands.d.ts +1 -1
- package/dist/config/cli-commands.js +3 -0
- package/dist/config/schema.d.ts +24 -4
- package/dist/config/schema.js +4 -0
- package/dist/exec/events.d.ts +4 -1
- package/dist/exec/runner.js +13 -0
- package/dist/input/csiu-keys.js +13 -2
- package/dist/input/limits.d.ts +15 -0
- package/dist/input/limits.js +15 -0
- package/dist/input/win32-input-mode.js +7 -1
- package/dist/logging/install.d.ts +9 -0
- package/dist/logging/install.js +11 -0
- package/dist/team/human-queue.d.ts +11 -0
- package/dist/team/human-queue.js +18 -1
- package/dist/team/limits.d.ts +40 -0
- package/dist/team/limits.js +41 -0
- package/dist/team/overseer.d.ts +157 -0
- package/dist/team/overseer.js +348 -0
- package/dist/team/report.js +26 -1
- package/dist/team/runtime.d.ts +71 -6
- package/dist/team/runtime.js +380 -23
- package/dist/team/subagent.d.ts +30 -1
- package/dist/team/subagent.js +8 -1
- package/dist/team/task-tool.js +4 -1
- package/dist/team/types.d.ts +63 -0
- package/dist/ui/App.d.ts +1 -1
- package/dist/ui/App.js +61 -0
- package/dist/ui/Header.js +13 -2
- package/dist/ui/PromptInput.js +11 -1
- package/dist/ui/overlays/HelpOverlay.js +1 -0
- package/package.json +2 -2
- package/skills/project-indexer/SKILL.md +163 -0
- package/skills/project-indexer/templates/cleancode-overrides.md +46 -0
- package/skills/project-indexer/templates/cleancode-template.md +94 -0
- package/skills/project-indexer/templates/config-template.md +69 -0
- package/skills/project-indexer/templates/index-template.md +129 -0
package/dist/team/runtime.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TeamRuntime — dispatch scheduling, budgets, the `TeamEvent` stream, abort and
|
|
3
|
-
* disposal (team-subagents §3.2 / §3.5 / §3.8).
|
|
3
|
+
* disposal (team-subagents §3.2 / §3.5 / §3.8; team-overseer §3.4).
|
|
4
4
|
*
|
|
5
5
|
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
6
6
|
*
|
|
@@ -16,11 +16,104 @@
|
|
|
16
16
|
* worker loops, awaited with `Promise.allSettled`. No worker abstraction, no
|
|
17
17
|
* queue class, no event-loop trickery — every path is observable from the event
|
|
18
18
|
* stream.
|
|
19
|
+
*
|
|
20
|
+
* THE DISPATCH SUPERVISOR (team-overseer) — a time ceiling is an inspection
|
|
21
|
+
* trigger, not a death sentence. When a supervisor is live for the dispatch,
|
|
22
|
+
* two SOFT timers per child (wall clock and event silence) fire an inspection;
|
|
23
|
+
* the supervisor decides wait / nudge / replace / abandon; THIS module applies.
|
|
24
|
+
* Lifecycle changes are funnelled exclusively through `runOne`, the only place
|
|
25
|
+
* a child's `prompt()` is awaited (I-OV1): the supervisor may steer, flag and
|
|
26
|
+
* abort, but a replacement is built by the worker loop or it is built for
|
|
27
|
+
* nobody. Without a supervisor, every path below is byte for byte the
|
|
28
|
+
* pre-feature behaviour.
|
|
19
29
|
*/
|
|
20
30
|
import { TeamBus } from './bus.js';
|
|
21
31
|
import { TEAM_LIMITS } from './limits.js';
|
|
32
|
+
import { TeamOverseer, buildReplacementPrompt } from './overseer.js';
|
|
22
33
|
import { shouldRetryColdStart } from './retry.js';
|
|
23
34
|
import { createSubagent, defaultSubagentFactory, } from './subagent.js';
|
|
35
|
+
/**
|
|
36
|
+
* The two soft timers per child (team-overseer §3.2). Timers only — no state,
|
|
37
|
+
* no decisions; the runtime applies what the supervisor decides.
|
|
38
|
+
*
|
|
39
|
+
* Every timer is `unref`'d (the `backoff` argument): a pending supervision
|
|
40
|
+
* look must not hold the process open on exit.
|
|
41
|
+
*/
|
|
42
|
+
class ChildSupervisor {
|
|
43
|
+
deps;
|
|
44
|
+
clocks = new Map();
|
|
45
|
+
silences = new Map();
|
|
46
|
+
/** The interval used when a decision did not name one. */
|
|
47
|
+
defaultCheckMs;
|
|
48
|
+
constructor(deps) {
|
|
49
|
+
this.deps = deps;
|
|
50
|
+
this.defaultCheckMs = Math.max(TEAM_LIMITS.overseerNextCheckMinMs, deps.firstCheckMs);
|
|
51
|
+
}
|
|
52
|
+
/** Arm (or re-arm) both windows for a child starting or restarting an attempt. */
|
|
53
|
+
start(label) {
|
|
54
|
+
this.armClock(label, this.deps.firstCheckMs);
|
|
55
|
+
this.armSilence(label);
|
|
56
|
+
}
|
|
57
|
+
/** Reset the silence window. A no-op for a child nobody is supervising. */
|
|
58
|
+
kick(label) {
|
|
59
|
+
if (this.silences.has(label))
|
|
60
|
+
this.armSilence(label);
|
|
61
|
+
}
|
|
62
|
+
rearmSilence(label) {
|
|
63
|
+
this.armSilence(label);
|
|
64
|
+
}
|
|
65
|
+
rearmClock(label, ms) {
|
|
66
|
+
this.armClock(label, ms);
|
|
67
|
+
}
|
|
68
|
+
clear(label) {
|
|
69
|
+
const clock = this.clocks.get(label);
|
|
70
|
+
if (clock !== undefined)
|
|
71
|
+
clearTimeout(clock);
|
|
72
|
+
const silence = this.silences.get(label);
|
|
73
|
+
if (silence !== undefined)
|
|
74
|
+
clearTimeout(silence);
|
|
75
|
+
this.clocks.delete(label);
|
|
76
|
+
this.silences.delete(label);
|
|
77
|
+
}
|
|
78
|
+
clearAll() {
|
|
79
|
+
for (const label of [...this.clocks.keys()])
|
|
80
|
+
this.clear(label);
|
|
81
|
+
for (const label of [...this.silences.keys()])
|
|
82
|
+
this.clear(label);
|
|
83
|
+
}
|
|
84
|
+
armClock(label, ms) {
|
|
85
|
+
const previous = this.clocks.get(label);
|
|
86
|
+
if (previous !== undefined)
|
|
87
|
+
clearTimeout(previous);
|
|
88
|
+
const timer = setTimeout(() => this.fire(label, 'clock'), ms);
|
|
89
|
+
timer.unref?.();
|
|
90
|
+
this.clocks.set(label, timer);
|
|
91
|
+
}
|
|
92
|
+
armSilence(label) {
|
|
93
|
+
const previous = this.silences.get(label);
|
|
94
|
+
if (previous !== undefined)
|
|
95
|
+
clearTimeout(previous);
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
// I-OV3: a child legitimately blocked on `team_wait` or the confirm
|
|
98
|
+
// queue re-arms quietly instead of spending an inspection on a fact the
|
|
99
|
+
// digest would only re-derive.
|
|
100
|
+
if (this.deps.isLegitimatelyBlocked(label)) {
|
|
101
|
+
this.armSilence(label);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.fire(label, 'silence');
|
|
105
|
+
}, this.deps.idleMs);
|
|
106
|
+
timer.unref?.();
|
|
107
|
+
this.silences.set(label, timer);
|
|
108
|
+
}
|
|
109
|
+
fire(label, trigger) {
|
|
110
|
+
if (trigger === 'clock')
|
|
111
|
+
this.clocks.delete(label);
|
|
112
|
+
else
|
|
113
|
+
this.silences.delete(label);
|
|
114
|
+
this.deps.onFire(label, trigger);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
24
117
|
export class TeamRuntime {
|
|
25
118
|
deps;
|
|
26
119
|
listeners = new Set();
|
|
@@ -43,6 +136,16 @@ export class TeamRuntime {
|
|
|
43
136
|
* quietly not for the others.
|
|
44
137
|
*/
|
|
45
138
|
pendingBackoffs = new Set();
|
|
139
|
+
/**
|
|
140
|
+
* The dispatch supervisor, LIVE for the current dispatch only (team-overseer).
|
|
141
|
+
* Null when the provider is absent/disabled/off, and reset to null at every
|
|
142
|
+
* dispatch end so nothing supervision-shaped leaks across dispatches.
|
|
143
|
+
*/
|
|
144
|
+
overseer = null;
|
|
145
|
+
/** The soft timers, same lifetime as `overseer`. */
|
|
146
|
+
supervision = null;
|
|
147
|
+
/** Applied interventions for the in-flight dispatch's outcome. */
|
|
148
|
+
interventions = [];
|
|
46
149
|
constructor(deps) {
|
|
47
150
|
this.deps = deps;
|
|
48
151
|
}
|
|
@@ -73,6 +176,30 @@ export class TeamRuntime {
|
|
|
73
176
|
const startedAt = Date.now();
|
|
74
177
|
this.busy = true;
|
|
75
178
|
this.aborted = false;
|
|
179
|
+
this.interventions = [];
|
|
180
|
+
// THE SUPERVISOR IS CREATED PER DISPATCH and read live: provider present,
|
|
181
|
+
// provider active, policy on. `TeamOverseer.isActive()` re-checks the
|
|
182
|
+
// provider per inspection, so a mid-dispatch `/fast off` stops the next
|
|
183
|
+
// look without unwinding anything already applied.
|
|
184
|
+
this.overseer =
|
|
185
|
+
this.deps.overseer !== undefined &&
|
|
186
|
+
this.deps.overseer.active() &&
|
|
187
|
+
team.overseer
|
|
188
|
+
? new TeamOverseer(this.deps.overseer, { startedAt })
|
|
189
|
+
: null;
|
|
190
|
+
this.supervision =
|
|
191
|
+
this.overseer !== null
|
|
192
|
+
? new ChildSupervisor({
|
|
193
|
+
idleMs: config.idleTimeoutMs,
|
|
194
|
+
firstCheckMs: team.subagentTimeoutMs > 0
|
|
195
|
+
? team.subagentTimeoutMs
|
|
196
|
+
: TEAM_LIMITS.overseerDefaultCheckMs,
|
|
197
|
+
isLegitimatelyBlocked: (label) => this.isBlocked(label),
|
|
198
|
+
onFire: (label, trigger) => {
|
|
199
|
+
void this.inspectAndApply(dispatchId, label, trigger);
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
: null;
|
|
76
203
|
// DECLARED BEFORE THE BUS, ASSIGNED AFTER THE HANDLES. `canSend` closes over
|
|
77
204
|
// this binding so it reads the LIVE array: F-4 replaces `handles[index]`
|
|
78
205
|
// mid-dispatch, and a captured array or a captured handle would answer for
|
|
@@ -85,7 +212,7 @@ export class TeamRuntime {
|
|
|
85
212
|
* Whether `label` still has a loop that could call `team_send` (F-3 / P0-2).
|
|
86
213
|
*
|
|
87
214
|
* NEGATIVE, over TERMINAL PHASES ONLY. "The child's phase is running" is the
|
|
88
|
-
* natural way to write this and
|
|
215
|
+
* natural way to write this and is wrong on the SHIPPED DEFAULTS: at
|
|
89
216
|
* `maxConcurrent: 3, maxSubagents: 5`, children 4 and 5 sit in `queued` for
|
|
90
217
|
* the first part of every five-way dispatch, and a wait on one of them would
|
|
91
218
|
* be refused with "nobody can answer: a4 already finished". `queued` is not
|
|
@@ -136,6 +263,10 @@ export class TeamRuntime {
|
|
|
136
263
|
...(this.deps.contextManagerFor
|
|
137
264
|
? { contextManagerFor: this.deps.contextManagerFor }
|
|
138
265
|
: {}),
|
|
266
|
+
// Same conditional-spread rule: a dispatch without a supervisor builds
|
|
267
|
+
// the exact pre-feature `SubagentDeps`, and the child's idle watchdog
|
|
268
|
+
// keeps its un-lengthened ceiling (team-overseer §3.6).
|
|
269
|
+
...(this.overseer !== null ? { overseerActive: true } : {}),
|
|
139
270
|
};
|
|
140
271
|
// Hoisted into a factory so a retry can build a fresh child with IDENTICAL
|
|
141
272
|
// wiring (F-4). Replacing the child rather than re-prompting it is the only
|
|
@@ -145,6 +276,9 @@ export class TeamRuntime {
|
|
|
145
276
|
const makeHandle = (spec) => createSubagent(spec, subDeps, {
|
|
146
277
|
onUpdate: (run) => this.publish(dispatchId, run),
|
|
147
278
|
onUsage: (label, usage) => this.emit({ type: 'usage', dispatchId, label, usage }),
|
|
279
|
+
// ONE KICK PER REAL AGENT EVENT, unthrottled (I-OV3): the silence
|
|
280
|
+
// window resets on evidence, not on a coalesced UI frame.
|
|
281
|
+
onActivity: () => this.supervision?.kick(spec.label),
|
|
148
282
|
});
|
|
149
283
|
// ASSIGNMENT, not a declaration: a `const` here would shadow the `let` above
|
|
150
284
|
// the bus, `canSend` would see the empty array forever, and every peer would
|
|
@@ -196,6 +330,9 @@ export class TeamRuntime {
|
|
|
196
330
|
signal?.removeEventListener('abort', onAbort);
|
|
197
331
|
for (const handle of handles)
|
|
198
332
|
handle.unsubscribe();
|
|
333
|
+
this.supervision?.clearAll();
|
|
334
|
+
this.supervision = null;
|
|
335
|
+
this.overseer = null;
|
|
199
336
|
// ONE TOTAL, AT THE LEAD'S TABLE. Every child runs the lead's own model
|
|
200
337
|
// (main-agent parity), so there is exactly one tier to sum and one price to
|
|
201
338
|
// sum it at; a split would be two answers to one question.
|
|
@@ -211,6 +348,7 @@ export class TeamRuntime {
|
|
|
211
348
|
endedAt: Date.now(),
|
|
212
349
|
aborted: this.aborted,
|
|
213
350
|
leadMail: bus.leadMail(),
|
|
351
|
+
interventions: this.interventions,
|
|
214
352
|
usage,
|
|
215
353
|
};
|
|
216
354
|
this.live = [];
|
|
@@ -241,11 +379,19 @@ export class TeamRuntime {
|
|
|
241
379
|
}
|
|
242
380
|
/**
|
|
243
381
|
* Run one child to a terminal phase, replacing it once if its very first
|
|
244
|
-
* request died on a transport error before it did anything at all (F-4)
|
|
382
|
+
* request died on a transport error before it did anything at all (F-4), and
|
|
383
|
+
* replacing it when the SUPERVISOR decides to (team-overseer §3.4).
|
|
384
|
+
*
|
|
385
|
+
* ONE LOOP, ALL LIFECYCLE: the cold-start replacement, the supervisor's
|
|
386
|
+
* replace and the post-mortem replace all swap `handles[index]` and continue
|
|
387
|
+
* THIS loop, because the await below is the only place a child is driven and
|
|
388
|
+
* a handle swapped anywhere else would never be (I-OV1).
|
|
245
389
|
*/
|
|
246
390
|
async runOne(ctx, index) {
|
|
247
391
|
const { dispatchId, handles, makeHandle, subagentTimeoutMs } = ctx;
|
|
248
|
-
|
|
392
|
+
let attempt = 0;
|
|
393
|
+
for (;;) {
|
|
394
|
+
attempt += 1;
|
|
249
395
|
const handle = handles[index];
|
|
250
396
|
const { run, agent } = handle;
|
|
251
397
|
// ABORTS GO THROUGH THE HANDLE (never `agent.abort()` directly): the
|
|
@@ -254,12 +400,15 @@ export class TeamRuntime {
|
|
|
254
400
|
run.phase = 'starting';
|
|
255
401
|
run.startedAt = Date.now();
|
|
256
402
|
this.publish(dispatchId, run);
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
403
|
+
// WITH A SUPERVISOR there is no hard wall clock on the child: the
|
|
404
|
+
// supervisor looks and decides, and the child's own (lengthened) idle
|
|
405
|
+
// watchdog is the silence backstop. WITHOUT one, the legacy hard timer —
|
|
406
|
+
// armed exactly as before, `0` meaning no ceiling.
|
|
407
|
+
const supervised = this.overseer !== null;
|
|
408
|
+
if (supervised)
|
|
409
|
+
this.supervision?.start(run.label);
|
|
261
410
|
let timedOut = false;
|
|
262
|
-
const timer = subagentTimeoutMs > 0
|
|
411
|
+
const timer = !supervised && subagentTimeoutMs > 0
|
|
263
412
|
? setTimeout(() => {
|
|
264
413
|
timedOut = true;
|
|
265
414
|
handle.abort();
|
|
@@ -279,21 +428,46 @@ export class TeamRuntime {
|
|
|
279
428
|
if (timer)
|
|
280
429
|
clearTimeout(timer);
|
|
281
430
|
}
|
|
282
|
-
|
|
431
|
+
const verdict = handle.overseerVerdict ?? null;
|
|
432
|
+
// THE SUPERVISOR'S REBUILD. Same loop, fresh body, amended brief: the
|
|
433
|
+
// verdict was set and the child aborted by `inspectAndApply`; here is
|
|
434
|
+
// where the replacement is actually built and driven. `attempt` resets so
|
|
435
|
+
// the fresh body gets its own cold-start budget.
|
|
436
|
+
if (verdict !== null && verdict.action === 'replace' && !this.aborted) {
|
|
437
|
+
this.replaceChild(dispatchId, handles, index, makeHandle, handle, verdict);
|
|
438
|
+
attempt = 0;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
// THE ABORT EXCLUSIONS THAT MAKE THE PREDICATE SAFE. Every abort in
|
|
283
442
|
// this system reaches the provider as an aborted fetch, and `core` marks
|
|
284
443
|
// an `AbortError` retryable - so Esc, `ctx.signal`, the dispatch timeout
|
|
285
|
-
// (all three via `this.aborted`)
|
|
286
|
-
// are excluded HERE, before
|
|
287
|
-
|
|
444
|
+
// (all three via `this.aborted`), the per-child timeout (`timedOut`) and
|
|
445
|
+
// now the SUPERVISOR's verdict are excluded HERE, before
|
|
446
|
+
// `shouldRetryColdStart` is ever consulted.
|
|
447
|
+
const retryable = !this.aborted &&
|
|
448
|
+
!timedOut &&
|
|
449
|
+
verdict === null &&
|
|
450
|
+
shouldRetryColdStart(run, attempt);
|
|
288
451
|
if (!retryable) {
|
|
289
452
|
run.endedAt = Date.now();
|
|
453
|
+
// The supervisor's abandon is an error like any other, with the
|
|
454
|
+
// supervisor's own reason so the report explains WHO stopped it and why.
|
|
455
|
+
if (verdict !== null && verdict.action === 'abandon') {
|
|
456
|
+
run.error = run.error ?? `overseer abandoned: ${verdict.reason}`;
|
|
457
|
+
}
|
|
290
458
|
this.settlePhase(run, timedOut, subagentTimeoutMs);
|
|
291
459
|
this.publish(dispatchId, run);
|
|
460
|
+
// ONE post-mortem look at a child that died silently (team-overseer
|
|
461
|
+
// §3.4): a watchdog death is the "timed out" case the requirement most
|
|
462
|
+
// wants diagnosed, and replace-after-death is exactly re-assignment.
|
|
463
|
+
await this.maybePostMortem(ctx, index, handle, verdict);
|
|
464
|
+
this.supervision?.clear(run.label);
|
|
292
465
|
return;
|
|
293
466
|
}
|
|
294
|
-
|
|
467
|
+
this.replaceChild(dispatchId, handles, index, makeHandle, handle, null);
|
|
295
468
|
await this.backoff(TEAM_LIMITS.retryBackoffMs);
|
|
296
469
|
if (this.aborted) {
|
|
470
|
+
const fresh = handles[index];
|
|
297
471
|
fresh.run.phase = 'aborted';
|
|
298
472
|
fresh.run.endedAt = Date.now();
|
|
299
473
|
this.publish(dispatchId, fresh.run);
|
|
@@ -302,17 +476,64 @@ export class TeamRuntime {
|
|
|
302
476
|
}
|
|
303
477
|
}
|
|
304
478
|
/**
|
|
305
|
-
*
|
|
479
|
+
* One post-mortem inspection, and possibly ONE replacement driven by a
|
|
480
|
+
* recursive `runOne` — the worker that called us has already moved on to the
|
|
481
|
+
* next index, so nobody else will drive the fresh child (I-OV1 holds here
|
|
482
|
+
* too, one frame deeper). Recursion depth is bounded by the replacement
|
|
483
|
+
* budget, which is 1.
|
|
484
|
+
*/
|
|
485
|
+
async maybePostMortem(ctx, index, handle, verdict) {
|
|
486
|
+
const overseer = this.overseer;
|
|
487
|
+
if (overseer === null || this.aborted || verdict !== null)
|
|
488
|
+
return;
|
|
489
|
+
const { run, agent } = handle;
|
|
490
|
+
if (run.phase !== 'failed' || run.summary !== undefined)
|
|
491
|
+
return;
|
|
492
|
+
if (!overseer.replacementsLeft(run.label))
|
|
493
|
+
return;
|
|
494
|
+
const decision = await overseer.inspect({
|
|
495
|
+
run: { ...run },
|
|
496
|
+
messages: agent.state.messages,
|
|
497
|
+
trigger: 'postmortem',
|
|
498
|
+
childAlive: false,
|
|
499
|
+
});
|
|
500
|
+
if (decision === null || this.aborted)
|
|
501
|
+
return;
|
|
502
|
+
this.recordIntervention(ctx.dispatchId, run.label, 'postmortem', decision);
|
|
503
|
+
this.emit({
|
|
504
|
+
type: 'overseer',
|
|
505
|
+
dispatchId: ctx.dispatchId,
|
|
506
|
+
label: run.label,
|
|
507
|
+
decision,
|
|
508
|
+
trigger: 'postmortem',
|
|
509
|
+
});
|
|
510
|
+
if (decision.action === 'replace' && overseer.replacementsLeft(run.label)) {
|
|
511
|
+
this.replaceChild(ctx.dispatchId, ctx.handles, index, ctx.makeHandle, handle, {
|
|
512
|
+
action: 'replace',
|
|
513
|
+
reason: decision.reason,
|
|
514
|
+
...(decision.guidance !== undefined ? { guidance: decision.guidance } : {}),
|
|
515
|
+
});
|
|
516
|
+
await this.runOne(ctx, index);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Swap one child for a fresh one, carrying only what must survive.
|
|
306
521
|
*
|
|
307
522
|
* `this.live` IS `handles`, so the single assignment below also makes
|
|
308
523
|
* `abortAll()` abort the NEW child and makes the outcome carry the new run.
|
|
309
524
|
* Anyone tempted to "clean up" that aliasing into a defensive copy must update
|
|
310
525
|
* both places.
|
|
526
|
+
*
|
|
527
|
+
* `verdict === null` is the COLD-START retry (F-4): identical spec, `retries`
|
|
528
|
+
* carried. A verdict is the SUPERVISOR's replace: an amended brief
|
|
529
|
+
* (`buildReplacementPrompt`), `replacements` carried, and the supervisor's
|
|
530
|
+
* budget spent HERE, at the moment the rebuild is real.
|
|
311
531
|
*/
|
|
312
|
-
|
|
313
|
-
const handle = handles[index];
|
|
532
|
+
replaceChild(dispatchId, handles, index, makeHandle, handle, verdict) {
|
|
314
533
|
const carried = { ...handle.run.usage };
|
|
315
|
-
const retries = (handle.run.retries ?? 0) + 1;
|
|
534
|
+
const retries = (handle.run.retries ?? 0) + (verdict === null ? 1 : 0);
|
|
535
|
+
const replacements = (handle.run.replacements ?? 0) + (verdict !== null ? 1 : 0);
|
|
536
|
+
const interventions = handle.run.interventions;
|
|
316
537
|
handle.unsubscribe();
|
|
317
538
|
try {
|
|
318
539
|
handle.abort();
|
|
@@ -320,17 +541,149 @@ export class TeamRuntime {
|
|
|
320
541
|
catch {
|
|
321
542
|
// Already gone; this must not stop the retry.
|
|
322
543
|
}
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
544
|
+
const spec = verdict !== null
|
|
545
|
+
? {
|
|
546
|
+
...handle.spec,
|
|
547
|
+
prompt: buildReplacementPrompt(handle.spec, {
|
|
548
|
+
action: 'replace',
|
|
549
|
+
reason: verdict.reason,
|
|
550
|
+
...(verdict.guidance !== undefined ? { guidance: verdict.guidance } : {}),
|
|
551
|
+
}, handle.run),
|
|
552
|
+
}
|
|
553
|
+
: handle.spec;
|
|
554
|
+
const fresh = makeHandle(spec);
|
|
555
|
+
// Provably {0,0} under the cold-start predicate above (no turn completed,
|
|
556
|
+
// so no `turn_end` and no usage event). Carried anyway so that a future
|
|
557
|
+
// relaxation of the predicate cannot silently under-report spend - the most
|
|
558
|
+
// misleading failure this feature can produce.
|
|
328
559
|
fresh.run.usage = carried;
|
|
329
560
|
fresh.run.retries = retries;
|
|
561
|
+
if (replacements > 0)
|
|
562
|
+
fresh.run.replacements = replacements;
|
|
563
|
+
if (interventions !== undefined)
|
|
564
|
+
fresh.run.interventions = interventions;
|
|
565
|
+
if (verdict !== null)
|
|
566
|
+
this.overseer?.noteReplacement(handle.run.label);
|
|
330
567
|
handles[index] = fresh;
|
|
331
568
|
this.publish(dispatchId, fresh.run);
|
|
332
569
|
return fresh;
|
|
333
570
|
}
|
|
571
|
+
// -------------------------------------------------------------------------
|
|
572
|
+
// Supervision (team-overseer)
|
|
573
|
+
// -------------------------------------------------------------------------
|
|
574
|
+
/**
|
|
575
|
+
* Whether a child's silence is a legitimate wait rather than a stall:
|
|
576
|
+
* `team_wait` (the comm tool already pauses the child's own watchdog for
|
|
577
|
+
* exactly this wait) or a confirmation queued on the one human slot.
|
|
578
|
+
*/
|
|
579
|
+
isBlocked(label) {
|
|
580
|
+
const handle = this.live.find((h) => h.run.label === label);
|
|
581
|
+
if (handle !== undefined && handle.run.phase === 'waiting')
|
|
582
|
+
return true;
|
|
583
|
+
return this.deps.humanQueue?.isWaiting(label) === true;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* One trigger handled: look, then apply. Fired from a supervision timer and
|
|
587
|
+
* therefore async-and-forgotten; nothing here may throw.
|
|
588
|
+
*
|
|
589
|
+
* QUIET (`decision === null` — provider gone, budget spent) CLEARS the
|
|
590
|
+
* child's timers: supervision that has nothing left to say should stop
|
|
591
|
+
* waking up, and the child's own hard ceilings take over.
|
|
592
|
+
*/
|
|
593
|
+
async inspectAndApply(dispatchId, label, trigger) {
|
|
594
|
+
const overseer = this.overseer;
|
|
595
|
+
const supervision = this.supervision;
|
|
596
|
+
if (overseer === null || supervision === null || this.aborted)
|
|
597
|
+
return;
|
|
598
|
+
const handle = this.live.find((h) => h.run.label === label);
|
|
599
|
+
if (handle === undefined)
|
|
600
|
+
return;
|
|
601
|
+
const phase = handle.run.phase;
|
|
602
|
+
if (phase === 'done' || phase === 'failed' || phase === 'aborted')
|
|
603
|
+
return;
|
|
604
|
+
try {
|
|
605
|
+
const decision = await overseer.inspect({
|
|
606
|
+
run: handle.run,
|
|
607
|
+
messages: handle.agent.state.messages,
|
|
608
|
+
trigger,
|
|
609
|
+
childAlive: true,
|
|
610
|
+
});
|
|
611
|
+
if (decision === null || this.aborted) {
|
|
612
|
+
supervision.clear(label);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// The child may have settled while the call was in flight; an
|
|
616
|
+
// intervention is still recorded (it happened), but nothing is applied.
|
|
617
|
+
const settled = handle.run.phase === 'done' ||
|
|
618
|
+
handle.run.phase === 'failed' ||
|
|
619
|
+
handle.run.phase === 'aborted';
|
|
620
|
+
this.recordIntervention(dispatchId, label, trigger, decision);
|
|
621
|
+
this.emit({ type: 'overseer', dispatchId, label, decision, trigger });
|
|
622
|
+
if (settled) {
|
|
623
|
+
supervision.clear(label);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
this.applyDecision(supervision, overseer, handle, label, decision);
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
// Defence in depth around an async timer callback: an unexpected throw
|
|
630
|
+
// must not take the dispatch down. The child keeps its timers.
|
|
631
|
+
supervision.rearmSilence(label);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
applyDecision(supervision, overseer, handle, label, decision) {
|
|
635
|
+
switch (decision.action) {
|
|
636
|
+
case 'wait':
|
|
637
|
+
supervision.rearmClock(label, decision.nextCheckMs ?? supervision.defaultCheckMs);
|
|
638
|
+
supervision.rearmSilence(label);
|
|
639
|
+
break;
|
|
640
|
+
case 'nudge':
|
|
641
|
+
// I-OV4: never steer into an abort already requested — the same
|
|
642
|
+
// guard-1 fact the child's fast reviewer reads. Queued steering is
|
|
643
|
+
// drained at the safe top-of-loop checkpoint by core.
|
|
644
|
+
if (!handle.isAborted() && decision.guidance !== undefined) {
|
|
645
|
+
handle.agent.steer(decision.guidance);
|
|
646
|
+
}
|
|
647
|
+
supervision.rearmClock(label, supervision.defaultCheckMs);
|
|
648
|
+
supervision.rearmSilence(label);
|
|
649
|
+
break;
|
|
650
|
+
case 'replace': {
|
|
651
|
+
// Budget checked BEFORE the verdict is set, so two near-simultaneous
|
|
652
|
+
// replace decisions cannot both rebuild (the second becomes an
|
|
653
|
+
// abandon with the budget stated in the reason).
|
|
654
|
+
handle.overseerVerdict = overseer.replacementsLeft(label)
|
|
655
|
+
? {
|
|
656
|
+
action: 'replace',
|
|
657
|
+
reason: decision.reason,
|
|
658
|
+
...(decision.guidance !== undefined ? { guidance: decision.guidance } : {}),
|
|
659
|
+
}
|
|
660
|
+
: { action: 'abandon', reason: `replacement budget exhausted; ${decision.reason}` };
|
|
661
|
+
supervision.clear(label);
|
|
662
|
+
handle.abort();
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
case 'abandon':
|
|
666
|
+
handle.overseerVerdict = { action: 'abandon', reason: decision.reason };
|
|
667
|
+
supervision.clear(label);
|
|
668
|
+
handle.abort();
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/** Record one applied decision on the outcome, the run and the event stream. */
|
|
673
|
+
recordIntervention(dispatchId, label, trigger, decision) {
|
|
674
|
+
this.interventions.push({
|
|
675
|
+
label,
|
|
676
|
+
at: Date.now(),
|
|
677
|
+
action: decision.action,
|
|
678
|
+
reason: decision.reason,
|
|
679
|
+
trigger,
|
|
680
|
+
});
|
|
681
|
+
const handle = this.live.find((h) => h.run.label === label);
|
|
682
|
+
if (handle !== undefined) {
|
|
683
|
+
handle.run.interventions = (handle.run.interventions ?? 0) + 1;
|
|
684
|
+
this.publish(dispatchId, handle.run);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
334
687
|
/**
|
|
335
688
|
* The final phase of an attempt, in a FIXED ORDER: aborted, then timed out,
|
|
336
689
|
* then error, then no-summary, then done.
|
|
@@ -401,6 +754,7 @@ export class TeamRuntime {
|
|
|
401
754
|
}
|
|
402
755
|
}
|
|
403
756
|
this.bus?.cancelAllWaits();
|
|
757
|
+
this.supervision?.clearAll();
|
|
404
758
|
// Every in-flight retry backoff, not just the last one registered: a
|
|
405
759
|
// provider-wide failure puts up to `maxConcurrent` children here at once.
|
|
406
760
|
for (const release of [...this.pendingBackoffs])
|
|
@@ -412,6 +766,9 @@ export class TeamRuntime {
|
|
|
412
766
|
if (this.dispatchTimer)
|
|
413
767
|
clearTimeout(this.dispatchTimer);
|
|
414
768
|
this.dispatchTimer = null;
|
|
769
|
+
this.supervision?.clearAll();
|
|
770
|
+
this.supervision = null;
|
|
771
|
+
this.overseer = null;
|
|
415
772
|
this.listeners.clear();
|
|
416
773
|
}
|
|
417
774
|
// -------------------------------------------------------------------------
|
package/dist/team/subagent.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type { ToolPermission } from '../exec/permission.js';
|
|
|
19
19
|
import type { ChildReviewProvider } from './child-reviewer.js';
|
|
20
20
|
import { TeamBus } from './bus.js';
|
|
21
21
|
import type { TeamHumanQueue, WatchdogPausable } from './human-queue.js';
|
|
22
|
-
import type { SubagentRun, SubagentSpec } from './types.js';
|
|
22
|
+
import type { OverseerVerdict, SubagentRun, SubagentSpec } from './types.js';
|
|
23
23
|
/**
|
|
24
24
|
* The slice of `Agent` a child is used through.
|
|
25
25
|
*
|
|
@@ -98,6 +98,14 @@ export interface SubagentDeps {
|
|
|
98
98
|
/** Present only when `--confirm` is on AND a human channel exists. */
|
|
99
99
|
humanQueue?: TeamHumanQueue;
|
|
100
100
|
agentFactory: SubagentAgentFactory;
|
|
101
|
+
/**
|
|
102
|
+
* Whether the dispatch supervisor is live for THIS dispatch
|
|
103
|
+
* (team-overseer). Read once per dispatch by the runtime and spread here;
|
|
104
|
+
* when true the child's OWN idle watchdog is lengthened by
|
|
105
|
+
* `TEAM_LIMITS.overseerWatchdogFactor`, so the supervisor's soft silence
|
|
106
|
+
* inspection at `idleTimeoutMs` always precedes the hard abort.
|
|
107
|
+
*/
|
|
108
|
+
overseerActive?: boolean;
|
|
101
109
|
/**
|
|
102
110
|
* Per-child fast review (main-agent parity): the controller's shared
|
|
103
111
|
* provider, built on the lead's `FastWiring`. ABSENT means the session
|
|
@@ -120,6 +128,13 @@ export interface SubagentHooks {
|
|
|
120
128
|
/** Coalesced at the source (§5.2); phase transitions always pass. */
|
|
121
129
|
onUpdate: (run: SubagentRun) => void;
|
|
122
130
|
onUsage: (label: string, usage: TokenUsage) => void;
|
|
131
|
+
/**
|
|
132
|
+
* UNTHROTTLED per-event callback (team-overseer I-OV3). `onUpdate` is
|
|
133
|
+
* coalesced, so it would lie about silence; the supervisor's soft stall
|
|
134
|
+
* timer needs one kick per REAL agent event. Called after the phase switch
|
|
135
|
+
* so the phase read at fire time is current.
|
|
136
|
+
*/
|
|
137
|
+
onActivity?: () => void;
|
|
123
138
|
}
|
|
124
139
|
export interface SubagentHandle {
|
|
125
140
|
spec: SubagentSpec;
|
|
@@ -134,6 +149,20 @@ export interface SubagentHandle {
|
|
|
134
149
|
* reviewer steers a `<fast_review>` block into a run that is already dying.
|
|
135
150
|
*/
|
|
136
151
|
abort: () => void;
|
|
152
|
+
/**
|
|
153
|
+
* Whether `abort()` has been called on this handle (team-overseer
|
|
154
|
+
* I-OV4): the supervisor reads it before steering a nudge, so advice is
|
|
155
|
+
* never queued into a child that is already dying - the same guard-1 fact
|
|
156
|
+
* the child's own fast reviewer reads.
|
|
157
|
+
*/
|
|
158
|
+
isAborted: () => boolean;
|
|
159
|
+
/**
|
|
160
|
+
* A terminal supervisor decision awaiting application by the WORKER LOOP
|
|
161
|
+
* (team-overseer I-OV1). Written by `TeamRuntime` immediately before
|
|
162
|
+
* `abort()`; read by `runOne` after `prompt()` resolves. NEVER cleared,
|
|
163
|
+
* so an abort racing the read cannot hide the cause.
|
|
164
|
+
*/
|
|
165
|
+
overseerVerdict?: OverseerVerdict;
|
|
137
166
|
unsubscribe: () => void;
|
|
138
167
|
}
|
|
139
168
|
/**
|
package/dist/team/subagent.js
CHANGED
|
@@ -241,7 +241,9 @@ export function createSubagent(spec, deps, hooks) {
|
|
|
241
241
|
// (§3.8). The two places a child legitimately blocks on something other
|
|
242
242
|
// than its own LLM or tool — `team_wait` and the confirm queue — pause it
|
|
243
243
|
// explicitly.
|
|
244
|
-
idleTimeout: deps.
|
|
244
|
+
idleTimeout: deps.overseerActive === true
|
|
245
|
+
? deps.config.idleTimeoutMs * TEAM_LIMITS.overseerWatchdogFactor
|
|
246
|
+
: deps.config.idleTimeoutMs,
|
|
245
247
|
},
|
|
246
248
|
// SPREAD CONDITIONALLY, never `contextManager: deps.contextManagerFor?.(...)`.
|
|
247
249
|
// With the feature off the options bag has no such key, so the loop's gate
|
|
@@ -437,6 +439,10 @@ export function createSubagent(spec, deps, hooks) {
|
|
|
437
439
|
default:
|
|
438
440
|
break;
|
|
439
441
|
}
|
|
442
|
+
// AFTER the switch, so `run.phase` is current when the supervisor's
|
|
443
|
+
// silence timer consults it (team-overseer I-OV3): a `team_wait` start
|
|
444
|
+
// must be observable as `waiting` on the very kick that follows it.
|
|
445
|
+
hooks.onActivity?.();
|
|
440
446
|
emit();
|
|
441
447
|
});
|
|
442
448
|
return {
|
|
@@ -450,6 +456,7 @@ export function createSubagent(spec, deps, hooks) {
|
|
|
450
456
|
abortRequested = true;
|
|
451
457
|
agent.abort();
|
|
452
458
|
},
|
|
459
|
+
isAborted: () => abortRequested,
|
|
453
460
|
unsubscribe: () => {
|
|
454
461
|
reviewer?.dispose();
|
|
455
462
|
unsubscribe();
|
package/dist/team/task-tool.js
CHANGED
|
@@ -47,7 +47,10 @@ const TASK_DESCRIPTION = 'Run several independent subagents in parallel and get
|
|
|
47
47
|
'a change small enough to just make. Subagents cannot dispatch further subagents and ' +
|
|
48
48
|
'cannot ask the user anything. Give each a description of at most ' +
|
|
49
49
|
`${TEAM_LIMITS.descriptionChars} characters and a complete prompt. Extra entries beyond the ` +
|
|
50
|
-
'configured maximum are dropped, so keep within it.'
|
|
50
|
+
'configured maximum are dropped, so keep within it. A supervisor watches the ' +
|
|
51
|
+
'fan-out: a child that stalls or runs very long may be nudged with guidance ' +
|
|
52
|
+
'or replaced by a fresh attempt, and the report\'s Supervisor section ' +
|
|
53
|
+
'records every intervention.';
|
|
51
54
|
export function createTaskTool(deps) {
|
|
52
55
|
return {
|
|
53
56
|
name: 'task',
|