@dorokuma/herdsman-pi 0.14.2 → 0.14.3

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +638 -340
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  import {
24
24
  projectAgentOutcomes,
25
25
  formatAgentOutcomeUpdates,
26
+ type AgentOutcome,
26
27
  WAKE_SETTLE_MS,
27
28
  } from "./wake.js";
28
29
  import { loadWakeFilterConfig } from "./wake-filter-config.js";
@@ -121,30 +122,20 @@ type HerdsmanState = {
121
122
  */
122
123
  unackedDelivered: Map<number, AgentEventWireRecord>;
123
124
  wakeDeferredUntilSettled: boolean;
124
- /** Wall-clock start of the current bounded wake deferral, if any. */
125
- wakeDeferredSince: number | undefined;
126
125
  /**
127
- * Set once the hard deferral budget elapsed: the next pass injects the batch
128
- * from the current state instead of deferring again.
129
- */
130
- wakeForcedRelease: boolean;
131
- /**
132
- * Event ids handed to Pi as a *queued* (non-triggering) follow-up whose
133
- * content has not been seen entering the transcript yet.
126
+ * Event ids handed to Pi as a follow-up whose content has not been seen
127
+ * entering the transcript yet.
134
128
  *
135
- * A wake injected while the orchestrator streams is parked in the agent's
136
- * follow-up queue, and Pi only drains that queue when a run reaches its stop
137
- * point. When the run it rode on already passed that point, the update sits in
138
- * the queue until the next user message. This set is what keeps such a
139
- * delivery from being written off, and it carries three guarantees at once:
129
+ * A wake is only ever injected into an idle orchestrator, and it always
130
+ * triggers its own turn, so the copy Pi holds is a normal turning message
131
+ * rather than a queued follow-up nothing may drain. This set is what keeps such
132
+ * an in-flight delivery out of both the acknowledgement path and the
133
+ * transcript, and it carries three guarantees at once:
140
134
  *
141
- * - never lost: while it is non-empty the settlement drives a continuation
142
- * (bounded by MAX_WAKE_CONTINUATION_ATTEMPTS drives) so a later run drains
143
- * the queue and carries the update out. The bound is a real ceiling, not a
144
- * suggestion: once it is spent the ids are released from here and
145
- * acknowledged (`writeOffStrandedWakeDelivery`), because an update that no
146
- * turn will ever carry out must not keep a run loop — and the daemon's
147
- * redelivery of it — alive forever;
135
+ * - never lost: while it is non-empty the next settlement leaves the id
136
+ * pending, so the next `scheduleWake` re-presents it (with the real update
137
+ * content, never with an empty marker run) instead of parking it in a
138
+ * follow-up queue nothing drains;
148
139
  * - never acknowledged unseen: these ids are excluded from the acknowledgement
149
140
  * path, so the daemon keeps them pending and redelivers them when this
150
141
  * session never consumes them (the only way a delivery Pi itself dropped —
@@ -157,7 +148,10 @@ type HerdsmanState = {
157
148
  * evidence: the hidden wake message's `message_end`, which then moves them to
158
149
  * `wakeConsumptionObserved`) or when the event leaves the delivery queue for
159
150
  * good (acknowledged, covered by the acknowledgement watermark,
160
- * dead-lettered), and with the delivery queue on a role/scope reset.
151
+ * dead-lettered), and with the delivery queue on a role/scope reset. A
152
+ * settlement that saw no evidence dead-letters them: it lifts the injection
153
+ * barrier (via `wakeRetryableEventIds`) but not the watermark one, because the
154
+ * content was never confirmed.
161
155
  */
162
156
  wakeAwaitingConsumption: Set<number>;
163
157
  /**
@@ -205,24 +199,45 @@ type HerdsmanState = {
205
199
  */
206
200
  wakeSkipLogReasons: Map<number, string>;
207
201
  /**
208
- * Continuation drives already spent, keyed by event id.
202
+ * Ids whose delivery was dead-lettered and which a later wake must be allowed
203
+ * to present again.
209
204
  *
210
- * The bound is per event, never per delivery and never per session: an
211
- * unrelated event being injected must not hand a stuck one a fresh budget. A
212
- * session that keeps receiving new updates used to reset a shared counter at
213
- * every injection, so the one id no run ever drained drove on unboundedly and
214
- * `MAX_WAKE_CONTINUATION_ATTEMPTS` never fired — the loop that flooded the
215
- * orchestrator session with empty turns.
205
+ * `wakeAwaitingConsumption` deliberately keeps holding these ids: unconfirmed
206
+ * content must still block the acknowledgement watermark, because the daemon
207
+ * confirms by `where id <= ?` and letting a larger id through would swallow the
208
+ * dead-lettered one for good. What the write-off changes is only the *other*
209
+ * role of that set: an id that is still awaiting consumption is also barred
210
+ * from every later injection, and a dead-lettered one has to be presented
211
+ * again — it never reached the transcript, so a redelivery is the only remedy.
216
212
  *
217
- * `MAX_WAKE_CONTINUATION_ATTEMPTS` is therefore a hard ceiling per id: the
218
- * drive that reaches it writes that id off
219
- * (`writeOffStrandedWakeDelivery`), and an id that still has drives left keeps
220
- * both its copy and its count even when a sibling is written off next to it.
213
+ * An id leaves the set as soon as a wake hands it to Pi again (it is in flight
214
+ * once more, so the ordinary `awaiting` guard applies), when the daemon
215
+ * confirms it, or on a role/scope reset.
216
+ *
217
+ * Membership here does *not* by itself authorise a re-presentation: an
218
+ * unconditional pass turned every write-off into an immediate re-injection on
219
+ * the settlement that produced it, and an injection that fails the same way
220
+ * dead-letters the id again — an unbounded turn loop with no backoff and no
221
+ * limit. `representableWakeOutcomes` gates that instead (a wake carried by
222
+ * fresh content, or an explicit backoff with a bounded attempt budget).
223
+ */
224
+ wakeRetryableEventIds: Set<number>;
225
+ /**
226
+ * Automatic re-presentations already spent per dead-lettered event id.
221
227
  *
222
- * An entry leaves the map with the id itself: acknowledged, dead-lettered,
223
- * consumed (its copy reached the transcript), or a role/scope reset.
228
+ * Counted here rather than on the wire record because the budget is a
229
+ * property of *this* client's retry policy: the daemon's own `attempts`
230
+ * counter tracks acknowledgement failures, which are a different thing.
231
+ */
232
+ wakeDeadLetterAttempts: Map<number, number>;
233
+ /** Earliest timestamp at which an id's next automatic retry may fire. */
234
+ wakeDeadLetterRetryAt: Map<number, number>;
235
+ /**
236
+ * Consecutive ticks spent waiting for the orchestrator, i.e. the position in
237
+ * `WAKE_BUSY_BACKOFF_MS`. Reset when the orchestrator settles or the wake is
238
+ * cancelled, so a new busy period starts at the first rung again.
224
239
  */
225
- wakeContinuationDrives: Map<number, number>;
240
+ wakeBusySpinRung: number;
226
241
  wakeRequested: boolean;
227
242
  wakeRequestedThroughEventId: number;
228
243
  wakeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -279,53 +294,93 @@ const RECONNECTING_MESSAGE = "Herdsman is reconnecting · try again shortly";
279
294
  export const MAX_ACK_ATTEMPTS = 5;
280
295
  export const ACK_BACKOFF_CAP_MS = 30_000;
281
296
  const KEEPALIVE_INTERVAL_MS = 30_000;
282
- /** Retry interval used while a wake cannot be injected (busy orchestrator). */
297
+ /**
298
+ * First tick of the busy poll ladder, and the first rung of
299
+ * `WAKE_BUSY_BACKOFF_MS`.
300
+ *
301
+ * Kept as its own export for callers/tests that only care about the latency of
302
+ * the first deferral; everything else about the wait is in the ladder.
303
+ */
283
304
  export const WAKE_BUSY_SPIN_MS = 100;
284
305
  /**
285
- * Hard upper bound for every deferred wake. Once it elapses the scheduler stops
286
- * waiting for the orchestrator to become idle or for a settlement to arrive and
287
- * forces the injection decision from the current state, so a wake can never be
288
- * parked forever.
306
+ * Ceiling the busy poll ladder settles on for an arbitrarily long turn.
307
+ *
308
+ * The ladder does not simply stop at its last rung: `scheduleDeferredWake`
309
+ * clamps every tick to this value (the same shape `ackBackoffMs` uses for
310
+ * `ACK_BACKOFF_CAP_MS`), so raising a rung above the ceiling cannot silently
311
+ * raise the real cap. The ladder's top rung is this constant, which keeps the
312
+ * exported value, the comment, the decision note and the timed behaviour one
313
+ * and the same number.
289
314
  */
290
- export const WAKE_DEFERRED_TIMEOUT_MS = 5_000;
315
+ export const WAKE_BUSY_BACKOFF_CAP_MS = 5_000;
291
316
  /**
292
- * Upper bound on the continuation drives spent on **one** unconsumed wake
293
- * event id.
317
+ * Poll-frequency ladder while a wake waits for the orchestrator (milliseconds).
294
318
  *
295
- * Every drive costs a full agent run, so the budget is deliberately small: three
296
- * attempts already cover the drive that follows the settlement which missed Pi's
297
- * follow-up queue plus two retries after intervening runs that also ended before
298
- * their stop point, and five adds margin for a run that spent its stop point on
299
- * a tool call. Beyond that the cause is systemic — the runs keep ending early, so
300
- * starting another one could only produce another empty turn — and that id is
301
- * written off instead (`writeOffStrandedWakeDelivery`): it leaves
302
- * `wakeAwaitingConsumption` and is acknowledged, which stops both the
303
- * continuation loop and the daemon's redelivery of the same event.
319
+ * Delivery does not branch on busy versus idle: every wake is queued and is
320
+ * injected once the orchestrator has settled (or a poll observes it idle), and
321
+ * a busy one cannot be interrupted anyway. `agent_settled` is therefore the
322
+ * primary signal that closes a deferral — it fires exactly when the turn ends
323
+ * and re-takes the wake decision — so the liveness bound is "until
324
+ * `agent_settled`, or until one of these ticks observes an idle orchestrator",
325
+ * never a deadline. The ladder exists to make that wait cheap, and it is a
326
+ * poll-frequency reduction, not a safety net: there is no deadline to miss. It
327
+ * used to tick at a flat 100ms forever, which made the extension re-evaluate
328
+ * the same deferral ten times a second for the whole turn and burn the turn's
329
+ * wall clock without ever being able to inject anything.
304
330
  *
305
- * The bound is per event id (`wakeContinuationDrives`) and is never reset by a
306
- * later injection: a session that keeps receiving new updates must not hand an
307
- * already-stuck id a fresh ceiling, or the loop it was meant to stop continues
308
- * for exactly the events that are stuck.
331
+ * Growing the gap keeps the correctness contract (nothing is injected until
332
+ * the orchestrator is idle, however long that takes) while making the poll
333
+ * cheap: the tick count per turn drops from ~10/s to at most ~2 in the first
334
+ * second and then ~1/s. The last rung is `WAKE_BUSY_BACKOFF_CAP_MS`, so a turn
335
+ * that outlives the ladder is re-evaluated at most once every 5s.
336
+ */
337
+ export const WAKE_BUSY_BACKOFF_MS = [
338
+ WAKE_BUSY_SPIN_MS,
339
+ 200,
340
+ 500,
341
+ 1_000,
342
+ 2_000,
343
+ WAKE_BUSY_BACKOFF_CAP_MS,
344
+ ] as const;
345
+ /**
346
+ * How many times a dead-lettered wake event may be re-presented automatically
347
+ * before the client stops driving turns for it.
309
348
  *
310
- * The trade-off is deliberate and asymmetric: losing one agent update is
311
- * recoverable (the agent is still there and its transcript can be read
312
- * directly), while an unbounded continuation loop floods the orchestrator
313
- * session with empty turns and makes it unusable. A missed update is therefore
314
- * strictly better than an endless one.
349
+ * Two, because a dead-letter means the content never reached the transcript and
350
+ * the usual causes (a turn that ended before its stop point, an abort, a
351
+ * message Pi dropped) leave no evidence that an immediate retry helps: one
352
+ * extra attempt covers a genuinely transient hiccup, the second covers a
353
+ * hiccup that also broke the first retry, and a third would only turn the
354
+ * retry into the unbounded turn-churn this cap exists to prevent. Each attempt
355
+ * is separated by `WAKE_DEAD_LETTER_RETRY_BACKOFF_MS`, so the bounded worst case
356
+ * is ~20s of automatic effort; after that the pending update is the human's
357
+ * (the write-off notify already says so), and the id stays unacked as the
358
+ * watermark barrier until a new external event merges it into a wake.
315
359
  */
316
- export const MAX_WAKE_CONTINUATION_ATTEMPTS = 5;
317
- /** `customType` of the hidden wake context this extension injects. */
318
- const WAKE_CONTEXT_CUSTOM_TYPE = "herdsman-wake-context";
319
- /** `customType` of the hidden marker that drives a missed wake continuation. */
320
- const WAKE_CONTINUATION_CUSTOM_TYPE = "herdsman-wake-continuation";
360
+ export const WAKE_DEAD_LETTER_RETRY_LIMIT = 2;
361
+ /**
362
+ * Delay before the n-th automatic re-presentation of a dead-lettered event
363
+ * (milliseconds), i.e. `[0] === 5_000` for the first retry, `[1] === 15_000` for
364
+ * the second.
365
+ *
366
+ * Deliberately far from the 100ms busy tick: the retry must not read as a spin,
367
+ * and the settlement that produced the dead-letter has just finished, so an
368
+ * immediate re-injection would re-run exactly the turn that just failed.
369
+ */
370
+ const WAKE_DEAD_LETTER_RETRY_BACKOFF_MS = [5_000, 15_000] as const;
321
371
  /**
322
- * Content of the continuation marker. Its only job is to start a run
323
- * (`triggerTurn: true`) so the run's loop drains the queued follow-up that was
324
- * never delivered; the wake content itself is not repeated here, so the
325
- * evidence is not presented twice.
372
+ * Window after which an unchanged watermark hold-back is noted again
373
+ * (milliseconds).
374
+ *
375
+ * The hold-back note is deduplicated on its exact state (blocked id, reason,
376
+ * count), so a stable stall is logged once when it starts; this window is what
377
+ * keeps a stall that outlives that first line visible instead of silently
378
+ * freezing the queue forever, while still capping the noise at one line per
379
+ * minute per state.
326
380
  */
327
- const WAKE_CONTINUATION_CONTENT =
328
- "[HERDSMAN WAKE CONTINUATION]\nA queued Herdsman agent update was not delivered by the previous turn; it follows this message. Handle it, and do not start unrelated work.";
381
+ const WAKE_BARRIER_NOTE_INTERVAL_MS = 60_000;
382
+ /** `customType` of the hidden wake context this extension injects. */
383
+ const WAKE_CONTEXT_CUSTOM_TYPE = "herdsman-wake-context";
329
384
 
330
385
  type AckFailureClass = "terminal" | "resync" | "transient";
331
386
 
@@ -409,13 +464,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
409
464
  subscriberId: undefined,
410
465
  unackedDelivered: new Map(),
411
466
  wakeDeferredUntilSettled: false,
412
- wakeDeferredSince: undefined,
413
- wakeForcedRelease: false,
414
467
  wakeAwaitingConsumption: new Set(),
415
468
  wakeConsumptionObserved: new Set(),
416
469
  wakeSuppressedEventIds: new Set(),
417
470
  wakeSkipLogReasons: new Map(),
418
- wakeContinuationDrives: new Map(),
471
+ wakeRetryableEventIds: new Set(),
472
+ wakeDeadLetterAttempts: new Map(),
473
+ wakeDeadLetterRetryAt: new Map(),
474
+ wakeBusySpinRung: 0,
419
475
  wakeRequested: false,
420
476
  wakeRequestedThroughEventId: 0,
421
477
  wakeTimer: undefined,
@@ -425,6 +481,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
425
481
  let activeContext: PiContext | undefined;
426
482
  let keepaliveTimer: ReturnType<typeof setInterval> | undefined;
427
483
  let wakeGeneration = 0;
484
+ // Throttle state for the watermark hold-back note (see
485
+ // `noteWakeWatermarkHoldBack`): the last noted barrier state and when it was
486
+ // noted, so a stable stall notes itself once and a long one re-notes itself
487
+ // per window instead of flooding the log.
488
+ let lastWakeBarrierNote: { at: number; key: string } | undefined;
428
489
 
429
490
  const stopKeepalive = () => {
430
491
  if (!keepaliveTimer) return;
@@ -442,15 +503,24 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
442
503
 
443
504
  const setHerdsmanUi = (ctx: PiContext | undefined) => {
444
505
  if (!ctx) return;
506
+ const pendingOutcomeCount = projectAgentOutcomes(state.pendingEvents, wakeFilter).outcomes.length;
445
507
  const footerState: HerdsmanFooterState = state.reconnectingFromOn
446
508
  ? { kind: "reconnecting" }
447
509
  : state.isOrchestrator
448
- ? {
449
- kind: "on",
450
- updateCount: projectAgentOutcomes(state.pendingEvents, wakeFilter).outcomes.length,
451
- }
510
+ ? { kind: "on", updateCount: pendingOutcomeCount }
452
511
  : { kind: "off" };
453
512
  ctx.ui.setStatus?.("herdsman", formatHerdsmanFooterStatus(footerState));
513
+ // A held-back wake is otherwise invisible during a long turn: updates are
514
+ // pending and nothing is injected, so the count alone reads as "Herdsman
515
+ // has work queued" instead of "the orchestrator is busy and this is what
516
+ // it is waiting for". The hint gets its own line (the pending count stays a
517
+ // count) and disappears the moment the wake is released or the queue drains.
518
+ ctx.ui.setStatus?.(
519
+ "herdsman-wake",
520
+ state.isOrchestrator && state.wakeDeferredUntilSettled && pendingOutcomeCount > 0
521
+ ? "Herdsman · waiting for the current turn to end"
522
+ : undefined,
523
+ );
454
524
  };
455
525
 
456
526
  const cancelWakeTimer = () => {
@@ -458,8 +528,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
458
528
  if (state.wakeTimer) clearTimeout(state.wakeTimer);
459
529
  state.wakeTimer = undefined;
460
530
  state.wakeDeferredUntilSettled = false;
461
- state.wakeDeferredSince = undefined;
462
- state.wakeForcedRelease = false;
531
+ state.wakeBusySpinRung = 0;
463
532
  };
464
533
 
465
534
  const cancelWake = () => {
@@ -512,15 +581,18 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
512
581
  * (and holding its batch open) for content that can never arrive. The other
513
582
  * two sets describe the same ids and follow the queue just as well: a
514
583
  * confirmed id needs no evidence flag, and an id that is gone from the queue
515
- * cannot be re-injected, so it needs no suppression either.
584
+ * cannot be re-injected, so it needs no suppression either. The dead-letter
585
+ * retry bookkeeping follows for the same reason: a confirmed event needs no
586
+ * backoff and no attempt budget.
516
587
  */
517
588
  const dropUnackedDelivered = (eventId: number): void => {
518
589
  state.unackedDelivered.delete(eventId);
519
590
  state.wakeAwaitingConsumption.delete(eventId);
520
591
  state.wakeConsumptionObserved.delete(eventId);
521
592
  state.wakeSuppressedEventIds.delete(eventId);
522
- // The id can no longer owe a drive, so its own budget leaves with it.
523
- state.wakeContinuationDrives.delete(eventId);
593
+ state.wakeRetryableEventIds.delete(eventId);
594
+ state.wakeDeadLetterAttempts.delete(eventId);
595
+ state.wakeDeadLetterRetryAt.delete(eventId);
524
596
  };
525
597
 
526
598
  /**
@@ -560,7 +632,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
560
632
  state.wakeAwaitingConsumption.clear();
561
633
  state.wakeConsumptionObserved.clear();
562
634
  state.wakeSkipLogReasons.clear();
563
- state.wakeContinuationDrives.clear();
635
+ state.wakeRetryableEventIds.clear();
636
+ // The automatic retry budget is a property of the id inside this scope: a
637
+ // scope that never held the id cannot owe it a retry, and keeping the
638
+ // counter would silently make the next scope's first write-off final.
639
+ state.wakeDeadLetterAttempts.clear();
640
+ state.wakeDeadLetterRetryAt.clear();
641
+ state.wakeBusySpinRung = 0;
564
642
  };
565
643
 
566
644
  const pruneAcknowledgedEvents = (ackedEventId: number | undefined) => {
@@ -590,6 +668,30 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
590
668
  for (const eventId of [...state.wakeSkipLogReasons.keys()]) {
591
669
  if (eventId <= ackedEventId) state.wakeSkipLogReasons.delete(eventId);
592
670
  }
671
+ // A confirmed event is neither re-presentable nor owed a retry, so the three
672
+ // fields that describe a dead letter for it are swept together, here and in
673
+ // `dropUnackedDelivered`: the flag (may a wake present it again?), the spent
674
+ // attempt counter (does it still owe automatic retries?) and the retry
675
+ // deadline (when is the next one due?). Keeping any of them for an id the
676
+ // daemon has confirmed would let a later redelivery treat a settled event as
677
+ // an unfinished one — `alreadyPresented` would wave the id through and
678
+ // `deadLetterRetryDue` would hand it a budget it no longer needs.
679
+ //
680
+ // Ids still in the delivery queue are swept by the queue loop above (which
681
+ // routes through `dropUnackedDelivered`); this loop is what keeps the same
682
+ // three fields consistent for an id the queue no longer holds.
683
+ for (const eventId of [...state.wakeRetryableEventIds]) {
684
+ if (eventId <= ackedEventId) state.wakeRetryableEventIds.delete(eventId);
685
+ }
686
+ // A confirmed event owes no retry, so the dead-letter bookkeeping for it is
687
+ // garbage from here on: the counter would otherwise make the id's next
688
+ // write-off (after a reset) start from a spent budget.
689
+ for (const eventId of [...state.wakeDeadLetterAttempts.keys()]) {
690
+ if (eventId <= ackedEventId) {
691
+ state.wakeDeadLetterAttempts.delete(eventId);
692
+ state.wakeDeadLetterRetryAt.delete(eventId);
693
+ }
694
+ }
593
695
  };
594
696
 
595
697
  /**
@@ -619,9 +721,9 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
619
721
  * Whether an event was already handed to the orchestrator in this session and
620
722
  * therefore must not be injected again (logging why, at most once per reason).
621
723
  *
622
- * - `awaiting`: a copy of it was handed over as a queued follow-up that no run
623
- * has drained yet, and the continuation is what carries it out (checked
624
- * first: for a queued copy this is the state that explains the redelivery);
724
+ * - `awaiting`: a copy of it was handed to Pi and has not been seen in the
725
+ * transcript yet (a dead-lettered one is re-presentable instead, see
726
+ * `wakeRetryableEventIds`, and is therefore checked first);
625
727
  * - `presented`: it was injected before (or its content has since been observed
626
728
  * in the transcript), so a redelivery would duplicate it;
627
729
  * - `suppressed`: its copy may still sit in Pi's process-wide follow-up queue
@@ -629,6 +731,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
629
731
  * only one that must not be shown (see `wakeSuppressedEventIds`).
630
732
  */
631
733
  const alreadyPresented = (eventId: number): boolean => {
734
+ if (state.wakeRetryableEventIds.has(eventId)) {
735
+ // Its delivery was dead-lettered, so the content never reached the
736
+ // transcript: a redelivery is the only remedy and must not be skipped.
737
+ // *When* that redelivery may happen is a separate question, answered by
738
+ // `representableWakeOutcomes`; this predicate only says "no copy of this
739
+ // id is rightfully in the transcript".
740
+ return false;
741
+ }
632
742
  if (state.wakeAwaitingConsumption.has(eventId)) {
633
743
  noteSkippedWakeInjection(eventId, "awaiting");
634
744
  return true;
@@ -644,6 +754,92 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
644
754
  return false;
645
755
  };
646
756
 
757
+ /**
758
+ * Backoff before the n-th automatic re-presentation of a dead-lettered id.
759
+ *
760
+ * The deadline and the budget are read together, so this maps "attempts
761
+ * already spent" to "how long this id must now stay quiet": 0 spent → 5s,
762
+ * 1 spent → 15s, anything beyond the budget is unreachable (the caller stops
763
+ * asking once `WAKE_DEAD_LETTER_RETRY_LIMIT` is reached).
764
+ */
765
+ const deadLetterRetryBackoffMs = (attempts: number): number => {
766
+ const ladder = WAKE_DEAD_LETTER_RETRY_BACKOFF_MS;
767
+ const rung = Math.min(Math.max(attempts, 0), ladder.length - 1);
768
+ // `noUncheckedIndexedAccess` types every index access as optional; `rung` is
769
+ // clamped into a non-empty ladder, so the fallback is unreachable.
770
+ return ladder[rung] ?? ladder[0];
771
+ };
772
+
773
+ /**
774
+ * Whether a dead-lettered id's automatic retry is due and still budgeted.
775
+ *
776
+ * This answers the question "may this id drive a turn of its own right
777
+ * now?", which is deliberately much narrower than "may it be presented
778
+ * again": a dead-lettered id may also ride a wake that fresh content leads
779
+ * (see `representableWakeOutcomes`), and that ride costs it nothing.
780
+ *
781
+ * The deadline is set when the id is written off (`writeOffStrandedWakeDelivery`)
782
+ * and the budget is `WAKE_DEAD_LETTER_RETRY_LIMIT`, so standing on its own
783
+ * means: not before the backoff has elapsed, and not after the last attempt
784
+ * has been spent. Once it is spent the id stops driving turns entirely — it
785
+ * stays in `unackedDelivered` as the watermark barrier and in the pending
786
+ * projection with its human-visible warning, and only fresh content can
787
+ * still carry it.
788
+ */
789
+ const deadLetterRetryDue = (eventId: number): boolean => {
790
+ if (!state.wakeRetryableEventIds.has(eventId)) return false;
791
+ if ((state.wakeDeadLetterAttempts.get(eventId) ?? 0) >= WAKE_DEAD_LETTER_RETRY_LIMIT) {
792
+ return false;
793
+ }
794
+ return (state.wakeDeadLetterRetryAt.get(eventId) ?? 0) <= Date.now();
795
+ };
796
+
797
+ /** Earliest moment a still-budgeted id's automatic retry may fire. */
798
+ const deadLetterRetryAt = (eventId: number): number | undefined => {
799
+ if (!state.wakeRetryableEventIds.has(eventId)) return undefined;
800
+ if ((state.wakeDeadLetterAttempts.get(eventId) ?? 0) >= WAKE_DEAD_LETTER_RETRY_LIMIT) {
801
+ return undefined;
802
+ }
803
+ return state.wakeDeadLetterRetryAt.get(eventId);
804
+ };
805
+
806
+ /**
807
+ * The outcomes a wake may present: everything presentable that is not
808
+ * dead-lettered, plus the dead-lettered ids that may join it.
809
+ *
810
+ * A dead-letter used to be an unconditional pass through `alreadyPresented`,
811
+ * and because the settlement that produced it immediately scheduled the next
812
+ * wake, that turned every failed wake turn into the next wake turn: an
813
+ * unbounded loop with no backoff and no ceiling. The remedy is to separate
814
+ * the two ways an id can come back:
815
+ *
816
+ * - *rides*: the wake is led by content that is not a dead letter — an id this
817
+ * session has not handed over yet, or a genuinely new external event. That
818
+ * turn belongs to the fresh content, and merging the dead-lettered id into
819
+ * it is exactly the recovery it needs (the batch merge invariant), so it
820
+ * costs none of the id's retry budget;
821
+ * - *retries*: no fresh content is left, and the id waits for its explicit
822
+ * backoff (`deadLetterRetryDue`) and then spends one automatic attempt.
823
+ *
824
+ * Everything else leaves the id alone: the settlement that wrote it off, a
825
+ * daemon redelivery of the same id, an acknowledgement sweep, a reconnect,
826
+ * or an exhausted budget. The id is not lost in that case — it keeps blocking
827
+ * the watermark, keeps its pending row and its human-visible warning, and is
828
+ * still picked up by the next wake that fresh content leads.
829
+ */
830
+ const representableWakeOutcomes = (
831
+ candidates: readonly AgentOutcome[],
832
+ ): { lead: readonly AgentOutcome[]; outcomes: readonly AgentOutcome[] } => {
833
+ const lead = candidates.filter(
834
+ (outcome) => !state.wakeRetryableEventIds.has(outcome.eventId),
835
+ );
836
+ if (lead.length > 0) return { lead, outcomes: candidates };
837
+ return {
838
+ lead,
839
+ outcomes: candidates.filter((outcome) => deadLetterRetryDue(outcome.eventId)),
840
+ };
841
+ };
842
+
647
843
  const isWakeableEvent = (event: AgentEventWireRecord | undefined) =>
648
844
  !event?.nextAttemptAt || event.nextAttemptAt <= Date.now();
649
845
 
@@ -846,30 +1042,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
846
1042
  }, WAKE_SETTLE_MS);
847
1043
  };
848
1044
 
849
- /**
850
- * Ends the current wake-deferral episode without injecting.
851
- *
852
- * `wakeForcedRelease` and `wakeDeferredSince` describe *one* bounded
853
- * deferral: the released deadline is re-derived from `wakeDeferredSince`
854
- * every time `scheduleDeferredWake` runs, so a stale pair would let an
855
- * unrelated later wake bypass the busy gate. Every path that ends a wake
856
- * pass without injecting clears both, which gives the next deferral its own
857
- * full `WAKE_DEFERRED_TIMEOUT_MS` budget. The 5s hard deadline itself is
858
- * unchanged: it is measured inside a single episode, and an episode that
859
- * reaches it still force-releases the batch on its next pass.
860
- */
861
- const endWakeDeferral = () => {
862
- state.wakeForcedRelease = false;
863
- state.wakeDeferredSince = undefined;
864
- };
865
-
866
1045
  /**
867
1046
  * Event ids a hidden wake message proves to have reached the transcript.
868
1047
  *
869
1048
  * A wake injection names the outcomes it presents in
870
1049
  * `details.presentedEventIds`, and Pi writes those details onto the session
871
- * entry it emits on `message_end` (both for a triggered turn and when a run
872
- * finally drains the queued follow-up). That emission is the only consumption
1050
+ * entry it emits on `message_end`. That emission is the only consumption
873
1051
  * evidence there is: nothing else tells us that the content — not merely the
874
1052
  * request that carried it — reached the orchestrator.
875
1053
  *
@@ -909,6 +1087,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
909
1087
  * blocks the prefix: leaving it unacknowledged keeps it pending, which is what
910
1088
  * lets the daemon redeliver the one copy that never arrived.
911
1089
  *
1090
+ * An id awaiting consumption is the in-flight state of a delivery that has been
1091
+ * handed to Pi but whose `message_end` has not been seen yet. It blocks the
1092
+ * prefix, and the settlement that still finds it there dead-letters it
1093
+ * (`writeOffStrandedWakeDelivery`): the content never reached the transcript, so
1094
+ * it must stay pending in the daemon and be eligible for the next wake instead
1095
+ * of being confirmed by a heuristic.
1096
+ *
912
1097
  * An event whose own acknowledgement already failed is left out (the daemon's
913
1098
  * cursor advance sweeps it, and a retry would reset its attempt/backoff
914
1099
  * accounting), but it must not hold the prefix back: it stays in the queue until
@@ -927,7 +1112,19 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
927
1112
  const confirmablePrefix: AgentEventWireRecord[] = [];
928
1113
  let blockedByMissingTurn = false;
929
1114
  for (const event of deliveryQueue) {
930
- if (state.wakeAwaitingConsumption.has(event.id)) break;
1115
+ // An id that was handed to Pi and whose evidence has not arrived yet
1116
+ // blocks the prefix: it has no evidence at all, and the settlement still
1117
+ // owes it either that evidence (a run drains the copy) or a dead-letter.
1118
+ // A dead-lettered id keeps blocking it for the same reason — unconfirmed
1119
+ // content must never be swept by the watermark — while an id that is not
1120
+ // awaiting consumption and not observed otherwise looks like a delivered,
1121
+ // triggered prompt and would be confirmed by the turn heuristic.
1122
+ if (
1123
+ state.wakeAwaitingConsumption.has(event.id) ||
1124
+ state.wakeRetryableEventIds.has(event.id)
1125
+ ) {
1126
+ break;
1127
+ }
931
1128
  if (!state.wakeConsumptionObserved.has(event.id) && !turnProducedFinalResponse) {
932
1129
  blockedByMissingTurn = true;
933
1130
  break;
@@ -939,161 +1136,217 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
939
1136
  }
940
1137
  confirmablePrefix.push(event);
941
1138
  }
942
- // Any id still awaiting consumption keeps the batch in flight: it owns the
943
- // acknowledgement cursor, so the settlement of the run that finally drains
944
- // the queued copy (the continuation driven by the settle handler) confirms it
945
- // then. Reading the queue directly — instead of comparing two filtered lists —
946
- // keeps that decision independent of the `attempts` exclusion above, which
947
- // would otherwise hide an unconsumed id and drop the batch too early.
948
- const stillAwaitingConsumption = deliveryQueue.some((event) =>
949
- state.wakeAwaitingConsumption.has(event.id),
950
- );
951
- return { deliveryQueue, confirmablePrefix, blockedByMissingTurn, stillAwaitingConsumption };
1139
+ // Nothing else keeps the batch in flight once the settlement has decided:
1140
+ // any id that is still awaiting consumption has been dead-lettered above,
1141
+ // which lifts only the injection barrier — it still blocks the watermark.
1142
+ return { deliveryQueue, confirmablePrefix, blockedByMissingTurn };
952
1143
  };
953
1144
 
954
- const writeOffStrandedWakeDelivery = (eventIds: number[], ctx: PiContext): void => {
955
- // Release first, acknowledge second: the release is what breaks the loop
956
- // synchronously, while the acknowledgement is an RPC that may fail.
957
- for (const eventId of eventIds) {
958
- state.wakeAwaitingConsumption.delete(eventId);
959
- // This set only ever holds the id of a delivery that was already handed
960
- // to Pi, so releasing it is not permission to present it again: the
961
- // session-wide presented guard stays behind and keeps a daemon replay
962
- // of the same id out of the transcript.
963
- state.presentedEventIds.add(eventId);
1145
+ /**
1146
+ * The smallest delivery-queue id that still blocks the acknowledgement
1147
+ * watermark, plus why it blocks and how many ids it holds back.
1148
+ *
1149
+ * This is the barrier `confirmableDeliveryPrefix` walks (the `break` in its
1150
+ * loop), read on its own because the silent upstream-error acknowledgement
1151
+ * must respect it without a turn authorising a prefix: the daemon confirms
1152
+ * by watermark (`update agent_events set status = 'acked' where id <= ?`), so
1153
+ * an acknowledgement at or above this id would hand the daemon the authority
1154
+ * to mark it confirmed without its content ever reaching the transcript —
1155
+ * the "busy delivery never arrives" failure in its silent form, with no
1156
+ * redelivery and no warning anywhere afterwards.
1157
+ *
1158
+ * An id awaiting consumption has no evidence at all yet, and a dead-lettered
1159
+ * one never reached the transcript: exactly the two states that must not be
1160
+ * confirmed by an ack meant for someone else's id. Ids the queue no longer
1161
+ * holds are not barriers — a confirmed id is done, and an id the daemon
1162
+ * itself terminally dead-lettered was its decision, made visible when it
1163
+ * happened.
1164
+ */
1165
+ const unconfirmedWatermarkBarrier = ():
1166
+ | { blocked: number; eventId: number; reason: string }
1167
+ | undefined => {
1168
+ // How many ids the barrier holds back: awaiting consumption, or still
1169
+ // owed a dead-letter recovery (the two sets this walk checks).
1170
+ const blocked = new Set([
1171
+ ...state.wakeAwaitingConsumption,
1172
+ ...state.wakeRetryableEventIds,
1173
+ ]).size;
1174
+ for (const queued of unackedDeliveredAscending()) {
1175
+ if (state.wakeRetryableEventIds.has(queued.id)) {
1176
+ return {
1177
+ blocked,
1178
+ eventId: queued.id,
1179
+ reason:
1180
+ (state.wakeDeadLetterAttempts.get(queued.id) ?? 0) >= WAKE_DEAD_LETTER_RETRY_LIMIT
1181
+ ? "dead-letter-budget-exhausted"
1182
+ : "dead-letter-backoff",
1183
+ };
1184
+ }
1185
+ if (state.wakeAwaitingConsumption.has(queued.id)) {
1186
+ return { blocked, eventId: queued.id, reason: "awaiting-consumption" };
1187
+ }
964
1188
  }
965
- // The written-off ids can no longer owe a drive, so their budgets leave
966
- // with them. Every id that is still awaiting consumption keeps its own
967
- // budget: it has not spent its own MAX_WAKE_CONTINUATION_ATTEMPTS drives
968
- // yet, and resetting it here is what used to let a sibling's fresh budget
969
- // cover for an unrelated stuck id.
970
- for (const eventId of eventIds) state.wakeContinuationDrives.delete(eventId);
971
- const stranded = eventIds
972
- .map((eventId) => state.unackedDelivered.get(eventId))
973
- .filter((event): event is AgentEventWireRecord => event !== undefined)
974
- .sort((left, right) => left.id - right.id);
1189
+ return undefined;
1190
+ };
1191
+
1192
+ /**
1193
+ * Notes — once per state, and again after `WAKE_BARRIER_NOTE_INTERVAL_MS` —
1194
+ * that the acknowledgement watermark is being held back on purpose.
1195
+ *
1196
+ * The hold-back is invisible otherwise: the queue simply stops advancing and
1197
+ * the log shows nothing, so a stalled delivery cannot be told apart from an
1198
+ * idle one. The line names the smallest blocked id, why it blocks
1199
+ * (dead-letter backoff, spent dead-letter budget, or awaiting consumption)
1200
+ * and how many ids it holds back, in the same field style as the write-off
1201
+ * warning and the injection-skip note. Deduplicating on the exact state
1202
+ * keeps a long stall from flooding the file; the window keeps a stall that
1203
+ * outlives its first line visible.
1204
+ *
1205
+ * "Long-term" cannot be decided from inside the extension — a barrier that
1206
+ * is about to be released looks exactly like one that never will be — so the
1207
+ * minimal observable contract is this: note the moment a due
1208
+ * acknowledgement is declined because of the barrier, not when a timer
1209
+ * merely fires.
1210
+ */
1211
+ const noteWakeWatermarkHoldBack = (barrier: {
1212
+ blocked: number;
1213
+ eventId: number;
1214
+ reason: string;
1215
+ }): void => {
1216
+ const key = `${barrier.eventId}:${barrier.reason}:${barrier.blocked}`;
1217
+ const now = Date.now();
1218
+ if (
1219
+ lastWakeBarrierNote !== undefined &&
1220
+ lastWakeBarrierNote.key === key &&
1221
+ now - lastWakeBarrierNote.at < WAKE_BARRIER_NOTE_INTERVAL_MS
1222
+ ) {
1223
+ return;
1224
+ }
1225
+ lastWakeBarrierNote = { at: now, key };
975
1226
  logHerdsmanPi(
976
- "warn",
977
- `[herdsman-pi] wake continuation gave up eventIds=${eventIds.join(",")} drives=${MAX_WAKE_CONTINUATION_ATTEMPTS} · released and acknowledged as consumed so the daemon stops redelivering them`,
1227
+ "info",
1228
+ `[herdsman-pi] wake acknowledgement held eventId=${barrier.eventId} reason=${barrier.reason} blocked=${barrier.blocked} · the daemon confirms by watermark (id <= ?), so no larger id may be acknowledged while this one is unconfirmed; the held update stays pending in the daemon and recoverable`,
978
1229
  );
979
- ctx.ui.notify?.(
980
- `Herdsman · ${eventIds.length} agent update${eventIds.length === 1 ? "" : "s"} could not be delivered by a wake turn · given up on (possibly never seen): read the agent directly for the details, or hand this workspace to another terminal so the daemon delivers it there`,
981
- "warning",
982
- );
983
- if (stranded.length === 0) return;
984
- void acknowledgeEventIds(stranded, { notify: false }, ctx).catch((error: unknown) => {
985
- logHerdsmanPi(
986
- "warn",
987
- `[herdsman-pi] wake write-off acknowledgement failed eventIds=${stranded.map((event) => event.id).join(",")} · ${String(error)}`,
988
- );
989
- });
990
1230
  };
991
1231
 
992
1232
  /**
993
- * Drives one continuation that carries out a wake Pi has not drained.
1233
+ * Dead-letters a wake delivery whose content never reached the transcript.
994
1234
  *
995
- * A wake injected while the orchestrator streams is delivered as a queued
996
- * follow-up (`triggerTurn: false`), and Pi only drains that queue when a run
997
- * reaches its stop point (`agent-loop` "Agent would stop here. Check for
998
- * follow-up messages."). When the run it rode on already passed that point,
999
- * the update sits in the queue until the next user message. At settlement the
1000
- * orchestrator is no longer streaming, so `triggerTurn: true` starts a real
1001
- * run (`_runAgentPrompt`) and that run's loop drains the queued follow-up.
1002
- * The marker carries no wake content: the queued follow-up is what delivers
1003
- * the evidence, exactly once.
1235
+ * The caller is a settlement that produced no consumption evidence for these
1236
+ * ids: the injected message was never carried out by any run (the turn ended
1237
+ * before its stop point, was aborted, or Pi dropped the message), so nothing
1238
+ * may be told to the daemon about them. An acknowledgement is a statement
1239
+ * that the orchestrator has the content, and here that statement would be a
1240
+ * lie: the daemon would mark the event consumed and never redeliver it.
1004
1241
  *
1005
- * Only consumption evidence writes a delivery off, so an intervening run that
1006
- * ends before its stop point (error, user abort, a refused tool) leaves the
1007
- * next settlement driving again. That bound is per event id and nothing
1008
- * resets it: starting runs cannot fix a cause that is not about the queue, so
1009
- * once an id's own budget is spent the id is written off (released and
1010
- * acknowledged) instead of looping forever — see
1011
- * `writeOffStrandedWakeDelivery` for the trade-off.
1242
+ * What the write-off does is local only: the ids stop being barred from every
1243
+ * later injection, so a later `scheduleWake` may re-present them with the real
1244
+ * update content. *May* is the whole point: `representableWakeOutcomes` decides
1245
+ * when, and until content that is not itself a dead letter leads a wake, the
1246
+ * only automatic resurface is a bounded, explicitly backed-off retry.
1247
+ * Re-presenting unconditionally on the settlement that had just written the id
1248
+ * off is what produced the turn loop this replaces. They deliberately stay in
1249
+ * `wakeAwaitingConsumption` (and therefore in the delivery queue): unconfirmed
1250
+ * content must keep blocking the acknowledgement watermark, because the daemon
1251
+ * confirms by `where id <= ?` and confirming a larger id would swallow them for
1252
+ * good. The events themselves stay pending in the daemon: nothing was
1253
+ * acknowledged, so a later wake is still eligible to present them, and a
1254
+ * replacement session can still receive them.
1255
+ *
1256
+ * The presentation guard is deliberately *not* extended here:
1257
+ * `presentedEventIds` is the "this content is in the transcript" set, and
1258
+ * these ids have no evidence of ever being there. Writing them off would
1259
+ * lock the update out of this session for good, which is exactly the
1260
+ * "busy delivery never arrives" failure this replaces — it is only the
1261
+ * *timing* of the next presentation that is now bounded.
1012
1262
  */
1013
- const driveWakeContinuation = (ctx: PiContext) => {
1014
- if (state.wakeAwaitingConsumption.size === 0) return;
1015
- if (!pi.sendMessage) return;
1016
- const eventIds = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
1017
- // Every id that is still waiting spends one drive of its own budget. The
1018
- // counters are keyed by event id and are never reset by an injection, so a
1019
- // delivery handed over later cannot buy an already-stuck id a fresh budget.
1020
- const spent: number[] = [];
1263
+ const writeOffStrandedWakeDelivery = (eventIds: number[], ctx: PiContext): void => {
1264
+ if (eventIds.length === 0) return;
1021
1265
  for (const eventId of eventIds) {
1022
- const drives = (state.wakeContinuationDrives.get(eventId) ?? 0) + 1;
1023
- state.wakeContinuationDrives.set(eventId, drives);
1024
- spent.push(drives);
1025
- }
1026
- try {
1027
- pi.sendMessage(
1028
- {
1029
- content: WAKE_CONTINUATION_CONTENT,
1030
- customType: WAKE_CONTINUATION_CUSTOM_TYPE,
1031
- display: false,
1032
- },
1033
- { deliverAs: "followUp", triggerTurn: true },
1034
- );
1035
- logHerdsmanPi(
1036
- "info",
1037
- `[herdsman-pi] wake continuation driven eventIds=${eventIds[0] ?? 0}-${eventIds.at(-1) ?? 0} count=${eventIds.length} drives=${spent.join(",")}`,
1038
- );
1039
- } catch {
1040
- logHerdsmanPi("warn", "[herdsman-pi] wake continuation refused by pi");
1266
+ state.wakeAwaitingConsumption.delete(eventId);
1267
+ // Unconfirmed content must still block the acknowledgement watermark: the
1268
+ // daemon confirms by `where id <= ?`, so letting a later id through would
1269
+ // swallow this one for good. The retry set is that barrier, and it is also
1270
+ // what lifts the injection barrier — a delivery that never reached the
1271
+ // transcript has to be presented again (see `alreadyPresented`), but only
1272
+ // on `representableWakeOutcomes`' terms.
1273
+ state.wakeRetryableEventIds.add(eventId);
1274
+ // The write-off happens *after* the turn that failed to carry the content
1275
+ // out, so this is where "quiet since" is pinned: until the backoff
1276
+ // elapses, nothing may present the id on its own. A budget that is already
1277
+ // spent gets no new deadline either — the id stays a barrier, not a turn
1278
+ // driver, and only content that is fresh can still carry it.
1279
+ const attempts = state.wakeDeadLetterAttempts.get(eventId) ?? 0;
1280
+ if (attempts < WAKE_DEAD_LETTER_RETRY_LIMIT) {
1281
+ state.wakeDeadLetterRetryAt.set(
1282
+ eventId,
1283
+ Date.now() + deadLetterRetryBackoffMs(attempts),
1284
+ );
1285
+ } else {
1286
+ state.wakeDeadLetterRetryAt.delete(eventId);
1287
+ }
1041
1288
  }
1042
- // Only the ids whose own budget this drive spent are written off. A sibling
1043
- // that still has drives left keeps its copy in the queue and its count, so
1044
- // it is neither lost nor granted a fresh ceiling.
1045
- const exhausted = eventIds.filter(
1046
- (_eventId, index) => (spent[index] ?? 0) >= MAX_WAKE_CONTINUATION_ATTEMPTS,
1289
+ logHerdsmanPi(
1290
+ "warn",
1291
+ `[herdsman-pi] wake delivery dead-lettered eventIds=${eventIds.join(",")} automaticRetries=${eventIds.map((eventId) => state.wakeDeadLetterAttempts.get(eventId) ?? 0).join(",")} limit=${WAKE_DEAD_LETTER_RETRY_LIMIT} · updates retained unacked in daemon: no consumption evidence, nothing acknowledged, and the ids stay eligible for a later wake`,
1292
+ );
1293
+ ctx.ui.notify?.(
1294
+ `Herdsman · ${eventIds.length} agent update${eventIds.length === 1 ? "" : "s"} could not be delivered by a wake turn · updates retained unacked in daemon: read the agent directly for the details, or hand this workspace to another terminal so the daemon delivers it there`,
1295
+ "warning",
1047
1296
  );
1048
- if (exhausted.length > 0) {
1049
- // This was the last drive the id's own budget allows, and the queued copy
1050
- // is still undrained: the id is written off instead of being left
1051
- // pending forever. The ids are released from
1052
- // `wakeAwaitingConsumption` (no further run is started for them) and
1053
- // acknowledged, which is what stops the daemon from redelivering them
1054
- // every freshness window — the loop that used to flood the orchestrator
1055
- // session until manual sqlite surgery. Losing one update is the accepted
1056
- // cost; see `writeOffStrandedWakeDelivery`.
1057
- writeOffStrandedWakeDelivery(exhausted, ctx);
1058
- }
1059
1297
  };
1060
1298
 
1299
+
1061
1300
  /**
1062
- * Arms the bounded deferral for a wake that cannot be injected right now.
1301
+ * Arms the deferral for a wake that cannot be injected right now.
1063
1302
  *
1064
- * The retry spins every `WAKE_BUSY_SPIN_MS` while the orchestrator is busy
1065
- * and flips `wakeForcedRelease` once `WAKE_DEFERRED_TIMEOUT_MS` has elapsed,
1066
- * so the next pass injects the batch from the current state (as a queued,
1067
- * non-triggering follow-up) instead of waiting for an idle signal or a
1068
- * settlement that may never arrive.
1303
+ * Delivery does not depend on whether the orchestrator is busy or idle:
1304
+ * every wake is queued and injected once the orchestrator has settled (or a
1305
+ * poll observes it idle). `agent_settled` is therefore the primary close
1306
+ * signal — it fires exactly when the turn ends and re-takes the wake
1307
+ * decision — and the timer here is only the poll that keeps the decision
1308
+ * alive while the turn runs. It backs off along `WAKE_BUSY_BACKOFF_MS` up to
1309
+ * `WAKE_BUSY_BACKOFF_CAP_MS` (a poll-frequency reduction, not a safety net):
1310
+ * the ladder used to tick at a flat `WAKE_BUSY_SPIN_MS`, so a long turn was
1311
+ * re-evaluated ten times a second forever.
1312
+ *
1313
+ * There is deliberately no deadline (nothing like a `WAKE_DEFERRED_TIMEOUT_MS`
1314
+ * that force-releases a busy orchestrator after N seconds): a wake injected
1315
+ * into a busy orchestrator either lands in a follow-up queue nothing drains
1316
+ * — the "busy delivery never arrives" failure this replaces — or interrupts
1317
+ * the user's own turn, so waiting is the only outcome that loses nothing.
1318
+ * The liveness bound is exactly "until `agent_settled`, or until one of these
1319
+ * ticks observes an idle orchestrator"; backing off only makes that wait
1320
+ * cheaper, never shorter.
1069
1321
  */
1070
1322
  const scheduleDeferredWake = (ctx: PiContext) => {
1071
1323
  state.wakeDeferredUntilSettled = true;
1072
- const since = state.wakeDeferredSince ?? Date.now();
1073
- state.wakeDeferredSince = since;
1074
- const remaining = WAKE_DEFERRED_TIMEOUT_MS - (Date.now() - since);
1075
- if (remaining <= 0) state.wakeForcedRelease = true;
1324
+ // The deferral is the state the user cannot see (nothing is injected, no
1325
+ // turn is started, and the pending count alone reads as idle work), so it
1326
+ // is announced once per arming rather than left to be guessed.
1327
+ setHerdsmanUi(ctx);
1076
1328
  if (state.wakeTimer) return;
1329
+ const rung = Math.min(state.wakeBusySpinRung, WAKE_BUSY_BACKOFF_MS.length - 1);
1330
+ state.wakeBusySpinRung = Math.min(rung + 1, WAKE_BUSY_BACKOFF_MS.length - 1);
1331
+ // Clamped to the cap rather than trusted to the ladder's last rung: a rung
1332
+ // edited above the ceiling would otherwise raise the real backoff while the
1333
+ // exported constant, the note and the tests still say 5s.
1334
+ const delay = Math.min(
1335
+ WAKE_BUSY_BACKOFF_MS[rung] ?? WAKE_BUSY_SPIN_MS,
1336
+ WAKE_BUSY_BACKOFF_CAP_MS,
1337
+ );
1077
1338
  state.wakeTimer = setTimeout(() => {
1078
1339
  state.wakeTimer = undefined;
1079
- if (state.wakeForcedRelease && state.deliveredBatch && ctx.isIdle?.() !== false) {
1080
- // The hard deadline only releases the delivery — it never discards an
1081
- // unconfirmed event. The batch record is dropped so a wake turn that
1082
- // is no longer running cannot gate later wakes, but its events stay in
1083
- // the delivery queue (`unackedDelivered`) and are re-attached to the
1084
- // batch injected right below, which acknowledges them once it settles.
1085
- state.deliveredBatch = undefined;
1086
- }
1087
1340
  scheduleWake(ctx);
1088
- }, Math.max(0, Math.min(WAKE_BUSY_SPIN_MS, remaining)));
1341
+ }, delay);
1089
1342
  };
1090
1343
 
1091
1344
  const scheduleWake = (ctx: PiContext | undefined) => {
1092
1345
  if (!ctx || !state.isOrchestrator || !state.currentScope || !pi.sendMessage) return;
1093
1346
  if (state.wakeTimer || state.wakeRequested) {
1094
1347
  // A pending wake owns the release; this branch is also hit re-entrantly by
1095
- // a running pass (its fired settle timer is still set), where clearing
1096
- // `wakeForcedRelease` would re-defer a batch the deadline just released.
1348
+ // a running pass (its fired settle timer is still set), and ending the
1349
+ // deferral there would let a second injection ride in behind it.
1097
1350
  return;
1098
1351
  }
1099
1352
  const projection = projectAgentOutcomes(state.pendingEvents, wakeFilter);
@@ -1116,11 +1369,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1116
1369
  const wakeable = outcomes.filter((outcome) =>
1117
1370
  isWakeableEvent(state.pendingEvents.find((pending) => pending.id === outcome.eventId)),
1118
1371
  );
1119
- if (wakeable.length === 0) {
1120
- // Nothing is wakeable right now, so the deferral that led here is over:
1121
- // its released deadline must not let a later, unrelated wake bypass the
1122
- // busy gate.
1123
- endWakeDeferral();
1372
+ // A dead-lettered id may only join a wake that fresh content leads, or, on
1373
+ // its own, wait for its explicit backoff (see `representableWakeOutcomes`).
1374
+ // Without this the settlement that wrote it off re-injects it in the same
1375
+ // cycle, which is the turn loop this replaces.
1376
+ const { outcomes: injectableOutcomes } = representableWakeOutcomes(wakeable);
1377
+ if (injectableOutcomes.length === 0) {
1124
1378
  // A suppressed upstream error that is now due must be silently
1125
1379
  // acknowledged before any backoff timer is planted: planting the timer
1126
1380
  // first would make scheduleSilentUpstreamErrorAck's entry guard
@@ -1128,19 +1382,48 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1128
1382
  // off its own timer and, once the backoff window has elapsed, the
1129
1383
  // 0ms-timer + blocked-ack loop never converges the queue.
1130
1384
  const dueSuppressed = suppressedEvents.filter(isWakeableEvent);
1131
- if (dueSuppressed.length > 0) {
1132
- scheduleSilentUpstreamErrorAck(ctx, dueSuppressed);
1385
+ // A silent acknowledgement is a watermark statement, though: the daemon
1386
+ // confirms every id at or below the acked one, so it may not cross an id
1387
+ // that is still unconfirmed locally. The barrier is the same one
1388
+ // `confirmableDeliveryPrefix` walks (`unconfirmedWatermarkBarrier`), and
1389
+ // crossing it would hand the daemon the authority to mark the blocked id
1390
+ // confirmed without its content ever reaching the transcript — the
1391
+ // "busy delivery never arrives" failure in its silent form, with no
1392
+ // redelivery and no warning afterwards. The held ids stay due and are
1393
+ // acknowledged by a later pass, once the barrier id is confirmed (or
1394
+ // recovered) and the watermark has moved past it.
1395
+ const watermarkBarrier = unconfirmedWatermarkBarrier();
1396
+ const ackableSuppressed =
1397
+ watermarkBarrier === undefined
1398
+ ? dueSuppressed
1399
+ : dueSuppressed.filter((event) => event.id < watermarkBarrier.eventId);
1400
+ if (ackableSuppressed.length > 0) {
1401
+ scheduleSilentUpstreamErrorAck(ctx, ackableSuppressed);
1133
1402
  return;
1134
1403
  }
1404
+ if (watermarkBarrier !== undefined && dueSuppressed.length > 0) {
1405
+ // Held back by the barrier rather than by a missing tick: say so, in
1406
+ // the throttled shape above, so the stall is observable.
1407
+ noteWakeWatermarkHoldBack(watermarkBarrier);
1408
+ }
1135
1409
  // No suppressed event is due, so plant a backoff timer for the next
1136
1410
  // future `nextAttemptAt`. Expired timestamps are excluded (strictly
1137
1411
  // greater than now) so an already-past window does not produce a
1138
- // zero-delay spin.
1412
+ // zero-delay spin. A dead-lettered id that still owes an automatic retry
1413
+ // contributes its own deadline here: without it the retry the write-off
1414
+ // scheduled would wait for an unrelated event to come along.
1139
1415
  const nextAttemptAt = [
1140
1416
  ...outcomes.map((outcome) => outcome.eventId),
1141
1417
  ...suppressedEvents.map((event) => event.id),
1142
1418
  ]
1143
- .map((eventId) => state.pendingEvents.find((event) => event.id === eventId)?.nextAttemptAt)
1419
+ .flatMap((eventId) => {
1420
+ const nextAttempt = state.pendingEvents.find((event) => event.id === eventId)
1421
+ ?.nextAttemptAt;
1422
+ const retryAt = state.wakeRetryableEventIds.has(eventId)
1423
+ ? deadLetterRetryAt(eventId)
1424
+ : undefined;
1425
+ return [nextAttempt, retryAt];
1426
+ })
1144
1427
  .filter((value): value is number => value !== undefined && value > Date.now())
1145
1428
  .sort((left, right) => left - right)[0];
1146
1429
  if (nextAttemptAt !== undefined) {
@@ -1153,10 +1436,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1153
1436
  }
1154
1437
 
1155
1438
  // An in-flight batch owns the ack cursor and a busy orchestrator must not
1156
- // be interrupted, so neither is woken immediately — but both are deferred
1157
- // on a bounded spin (never parked until an event that may never come).
1439
+ // be interrupted, so neither is woken immediately — the wake stays
1440
+ // deferred on the backed-off poll until the batch settles and the
1441
+ // orchestrator is idle, and nothing is released past that gate.
1158
1442
  const inFlight = state.deliveredBatch !== undefined || state.ackInFlight;
1159
- if (!state.wakeForcedRelease && (inFlight || ctx.isIdle?.() === false)) {
1443
+ if (inFlight || ctx.isIdle?.() === false) {
1160
1444
  scheduleDeferredWake(ctx);
1161
1445
  return;
1162
1446
  }
@@ -1165,8 +1449,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1165
1449
  const ownerTerminalId = state.currentScope.terminalId;
1166
1450
  const ownerWorkspaceId = state.currentScope.workspaceId;
1167
1451
  state.wakeTimer = setTimeout(() => {
1168
- const startWake = async () => {
1169
- if (
1452
+ const startWake = async () => { if (
1170
1453
  generation !== wakeGeneration ||
1171
1454
  !state.isOrchestrator ||
1172
1455
  state.currentScope?.herdrSessionName !== ownerHerdrSessionName ||
@@ -1174,16 +1457,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1174
1457
  state.currentScope?.workspaceId !== ownerWorkspaceId
1175
1458
  ) {
1176
1459
  state.wakeTimer = undefined;
1177
- // The pass belongs to a stale generation or scope, so its deferral
1178
- // episode ends here (a scope reset usually got there first through
1179
- // `cancelWakeTimer`).
1180
- endWakeDeferral();
1181
1460
  return;
1182
1461
  }
1183
- if (ctx.isIdle?.() === false && !state.wakeForcedRelease) {
1462
+ if (ctx.isIdle?.() === false) {
1184
1463
  state.wakeTimer = undefined;
1185
- // Still busy: this pass re-defers, so the deferral episode (and with
1186
- // it the 5s deadline) must keep running instead of restarting.
1464
+ // Still busy: this pass re-defers and keeps waiting, so nothing is
1465
+ // injected into (or parked for) a running turn.
1187
1466
  scheduleDeferredWake(ctx);
1188
1467
  return;
1189
1468
  }
@@ -1195,13 +1474,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1195
1474
  )) as ConnectionStateResponse | undefined;
1196
1475
  if (!response) {
1197
1476
  state.wakeTimer = undefined;
1198
- endWakeDeferral();
1199
1477
  return;
1200
1478
  }
1201
1479
  applyConnectionStateResponse(response, ctx);
1202
1480
  } catch {
1203
1481
  state.wakeTimer = undefined;
1204
- endWakeDeferral();
1205
1482
  // A failed load is only temporary: the batch stays pending and is
1206
1483
  // retried on the next wake instead of being permanently suppressed.
1207
1484
  ctx.ui.notify?.(
@@ -1219,14 +1496,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1219
1496
  state.currentScope?.workspaceId !== ownerWorkspaceId
1220
1497
  ) {
1221
1498
  state.wakeTimer = undefined;
1222
- // See the earlier generation re-check: the episode ends with it.
1223
- endWakeDeferral();
1224
1499
  return;
1225
1500
  }
1226
- if (ctx.isIdle?.() === false && !state.wakeForcedRelease) {
1501
+ if (ctx.isIdle?.() === false) {
1227
1502
  state.wakeTimer = undefined;
1228
- // See the earlier re-check: re-deferring keeps this episode's 5s
1229
- // deadline intact.
1503
+ // See the earlier re-check: a busy orchestrator keeps waiting.
1230
1504
  scheduleDeferredWake(ctx);
1231
1505
  return;
1232
1506
  }
@@ -1234,15 +1508,20 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1234
1508
  const batchEvents = [...state.pendingEvents].sort((left, right) => left.id - right.id);
1235
1509
  const batchProjection = projectAgentOutcomes(batchEvents, wakeFilter);
1236
1510
  const batchSuppressedIds = new Set(batchProjection.suppressedUpstreamErrorEventIds);
1237
- const batchOutcomes = batchProjection.outcomes.filter(
1511
+ const projectableBatchOutcomes = batchProjection.outcomes.filter(
1238
1512
  (outcome) =>
1239
1513
  outcome.eventId > state.failedWakeThroughEventId &&
1240
1514
  !alreadyPresented(outcome.eventId) &&
1241
1515
  isWakeableEvent(batchEvents.find((event) => event.id === outcome.eventId)),
1242
1516
  );
1517
+ // Same split as in `scheduleWake`, re-taken here because the pass
1518
+ // awaited an `agent.orchestrator.get` in between: a dead-lettered id
1519
+ // may only ride content that is fresh, or retry on its own once its
1520
+ // backoff is due (see `representableWakeOutcomes`).
1521
+ const { lead: batchLeadOutcomes, outcomes: batchOutcomes } =
1522
+ representableWakeOutcomes(projectableBatchOutcomes);
1243
1523
  if (batchOutcomes.length === 0) {
1244
1524
  state.wakeTimer = undefined;
1245
- endWakeDeferral();
1246
1525
  return;
1247
1526
  }
1248
1527
  const current = batchOutcomes;
@@ -1254,24 +1533,19 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1254
1533
  state.wakeRequested = true;
1255
1534
  state.wakeRequestedThroughEventId = current.at(-1)?.eventId ?? 0;
1256
1535
  // Single-track injection: the wake content is handed to Pi exactly
1257
- // once, as the follow-up message below. An idle orchestrator gets a
1258
- // triggered follow-up turn (immediate delivery); a busy one is not
1259
- // interrupted and receives the same content as a queued,
1260
- // non-triggering follow-up that a later run drains out of the
1261
- // follow-up queue. There is deliberately no second copy on the
1262
- // `context` hook: a run that is busy cannot act on an early copy
1263
- // anyway, while a second copy is exactly what puts the same update
1264
- // into the session twice. So the transcript holds one entry per event
1265
- // id, and the continuation drive (see `driveWakeContinuation`) is what
1266
- // carries a queued copy that no run drained into the transcript.
1267
- const orchestratorBusy = ctx.isIdle?.() === false;
1268
- const wakeContent = formatAgentOutcomeUpdates(batchOutcomes);
1269
- // Single line, injection path only: the decision that produced this
1270
- // batch plus the signals it came from, so a wake that still arrives
1271
- // late can be told apart from one parked by a stale gate.
1536
+ // once, as the follow-up message below, and only once the orchestrator
1537
+ // is idle — a busy one has been deferred (see `scheduleDeferredWake`),
1538
+ // because a follow-up parked in a running turn's queue may never be
1539
+ // drained. There is deliberately no second copy on the `context` hook
1540
+ // either: the update enters the transcript exactly once per event id,
1541
+ // through the one message this path sends.
1542
+ const wakeContent = formatAgentOutcomeUpdates([...batchOutcomes]);
1543
+ // Single line, injection path only: the state the batch was taken from,
1544
+ // so a wake that still arrives late can be told apart from one that was
1545
+ // held back by the busy gate.
1272
1546
  logHerdsmanPi(
1273
1547
  "info",
1274
- `[herdsman-pi] wake inject deliverAs=followUp triggerTurn=${String(!orchestratorBusy)} forced=${state.wakeForcedRelease} runActive=${String(state.runActive)} isIdle=${ctx.isIdle === undefined ? "unknown" : String(ctx.isIdle())} eventIds=${batchOutcomes[0]?.eventId ?? 0}-${batchOutcomes.at(-1)?.eventId ?? 0} count=${batchOutcomes.length}`,
1548
+ `[herdsman-pi] wake inject deliverAs=followUp triggerTurn=true runActive=${String(state.runActive)} isIdle=${ctx.isIdle === undefined ? "unknown" : String(ctx.isIdle())} eventIds=${batchOutcomes[0]?.eventId ?? 0}-${batchOutcomes.at(-1)?.eventId ?? 0} count=${batchOutcomes.length}`,
1275
1549
  );
1276
1550
  try {
1277
1551
  pi.sendMessage?.(
@@ -1293,35 +1567,43 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1293
1567
  },
1294
1568
  display: false,
1295
1569
  },
1296
- orchestratorBusy
1297
- ? { deliverAs: "followUp", triggerTurn: false }
1298
- : { deliverAs: "followUp", triggerTurn: true },
1570
+ // `triggerTurn: true` is what makes this a real turn: pi hands the
1571
+ // message to the agent core, which produces a regular `message_end`
1572
+ // for the hidden entry, and only that emission is the consumption
1573
+ // evidence the acknowledgement waits for. A `triggerTurn: false`
1574
+ // variant only parks the message in a follow-up queue that a run
1575
+ // drains at its stop point — the copy that made "busy delivery
1576
+ // failed" possible.
1577
+ { deliverAs: "followUp", triggerTurn: true },
1299
1578
  );
1300
- // A queued (non-triggering) delivery rides the running turn: Pi parks it
1301
- // in the agent's follow-up queue, which a run only drains when it reaches
1302
- // its stop point. If the run it rode on already passed that point, nothing
1303
- // drains it — the settlement drives a continuation instead (see
1304
- // `driveWakeContinuation`), and until the content is seen in the
1305
- // transcript the delivery is neither acknowledged nor injected again.
1306
- if (orchestratorBusy) {
1307
- // A freshly queued id starts its own continuation budget; an id that
1308
- // was already awaiting consumption (merged into this batch from an
1309
- // earlier injection) keeps the drives it already spent. Only a new
1310
- // id may get a fresh ceiling — never an unrelated one.
1311
- for (const outcome of batchOutcomes) {
1312
- state.wakeAwaitingConsumption.add(outcome.eventId);
1313
- if (!state.wakeContinuationDrives.has(outcome.eventId)) {
1314
- state.wakeContinuationDrives.set(outcome.eventId, 0);
1315
- }
1579
+ // A freshly delivered id is in flight until its content is seen in
1580
+ // the transcript: it can neither be acknowledged (the evidence is
1581
+ // what authorises that) nor injected again (the content must not
1582
+ // enter the transcript twice). An id merged into this batch from an
1583
+ // earlier delivery keeps that same state; a dead-lettered one is in
1584
+ // flight again and loses its retry flag.
1585
+ for (const outcome of batchOutcomes) {
1586
+ // A dead-lettered id that retried on its own spends one of its
1587
+ // automatic attempts; one that rode a wake fresh content led is
1588
+ // riding that update's turn, not its own, and rides for free.
1589
+ if (
1590
+ batchLeadOutcomes.length === 0 &&
1591
+ state.wakeRetryableEventIds.has(outcome.eventId)
1592
+ ) {
1593
+ state.wakeDeadLetterAttempts.set(
1594
+ outcome.eventId,
1595
+ (state.wakeDeadLetterAttempts.get(outcome.eventId) ?? 0) + 1,
1596
+ );
1316
1597
  }
1598
+ state.wakeAwaitingConsumption.add(outcome.eventId);
1599
+ state.wakeRetryableEventIds.delete(outcome.eventId);
1600
+ state.wakeDeadLetterRetryAt.delete(outcome.eventId);
1317
1601
  }
1318
- state.wakeForcedRelease = false;
1319
- state.wakeDeferredSince = undefined;
1320
1602
  // Only expose the batch after the hidden context was accepted by pi. This
1321
1603
  // keeps an injection failure eligible for daemon redelivery.
1322
1604
  //
1323
1605
  // The batch is the delivery queue plus this injection: previously
1324
- // unconfirmed events are merged (never replaced) so that a forced
1606
+ // unconfirmed events are merged (never replaced) so that a later
1325
1607
  // release always leaves both the old and the new events deliverable
1326
1608
  // and acknowledgeable in id order. The turn-consumption flags of a
1327
1609
  // still-running previous batch are carried over, because they
@@ -1330,39 +1612,40 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1330
1612
  // `hasSubstantiveWork` is the sole gate that decides whether an
1331
1613
  // ownership/scope change may abort the in-flight turn (see loseRole
1332
1614
  // and resetForScopeChange), and aborting is only ever allowed for a
1333
- // *pure* Herdsman wake turn. A busy orchestrator gets the batch as a
1334
- // non-triggering queued follow-up, which rides the user's own turn:
1335
- // that turn is not a Herdsman wake turn, so it must never be aborted
1336
- // on our behalf and the flag is set here.
1615
+ // *pure* Herdsman wake turn. The injected message starts one of those
1616
+ // (it is only ever sent to an idle orchestrator, and it triggers its
1617
+ // own turn), and nothing here has produced substance yet, so the
1618
+ // flag starts false and the tool/assistant hooks raise it.
1337
1619
  state.deliveredBatch = {
1338
1620
  abortedByUser: previousBatch?.abortedByUser ?? false,
1339
1621
  assistantFinalSucceeded: previousBatch?.assistantFinalSucceeded ?? false,
1340
1622
  events: mergeUnackedDelivered(incomingEvents),
1341
- hasSubstantiveWork: orchestratorBusy || (previousBatch?.hasSubstantiveWork ?? false),
1623
+ hasSubstantiveWork: previousBatch?.hasSubstantiveWork ?? false,
1342
1624
  invalidated: false,
1343
1625
  ownerTerminalId,
1344
1626
  herdsmanTriggered: true,
1345
1627
  };
1346
1628
  state.wakeRequested = false;
1347
1629
  state.wakeRequestedThroughEventId = 0;
1348
- // Record the presentation so a reclaim redelivery of the same id is
1349
- // never presented twice. The set is monotonic for the lifetime of
1350
- // the current orchestrator scope: ids are only removed on role
1351
- // loss, scope change, or shutdown (pruneAcknowledgedEvents keeps
1352
- // acknowledged ids on purpose).
1353
- for (const outcome of batchOutcomes) {
1354
- state.presentedEventIds.add(outcome.eventId);
1355
- }
1630
+ // The busy wait is over: whatever turn comes next starts the poll
1631
+ // ladder from its first rung again.
1632
+ state.wakeBusySpinRung = 0;
1633
+ // The ids deliberately do *not* enter `presentedEventIds` here: that set
1634
+ // means "this content is in the transcript", and only consumption
1635
+ // evidence (`pi.on("message_end")` for this hidden wake message, see
1636
+ // the handler below) knows that. Recording the injection instead would
1637
+ // let an update whose run never carried it out — a failed turn, an
1638
+ // abort, a message Pi dropped — become unrecoverable in this session,
1639
+ // which is the lock-out this path is meant to remove. The in-flight
1640
+ // state is exactly what keeps it out of the transcript twice in the
1641
+ // meantime: `alreadyPresented` reports "awaiting".
1356
1642
  } catch {
1357
1643
  state.deliveredBatch = undefined;
1358
1644
  state.wakeRequested = false;
1359
- // Nothing was queued by the refused injection, so nothing is awaiting
1360
- // consumption on its account either: the ids only enter the awaiting set
1361
- // once Pi accepted the message (see the queued branch above). The
1362
- // refused injection consumed this deferral, so clearing it keeps the
1363
- // elapsed deadline from letting a later, unrelated wake bypass the busy
1364
- // gate.
1365
- endWakeDeferral();
1645
+ // Nothing was sent, so nothing is awaiting consumption on its account
1646
+ // either: the ids only enter the awaiting set once Pi accepted the
1647
+ // message. Nothing was delivered, so the event stays pending and the
1648
+ // next wake retries it.
1366
1649
  }
1367
1650
  };
1368
1651
  void startWake();
@@ -1826,11 +2109,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1826
2109
  pi.on("message_end", (event: Record<string, unknown>) => {
1827
2110
  const message = record(event.message);
1828
2111
  if (message.role === "custom" && message.customType === WAKE_CONTEXT_CUSTOM_TYPE) {
1829
- // Consumption evidence: the wake content reached the transcript, so some
1830
- // run drained the queued follow-up and carried the update out. The ids the
1831
- // message presented are confirmed — no further continuation is owed for
1832
- // them, and they are now authorised for acknowledgement on their own,
1833
- // whatever conclusion the turn reached.
2112
+ // Consumption evidence: the wake content reached the transcript, so the
2113
+ // turn that carried it out is confirmed. The ids the message presented are
2114
+ // now authorised for acknowledgement on their own, whatever conclusion the
2115
+ // turn reached.
1834
2116
  const consumedEventIds = wakeConsumedEventIds(message);
1835
2117
  if (consumedEventIds.length === 0) {
1836
2118
  // Nothing may be confirmed from a message whose evidence cannot be read
@@ -1845,9 +2127,9 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1845
2127
  }
1846
2128
  for (const eventId of consumedEventIds) {
1847
2129
  state.wakeAwaitingConsumption.delete(eventId);
1848
- // Its copy reached the transcript, so the id owes no drive any more and
1849
- // its budget leaves with it.
1850
- state.wakeContinuationDrives.delete(eventId);
2130
+ // The delivery is settled (carried out, or dead-lettered and now
2131
+ // re-delivered): it owes no further drive and no further retry.
2132
+ state.wakeRetryableEventIds.delete(eventId);
1851
2133
  // The content reached the transcript, so this id counts as presented from
1852
2134
  // now on: a daemon redelivery of it (it is still unacknowledged whenever
1853
2135
  // its batch was already dropped) must not inject the same update again.
@@ -1916,12 +2198,26 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1916
2198
 
1917
2199
  pi.on("agent_settled", async (_event: unknown, ctx: PiContext) => {
1918
2200
  state.runActive = false;
1919
- // A wake delivered as a queued follow-up is drained only when a run reaches
1920
- // its stop point. If the run that received it settled without draining it,
1921
- // no further run exists to carry the update out: drive a continuation (up to
1922
- // the per-delivery bound, see `driveWakeContinuation`), which is what
1923
- // surfaces the queued update.
1924
- driveWakeContinuation(ctx);
2201
+ // The busy period this settlement ends is over, so the poll ladder starts
2202
+ // from its first rung again for whatever turn comes next (see
2203
+ // `WAKE_BUSY_BACKOFF_MS`).
2204
+ state.wakeBusySpinRung = 0;
2205
+ // The settlement is the signal a deferral has been waiting for, so the
2206
+ // pending spin is dropped: the wake decision below is taken now instead of
2207
+ // one `WAKE_BUSY_SPIN_MS` tick later. A pass that is already being injected
2208
+ // (`wakeRequested`) keeps its timer — clearing it would drop the injection.
2209
+ if (state.wakeTimer && !state.wakeRequested) {
2210
+ const pendingDeferral = state.wakeTimer;
2211
+ state.wakeTimer = undefined;
2212
+ clearTimeout(pendingDeferral);
2213
+ }
2214
+ // A wake whose content never reached the transcript gets no run of its own:
2215
+ // there is deliberately no empty-marker continuation. The settlement writes
2216
+ // those ids off locally (dead-letter, no acknowledgement) and the next
2217
+ // `scheduleWake` re-presents them with the real update content, or a later
2218
+ // settlement confirms them once their copy finally drains.
2219
+ const unconsumed = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
2220
+ if (unconsumed.length > 0) writeOffStrandedWakeDelivery(unconsumed, ctx);
1925
2221
  const failBatch = () => {
1926
2222
  ctx.ui.notify?.(
1927
2223
  "Herdsman couldn’t acknowledge agent updates · updates remain pending",
@@ -1931,8 +2227,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1931
2227
  const finishBatch = () => {
1932
2228
  state.ackInFlight = false;
1933
2229
  state.wakeDeferredUntilSettled = false;
1934
- state.wakeDeferredSince = undefined;
1935
- state.wakeForcedRelease = false;
1936
2230
  setHerdsmanUi(ctx);
1937
2231
  scheduleWake(ctx);
1938
2232
  };
@@ -1969,10 +2263,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1969
2263
 
1970
2264
  const stillOwner =
1971
2265
  state.isOrchestrator && state.currentScope?.terminalId === batch.ownerTerminalId;
1972
- const { deliveryQueue, confirmablePrefix, blockedByMissingTurn, stillAwaitingConsumption } =
1973
- confirmableDeliveryPrefix(batch.assistantFinalSucceeded || batch.abortedByUser);
2266
+ const { deliveryQueue, confirmablePrefix, blockedByMissingTurn } = confirmableDeliveryPrefix(
2267
+ batch.assistantFinalSucceeded || batch.abortedByUser,
2268
+ );
1974
2269
  const reachable = stillOwner && state.client !== undefined && state.connected;
1975
- if (!stillAwaitingConsumption) state.deliveredBatch = undefined;
2270
+ // Every id awaiting consumption was dead-lettered at the top of this
2271
+ // handler, so nothing holds the cursor open any more and the batch can be
2272
+ // dropped: a later wake is not gated by it.
2273
+ state.deliveredBatch = undefined;
1976
2274
  state.ackInFlight = true;
1977
2275
  if (!reachable) {
1978
2276
  // Unreachable (ownership gone, or a disconnect): attempting an