@aragon-agent/cli 0.6.21 → 0.6.23

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.
@@ -28,30 +28,48 @@
28
28
  * pre-feature behaviour.
29
29
  */
30
30
  import { TeamBus } from './bus.js';
31
- import { TEAM_LIMITS } from './limits.js';
32
- import { TeamOverseer, buildReplacementPrompt } from './overseer.js';
31
+ import { TEAM_AGGREGATE_LABEL, TEAM_LIMITS } from './limits.js';
32
+ import { TeamOverseer, buildReplacementPrompt, nextOverseerLadderStepMs, resolveOverseerFirstCheckMs, } from './overseer.js';
33
33
  import { shouldRetryColdStart } from './retry.js';
34
34
  import { createSubagent, defaultSubagentFactory, } from './subagent.js';
35
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.
36
+ * The pseudo-label aggregate supervisor notices travel under (D-5 / R-P1-6):
37
+ * `TEAM_AGGREGATE_LABEL`, RESERVED at normalization so no child slug can be
38
+ * `team`. The reservation is what makes this label a safe namespace - a
39
+ * colliding child would have the aggregate notice attributed to it in the
40
+ * ledger and its own quiet note swallowed by the report's aggregate count.
41
+ */
42
+ const MONITOR_TEAM_LABEL = TEAM_AGGREGATE_LABEL;
43
+ const MONITOR_EXHAUSTED_DECISION = {
44
+ action: 'wait',
45
+ reason: 'dispatch look budget exhausted; supervision quiet for the rest of the dispatch',
46
+ };
47
+ /**
48
+ * The wait a DEGRADED tick announces per child (D-4 / D-10). One shared
49
+ * object, not a fresh literal per event: it is the same fact every time and
50
+ * identity makes that obvious to any consumer that looks.
51
+ */
52
+ const DEGRADED_WAIT_DECISION = {
53
+ action: 'wait',
54
+ reason: 'fast tier unavailable; waiting unassisted',
55
+ };
56
+ /**
57
+ * The per-child SILENCE windows (team-overseer §3.2; subagent-overseer-v2
58
+ * keeps only this half). Timers only — no state, no decisions; the runtime
59
+ * applies what the supervisor decides. The wall-clock half moved to
60
+ * `MonitorLoop`, which owns the cadence ladder for the whole roster.
38
61
  *
39
62
  * Every timer is `unref`'d (the `backoff` argument): a pending supervision
40
63
  * look must not hold the process open on exit.
41
64
  */
