@aragon-agent/cli 0.6.16 → 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.
Files changed (79) hide show
  1. package/CHANGELOG.md +107 -51
  2. package/README.md +91 -48
  3. package/dist/agent/controller.js +52 -47
  4. package/dist/agent/headless.js +7 -0
  5. package/dist/agent/reducer.js +0 -4
  6. package/dist/cli.js +0 -13
  7. package/dist/commands/builtins.js +21 -20
  8. package/dist/commands/index-build.d.ts +39 -0
  9. package/dist/commands/index-build.js +61 -0
  10. package/dist/compaction/child.d.ts +10 -8
  11. package/dist/compaction/child.js +23 -25
  12. package/dist/compaction/limits.d.ts +0 -2
  13. package/dist/compaction/limits.js +0 -2
  14. package/dist/config/cli-commands.d.ts +2 -2
  15. package/dist/config/cli-commands.js +19 -15
  16. package/dist/config/load.d.ts +0 -17
  17. package/dist/config/load.js +0 -6
  18. package/dist/config/schema.d.ts +41 -9
  19. package/dist/config/schema.js +18 -11
  20. package/dist/exec/events.d.ts +16 -9
  21. package/dist/exec/events.js +2 -2
  22. package/dist/exec/runner.js +17 -1
  23. package/dist/fast/limits.d.ts +2 -2
  24. package/dist/fast/limits.js +2 -2
  25. package/dist/fast/prompt.d.ts +10 -11
  26. package/dist/fast/prompt.js +19 -20
  27. package/dist/fast/reviewer.d.ts +1 -1
  28. package/dist/fast/reviewer.js +1 -1
  29. package/dist/fast/types.d.ts +0 -2
  30. package/dist/fast/wiring.d.ts +18 -34
  31. package/dist/fast/wiring.js +20 -51
  32. package/dist/input/csiu-keys.js +13 -2
  33. package/dist/input/limits.d.ts +15 -0
  34. package/dist/input/limits.js +15 -0
  35. package/dist/input/win32-input-mode.js +7 -1
  36. package/dist/logging/install.d.ts +9 -0
  37. package/dist/logging/install.js +11 -0
  38. package/dist/session/validate-session.js +3 -1
  39. package/dist/team/child-reviewer.d.ts +105 -0
  40. package/dist/team/child-reviewer.js +117 -0
  41. package/dist/team/human-queue.d.ts +11 -0
  42. package/dist/team/human-queue.js +18 -1
  43. package/dist/team/limits.d.ts +40 -0
  44. package/dist/team/limits.js +41 -0
  45. package/dist/team/normalize.d.ts +7 -26
  46. package/dist/team/normalize.js +9 -22
  47. package/dist/team/overseer.d.ts +157 -0
  48. package/dist/team/overseer.js +348 -0
  49. package/dist/team/prompt.d.ts +0 -11
  50. package/dist/team/prompt.js +3 -12
  51. package/dist/team/report.d.ts +4 -19
  52. package/dist/team/report.js +27 -28
  53. package/dist/team/runtime.d.ts +76 -16
  54. package/dist/team/runtime.js +410 -57
  55. package/dist/team/subagent.d.ts +48 -19
  56. package/dist/team/subagent.js +88 -31
  57. package/dist/team/task-tool.d.ts +6 -26
  58. package/dist/team/task-tool.js +17 -48
  59. package/dist/team/types.d.ts +65 -41
  60. package/dist/ui/App.d.ts +1 -1
  61. package/dist/ui/App.js +64 -4
  62. package/dist/ui/BottomStatusRow.js +2 -1
  63. package/dist/ui/Header.js +13 -2
  64. package/dist/ui/PromptInput.js +11 -1
  65. package/dist/ui/StatusBar.js +2 -1
  66. package/dist/ui/TeamPanel.js +1 -1
  67. package/dist/ui/entries/TeamCard.js +1 -6
  68. package/dist/ui/layout/status-detail-layout.js +12 -2
  69. package/dist/ui/layout/status-layout.d.ts +4 -2
  70. package/dist/ui/layout/status-layout.js +24 -1
  71. package/dist/ui/overlays/HelpOverlay.js +2 -2
  72. package/package.json +2 -2
  73. package/skills/project-indexer/SKILL.md +163 -0
  74. package/skills/project-indexer/templates/cleancode-overrides.md +46 -0
  75. package/skills/project-indexer/templates/cleancode-template.md +94 -0
  76. package/skills/project-indexer/templates/config-template.md +69 -0
  77. package/skills/project-indexer/templates/index-template.md +129 -0
  78. package/dist/ui/ModeChip.d.ts +0 -18
  79. package/dist/ui/ModeChip.js +0 -24
