@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.
- package/package.json +1 -1
- 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
|
-
*
|
|
128
|
-
*
|
|
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
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
-
*
|
|
202
|
+
* Ids whose delivery was dead-lettered and which a later wake must be allowed
|
|
203
|
+
* to present again.
|
|
209
204
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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
|
-
*
|
|
223
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
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
|
|
315
|
+
export const WAKE_BUSY_BACKOFF_CAP_MS = 5_000;
|
|
291
316
|
/**
|
|
292
|
-
*
|
|
293
|
-
* event id.
|
|
317
|
+
* Poll-frequency ladder while a wake waits for the orchestrator (milliseconds).
|
|
294
318
|
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
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
|
|
317
|
-
/**
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
*
|
|
323
|
-
* (
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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
|
|
328
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
523
|
-
state.
|
|
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.
|
|
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
|
|
623
|
-
*
|
|
624
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
"
|
|
977
|
-
`[herdsman-pi] wake
|
|
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
|
-
*
|
|
1233
|
+
* Dead-letters a wake delivery whose content never reached the transcript.
|
|
994
1234
|
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
* the
|
|
1000
|
-
*
|
|
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
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
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
|
|
1014
|
-
if (
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
|
1301
|
+
* Arms the deferral for a wake that cannot be injected right now.
|
|
1063
1302
|
*
|
|
1064
|
-
*
|
|
1065
|
-
* and
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
},
|
|
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),
|
|
1096
|
-
//
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
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
|
-
.
|
|
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 —
|
|
1157
|
-
// on
|
|
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 (
|
|
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
|
|
1462
|
+
if (ctx.isIdle?.() === false) {
|
|
1184
1463
|
state.wakeTimer = undefined;
|
|
1185
|
-
// Still busy: this pass re-defers
|
|
1186
|
-
//
|
|
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
|
|
1501
|
+
if (ctx.isIdle?.() === false) {
|
|
1227
1502
|
state.wakeTimer = undefined;
|
|
1228
|
-
// See the earlier re-check:
|
|
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
|
|
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
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
1262
|
-
//
|
|
1263
|
-
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
//
|
|
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
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
|
|
1307
|
-
// A
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
state.
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
|
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.
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
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
|
|
1830
|
-
//
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
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
|
-
//
|
|
1849
|
-
//
|
|
1850
|
-
state.
|
|
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
|
-
//
|
|
1920
|
-
// its
|
|
1921
|
-
//
|
|
1922
|
-
|
|
1923
|
-
//
|
|
1924
|
-
|
|
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
|
|
1973
|
-
|
|
2266
|
+
const { deliveryQueue, confirmablePrefix, blockedByMissingTurn } = confirmableDeliveryPrefix(
|
|
2267
|
+
batch.assistantFinalSucceeded || batch.abortedByUser,
|
|
2268
|
+
);
|
|
1974
2269
|
const reachable = stillOwner && state.client !== undefined && state.connected;
|
|
1975
|
-
|
|
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
|