42
65
  class ChildSupervisor {
43
66
  deps;
44
- clocks = new Map();
45
67
  silences = new Map();
46
- /** The interval used when a decision did not name one. */
47
- defaultCheckMs;
48
68
  constructor(deps) {
49
69
  this.deps = deps;
50
- this.defaultCheckMs = Math.max(TEAM_LIMITS.overseerNextCheckMinMs, deps.firstCheckMs);
51
70
  }
52
- /** Arm (or re-arm) both windows for a child starting or restarting an attempt. */
71
+ /** Arm (or re-arm) the silence window for a child starting an attempt. */
53
72
  start(label) {
54
- this.armClock(label, this.deps.firstCheckMs);
55
73
  this.armSilence(label);
56
74
  }
57
75
  /** Reset the silence window. A no-op for a child nobody is supervising. */
@@ -62,33 +80,16 @@ class ChildSupervisor {
62
80
  rearmSilence(label) {
63
81
  this.armSilence(label);
64
82
  }
65
- rearmClock(label, ms) {
66
- this.armClock(label, ms);
67
- }
68
83
  clear(label) {
69
- const clock = this.clocks.get(label);
70
- if (clock !== undefined)
71
- clearTimeout(clock);
72
84
  const silence = this.silences.get(label);
73
85
  if (silence !== undefined)
74
86
  clearTimeout(silence);
75
- this.clocks.delete(label);
76
87
  this.silences.delete(label);
77
88
  }
78
89
  clearAll() {
79
- for (const label of [...this.clocks.keys()])
80
- this.clear(label);
81
90
  for (const label of [...this.silences.keys()])
82
91
  this.clear(label);
83
92
  }
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
93
  armSilence(label) {
93
94
  const previous = this.silences.get(label);
94
95
  if (previous !== undefined)
@@ -101,17 +102,191 @@ class ChildSupervisor {
101
102
  this.armSilence(label);
102
103
  return;
103
104
  }
104
- this.fire(label, 'silence');
105
+ this.silences.delete(label);
106
+ this.deps.onFire(label, 'silence');
105
107
  }, this.deps.idleMs);
106
108
  timer.unref?.();
107
109
  this.silences.set(label, timer);
108
110
  }
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);
111
+ }
112
+ /**
113
+ * The cadence ladder (subagent-overseer-v2 D-2): one timer for the whole
114
+ * roster, per-child `nextAt` / `ladderStep` entries armed at each child's
115
+ * ACTUAL start (R-P1-5), and a strict one-batch-in-flight rule.
116
+ *
117
+ * A SKIPPED LABEL RE-SCHEULES WITHOUT TOUCHING ITS LADDER STEP: the
118
+ * single-flight guard (R-P1-4) can leave a due `nextAt` in the past while a
119
+ * silence-triggered inspection of that child is still settling, and a timer
120
+ * that re-fired at `max(0, due - now)` would busy-loop until it settles. The
121
+ * recheck delay is invisible to the protocol - no look is spent, the ladder
122
+ * value does not move - it only keeps the event loop idle.
123
+ */
124
+ const MONITOR_RECHECK_MS = 500;
125
+ class MonitorLoop {
126
+ deps;
127
+ nextAt = new Map();
128
+ ladderStep = new Map();
129
+ timer = null;
130
+ ticking = false;
131
+ stopped = false;
132
+ constructor(deps) {
133
+ this.deps = deps;
134
+ }
135
+ /** Arm the FIRST rung for a child that just started (R-P1-5). */
136
+ arm(label) {
137
+ const now = this.deps.now();
138
+ this.nextAt.set(label, now + this.deps.firstCheckMs);
139
+ this.ladderStep.set(label, this.deps.firstCheckMs);
140
+ this.schedule();
141
+ }
142
+ /** A wait decision's `nextCheckMs` OVERRIDES the rung and becomes it (D-2). */
143
+ override(label, ms) {
144
+ const now = this.deps.now();
145
+ this.nextAt.set(label, now + ms);
146
+ this.ladderStep.set(label, ms);
147
+ this.schedule();
148
+ }
149
+ /** Any ACTION resets that child's ladder to the base rung (D-2). */
150
+ reset(label) {
151
+ this.arm(label);
152
+ }
153
+ /** Grow the rung and schedule the next visit (D-2). */
154
+ advance(label) {
155
+ const now = this.deps.now();
156
+ const step = nextOverseerLadderStepMs(this.ladderStep.get(label) ?? this.deps.firstCheckMs);
157
+ this.ladderStep.set(label, step);
158
+ this.nextAt.set(label, now + step);
159
+ this.schedule();
160
+ }
161
+ /** Stop visiting one child (settled, quieted, or its verdict applied). */
162
+ clear(label) {
163
+ this.nextAt.delete(label);
164
+ this.ladderStep.delete(label);
165
+ this.schedule();
166
+ }
167
+ /** Stop the whole loop; the dispatch is over or the total budget is spent. */
168
+ stop() {
169
+ this.stopped = true;
170
+ if (this.timer !== null)
171
+ clearTimeout(this.timer);
172
+ this.timer = null;
173
+ this.nextAt.clear();
174
+ this.ladderStep.clear();
175
+ }
176
+ dispose() {
177
+ this.stop();
178
+ }
179
+ /** The earliest due time among live labels, or null when nothing is armed. */
180
+ earliestDue() {
181
+ let earliest = null;
182
+ for (const [label, at] of this.nextAt) {
183
+ if (!this.deps.isLive(label))
184
+ continue;
185
+ earliest = earliest === null ? at : Math.min(earliest, at);
186
+ }
187
+ return earliest;
188
+ }
189
+ schedule() {
190
+ if (this.timer !== null) {
191
+ clearTimeout(this.timer);
192
+ this.timer = null;
193
+ }
194
+ if (this.stopped || this.ticking)
195
+ return;
196
+ const earliest = this.earliestDue();
197
+ if (earliest === null)
198
+ return;
199
+ const delay = Math.max(0, earliest - this.deps.now());
200
+ const timer = setTimeout(() => {
201
+ this.timer = null;
202
+ void this.tick();
203
+ }, delay);
204
+ timer.unref?.();
205
+ this.timer = timer;
206
+ }
207
+ async tick() {
208
+ if (this.ticking || this.stopped)
209
+ return;
210
+ const now = this.deps.now();
211
+ const due = [];
212
+ for (const [label, at] of [...this.nextAt]) {
213
+ if (!this.deps.isLive(label)) {
214
+ // Settled while armed; R-P1-5 also means a re-queued replacement body
215
+ // re-arms itself at its own start.
216
+ this.nextAt.delete(label);
217
+ this.ladderStep.delete(label);
218
+ continue;
219
+ }
220
+ if (now >= at)
221
+ due.push(label);
222
+ }
223
+ if (due.length === 0) {
224
+ this.schedule();
225
+ return;
226
+ }
227
+ // DEGRADED TICK (D-4 / D-10): no fast tier right now. The children wait
228
+ // unassisted - announced per child, never killed - and the cadence keeps
229
+ // running so a `/fast on` restores assistance at the next rung.
230
+ if (!this.deps.isActive()) {
231
+ this.deps.onDegraded(due);
232
+ for (const label of due)
233
+ this.advance(label);
234
+ this.schedule();
235
+ return;
236
+ }
237
+ // PER-CHILD QUIET (D-5): budget spent says so once, then stop visiting.
238
+ const inspectable = [];
239
+ for (const label of due) {
240
+ if (this.deps.isExhausted(label)) {
241
+ this.clear(label);
242
+ this.deps.onQuiet(label);
243
+ continue;
244
+ }
245
+ inspectable.push(label);
246
+ }
247
+ if (inspectable.length === 0) {
248
+ this.schedule();
249
+ return;
250
+ }
251
+ this.ticking = true;
252
+ let decisions;
253
+ try {
254
+ decisions = await this.deps.inspectBatch(inspectable);
255
+ }
256
+ catch {
257
+ // Defence in depth: `inspectBatch` is fail-soft by contract. Treat the
258
+ // tick as if every child had answered wait.
259
+ decisions = new Map();
260
+ }
261
+ finally {
262
+ this.ticking = false;
263
+ }
264
+ const after = this.deps.now();
265
+ for (const label of inspectable) {
266
+ const decision = decisions.get(label);
267
+ if (decision === undefined) {
268
+ // Skipped for single-flight (R-P1-4): no decision, no look, and the
269
+ // ladder does not move - only the timer must not busy-loop.
270
+ this.nextAt.set(label, after + MONITOR_RECHECK_MS);
271
+ continue;
272
+ }
273
+ if (decision.action === 'wait' && decision.nextCheckMs !== undefined) {
274
+ this.override(label, decision.nextCheckMs);
275
+ }
276
+ else if (decision.action === 'wait') {
277
+ this.advance(label);
278
+ }
279
+ else {
280
+ this.reset(label);
281
+ }
282
+ }
283
+ if (this.deps.dispatchExhausted()) {
284
+ // The fool-proof total (R-P1-6). Stop the whole loop, once, loudly.
285
+ this.stop();
286
+ this.deps.onExhausted();
287
+ return;
288
+ }
289
+ this.schedule();
115
290
  }
116
291
  }
