@dorokuma/herdsman-pi 0.13.6 → 0.14.1
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 +610 -64
- package/src/wake.ts +20 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -130,6 +130,89 @@ type HerdsmanState = {
|
|
|
130
130
|
* from the current state instead of deferring again.
|
|
131
131
|
*/
|
|
132
132
|
wakeForcedRelease: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Event ids handed to Pi as a *queued* (non-triggering) follow-up whose
|
|
135
|
+
* content has not been seen entering the transcript yet.
|
|
136
|
+
*
|
|
137
|
+
* A wake injected while the orchestrator streams is parked in the agent's
|
|
138
|
+
* follow-up queue, and Pi only drains that queue when a run reaches its stop
|
|
139
|
+
* point. When the run it rode on already passed that point, the update sits in
|
|
140
|
+
* the queue until the next user message. This set is what keeps such a
|
|
141
|
+
* delivery from being written off, and it carries three guarantees at once:
|
|
142
|
+
*
|
|
143
|
+
* - never lost: while it is non-empty the settlement drives a continuation
|
|
144
|
+
* (bounded by MAX_WAKE_CONTINUATION_ATTEMPTS drives) so a later run drains
|
|
145
|
+
* the queue and carries the update out. The bound is a real ceiling, not a
|
|
146
|
+
* suggestion: once it is spent the ids are released from here and
|
|
147
|
+
* acknowledged (`writeOffStrandedWakeDelivery`), because an update that no
|
|
148
|
+
* turn will ever carry out must not keep a run loop — and the daemon's
|
|
149
|
+
* redelivery of it — alive forever;
|
|
150
|
+
* - never acknowledged unseen: these ids are excluded from the acknowledgement
|
|
151
|
+
* path, so the daemon keeps them pending and redelivers them when this
|
|
152
|
+
* session never consumes them (the only way a delivery Pi itself dropped —
|
|
153
|
+
* clearQueue / restoreQueuedMessagesToEditor — can still be recovered);
|
|
154
|
+
* - never duplicated: they are excluded from every later injection, so a
|
|
155
|
+
* redelivery of the same id cannot put the same content into the transcript
|
|
156
|
+
* twice.
|
|
157
|
+
*
|
|
158
|
+
* Ids leave the set when their content reaches the transcript (consumption
|
|
159
|
+
* evidence: the hidden wake message's `message_end`, which then moves them to
|
|
160
|
+
* `wakeConsumptionObserved`) or when the event leaves the delivery queue for
|
|
161
|
+
* good (acknowledged, covered by the acknowledgement watermark,
|
|
162
|
+
* dead-lettered), and with the delivery queue on a role/scope reset.
|
|
163
|
+
*/
|
|
164
|
+
wakeAwaitingConsumption: Set<number>;
|
|
165
|
+
/**
|
|
166
|
+
* Ids whose content was observed in the transcript and which are therefore
|
|
167
|
+
* confirmed on the evidence alone, whatever the turn that carried them did.
|
|
168
|
+
*
|
|
169
|
+
* The daemon confirms by a monotonic watermark (`where id <= ?`), so an id that
|
|
170
|
+
* stays unacknowledged blocks every later one: a turn that ends in error after
|
|
171
|
+
* the content already reached the orchestrator must not pin that watermark, so
|
|
172
|
+
* the evidence — not the turn outcome — authorises these ids.
|
|
173
|
+
*/
|
|
174
|
+
wakeConsumptionObserved: Set<number>;
|
|
175
|
+
/**
|
|
176
|
+
* Ids already handed to the orchestrator in this session that must never be
|
|
177
|
+
* injected again, because the copy that carries them can outlive the bookkeeping
|
|
178
|
+
* that knew about it.
|
|
179
|
+
*
|
|
180
|
+
* Pi's follow-up queue is process-wide and the extension has no API to query or
|
|
181
|
+
* clear it, so an id can become "un-presented" again while its content is still
|
|
182
|
+
* on its way: a role/scope reset clears the presentation guard (and the delivery
|
|
183
|
+
* queue), and a consumed id leaves the queue without ever being acknowledged (its
|
|
184
|
+
* batch is gone), so the daemon keeps redelivering it. This set is the guard that
|
|
185
|
+
* survives all of that:
|
|
186
|
+
*
|
|
187
|
+
* - an unconsumed delivery carried over a reset lands here (see
|
|
188
|
+
* `clearDeliveryBookkeeping`), which deliberately trades away the redelivery
|
|
189
|
+
* remedy for it (logged there) in exchange for never presenting a duplicate;
|
|
190
|
+
* - a consumed id lands here too, so a redelivery after the scope that consumed
|
|
191
|
+
* it is gone still cannot inject it a second time (the id also re-enters the
|
|
192
|
+
* current scope's `presentedEventIds`, see the consumption evidence handler).
|
|
193
|
+
*
|
|
194
|
+
* Ids leave the set when the daemon has confirmed them (their own
|
|
195
|
+
* acknowledgement, the acknowledgement watermark, dead-lettering) or when they
|
|
196
|
+
* leave the delivery queue for good — the daemon then holds nothing that could be
|
|
197
|
+
* redelivered, so there is nothing left to block.
|
|
198
|
+
*/
|
|
199
|
+
wakeSuppressedEventIds: Set<number>;
|
|
200
|
+
/**
|
|
201
|
+
* Last reason an event id was skipped for injection, for the rate-limited
|
|
202
|
+
* diagnostic in `noteSkippedWakeInjection`: one line per id and reason.
|
|
203
|
+
*
|
|
204
|
+
* Skipping a redelivery is intentional but invisible, so without this the log
|
|
205
|
+
* could not tell "no update arrived" from "updates were suppressed"; with it a
|
|
206
|
+
* daemon that redelivers the same event in a loop still cannot flood the file.
|
|
207
|
+
*/
|
|
208
|
+
wakeSkipLogReasons: Map<number, string>;
|
|
209
|
+
/**
|
|
210
|
+
* Continuation drives already spent on the current unconsumed delivery.
|
|
211
|
+
*
|
|
212
|
+
* Reset when a delivery is handed over and when the unconsumed set empties, so
|
|
213
|
+
* the bound applies per delivery instead of accumulating for the session.
|
|
214
|
+
*/
|
|
215
|
+
wakeContinuationAttempts: number;
|
|
133
216
|
/**
|
|
134
217
|
* Event content queued for a busy orchestrator. Injected through the
|
|
135
218
|
* `context` hook so the running turn sees the update without being
|
|
@@ -201,6 +284,38 @@ export const WAKE_BUSY_SPIN_MS = 100;
|
|
|
201
284
|
* parked forever.
|
|
202
285
|
*/
|
|
203
286
|
export const WAKE_DEFERRED_TIMEOUT_MS = 5_000;
|
|
287
|
+
/**
|
|
288
|
+
* Upper bound on the continuation drives spent on one unconsumed wake delivery.
|
|
289
|
+
*
|
|
290
|
+
* Every drive costs a full agent run, so the budget is deliberately small: three
|
|
291
|
+
* attempts already cover the drive that follows the settlement which missed Pi's
|
|
292
|
+
* follow-up queue plus two retries after intervening runs that also ended before
|
|
293
|
+
* their stop point, and five adds margin for a run that spent its stop point on
|
|
294
|
+
* a tool call. Beyond that the cause is systemic — the runs keep ending early, so
|
|
295
|
+
* starting another one could only produce another empty turn — and the delivery
|
|
296
|
+
* is written off instead (`writeOffStrandedWakeDelivery`): the ids leave
|
|
297
|
+
* `wakeAwaitingConsumption` and are acknowledged, which stops both the
|
|
298
|
+
* continuation loop and the daemon's redelivery of the same event.
|
|
299
|
+
*
|
|
300
|
+
* The trade-off is deliberate and asymmetric: losing one agent update is
|
|
301
|
+
* recoverable (the agent is still there and its transcript can be read
|
|
302
|
+
* directly), while an unbounded continuation loop floods the orchestrator
|
|
303
|
+
* session with empty turns and makes it unusable. A missed update is therefore
|
|
304
|
+
* strictly better than an endless one.
|
|
305
|
+
*/
|
|
306
|
+
export const MAX_WAKE_CONTINUATION_ATTEMPTS = 5;
|
|
307
|
+
/** `customType` of the hidden wake context this extension injects. */
|
|
308
|
+
const WAKE_CONTEXT_CUSTOM_TYPE = "herdsman-wake-context";
|
|
309
|
+
/** `customType` of the hidden marker that drives a missed wake continuation. */
|
|
310
|
+
const WAKE_CONTINUATION_CUSTOM_TYPE = "herdsman-wake-continuation";
|
|
311
|
+
/**
|
|
312
|
+
* Content of the continuation marker. Its only job is to start a run
|
|
313
|
+
* (`triggerTurn: true`) so the run's loop drains the queued follow-up that was
|
|
314
|
+
* never delivered; the wake content itself is not repeated here, so the
|
|
315
|
+
* evidence is not presented twice.
|
|
316
|
+
*/
|
|
317
|
+
const WAKE_CONTINUATION_CONTENT =
|
|
318
|
+
"[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.";
|
|
204
319
|
|
|
205
320
|
type AckFailureClass = "terminal" | "resync" | "transient";
|
|
206
321
|
|
|
@@ -287,6 +402,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
287
402
|
wakeDeferredUntilSettled: false,
|
|
288
403
|
wakeDeferredSince: undefined,
|
|
289
404
|
wakeForcedRelease: false,
|
|
405
|
+
wakeAwaitingConsumption: new Set(),
|
|
406
|
+
wakeConsumptionObserved: new Set(),
|
|
407
|
+
wakeSuppressedEventIds: new Set(),
|
|
408
|
+
wakeSkipLogReasons: new Map(),
|
|
409
|
+
wakeContinuationAttempts: 0,
|
|
290
410
|
wakeContext: undefined,
|
|
291
411
|
wakeRequested: false,
|
|
292
412
|
wakeRequestedThroughEventId: 0,
|
|
@@ -376,13 +496,67 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
376
496
|
};
|
|
377
497
|
|
|
378
498
|
/**
|
|
379
|
-
* Drops one event from the delivery queue. The
|
|
380
|
-
* acknowledgement outcomes (accepted, or terminally refused by the daemon)
|
|
381
|
-
*
|
|
382
|
-
*
|
|
499
|
+
* Drops one event from the delivery queue. The callers are the two
|
|
500
|
+
* acknowledgement outcomes (accepted, or terminally refused by the daemon),
|
|
501
|
+
* the acknowledgement watermark that covers a whole range of ids, and the
|
|
502
|
+
* role/scope reset, which clears the entire stream through
|
|
503
|
+
* `clearDeliveryBookkeeping`.
|
|
504
|
+
*
|
|
505
|
+
* An event that leaves the queue can no longer be awaiting consumption: its
|
|
506
|
+
* acknowledgement cursor covered it, or it is dead-lettered and will never be
|
|
507
|
+
* redelivered. The awaiting set therefore follows the queue here — without
|
|
508
|
+
* that, a dead-lettered id would keep the settlement driving continuations
|
|
509
|
+
* (and holding its batch open) for content that can never arrive. The other
|
|
510
|
+
* two sets describe the same ids and follow the queue just as well: a
|
|
511
|
+
* confirmed id needs no evidence flag, and an id that is gone from the queue
|
|
512
|
+
* cannot be re-injected, so it needs no suppression either.
|
|
383
513
|
*/
|
|
384
514
|
const dropUnackedDelivered = (eventId: number): void => {
|
|
385
515
|
state.unackedDelivered.delete(eventId);
|
|
516
|
+
state.wakeAwaitingConsumption.delete(eventId);
|
|
517
|
+
state.wakeConsumptionObserved.delete(eventId);
|
|
518
|
+
state.wakeSuppressedEventIds.delete(eventId);
|
|
519
|
+
if (state.wakeAwaitingConsumption.size === 0) state.wakeContinuationAttempts = 0;
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Drops the presentation guard, the delivery queue, and the unconsumed
|
|
524
|
+
* bookkeeping together.
|
|
525
|
+
*
|
|
526
|
+
* These describe the same events — handed to Pi, not yet confirmed by the
|
|
527
|
+
* daemon — so they are only ever cleared together, and only by a reset that
|
|
528
|
+
* also discards the pending projection: a genuine role/scope loss and
|
|
529
|
+
* shutdown. A transient disconnect keeps all of them (`preservePresented`) so
|
|
530
|
+
* an in-flight batch can still be settled and acknowledged after the
|
|
531
|
+
* reconnect, and so no already-presented event is presented again.
|
|
532
|
+
*
|
|
533
|
+
* One thing survives the reset (`wakeSuppressedEventIds`), because nothing here
|
|
534
|
+
* can invalidate it: an id handed to Pi may still sit in Pi's process-wide
|
|
535
|
+
* follow-up queue (no API to query or clear it), so dropping it together with
|
|
536
|
+
* the queue would let a daemon redelivery present the same update a second
|
|
537
|
+
* time in the scope that takes over. Unconsumed ids are carried over for that
|
|
538
|
+
* reason; ids consumed *after* their scope was reset join the same set from the
|
|
539
|
+
* consumption handler. The trade-off is explicit: for a carried-over id the
|
|
540
|
+
* redelivery remedy is given up on purpose (see the log line) until the daemon
|
|
541
|
+
* confirms it some other way.
|
|
542
|
+
*/
|
|
543
|
+
const clearDeliveryBookkeeping = () => {
|
|
544
|
+
const carriedOver = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
|
|
545
|
+
for (const eventId of carriedOver) state.wakeSuppressedEventIds.add(eventId);
|
|
546
|
+
if (carriedOver.length > 0) {
|
|
547
|
+
logHerdsmanPi(
|
|
548
|
+
"info",
|
|
549
|
+
`[herdsman-pi] keeping ${carriedOver.length} unconsumed wake event id(s) suppressed across the scope change eventIds=${carriedOver.join(",")} · daemon redelivery for them is traded away to keep the transcript single-copy`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
state.presentedEventIds.clear();
|
|
553
|
+
// Once the guard is gone the daemon's pending events can be presented (and
|
|
554
|
+
// acknowledged) again, so keeping the old queue would only risk a stale id.
|
|
555
|
+
state.unackedDelivered.clear();
|
|
556
|
+
state.wakeAwaitingConsumption.clear();
|
|
557
|
+
state.wakeConsumptionObserved.clear();
|
|
558
|
+
state.wakeSkipLogReasons.clear();
|
|
559
|
+
state.wakeContinuationAttempts = 0;
|
|
386
560
|
};
|
|
387
561
|
|
|
388
562
|
const pruneAcknowledgedEvents = (ackedEventId: number | undefined) => {
|
|
@@ -401,8 +575,69 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
401
575
|
// superseded (the daemon acknowledges by watermark), so it leaves the
|
|
402
576
|
// queue and is never re-acknowledged.
|
|
403
577
|
for (const eventId of [...state.unackedDelivered.keys()]) {
|
|
404
|
-
if (eventId <= ackedEventId)
|
|
578
|
+
if (eventId <= ackedEventId) dropUnackedDelivered(eventId);
|
|
579
|
+
}
|
|
580
|
+
// The watermark also covers ids that survived a reset as suppression
|
|
581
|
+
// entries: the daemon considers them confirmed, so they will not be
|
|
582
|
+
// redelivered and the suppression has nothing left to block.
|
|
583
|
+
for (const eventId of [...state.wakeSuppressedEventIds]) {
|
|
584
|
+
if (eventId <= ackedEventId) state.wakeSuppressedEventIds.delete(eventId);
|
|
585
|
+
}
|
|
586
|
+
for (const eventId of [...state.wakeSkipLogReasons.keys()]) {
|
|
587
|
+
if (eventId <= ackedEventId) state.wakeSkipLogReasons.delete(eventId);
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Records, once per id and reason, that an event which is already in the
|
|
593
|
+
* orchestrator's hands was not injected again.
|
|
594
|
+
*
|
|
595
|
+
* Skipping a redelivery is deliberate — the content must not enter the
|
|
596
|
+
* transcript twice — but it is also invisible: a daemon that keeps redelivering
|
|
597
|
+
* one event would otherwise leave no trace at all, and "an update never
|
|
598
|
+
* arrived" could not be told apart from "an update was suppressed" in the log.
|
|
599
|
+
* The line is emitted once per id and reason (a change of reason is logged
|
|
600
|
+
* again: `presented`, `awaiting` and `suppressed` describe different ownership
|
|
601
|
+
* of the same id), so a redelivery loop cannot flood the file.
|
|
602
|
+
*/
|
|
603
|
+
const noteSkippedWakeInjection = (eventId: number, reason: string): false => {
|
|
604
|
+
if (state.wakeSkipLogReasons.get(eventId) !== reason) {
|
|
605
|
+
state.wakeSkipLogReasons.set(eventId, reason);
|
|
606
|
+
logHerdsmanPi(
|
|
607
|
+
"info",
|
|
608
|
+
`[herdsman-pi] wake injection skipped eventId=${eventId} reason=${reason} · update already presented (or still in flight) in this session, so a daemon redelivery is not shown twice`,
|
|
609
|
+
);
|
|
405
610
|
}
|
|
611
|
+
return false;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Whether an event was already handed to the orchestrator in this session and
|
|
616
|
+
* therefore must not be injected again (logging why, at most once per reason).
|
|
617
|
+
*
|
|
618
|
+
* - `awaiting`: a copy of it was handed over as a queued follow-up that no run
|
|
619
|
+
* has drained yet, and the continuation is what carries it out (checked
|
|
620
|
+
* first: for a queued copy this is the state that explains the redelivery);
|
|
621
|
+
* - `presented`: it was injected before (or its content has since been observed
|
|
622
|
+
* in the transcript), so a redelivery would duplicate it;
|
|
623
|
+
* - `suppressed`: its copy may still sit in Pi's process-wide follow-up queue
|
|
624
|
+
* after a role/scope reset cleared the local guards, so the redelivery is the
|
|
625
|
+
* only one that must not be shown (see `wakeSuppressedEventIds`).
|
|
626
|
+
*/
|
|
627
|
+
const alreadyPresented = (eventId: number): boolean => {
|
|
628
|
+
if (state.wakeAwaitingConsumption.has(eventId)) {
|
|
629
|
+
noteSkippedWakeInjection(eventId, "awaiting");
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
if (state.presentedEventIds.has(eventId)) {
|
|
633
|
+
noteSkippedWakeInjection(eventId, "presented");
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
if (state.wakeSuppressedEventIds.has(eventId)) {
|
|
637
|
+
noteSkippedWakeInjection(eventId, "suppressed");
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
return false;
|
|
406
641
|
};
|
|
407
642
|
|
|
408
643
|
const isWakeableEvent = (event: AgentEventWireRecord | undefined) =>
|
|
@@ -607,6 +842,202 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
607
842
|
}, WAKE_SETTLE_MS);
|
|
608
843
|
};
|
|
609
844
|
|
|
845
|
+
/**
|
|
846
|
+
* Ends the current wake-deferral episode without injecting.
|
|
847
|
+
*
|
|
848
|
+
* `wakeForcedRelease` and `wakeDeferredSince` describe *one* bounded
|
|
849
|
+
* deferral: the released deadline is re-derived from `wakeDeferredSince`
|
|
850
|
+
* every time `scheduleDeferredWake` runs, so a stale pair would let an
|
|
851
|
+
* unrelated later wake bypass the busy gate. Every path that ends a wake
|
|
852
|
+
* pass without injecting clears both, which gives the next deferral its own
|
|
853
|
+
* full `WAKE_DEFERRED_TIMEOUT_MS` budget. The 5s hard deadline itself is
|
|
854
|
+
* unchanged: it is measured inside a single episode, and an episode that
|
|
855
|
+
* reaches it still force-releases the batch on its next pass.
|
|
856
|
+
*/
|
|
857
|
+
const endWakeDeferral = () => {
|
|
858
|
+
state.wakeForcedRelease = false;
|
|
859
|
+
state.wakeDeferredSince = undefined;
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Event ids a hidden wake message proves to have reached the transcript.
|
|
864
|
+
*
|
|
865
|
+
* A wake injection names the outcomes it presents in
|
|
866
|
+
* `details.presentedEventIds`, and Pi writes those details onto the session
|
|
867
|
+
* entry it emits on `message_end` (both for a triggered turn and when a run
|
|
868
|
+
* finally drains the queued follow-up). That emission is the only consumption
|
|
869
|
+
* evidence there is: nothing else tells us that the content — not merely the
|
|
870
|
+
* request that carried it — reached the orchestrator.
|
|
871
|
+
*
|
|
872
|
+
* The wider `details.eventIds` list is deliberately not evidence: it names
|
|
873
|
+
* every id that was pending at injection time, including copies that an
|
|
874
|
+
* earlier injection presented (and that may have been dropped by Pi), so using
|
|
875
|
+
* it would confirm content nobody ever saw. Only real, numeric ids count, and
|
|
876
|
+
* a message without readable ones proves nothing: the caller logs that instead
|
|
877
|
+
* of confirming anything.
|
|
878
|
+
*/
|
|
879
|
+
const wakeConsumedEventIds = (message: Record<string, unknown>): number[] => {
|
|
880
|
+
const presented = record(message.details).presentedEventIds;
|
|
881
|
+
if (!Array.isArray(presented)) return [];
|
|
882
|
+
return presented.filter((eventId): eventId is number => typeof eventId === "number");
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* The contiguous prefix of the delivery queue that may be acknowledged now.
|
|
887
|
+
*
|
|
888
|
+
* The delivery queue — not the injection snapshot — is what gets acknowledged:
|
|
889
|
+
* it holds every event handed to Pi that is still unconfirmed (a release merges
|
|
890
|
+
* batches instead of replacing them), is id-ascending, and an id leaves it only
|
|
891
|
+
* when the daemon accepts it.
|
|
892
|
+
*
|
|
893
|
+
* An acknowledgement tells the daemon "the orchestrator has this", and the
|
|
894
|
+
* daemon confirms by a monotonic watermark (`update agent_events set status =
|
|
895
|
+
* 'acked' where id <= ?`), so acking a larger id confirms every smaller one with
|
|
896
|
+
* it. The queue is therefore walked in ascending id order and only its
|
|
897
|
+
* *contiguous* confirmable prefix is returned: the walk stops at the first id
|
|
898
|
+
* that is not confirmable. Skipping an unconfirmed id would hand it to the
|
|
899
|
+
* watermark, which swallows it for good.
|
|
900
|
+
*
|
|
901
|
+
* An id is confirmable when its content is known to have reached the transcript
|
|
902
|
+
* (`wakeConsumptionObserved`), or — for a delivery that was never queued, i.e. a
|
|
903
|
+
* triggered prompt — when the turn that received it produced a final response or
|
|
904
|
+
* was aborted by the user. An id still awaiting consumption has no evidence and
|
|
905
|
+
* blocks the prefix: leaving it unacknowledged keeps it pending, which is what
|
|
906
|
+
* lets the daemon redeliver the one copy that never arrived.
|
|
907
|
+
*
|
|
908
|
+
* An event whose own acknowledgement already failed is left out (the daemon's
|
|
909
|
+
* cursor advance sweeps it, and a retry would reset its attempt/backoff
|
|
910
|
+
* accounting), but it must not hold the prefix back: it stays in the queue until
|
|
911
|
+
* that cursor or a scope reset confirms it, so a failed or dead-lettered
|
|
912
|
+
* acknowledgement never depends on the timing of the release to stay
|
|
913
|
+
* recoverable. Restricting to a *live* row with `attempts === 0` has two holes
|
|
914
|
+
* on purpose: `?? 0` covers an event the live projection no longer holds at all
|
|
915
|
+
* (the server stopped listing it, or `failedWakeThroughEventId` filters it out),
|
|
916
|
+
* and the projection cannot tell us it already failed — so the event gets one
|
|
917
|
+
* more attempt, a deliberate self-healing opportunity that then accumulates on
|
|
918
|
+
* the queue copy's counter and can reach MAX_ACK_ATTEMPTS instead of restarting
|
|
919
|
+
* at 1 every round.
|
|
920
|
+
*/
|
|
921
|
+
const confirmableDeliveryPrefix = (turnProducedFinalResponse: boolean) => {
|
|
922
|
+
const deliveryQueue = unackedDeliveredAscending();
|
|
923
|
+
const confirmablePrefix: AgentEventWireRecord[] = [];
|
|
924
|
+
let blockedByMissingTurn = false;
|
|
925
|
+
for (const event of deliveryQueue) {
|
|
926
|
+
if (state.wakeAwaitingConsumption.has(event.id)) break;
|
|
927
|
+
if (!state.wakeConsumptionObserved.has(event.id) && !turnProducedFinalResponse) {
|
|
928
|
+
blockedByMissingTurn = true;
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
// An event whose own acknowledgement already failed is left to the
|
|
932
|
+
// daemon's cursor sweep (see above); it must not hold the prefix back.
|
|
933
|
+
if ((state.pendingEvents.find((pending) => pending.id === event.id)?.attempts ?? 0) > 0) {
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
confirmablePrefix.push(event);
|
|
937
|
+
}
|
|
938
|
+
// Any id still awaiting consumption keeps the batch in flight: it owns the
|
|
939
|
+
// acknowledgement cursor, so the settlement of the run that finally drains
|
|
940
|
+
// the queued copy (the continuation driven by the settle handler) confirms it
|
|
941
|
+
// then. Reading the queue directly — instead of comparing two filtered lists —
|
|
942
|
+
// keeps that decision independent of the `attempts` exclusion above, which
|
|
943
|
+
// would otherwise hide an unconsumed id and drop the batch too early.
|
|
944
|
+
const stillAwaitingConsumption = deliveryQueue.some((event) =>
|
|
945
|
+
state.wakeAwaitingConsumption.has(event.id),
|
|
946
|
+
);
|
|
947
|
+
return { deliveryQueue, confirmablePrefix, blockedByMissingTurn, stillAwaitingConsumption };
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
const writeOffStrandedWakeDelivery = (eventIds: number[], ctx: PiContext): void => {
|
|
951
|
+
// Release first, acknowledge second: the release is what breaks the loop
|
|
952
|
+
// synchronously, while the acknowledgement is an RPC that may fail.
|
|
953
|
+
for (const eventId of eventIds) {
|
|
954
|
+
state.wakeAwaitingConsumption.delete(eventId);
|
|
955
|
+
// This set only ever holds the id of a delivery that was already handed
|
|
956
|
+
// to Pi, so releasing it is not permission to present it again: the
|
|
957
|
+
// session-wide presented guard stays behind and keeps a daemon replay
|
|
958
|
+
// of the same id out of the transcript.
|
|
959
|
+
state.presentedEventIds.add(eventId);
|
|
960
|
+
}
|
|
961
|
+
// Fresh budget for whatever is still awaiting consumption: the ids just
|
|
962
|
+
// written off are gone, and the ones that remain have not had their own
|
|
963
|
+
// MAX_WAKE_CONTINUATION_ATTEMPTS drives spent on them yet.
|
|
964
|
+
state.wakeContinuationAttempts = 0;
|
|
965
|
+
const stranded = eventIds
|
|
966
|
+
.map((eventId) => state.unackedDelivered.get(eventId))
|
|
967
|
+
.filter((event): event is AgentEventWireRecord => event !== undefined)
|
|
968
|
+
.sort((left, right) => left.id - right.id);
|
|
969
|
+
logHerdsmanPi(
|
|
970
|
+
"warn",
|
|
971
|
+
`[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`,
|
|
972
|
+
);
|
|
973
|
+
ctx.ui.notify?.(
|
|
974
|
+
`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`,
|
|
975
|
+
"warning",
|
|
976
|
+
);
|
|
977
|
+
if (stranded.length === 0) return;
|
|
978
|
+
void acknowledgeEventIds(stranded, { notify: false }, ctx).catch((error: unknown) => {
|
|
979
|
+
logHerdsmanPi(
|
|
980
|
+
"warn",
|
|
981
|
+
`[herdsman-pi] wake write-off acknowledgement failed eventIds=${stranded.map((event) => event.id).join(",")} · ${String(error)}`,
|
|
982
|
+
);
|
|
983
|
+
});
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Drives one continuation that carries out a wake Pi has not drained.
|
|
988
|
+
*
|
|
989
|
+
* A wake injected while the orchestrator streams is delivered as a queued
|
|
990
|
+
* follow-up (`triggerTurn: false`), and Pi only drains that queue when a run
|
|
991
|
+
* reaches its stop point (`agent-loop` "Agent would stop here. Check for
|
|
992
|
+
* follow-up messages."). When the run it rode on already passed that point,
|
|
993
|
+
* the update sits in the queue until the next user message. At settlement the
|
|
994
|
+
* orchestrator is no longer streaming, so `triggerTurn: true` starts a real
|
|
995
|
+
* run (`_runAgentPrompt`) and that run's loop drains the queued follow-up.
|
|
996
|
+
* The marker carries no wake content: the queued follow-up is what delivers
|
|
997
|
+
* the evidence, exactly once.
|
|
998
|
+
*
|
|
999
|
+
* Only consumption evidence writes a delivery off, so an intervening run that
|
|
1000
|
+
* ends before its stop point (error, user abort, a refused tool) leaves the
|
|
1001
|
+
* next settlement driving again. That is bounded by
|
|
1002
|
+
* MAX_WAKE_CONTINUATION_ATTEMPTS: starting runs cannot fix a cause that is
|
|
1003
|
+
* not about the queue, so once the budget is spent the delivery is written
|
|
1004
|
+
* off (released and acknowledged) instead of looping forever — see
|
|
1005
|
+
* `writeOffStrandedWakeDelivery` for the trade-off.
|
|
1006
|
+
*/
|
|
1007
|
+
const driveWakeContinuation = (ctx: PiContext) => {
|
|
1008
|
+
if (state.wakeAwaitingConsumption.size === 0) return;
|
|
1009
|
+
if (!pi.sendMessage) return;
|
|
1010
|
+
const eventIds = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
|
|
1011
|
+
state.wakeContinuationAttempts += 1;
|
|
1012
|
+
try {
|
|
1013
|
+
pi.sendMessage(
|
|
1014
|
+
{
|
|
1015
|
+
content: WAKE_CONTINUATION_CONTENT,
|
|
1016
|
+
customType: WAKE_CONTINUATION_CUSTOM_TYPE,
|
|
1017
|
+
display: false,
|
|
1018
|
+
},
|
|
1019
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
1020
|
+
);
|
|
1021
|
+
logHerdsmanPi(
|
|
1022
|
+
"info",
|
|
1023
|
+
`[herdsman-pi] wake continuation driven eventIds=${eventIds[0] ?? 0}-${eventIds.at(-1) ?? 0} count=${eventIds.length} attempt=${state.wakeContinuationAttempts}`,
|
|
1024
|
+
);
|
|
1025
|
+
} catch {
|
|
1026
|
+
logHerdsmanPi("warn", "[herdsman-pi] wake continuation refused by pi");
|
|
1027
|
+
}
|
|
1028
|
+
if (state.wakeContinuationAttempts >= MAX_WAKE_CONTINUATION_ATTEMPTS) {
|
|
1029
|
+
// This was the last drive the budget allows, and the queued copy is still
|
|
1030
|
+
// undrained: the delivery is written off instead of being left pending
|
|
1031
|
+
// forever. The ids are released from `wakeAwaitingConsumption` (no further
|
|
1032
|
+
// run is started for them) and acknowledged, which is what stops the
|
|
1033
|
+
// daemon from redelivering them every freshness window — the loop that
|
|
1034
|
+
// used to flood the orchestrator session until manual sqlite surgery.
|
|
1035
|
+
// Losing one update is the accepted cost; see
|
|
1036
|
+
// `writeOffStrandedWakeDelivery`.
|
|
1037
|
+
writeOffStrandedWakeDelivery([...state.wakeAwaitingConsumption], ctx);
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
|
|
610
1041
|
/**
|
|
611
1042
|
* Arms the bounded deferral for a wake that cannot be injected right now.
|
|
612
1043
|
*
|
|
@@ -639,12 +1070,19 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
639
1070
|
|
|
640
1071
|
const scheduleWake = (ctx: PiContext | undefined) => {
|
|
641
1072
|
if (!ctx || !state.isOrchestrator || !state.currentScope || !pi.sendMessage) return;
|
|
642
|
-
if (state.wakeTimer || state.wakeRequested)
|
|
1073
|
+
if (state.wakeTimer || state.wakeRequested) {
|
|
1074
|
+
// A pending wake owns the release; this branch is also hit re-entrantly by
|
|
1075
|
+
// a running pass (its fired settle timer is still set), where clearing
|
|
1076
|
+
// `wakeForcedRelease` would re-defer a batch the deadline just released.
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
643
1079
|
const projection = projectAgentOutcomes(state.pendingEvents, wakeFilter);
|
|
644
1080
|
const outcomes = projection.outcomes.filter(
|
|
645
1081
|
(outcome) =>
|
|
646
1082
|
outcome.eventId > state.failedWakeThroughEventId &&
|
|
647
|
-
|
|
1083
|
+
// Already in the orchestrator's hands: injecting it again would put the
|
|
1084
|
+
// same content into the transcript twice. See `alreadyPresented`.
|
|
1085
|
+
!alreadyPresented(outcome.eventId),
|
|
648
1086
|
);
|
|
649
1087
|
const suppressedEvents = projection.suppressedUpstreamErrorEventIds
|
|
650
1088
|
.filter(
|
|
@@ -659,6 +1097,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
659
1097
|
isWakeableEvent(state.pendingEvents.find((pending) => pending.id === outcome.eventId)),
|
|
660
1098
|
);
|
|
661
1099
|
if (wakeable.length === 0) {
|
|
1100
|
+
// Nothing is wakeable right now, so the deferral that led here is over:
|
|
1101
|
+
// its released deadline must not let a later, unrelated wake bypass the
|
|
1102
|
+
// busy gate.
|
|
1103
|
+
endWakeDeferral();
|
|
662
1104
|
// A suppressed upstream error that is now due must be silently
|
|
663
1105
|
// acknowledged before any backoff timer is planted: planting the timer
|
|
664
1106
|
// first would make scheduleSilentUpstreamErrorAck's entry guard
|
|
@@ -712,10 +1154,16 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
712
1154
|
state.currentScope?.workspaceId !== ownerWorkspaceId
|
|
713
1155
|
) {
|
|
714
1156
|
state.wakeTimer = undefined;
|
|
1157
|
+
// The pass belongs to a stale generation or scope, so its deferral
|
|
1158
|
+
// episode ends here (a scope reset usually got there first through
|
|
1159
|
+
// `cancelWakeTimer`).
|
|
1160
|
+
endWakeDeferral();
|
|
715
1161
|
return;
|
|
716
1162
|
}
|
|
717
1163
|
if (ctx.isIdle?.() === false && !state.wakeForcedRelease) {
|
|
718
1164
|
state.wakeTimer = undefined;
|
|
1165
|
+
// Still busy: this pass re-defers, so the deferral episode (and with
|
|
1166
|
+
// it the 5s deadline) must keep running instead of restarting.
|
|
719
1167
|
scheduleDeferredWake(ctx);
|
|
720
1168
|
return;
|
|
721
1169
|
}
|
|
@@ -727,11 +1175,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
727
1175
|
)) as ConnectionStateResponse | undefined;
|
|
728
1176
|
if (!response) {
|
|
729
1177
|
state.wakeTimer = undefined;
|
|
1178
|
+
endWakeDeferral();
|
|
730
1179
|
return;
|
|
731
1180
|
}
|
|
732
1181
|
applyConnectionStateResponse(response, ctx);
|
|
733
1182
|
} catch {
|
|
734
1183
|
state.wakeTimer = undefined;
|
|
1184
|
+
endWakeDeferral();
|
|
735
1185
|
// A failed load is only temporary: the batch stays pending and is
|
|
736
1186
|
// retried on the next wake instead of being permanently suppressed.
|
|
737
1187
|
ctx.ui.notify?.(
|
|
@@ -749,10 +1199,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
749
1199
|
state.currentScope?.workspaceId !== ownerWorkspaceId
|
|
750
1200
|
) {
|
|
751
1201
|
state.wakeTimer = undefined;
|
|
1202
|
+
// See the earlier generation re-check: the episode ends with it.
|
|
1203
|
+
endWakeDeferral();
|
|
752
1204
|
return;
|
|
753
1205
|
}
|
|
754
1206
|
if (ctx.isIdle?.() === false && !state.wakeForcedRelease) {
|
|
755
1207
|
state.wakeTimer = undefined;
|
|
1208
|
+
// See the earlier re-check: re-deferring keeps this episode's 5s
|
|
1209
|
+
// deadline intact.
|
|
756
1210
|
scheduleDeferredWake(ctx);
|
|
757
1211
|
return;
|
|
758
1212
|
}
|
|
@@ -763,11 +1217,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
763
1217
|
const batchOutcomes = batchProjection.outcomes.filter(
|
|
764
1218
|
(outcome) =>
|
|
765
1219
|
outcome.eventId > state.failedWakeThroughEventId &&
|
|
766
|
-
!
|
|
1220
|
+
!alreadyPresented(outcome.eventId) &&
|
|
767
1221
|
isWakeableEvent(batchEvents.find((event) => event.id === outcome.eventId)),
|
|
768
1222
|
);
|
|
769
1223
|
if (batchOutcomes.length === 0) {
|
|
770
1224
|
state.wakeTimer = undefined;
|
|
1225
|
+
endWakeDeferral();
|
|
771
1226
|
return;
|
|
772
1227
|
}
|
|
773
1228
|
const current = batchOutcomes;
|
|
@@ -785,11 +1240,18 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
785
1240
|
// running turn can already see it.
|
|
786
1241
|
const orchestratorBusy = ctx.isIdle?.() === false;
|
|
787
1242
|
const wakeContent = formatAgentOutcomeUpdates(batchOutcomes);
|
|
1243
|
+
// Single line, injection path only: the decision that produced this
|
|
1244
|
+
// batch plus the signals it came from, so a wake that still arrives
|
|
1245
|
+
// late can be told apart from one parked by a stale gate.
|
|
1246
|
+
logHerdsmanPi(
|
|
1247
|
+
"info",
|
|
1248
|
+
`[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}`,
|
|
1249
|
+
);
|
|
788
1250
|
try {
|
|
789
1251
|
pi.sendMessage?.(
|
|
790
1252
|
{
|
|
791
1253
|
content: wakeContent,
|
|
792
|
-
customType:
|
|
1254
|
+
customType: WAKE_CONTEXT_CUSTOM_TYPE,
|
|
793
1255
|
// Suppressed upstream errors are dropped from the injected
|
|
794
1256
|
// context, but every other pending id stays listed so the
|
|
795
1257
|
// evidence trail for the decision still names what was pending.
|
|
@@ -797,6 +1259,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
797
1259
|
eventIds: batchEvents
|
|
798
1260
|
.filter((event) => !batchSuppressedIds.has(event.id))
|
|
799
1261
|
.map((event) => event.id),
|
|
1262
|
+
// The evidence channel: the ids whose content this message
|
|
1263
|
+
// actually carries (`eventIds` above lists everything that was
|
|
1264
|
+
// pending, including copies an earlier injection presented).
|
|
1265
|
+
// Only these prove consumption when a run drains this message.
|
|
1266
|
+
presentedEventIds: batchOutcomes.map((outcome) => outcome.eventId),
|
|
800
1267
|
},
|
|
801
1268
|
display: false,
|
|
802
1269
|
},
|
|
@@ -804,6 +1271,18 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
804
1271
|
? { deliverAs: "followUp", triggerTurn: false }
|
|
805
1272
|
: { deliverAs: "followUp", triggerTurn: true },
|
|
806
1273
|
);
|
|
1274
|
+
// A queued (non-triggering) delivery rides the running turn: Pi parks it
|
|
1275
|
+
// in the agent's follow-up queue, which a run only drains when it reaches
|
|
1276
|
+
// its stop point. If the run it rode on already passed that point, nothing
|
|
1277
|
+
// drains it — the settlement drives a continuation instead (see
|
|
1278
|
+
// `driveWakeContinuation`), and until the content is seen in the
|
|
1279
|
+
// transcript the delivery is neither acknowledged nor injected again.
|
|
1280
|
+
if (orchestratorBusy) {
|
|
1281
|
+
for (const outcome of batchOutcomes) {
|
|
1282
|
+
state.wakeAwaitingConsumption.add(outcome.eventId);
|
|
1283
|
+
}
|
|
1284
|
+
state.wakeContinuationAttempts = 0;
|
|
1285
|
+
}
|
|
807
1286
|
// A queued (non-triggering) delivery keeps the content available to
|
|
808
1287
|
// the current turn through the context hook until it is settled or
|
|
809
1288
|
// superseded by the next injection.
|
|
@@ -851,6 +1330,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
851
1330
|
} catch {
|
|
852
1331
|
state.deliveredBatch = undefined;
|
|
853
1332
|
state.wakeRequested = false;
|
|
1333
|
+
// Nothing was queued by the refused injection, so nothing is awaiting
|
|
1334
|
+
// consumption on its account either: the ids only enter the awaiting set
|
|
1335
|
+
// once Pi accepted the message (see the queued branch above). The
|
|
1336
|
+
// refused injection consumed this deferral, so clearing it keeps the
|
|
1337
|
+
// elapsed deadline from letting a later, unrelated wake bypass the busy
|
|
1338
|
+
// gate.
|
|
1339
|
+
endWakeDeferral();
|
|
854
1340
|
}
|
|
855
1341
|
};
|
|
856
1342
|
void startWake();
|
|
@@ -894,13 +1380,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
894
1380
|
// A transient disconnect (reconnect) keeps the presentation guard so an
|
|
895
1381
|
// event already presented in this scope session is not presented again;
|
|
896
1382
|
// only a genuine role/scope loss or shutdown resets it.
|
|
897
|
-
if (!options.preservePresented)
|
|
898
|
-
state.presentedEventIds.clear();
|
|
899
|
-
// The delivery queue dies with the presentation guard: once the guard is
|
|
900
|
-
// gone the daemon's pending events can be presented (and acknowledged)
|
|
901
|
-
// again, so keeping the old queue would only risk a stale id.
|
|
902
|
-
state.unackedDelivered.clear();
|
|
903
|
-
}
|
|
1383
|
+
if (!options.preservePresented) clearDeliveryBookkeeping();
|
|
904
1384
|
state.reconnectingFromOn = false;
|
|
905
1385
|
setHerdsmanUi(ctx);
|
|
906
1386
|
};
|
|
@@ -930,8 +1410,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
930
1410
|
cancelWake();
|
|
931
1411
|
state.failedWakeThroughEventId = 0;
|
|
932
1412
|
state.pendingEvents = [];
|
|
933
|
-
|
|
934
|
-
state.unackedDelivered.clear();
|
|
1413
|
+
clearDeliveryBookkeeping();
|
|
935
1414
|
setHerdsmanUi(ctx);
|
|
936
1415
|
};
|
|
937
1416
|
|
|
@@ -1337,6 +1816,39 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1337
1816
|
|
|
1338
1817
|
pi.on("message_end", (event: Record<string, unknown>) => {
|
|
1339
1818
|
const message = record(event.message);
|
|
1819
|
+
if (message.role === "custom" && message.customType === WAKE_CONTEXT_CUSTOM_TYPE) {
|
|
1820
|
+
// Consumption evidence: the wake content reached the transcript, so some
|
|
1821
|
+
// run drained the queued follow-up and carried the update out. The ids the
|
|
1822
|
+
// message presented are confirmed — no further continuation is owed for
|
|
1823
|
+
// them, and they are now authorised for acknowledgement on their own,
|
|
1824
|
+
// whatever conclusion the turn reached.
|
|
1825
|
+
const consumedEventIds = wakeConsumedEventIds(message);
|
|
1826
|
+
if (consumedEventIds.length === 0) {
|
|
1827
|
+
// Nothing may be confirmed from a message whose evidence cannot be read
|
|
1828
|
+
// (a shape this extension never emits), and that must not pass silently:
|
|
1829
|
+
// the ids that are still waiting for their evidence must be named so the
|
|
1830
|
+
// stuck delivery is traceable.
|
|
1831
|
+
const awaiting = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
|
|
1832
|
+
logHerdsmanPi(
|
|
1833
|
+
"warn",
|
|
1834
|
+
`[herdsman-pi] wake consumption evidence unusable customType=${String(message.customType)} awaiting=${awaiting.length === 0 ? "none" : awaiting.join(",")} detailsKeys=${Object.keys(record(message.details)).join(",") || "none"} · no event confirmed by this message`,
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
for (const eventId of consumedEventIds) {
|
|
1838
|
+
state.wakeAwaitingConsumption.delete(eventId);
|
|
1839
|
+
// The content reached the transcript, so this id counts as presented from
|
|
1840
|
+
// now on: a daemon redelivery of it (it is still unacknowledged whenever
|
|
1841
|
+
// its batch was already dropped) must not inject the same update again.
|
|
1842
|
+
// The scope's own guard may be gone — the copy can be drained after a
|
|
1843
|
+
// role/scope reset cleared it — so the session-wide suppression set keeps
|
|
1844
|
+
// the id as well; it is released when the daemon confirms the id.
|
|
1845
|
+
state.presentedEventIds.add(eventId);
|
|
1846
|
+
state.wakeSuppressedEventIds.add(eventId);
|
|
1847
|
+
// Only an id the daemon can still be told about is worth remembering.
|
|
1848
|
+
if (state.unackedDelivered.has(eventId)) state.wakeConsumptionObserved.add(eventId);
|
|
1849
|
+
}
|
|
1850
|
+
if (state.wakeAwaitingConsumption.size === 0) state.wakeContinuationAttempts = 0;
|
|
1851
|
+
}
|
|
1340
1852
|
if (message.role !== "assistant") return;
|
|
1341
1853
|
const stopReason = stringValue(message.stopReason);
|
|
1342
1854
|
if (state.deliveredBatch) {
|
|
@@ -1365,6 +1877,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1365
1877
|
});
|
|
1366
1878
|
|
|
1367
1879
|
pi.on("agent_start", () => {
|
|
1880
|
+
// Deliberately no clearing here: a starting run does drain Pi's follow-up
|
|
1881
|
+
// queue, but that is not evidence that the content reached the transcript —
|
|
1882
|
+
// a run can end in error or be aborted before its stop point and leave the
|
|
1883
|
+
// queue untouched. Only the consumption signal (the hidden wake message's
|
|
1884
|
+
// `message_end`) writes a delivery off, so an update a run did not take
|
|
1885
|
+
// along is still driven out at the next settlement instead of waiting for
|
|
1886
|
+
// the next user message.
|
|
1368
1887
|
if (state.runActive) return;
|
|
1369
1888
|
state.runActive = true;
|
|
1370
1889
|
state.pinnedContext =
|
|
@@ -1400,6 +1919,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1400
1919
|
// to the orchestrator in the turn that presented them, so they are not
|
|
1401
1920
|
// re-listed here. The queued follow-up message itself keeps the wider
|
|
1402
1921
|
// `details.eventIds` set (everything still unconfirmed).
|
|
1922
|
+
//
|
|
1923
|
+
// The pin is not a second delivery of the update: it makes the same queued
|
|
1924
|
+
// copy visible early to the turn that is running, it is not written to the
|
|
1925
|
+
// transcript, it is never acknowledged on its own, and it is not consumption
|
|
1926
|
+
// evidence — only the hidden wake message's `message_end` is. So it may not be
|
|
1927
|
+
// dropped in the name of "one copy only" either: it is the only way this turn
|
|
1928
|
+
// ever sees the update.
|
|
1403
1929
|
const queuedWake = state.wakeContext;
|
|
1404
1930
|
if (queuedWake) {
|
|
1405
1931
|
additions.push({
|
|
@@ -1417,16 +1943,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1417
1943
|
pi.on("agent_settled", async (_event: unknown, ctx: PiContext) => {
|
|
1418
1944
|
state.runActive = false;
|
|
1419
1945
|
state.pinnedContext = undefined;
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
state.deliveredBatch = undefined;
|
|
1427
|
-
state.ackInFlight = true;
|
|
1428
|
-
const stillOwner =
|
|
1429
|
-
state.isOrchestrator && state.currentScope?.terminalId === batch.ownerTerminalId;
|
|
1946
|
+
// A wake delivered as a queued follow-up is drained only when a run reaches
|
|
1947
|
+
// its stop point. If the run that received it settled without draining it,
|
|
1948
|
+
// no further run exists to carry the update out: drive a continuation (up to
|
|
1949
|
+
// the per-delivery bound, see `driveWakeContinuation`), which is what
|
|
1950
|
+
// surfaces the queued update.
|
|
1951
|
+
driveWakeContinuation(ctx);
|
|
1430
1952
|
const failBatch = () => {
|
|
1431
1953
|
ctx.ui.notify?.(
|
|
1432
1954
|
"Herdsman couldn’t acknowledge agent updates · updates remain pending",
|
|
@@ -1443,47 +1965,61 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1443
1965
|
scheduleWake(ctx);
|
|
1444
1966
|
};
|
|
1445
1967
|
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1968
|
+
const batch = state.deliveredBatch;
|
|
1969
|
+
if (!batch) {
|
|
1970
|
+
state.wakeDeferredUntilSettled = false;
|
|
1971
|
+
// A queue can outlive its batch: the batch record is dropped as soon as
|
|
1972
|
+
// nothing awaits consumption, while an acknowledgement it still owed can be
|
|
1973
|
+
// missing — most visibly when the daemon was unreachable while the content
|
|
1974
|
+
// was consumed. Without this pass those ids would pin the watermark until an
|
|
1975
|
+
// unrelated event happened to form a new batch, and a session that receives
|
|
1976
|
+
// no further update would never confirm what its transcript already holds.
|
|
1977
|
+
// With no batch left, no turn can vouch for an untracked delivery, so only
|
|
1978
|
+
// consumption evidence confirms an id.
|
|
1979
|
+
const stranded = confirmableDeliveryPrefix(false);
|
|
1980
|
+
if (
|
|
1981
|
+
stranded.confirmablePrefix.length > 0 &&
|
|
1982
|
+
state.isOrchestrator &&
|
|
1983
|
+
state.client !== undefined &&
|
|
1984
|
+
state.connected
|
|
1985
|
+
) {
|
|
1986
|
+
const resumeAckInFlight = state.ackInFlight;
|
|
1987
|
+
state.ackInFlight = true;
|
|
1988
|
+
try {
|
|
1989
|
+
await acknowledgeEventIds(stranded.confirmablePrefix, { notify: true }, ctx);
|
|
1990
|
+
} finally {
|
|
1991
|
+
state.ackInFlight = resumeAckInFlight;
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
scheduleWake(ctx);
|
|
1471
1995
|
return;
|
|
1472
1996
|
}
|
|
1473
1997
|
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1998
|
+
const stillOwner =
|
|
1999
|
+
state.isOrchestrator && state.currentScope?.terminalId === batch.ownerTerminalId;
|
|
2000
|
+
const { deliveryQueue, confirmablePrefix, blockedByMissingTurn, stillAwaitingConsumption } =
|
|
2001
|
+
confirmableDeliveryPrefix(batch.assistantFinalSucceeded || batch.abortedByUser);
|
|
2002
|
+
const reachable = stillOwner && state.client !== undefined && state.connected;
|
|
2003
|
+
if (!stillAwaitingConsumption) state.deliveredBatch = undefined;
|
|
2004
|
+
state.ackInFlight = true;
|
|
2005
|
+
if (!reachable) {
|
|
2006
|
+
// Unreachable (ownership gone, or a disconnect): attempting an
|
|
2007
|
+
// acknowledgement now would only be refused and would burn the event's
|
|
2008
|
+
// attempt budget, while the queue keeps every event for the next
|
|
2009
|
+
// settlement on a live connection.
|
|
2010
|
+
if (deliveryQueue.length > 0) failBatch();
|
|
2011
|
+
finishBatch();
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
if (blockedByMissingTurn) failBatch();
|
|
2015
|
+
if (confirmablePrefix.length === 0) {
|
|
2016
|
+
// Nothing to confirm: an empty queue (the batch is settled) or a prefix
|
|
2017
|
+
// blocked by unconsumed content, which must not be confirmed yet.
|
|
1482
2018
|
finishBatch();
|
|
1483
2019
|
return;
|
|
1484
2020
|
}
|
|
1485
2021
|
|
|
1486
|
-
await acknowledgeEventIds(
|
|
2022
|
+
await acknowledgeEventIds(confirmablePrefix, { notify: true }, ctx);
|
|
1487
2023
|
finishBatch();
|
|
1488
2024
|
});
|
|
1489
2025
|
|
|
@@ -1623,12 +2159,22 @@ function record(value: unknown): Record<string, unknown> {
|
|
|
1623
2159
|
}
|
|
1624
2160
|
|
|
1625
2161
|
function cleanContextText(value: string): string {
|
|
2162
|
+
// Same newline-preserving scheme as `normalizeExcerpt` in wake.ts: which byte a
|
|
2163
|
+
// line break is (CRLF, CR, U+2028/U+2029, NEL) is normalised to LF, trailing
|
|
2164
|
+
// whitespace is dropped per line, 3+ newlines collapse to a blank line, and
|
|
2165
|
+
// leading indentation plus inline runs of whitespace are kept as they are.
|
|
2166
|
+
// NEL (U+0085) is excluded from the control-character clearing regex below,
|
|
2167
|
+
// which would otherwise delete it as an unprintable byte; the rest of that
|
|
2168
|
+
// regex is left intact.
|
|
1626
2169
|
return stripVTControlCharacters(value)
|
|
1627
2170
|
.replace(
|
|
1628
|
-
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
|
|
2171
|
+
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0084\u0086-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
|
|
1629
2172
|
"",
|
|
1630
2173
|
)
|
|
1631
|
-
.replace(/\
|
|
2174
|
+
.replace(/\r\n?/g, "\n")
|
|
2175
|
+
.replace(/[\u2028\u2029\u0085]/g, "\n")
|
|
2176
|
+
.replace(/[^\S\n]+$/gm, "")
|
|
2177
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1632
2178
|
.trim();
|
|
1633
2179
|
}
|
|
1634
2180
|
|
package/src/wake.ts
CHANGED
|
@@ -34,7 +34,26 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|
|
34
34
|
function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; }
|
|
35
35
|
function normalizeExcerpt(value: unknown): string {
|
|
36
36
|
const raw = stringValue(value) ?? "";
|
|
37
|
-
|
|
37
|
+
// Line structure is evidence, not noise: a code block or a markdown heading
|
|
38
|
+
// only reads as one while its newlines survive, so this chain normalises
|
|
39
|
+
// *which byte* a line break is and where whitespace sits inside a line, and
|
|
40
|
+
// never folds a newline into a space:
|
|
41
|
+
// - CRLF/CR and the Unicode line separators (U+2028, U+2029, NEL) become LF,
|
|
42
|
+
// so "a line" means one thing everywhere downstream (NEL is excluded from
|
|
43
|
+
// the control-character regex, which would otherwise delete it);
|
|
44
|
+
// - trailing whitespace is dropped per line, so a "blank" line padded with
|
|
45
|
+
// spaces still collapses (it would otherwise defeat the 3+ newline rule);
|
|
46
|
+
// - three or more newlines collapse to a single blank line;
|
|
47
|
+
// - leading indentation and inline runs of whitespace are kept as they are,
|
|
48
|
+
// because they are what keeps a code block readable.
|
|
49
|
+
// No length cap is applied here.
|
|
50
|
+
return stripVTControlCharacters(raw)
|
|
51
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0084\u0086-\u009f]/g, "")
|
|
52
|
+
.replace(/\r\n?/g, "\n")
|
|
53
|
+
.replace(/[\u2028\u2029\u0085]/g, "\n")
|
|
54
|
+
.replace(/[^\S\n]+$/gm, "")
|
|
55
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
56
|
+
.trim();
|
|
38
57
|
}
|
|
39
58
|
function outcomeKind(event: AgentEventWireRecord): AgentOutcome["kind"] | undefined {
|
|
40
59
|
if (!event.terminalId) return undefined;
|