@@ -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
  }
@@ -65,10 +168,7 @@ export class TeamRuntime {
65
168
  // -------------------------------------------------------------------------
66
169
  // Dispatch
67
170
  // -------------------------------------------------------------------------
68
- async dispatch(specs, requested, signal,
69
- /** Carried through from the normalizer so the report can state it (§3.4 /
70
- * R-6). Optional, so every existing caller compiles unchanged. */
71
- extra = {}) {
171
+ async dispatch(specs, requested, signal) {
72
172
  this.counter += 1;
73
173
  const dispatchId = `d${this.counter}`;
74
174
  const config = this.deps.getConfig();
@@ -76,6 +176,30 @@ export class TeamRuntime {
76
176
  const startedAt = Date.now();
77
177
  this.busy = true;
78
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;
79
203
  // DECLARED BEFORE THE BUS, ASSIGNED AFTER THE HANDLES. `canSend` closes over
80
204
  // this binding so it reads the LIVE array: F-4 replaces `handles[index]`
81
205
  // mid-dispatch, and a captured array or a captured handle would answer for
@@ -88,7 +212,7 @@ export class TeamRuntime {
88
212
  * Whether `label` still has a loop that could call `team_send` (F-3 / P0-2).
89
213
  *
90
214
  * NEGATIVE, over TERMINAL PHASES ONLY. "The child's phase is running" is the
91
- * natural way to write this and it is wrong on the SHIPPED DEFAULTS: at
215
+ * natural way to write this and is wrong on the SHIPPED DEFAULTS: at
92
216
  * `maxConcurrent: 3, maxSubagents: 5`, children 4 and 5 sit in `queued` for
93
217
  * the first part of every five-way dispatch, and a wait on one of them would
94
218
  * be refused with "nobody can answer: a4 already finished". `queued` is not
@@ -131,7 +255,7 @@ export class TeamRuntime {
131
255
  confirmTools: config.confirmTools,
132
256
  ...(this.deps.humanQueue ? { humanQueue: this.deps.humanQueue } : {}),
133
257
  agentFactory: this.deps.agentFactory ?? defaultSubagentFactory,
134
- ...(this.deps.resolveTier ? { resolveTier: this.deps.resolveTier } : {}),
258
+ ...(this.deps.review ? { review: this.deps.review } : {}),
135
259
  // Site two of two, and the omission is silent in the same way the policy
136
260
  // one above is: declaring it on `TeamRuntimeDeps` and forgetting this line
137
261
  // type-checks and forwards nothing, so every child would quietly run with
@@ -139,6 +263,10 @@ export class TeamRuntime {
139
263
  ...(this.deps.contextManagerFor
140
264
  ? { contextManagerFor: this.deps.contextManagerFor }
141
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 } : {}),
142
270
  };
143
271
  // Hoisted into a factory so a retry can build a fresh child with IDENTICAL
144
272
  // wiring (F-4). Replacing the child rather than re-prompting it is the only
@@ -147,11 +275,10 @@ export class TeamRuntime {
147
275
  // show the child its brief twice.
148
276
  const makeHandle = (spec) => createSubagent(spec, subDeps, {
149
277
  onUpdate: (run) => this.publish(dispatchId, run),
150
- // `tier` rides along so `App.tsx` can pick the RIGHT cost table for this
151
- // event rather than always using the lead's (§3.6 / R-7). Taken from the
152
- // SPEC, which the normalizer already downgraded when the tier was
153
- // unavailable, so it names the model that actually billed.
154
- onUsage: (label, usage) => this.emit({ type: 'usage', dispatchId, label, usage, tier: spec.tier }),
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),
155
282
  });
156
283
  // ASSIGNMENT, not a declaration: a `const` here would shadow the `let` above
157
284
  // the bus, `canSend` would see the empty array forever, and every peer would
@@ -179,7 +306,15 @@ export class TeamRuntime {
179
306
  // so the override only bites because `task` listens to `ctx.signal` — and a
180
307
  // second, independent timer means a signal that never arrives still ends the
181
308
  // dispatch.
182
- this.dispatchTimer = setTimeout(() => this.abortAll(), team.dispatchTimeoutMs);
309
+ //
310
+ // `0` MEANS NO CEILING (main-agent parity): the dispatch lasts as long as
311
+ // its slowest child and only an Esc ends it early. The lead's idle
312
+ // watchdog is paused for the whole dispatch either way, exactly as it is
313
+ // for the lead's own long turns.
314
+ this.dispatchTimer =
315
+ team.dispatchTimeoutMs > 0
316
+ ? setTimeout(() => this.abortAll(), team.dispatchTimeoutMs)
317
+ : null;
183
318
  const runCtx = {
184
319
  dispatchId,
185
320
  handles,
@@ -195,24 +330,16 @@ export class TeamRuntime {
195
330
  signal?.removeEventListener('abort', onAbort);
196
331
  for (const handle of handles)
197
332
  handle.unsubscribe();
198
- // SUMMED PER TIER (fast-model-tier §3.4 / R-7). One total priced at the
199
- // lead's table would over-report a Haiku child under a Sonnet lead by
200
- // roughly an order of magnitude — and the whole justification for delegating
201
- // to a cheaper model is a number in the report this feeds.
202
- // `main` IS THE COMPLEMENT OF `fast`, not an equality test. `SubagentRun.tier`
203
- // is populated by `createSubagent` from the spec, but a hand-built run in a
204
- // test — or a run restored from a session file written before this field
205
- // existed — has it `undefined`, and `=== 'main'` would silently drop that
206
- // child's spend from the total. Under-reporting is the one failure this
207
- // split exists to prevent, so the default direction has to be "counted".
208
- const sumTier = (tier) => handles
209
- .filter((h) => (tier === 'fast' ? h.run.tier === 'fast' : h.run.tier !== 'fast'))
210
- .reduce((acc, h) => ({
333
+ this.supervision?.clearAll();
334
+ this.supervision = null;
335
+ this.overseer = null;
336
+ // ONE TOTAL, AT THE LEAD'S TABLE. Every child runs the lead's own model
337
+ // (main-agent parity), so there is exactly one tier to sum and one price to
338
+ // sum it at; a split would be two answers to one question.
339
+ const usage = handles.reduce((acc, h) => ({
211
340
  inputTokens: acc.inputTokens + h.run.usage.inputTokens,
212
341
  outputTokens: acc.outputTokens + h.run.usage.outputTokens,
213
342
  }), { inputTokens: 0, outputTokens: 0 });
214
- const fastUsage = sumTier('fast');
215
- const ranFast = handles.some((h) => h.run.tier === 'fast');
216
343
  const outcome = {
217
344
  dispatchId,
218
345
  runs: handles.map((h) => ({ ...h.run, filesTouched: [...h.run.filesTouched] })),
@@ -221,11 +348,8 @@ export class TeamRuntime {
221
348
  endedAt: Date.now(),
222
349
  aborted: this.aborted,
223
350
  leadMail: bus.leadMail(),
224
- usage: sumTier('main'),
225
- // ABSENT when no fast child ran, which is what keeps a default session's
226
- // outcome shape byte-identical to the pre-feature one.
227
- ...(ranFast ? { fastUsage } : {}),
228
- ...(extra.downgraded ? { downgraded: extra.downgraded } : {}),
351
+ interventions: this.interventions,
352
+ usage,
229
353
  };
230
354
  this.live = [];
231
355
  this.bus = null;
@@ -255,21 +379,41 @@ export class TeamRuntime {
255
379
  }
256
380
  /**
257
381
  * Run one child to a terminal phase, replacing it once if its very first
258
- * 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).
259
389
  */
260
390
  async runOne(ctx, index) {
261
391
  const { dispatchId, handles, makeHandle, subagentTimeoutMs } = ctx;
262
- for (let attempt = 0;; attempt += 1) {
392
+ let attempt = 0;
393
+ for (;;) {
394
+ attempt += 1;
263
395
  const handle = handles[index];
264
396
  const { run, agent } = handle;
397
+ // ABORTS GO THROUGH THE HANDLE (never `agent.abort()` directly): the
398
+ // handle sets the synchronous `abortRequested` flag the child's fast
399
+ // reviewer reads as its never-steer-into-a-requested-abort fact.
265
400
  run.phase = 'starting';
266
401
  run.startedAt = Date.now();
267
402
  this.publish(dispatchId, run);
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);
268
410
  let timedOut = false;
269
- const timer = setTimeout(() => {
270
- timedOut = true;
271
- agent.abort();
272
- }, subagentTimeoutMs);
411
+ const timer = !supervised && subagentTimeoutMs > 0
412
+ ? setTimeout(() => {
413
+ timedOut = true;
414
+ handle.abort();
415
+ }, subagentTimeoutMs)
416
+ : null;
273
417
  try {
274
418
  await agent.prompt(handle.spec.prompt);
275
419
  }
@@ -281,23 +425,49 @@ export class TeamRuntime {
281
425
  run.error = err instanceof Error ? err.message : String(err);
282
426
  }
283
427
  finally {
284
- clearTimeout(timer);
428
+ if (timer)
429
+ clearTimeout(timer);
285
430
  }
286
- // THE TWO ABORT EXCLUSIONS THAT MAKE THE PREDICATE SAFE. Every abort in
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
287
442
  // this system reaches the provider as an aborted fetch, and `core` marks
288
443
  // an `AbortError` retryable - so Esc, `ctx.signal`, the dispatch timeout
289
- // (all three via `this.aborted`) and the per-child timeout (`timedOut`)
290
- // are excluded HERE, before `shouldRetryColdStart` is ever consulted.
291
- const retryable = !this.aborted && !timedOut && shouldRetryColdStart(run, attempt + 1);
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);
292
451
  if (!retryable) {
293
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
+ }
294
458
  this.settlePhase(run, timedOut, subagentTimeoutMs);
295
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);
296
465
  return;
297
466
  }
298
- const fresh = this.replaceForRetry(dispatchId, handles, index, makeHandle);
467
+ this.replaceChild(dispatchId, handles, index, makeHandle, handle, null);
299
468
  await this.backoff(TEAM_LIMITS.retryBackoffMs);
300
469
  if (this.aborted) {
470
+ const fresh = handles[index];
301
471
  fresh.run.phase = 'aborted';
302
472
  fresh.run.endedAt = Date.now();
303
473
  this.publish(dispatchId, fresh.run);
@@ -306,35 +476,214 @@ export class TeamRuntime {
306
476
  }
307
477
  }
308
478
  /**
309
- * Swap a cold-started child for a fresh one, carrying only what must survive.
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.
310
521
  *
311
522
  * `this.live` IS `handles`, so the single assignment below also makes
312
523
  * `abortAll()` abort the NEW child and makes the outcome carry the new run.
313
524
  * Anyone tempted to "clean up" that aliasing into a defensive copy must update
314
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.
315
531
  */
316
- replaceForRetry(dispatchId, handles, index, makeHandle) {
317
- const handle = handles[index];
532
+ replaceChild(dispatchId, handles, index, makeHandle, handle, verdict) {
318
533
  const carried = { ...handle.run.usage };
319
- 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;
320
537
  handle.unsubscribe();
321
538
  try {
322
- handle.agent.abort();
539
+ handle.abort();
323
540
  }
324
541
  catch {
325
542
  // Already gone; this must not stop the retry.
326
543
  }
327
- const fresh = makeHandle(handle.spec);
328
- // Provably {0,0} under the predicate above (no turn completed, so no
329
- // `turn_end` and no usage event). Carried anyway so that a future relaxation
330
- // of the predicate cannot silently under-report spend - the most misleading
331
- // failure this feature can produce.
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.
332
559
  fresh.run.usage = carried;
333
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);
334
567
  handles[index] = fresh;
335
568
  this.publish(dispatchId, fresh.run);
336
569
  return fresh;
337
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
+ }
338
687
  /**
339
688
  * The final phase of an attempt, in a FIXED ORDER: aborted, then timed out,
340
689
  * then error, then no-summary, then done.
@@ -346,7 +695,7 @@ export class TeamRuntime {
346
695
  if (this.aborted) {
347
696
  run.phase = 'aborted';
348
697
  }
349
- else if (timedOut) {
698
+ else if (timedOut && subagentTimeoutMs > 0) {
350
699
  run.phase = 'failed';
351
700
  run.error = run.error ?? `subagent exceeded ${Math.round(subagentTimeoutMs / 1000)}s`;
352
701
  }
@@ -398,13 +747,14 @@ export class TeamRuntime {
398
747
  this.aborted = true;
399
748
  for (const handle of this.live) {
400
749
  try {
401
- handle.agent.abort();
750
+ handle.abort();
402
751
  }
403
752
  catch {
404
753
  // A child that is already gone must not stop the others being aborted.
405
754
  }
406
755
  }
407
756
  this.bus?.cancelAllWaits();
757
+ this.supervision?.clearAll();
408
758
  // Every in-flight retry backoff, not just the last one registered: a
409
759
  // provider-wide failure puts up to `maxConcurrent` children here at once.
410
760
  for (const release of [...this.pendingBackoffs])
@@ -416,6 +766,9 @@ export class TeamRuntime {
416
766
  if (this.dispatchTimer)
417
767
  clearTimeout(this.dispatchTimer);
418
768
  this.dispatchTimer = null;
769
+ this.supervision?.clearAll();
770
+ this.supervision = null;
771
+ this.overseer = null;
419
772
  this.listeners.clear();
420
773
  }
421
774
  // -------------------------------------------------------------------------