117
292
  export class TeamRuntime {
@@ -137,15 +312,30 @@ export class TeamRuntime {
137
312
  */
138
313
  pendingBackoffs = new Set();
139
314
  /**
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.
315
+ * The dispatch supervisor, LIVE for the current dispatch only
316
+ * (team-overseer / subagent-overseer-v2). Null when the provider is not
317
+ * wired or the policy is off, and reset to null at every dispatch end so
318
+ * nothing supervision-shaped leaks across dispatches.
319
+ *
320
+ * THE GATE IS "PROVIDER PRESENT AND POLICY ON" (R-P0-2), deliberately NOT
321
+ * the provider's live `active()`: a dispatch that STARTS without a fast
322
+ * tier must still get a MonitorLoop, because the promise this feature
323
+ * makes is "timeout is never a death sentence" - an unavailable tier
324
+ * degrades the tick to an announced unassisted wait rather than falling
325
+ * back to the legacy hard abort. `active()` is re-read every tick.
143
326
  */
144
327
  overseer = null;
145
- /** The soft timers, same lifetime as `overseer`. */
328
+ /** The per-child silence windows, same lifetime as `overseer`. */
146
329
  supervision = null;
330
+ /** The cadence ladder (subagent-overseer-v2 D-2), same lifetime. */
331
+ monitor = null;
147
332
  /** Applied interventions for the in-flight dispatch's outcome. */
148
333
  interventions = [];
334
+ /** Labels already told the supervisor went quiet on them (D-5, once). */
335
+ quietNotified = new Set();
336
+ /** Edge-latched "a tick ran unassisted" (D-10), per dispatch. */
337
+ overseerDegraded = false;
338
+ overseerDegradedTicks = 0;
149
339
  constructor(deps) {
150
340
  this.deps = deps;
151
341
  }
@@ -177,29 +367,48 @@ export class TeamRuntime {
177
367
  this.busy = true;
178
368
  this.aborted = false;
179
369
  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.
370
+ this.quietNotified.clear();
371
+ this.overseerDegraded = false;
372
+ this.overseerDegradedTicks = 0;
373
+ // THE SUPERVISOR IS CREATED PER DISPATCH (R-P0-2): provider wired AND
374
+ // policy on. `TeamOverseer.isActive()` re-checks the provider per
375
+ // inspection and per tick, so a mid-dispatch `/fast off` degrades the
376
+ // NEXT look to an unassisted wait instead of unwinding anything already
377
+ // applied - and a dispatch that started without the tier is still
378
+ // supervised, in that degraded, never-kill sense.
379
+ const supervised = this.deps.overseer !== undefined && team.overseer;
184
380
  this.overseer =
185
- this.deps.overseer !== undefined &&
186
- this.deps.overseer.active() &&
187
- team.overseer
381
+ supervised && this.deps.overseer !== undefined
188
382
  ? new TeamOverseer(this.deps.overseer, { startedAt })
189
383
  : null;
190
384
  this.supervision =
191
385
  this.overseer !== null
192
386
  ? new ChildSupervisor({
193
387
  idleMs: config.idleTimeoutMs,
194
- firstCheckMs: team.subagentTimeoutMs > 0
195
- ? team.subagentTimeoutMs
196
- : TEAM_LIMITS.overseerDefaultCheckMs,
197
388
  isLegitimatelyBlocked: (label) => this.isBlocked(label),
198
389
  onFire: (label, trigger) => {
199
390
  void this.inspectAndApply(dispatchId, label, trigger);
200
391
  },
201
392
  })
202
393
  : null;
394
+ this.monitor =
395
+ this.overseer !== null
396
+ ? new MonitorLoop({
397
+ // D-8: `overseerIntervalMs` wins, then a positive
398
+ // `subagentTimeoutMs` (compat), then the structural default.
399
+ firstCheckMs: resolveOverseerFirstCheckMs(team),
400
+ now: () => Date.now(),
401
+ // R-P1-5: only STARTED, UNSETTLED children tick.
402
+ isLive: (label) => this.isMonitorLive(label),
403
+ isActive: () => this.overseer?.isActive() === true,
404
+ isExhausted: (label) => this.overseer?.looksExhausted(label) === true,
405
+ dispatchExhausted: () => this.overseer?.dispatchLooksExhausted() === true,
406
+ inspectBatch: (labels) => this.monitorInspectBatch(dispatchId, labels),
407
+ onDegraded: (labels) => this.noteDegradedTick(dispatchId, labels),
408
+ onQuiet: (label) => this.noteQuiet(dispatchId, label),
409
+ onExhausted: () => this.noteDispatchExhausted(dispatchId),
410
+ })
411
+ : null;
203
412
  // DECLARED BEFORE THE BUS, ASSIGNED AFTER THE HANDLES. `canSend` closes over
204
413
  // this binding so it reads the LIVE array: F-4 replaces `handles[index]`
205
414
  // mid-dispatch, and a captured array or a captured handle would answer for
@@ -279,6 +488,18 @@ export class TeamRuntime {
279
488
  // ONE KICK PER REAL AGENT EVENT, unthrottled (I-OV3): the silence
280
489
  // window resets on evidence, not on a coalesced UI frame.
281
490
  onActivity: () => this.supervision?.kick(spec.label),
491
+ // A'2: under supervision the turn cap is ONE inspection, fired from the
492
+ // child's own `turn_end` at the crossing turn. Async-and-forgotten
493
+ // exactly like the silence timer's `onFire`: nothing in the child's
494
+ // event path may block or throw. ABSENT in the legacy regime, which
495
+ // keeps the hard stop - the hook's presence IS the gate.
496
+ ...(this.overseer !== null
497
+ ? {
498
+ onTurnCapReached: (label) => {
499
+ void this.inspectAndApply(dispatchId, label, 'turncap');
500
+ },
501
+ }
502
+ : {}),
282
503
  });
283
504
  // ASSIGNMENT, not a declaration: a `const` here would shadow the `let` above
284
505
  // the bus, `canSend` would see the empty array forever, and every peer would
@@ -311,15 +532,24 @@ export class TeamRuntime {
311
532
  // its slowest child and only an Esc ends it early. The lead's idle
312
533
  // watchdog is paused for the whole dispatch either way, exactly as it is
313
534
  // for the lead's own long turns.
535
+ //
536
+ // D-4, NOW FOR THE WHOLE DISPATCH TOO: with the supervisor gate open the
537
+ // ceiling NEVER arms a hard abort - the cadence ladder is what looks at a
538
+ // long dispatch, exactly as it already looks at a long child. The legacy
539
+ // hard `abortAll()` exists ONLY under `team.overseer: false`, the same
540
+ // regime line `runCtx.subagentTimeoutMs` below draws.
541
+ const dispatchCeilingMs = team.overseer ? 0 : team.dispatchTimeoutMs;
314
542
  this.dispatchTimer =
315
- team.dispatchTimeoutMs > 0
316
- ? setTimeout(() => this.abortAll(), team.dispatchTimeoutMs)
317
- : null;
543
+ dispatchCeilingMs > 0 ? setTimeout(() => this.abortAll(), dispatchCeilingMs) : null;
318
544
  const runCtx = {
319
545
  dispatchId,
320
546
  handles,
321
547
  makeHandle,
322
- subagentTimeoutMs: team.subagentTimeoutMs,
548
+ // D-4: with the supervisor gate open, `subagentTimeoutMs` NEVER arms a
549
+ // hard abort - 0 here arms no timer, so the key's only remaining role
550
+ // is the ladder's compat first-check (D-8). The legacy hard abort
551
+ // exists ONLY under `team.overseer: false`.
552
+ subagentTimeoutMs: team.overseer ? 0 : team.subagentTimeoutMs,
323
553
  };
324
554
  const cursor = { next: 0 };
325
555
  const workers = Array.from({ length: Math.max(1, Math.min(team.maxConcurrent, handles.length)) }, () => this.worker(runCtx, cursor));
@@ -330,8 +560,17 @@ export class TeamRuntime {
330
560
  signal?.removeEventListener('abort', onAbort);
331
561
  for (const handle of handles)
332
562
  handle.unsubscribe();
563
+ this.monitor?.dispose();
564
+ this.monitor = null;
333
565
  this.supervision?.clearAll();
334
566
  this.supervision = null;
567
+ // CAPTURED BEFORE THE FIELD IS NULLED: the outcome carries what the
568
+ // supervisor spent (D-7) and whether any tick ran unassisted (D-10),
569
+ // both read off the per-dispatch state being torn down here.
570
+ const overseerUsage = this.overseer?.usage();
571
+ const overseerCalls = this.overseer?.calls();
572
+ const overseerDegraded = this.overseerDegraded;
573
+ const overseerDegradedTicks = this.overseerDegradedTicks;
335
574
  this.overseer = null;
336
575
  // ONE TOTAL, AT THE LEAD'S TABLE. Every child runs the lead's own model
337
576
  // (main-agent parity), so there is exactly one tier to sum and one price to
@@ -350,6 +589,13 @@ export class TeamRuntime {
350
589
  leadMail: bus.leadMail(),
351
590
  interventions: this.interventions,
352
591
  usage,
592
+ // Honest supervision accounting (D-7 / D-10). `overseerCalls > 0`
593
+ // with zero usage is a provider that never reported usage - the
594
+ // report then omits the spend line rather than pricing nothing.
595
+ ...(overseerUsage !== undefined ? { overseerUsage } : {}),
596
+ ...(overseerCalls !== undefined ? { overseerCalls } : {}),
597
+ ...(overseerDegraded ? { overseerDegraded: true } : {}),
598
+ ...(overseerDegradedTicks > 0 ? { overseerDegradedTicks } : {}),
353
599
  };
354
600
  this.live = [];
355
601
  this.bus = null;
@@ -400,13 +646,20 @@ export class TeamRuntime {
400
646
  run.phase = 'starting';
401
647
  run.startedAt = Date.now();
402
648
  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.
649
+ // WITH THE SUPERVISOR GATE OPEN there is no hard wall clock on the
650
+ // child at all (D-4): the cadence ladder looks and decides, and the
651
+ // child's own (lengthened) idle watchdog is the silence backstop. The
652
+ // rung and the silence window are both armed HERE, at the attempt's
653
+ // actual start (R-P1-5) - a `queued` child has neither. WITHOUT the
654
+ // gate - `team.overseer: false`, the legacy regime - the hard timer is
655
+ // armed exactly as before, `0` meaning no ceiling. A provider that is
656
+ // wired but INACTIVE arms no timer either: the tick degrades to an
657
+ // announced unassisted wait, never a kill (R-P0-2).
407
658
  const supervised = this.overseer !== null;
408
- if (supervised)
659
+ if (supervised) {
409
660
  this.supervision?.start(run.label);
661
+ this.monitor?.arm(run.label);
662
+ }
410
663
  let timedOut = false;
411
664
  const timer = !supervised && subagentTimeoutMs > 0
412
665
  ? setTimeout(() => {
@@ -462,6 +715,7 @@ export class TeamRuntime {
462
715
  // wants diagnosed, and replace-after-death is exactly re-assignment.
463
716
  await this.maybePostMortem(ctx, index, handle, verdict);
464
717
  this.supervision?.clear(run.label);
718
+ this.monitor?.clear(run.label);
465
719
  return;
466
720
  }
467
721
  this.replaceChild(dispatchId, handles, index, makeHandle, handle, null);
@@ -534,6 +788,7 @@ export class TeamRuntime {
534
788
  const retries = (handle.run.retries ?? 0) + (verdict === null ? 1 : 0);
535
789
  const replacements = (handle.run.replacements ?? 0) + (verdict !== null ? 1 : 0);
536
790
  const interventions = handle.run.interventions;
791
+ const lastIntervention = handle.run.lastIntervention;
537
792
  handle.unsubscribe();
538
793
  try {
539
794
  handle.abort();
@@ -562,6 +817,10 @@ export class TeamRuntime {
562
817
  fresh.run.replacements = replacements;
563
818
  if (interventions !== undefined)
564
819
  fresh.run.interventions = interventions;
820
+ // The badge survives a rebuild (`replaced 5s`) - the panel row does not
821
+ // reset just because the body under it did.
822
+ if (lastIntervention !== undefined)
823
+ fresh.run.lastIntervention = lastIntervention;
565
824
  if (verdict !== null)
566
825
  this.overseer?.noteReplacement(handle.run.label);
567
826
  handles[index] = fresh;
@@ -583,12 +842,14 @@ export class TeamRuntime {
583
842
  return this.deps.humanQueue?.isWaiting(label) === true;
584
843
  }
585
844
  /**
586
- * One trigger handled: look, then apply. Fired from a supervision timer and
587
- * therefore async-and-forgotten; nothing here may throw.
845
+ * One SILENCE trigger handled: look, then apply. Fired from a supervision
846
+ * timer and therefore async-and-forgotten; nothing here may throw.
588
847
  *
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.
848
+ * A NULL DECISION HAS THREE DISTINCT QUIETS: the look budget is spent ->
849
+ * say so ONCE and stop supervising this child (D-5); an inspection of this
850
+ * child is already in flight -> re-arm and let it settle (R-P1-4); the
851
+ * tier is gone -> clear quietly, because the cadence tick - not the
852
+ * silence path - owns the degraded announcements (D-4).
592
853
  */
593
854
  async inspectAndApply(dispatchId, label, trigger) {
594
855
  const overseer = this.overseer;
@@ -609,7 +870,17 @@ export class TeamRuntime {
609
870
  childAlive: true,
610
871
  });
611
872
  if (decision === null || this.aborted) {
612
- supervision.clear(label);
873
+ if (!this.aborted && overseer.looksExhausted(label)) {
874
+ this.noteQuiet(dispatchId, label);
875
+ supervision.clear(label);
876
+ this.monitor?.clear(label);
877
+ }
878
+ else if (!this.aborted && overseer.isInFlight(label)) {
879
+ supervision.rearmSilence(label);
880
+ }
881
+ else {
882
+ supervision.clear(label);
883
+ }
613
884
  return;
614
885
  }
615
886
  // The child may have settled while the call was in flight; an
@@ -621,9 +892,10 @@ export class TeamRuntime {
621
892
  this.emit({ type: 'overseer', dispatchId, label, decision, trigger });
622
893
  if (settled) {
623
894
  supervision.clear(label);
895
+ this.monitor?.clear(label);
624
896
  return;
625
897
  }
626
- this.applyDecision(supervision, overseer, handle, label, decision);
898
+ this.applyDecision(handle, label, decision);
627
899
  }
628
900
  catch {
629
901
  // Defence in depth around an async timer callback: an unexpected throw
@@ -631,49 +903,88 @@ export class TeamRuntime {
631
903
  supervision.rearmSilence(label);
632
904
  }
633
905
  }
634
- applyDecision(supervision, overseer, handle, label, decision) {
906
+ applyDecision(handle, label, decision) {
907
+ const supervision = this.supervision;
908
+ const overseer = this.overseer;
909
+ if (supervision === null || overseer === null)
910
+ return;
635
911
  switch (decision.action) {
636
912
  case 'wait':
637
- supervision.rearmClock(label, decision.nextCheckMs ?? supervision.defaultCheckMs);
913
+ // A model-chosen interval OVERRIDES the rung and becomes it (D-2);
914
+ // without one the rung is untouched - the silence window is the only
915
+ // clock this path re-arms.
916
+ if (decision.nextCheckMs !== undefined) {
917
+ this.monitor?.override(label, decision.nextCheckMs);
918
+ }
638
919
  supervision.rearmSilence(label);
639
920
  break;
640
- case 'nudge':
921
+ case 'nudge': {
922
+ // THE NUDGE BUDGET IS CHECKED BEFORE STEERING (D-5): a fourth nudge
923
+ // is not advice, it is the supervisor refusing to decide - it waits.
924
+ if (!overseer.nudgesLeft(label)) {
925
+ supervision.rearmSilence(label);
926
+ break;
927
+ }
641
928
  // I-OV4: never steer into an abort already requested — the same
642
929
  // guard-1 fact the child's fast reviewer reads. Queued steering is
643
930
  // drained at the safe top-of-loop checkpoint by core.
644
931
  if (!handle.isAborted() && decision.guidance !== undefined) {
645
932
  handle.agent.steer(decision.guidance);
933
+ overseer.noteNudge(label);
646
934
  }
647
- supervision.rearmClock(label, supervision.defaultCheckMs);
935
+ this.monitor?.reset(label);
648
936
  supervision.rearmSilence(label);
649
937
  break;
938
+ }
650
939
  case 'replace': {
651
- // Budget checked BEFORE the verdict is set, so two near-simultaneous
940
+ // Budgets checked BEFORE the verdict is set, so two near-simultaneous
652
941
  // replace decisions cannot both rebuild (the second becomes an
653
942
  // abandon with the budget stated in the reason).
654
- handle.overseerVerdict = overseer.replacementsLeft(label)
655
- ? {
943
+ if (overseer.replacementsLeft(label)) {
944
+ handle.overseerVerdict = {
656
945
  action: 'replace',
657
946
  reason: decision.reason,
658
947
  ...(decision.guidance !== undefined ? { guidance: decision.guidance } : {}),
659
- }
660
- : { action: 'abandon', reason: `replacement budget exhausted; ${decision.reason}` };
948
+ };
949
+ }
950
+ else if (overseer.abandonsLeft(label)) {
951
+ handle.overseerVerdict = {
952
+ action: 'abandon',
953
+ reason: `replacement budget exhausted; ${decision.reason}`,
954
+ };
955
+ overseer.noteAbandon(label);
956
+ }
957
+ else {
958
+ // Both mutation budgets spent: the honest reading is wait (I-OV2).
959
+ supervision.rearmSilence(label);
960
+ this.monitor?.advance(label);
961
+ break;
962
+ }
661
963
  supervision.clear(label);
964
+ this.monitor?.clear(label);
662
965
  handle.abort();
663
966
  break;
664
967
  }
665
968
  case 'abandon':
969
+ if (!overseer.abandonsLeft(label)) {
970
+ supervision.rearmSilence(label);
971
+ this.monitor?.advance(label);
972
+ break;
973
+ }
666
974
  handle.overseerVerdict = { action: 'abandon', reason: decision.reason };
975
+ overseer.noteAbandon(label);
667
976
  supervision.clear(label);
977
+ this.monitor?.clear(label);
668
978
  handle.abort();
669
979
  break;
670
980
  }
671
981
  }
672
982
  /** Record one applied decision on the outcome, the run and the event stream. */
673
983
  recordIntervention(dispatchId, label, trigger, decision) {
984
+ const at = Date.now();
674
985
  this.interventions.push({
675
986
  label,
676
- at: Date.now(),
987
+ at,
677
988
  action: decision.action,
678
989
  reason: decision.reason,
679
990
  trigger,
@@ -681,9 +992,116 @@ export class TeamRuntime {
681
992
  const handle = this.live.find((h) => h.run.label === label);
682
993
  if (handle !== undefined) {
683
994
  handle.run.interventions = (handle.run.interventions ?? 0) + 1;
995
+ // The panel's badge (AC-5): ABSOLUTE `at`, same units as `startedAt`
996
+ // (R-P2-3), so rendering is one subtraction.
997
+ handle.run.lastIntervention = {
998
+ action: decision.action,
999
+ at,
1000
+ reasonHead: decision.reason.slice(0, 60),
1001
+ };
684
1002
  this.publish(dispatchId, handle.run);
685
1003
  }
686
1004
  }
1005
+ // -------------------------------------------------------------------------
1006
+ // The cadence loop's callbacks (subagent-overseer-v2 D-2 / D-3 / D-4)
1007
+ // -------------------------------------------------------------------------
1008
+ /** R-P1-5: a label ticks only once STARTED and not yet settled. */
1009
+ isMonitorLive(label) {
1010
+ const handle = this.live.find((h) => h.run.label === label);
1011
+ if (handle === undefined || handle.run.startedAt === undefined)
1012
+ return false;
1013
+ const phase = handle.run.phase;
1014
+ return phase !== 'done' && phase !== 'failed' && phase !== 'aborted';
1015
+ }
1016
+ /**
1017
+ * One ASSISTED cadence tick: build the batch over the due labels, apply
1018
+ * every returned decision through the same `applyDecision` the silence
1019
+ * path uses (D-3's single application path), and hand the map back so the
1020
+ * loop can move the ladders. Labels the batch skipped (single-flight,
1021
+ * R-P1-4) are absent from the map.
1022
+ */
1023
+ async monitorInspectBatch(dispatchId, labels) {
1024
+ const overseer = this.overseer;
1025
+ if (overseer === null || this.aborted)
1026
+ return new Map();
1027
+ const reqs = [];
1028
+ for (const label of labels) {
1029
+ const handle = this.live.find((h) => h.run.label === label);
1030
+ if (handle === undefined)
1031
+ continue;
1032
+ reqs.push({
1033
+ run: handle.run,
1034
+ messages: handle.agent.state.messages,
1035
+ trigger: 'clock',
1036
+ childAlive: true,
1037
+ });
1038
+ }
1039
+ const decisions = await overseer.inspectBatch(reqs);
1040
+ for (const [label, decision] of decisions) {
1041
+ if (this.aborted)
1042
+ break;
1043
+ const handle = this.live.find((h) => h.run.label === label);
1044
+ const settled = handle === undefined ||
1045
+ handle.run.phase === 'done' ||
1046
+ handle.run.phase === 'failed' ||
1047
+ handle.run.phase === 'aborted';
1048
+ this.recordIntervention(dispatchId, label, 'clock', decision);
1049
+ this.emit({ type: 'overseer', dispatchId, label, decision, trigger: 'clock' });
1050
+ if (settled) {
1051
+ this.monitor?.clear(label);
1052
+ continue;
1053
+ }
1054
+ this.applyDecision(handle, label, decision);
1055
+ }
1056
+ return decisions;
1057
+ }
1058
+ /**
1059
+ * One DEGRADED cadence tick (D-4 / D-10): the fast tier is missing right
1060
+ * now. The children wait unassisted - announced per child through the same
1061
+ * event shape, never killed - and the fact is edge-latched here, NOT in
1062
+ * the interventions ledger.
1063
+ */
1064
+ noteDegradedTick(dispatchId, labels) {
1065
+ this.overseerDegraded = true;
1066
+ this.overseerDegradedTicks += 1;
1067
+ for (const label of labels) {
1068
+ this.emit({
1069
+ type: 'overseer',
1070
+ dispatchId,
1071
+ label,
1072
+ decision: DEGRADED_WAIT_DECISION,
1073
+ trigger: 'clock',
1074
+ });
1075
+ }
1076
+ }
1077
+ /** The supervisor goes quiet on one child, ONCE (D-5 / D-10). */
1078
+ noteQuiet(dispatchId, label) {
1079
+ if (this.quietNotified.has(label))
1080
+ return;
1081
+ this.quietNotified.add(label);
1082
+ const decision = {
1083
+ action: 'wait',
1084
+ reason: 'look budget exhausted; supervision quiet for this child',
1085
+ };
1086
+ this.recordIntervention(dispatchId, label, 'quiet', decision);
1087
+ this.emit({ type: 'overseer', dispatchId, label, decision, trigger: 'quiet' });
1088
+ }
1089
+ /**
1090
+ * The dispatch-wide fool-proof total is spent (R-P1-6): stop the whole
1091
+ * loop and emit ONE aggregate notice under a pseudo-label that is never a
1092
+ * child slug.
1093
+ */
1094
+ noteDispatchExhausted(dispatchId) {
1095
+ this.monitor?.stop();
1096
+ this.recordIntervention(dispatchId, MONITOR_TEAM_LABEL, 'quiet', MONITOR_EXHAUSTED_DECISION);
1097
+ this.emit({
1098
+ type: 'overseer',
1099
+ dispatchId,
1100
+ label: MONITOR_TEAM_LABEL,
1101
+ decision: MONITOR_EXHAUSTED_DECISION,
1102
+ trigger: 'quiet',
1103
+ });
1104
+ }
687
1105
  /**
688
1106
  * The final phase of an attempt, in a FIXED ORDER: aborted, then timed out,
689
1107
  * then error, then no-summary, then done.
@@ -755,6 +1173,10 @@ export class TeamRuntime {
755
1173
  }
756
1174
  this.bus?.cancelAllWaits();
757
1175
  this.supervision?.clearAll();
1176
+ // The cadence loop too: every child is being aborted, so no tick has
1177
+ // anything left to look at (an in-flight batch's decisions are dropped
1178
+ // by the aborted guard in `monitorInspectBatch`).
1179
+ this.monitor?.stop();
758
1180
  // Every in-flight retry backoff, not just the last one registered: a
759
1181
  // provider-wide failure puts up to `maxConcurrent` children here at once.
760
1182
  for (const release of [...this.pendingBackoffs])
@@ -766,6 +1188,8 @@ export class TeamRuntime {
766
1188
  if (this.dispatchTimer)
767
1189
  clearTimeout(this.dispatchTimer);
768
1190
  this.dispatchTimer = null;
1191
+ this.monitor?.dispose();
1192
+ this.monitor = null;
769
1193
  this.supervision?.clearAll();
770
1194
  this.supervision = null;
771
1195
  this.overseer = null;