@dorokuma/herdsman-pi 0.12.1 → 0.13.2
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/README.md +4 -2
- package/package.json +2 -5
- package/src/index.ts +276 -39
- package/src/wake.ts +28 -3
package/README.md
CHANGED
|
@@ -4,14 +4,16 @@ Pi >= 0.80.6 extension for Herdsman agent history and automatic agent-update wak
|
|
|
4
4
|
|
|
5
5
|
This package contains the runtime extension only. The Agent Skill remains at the repository root.
|
|
6
6
|
|
|
7
|
-
Install the Herdsman CLI and Pi package, then
|
|
7
|
+
Install the Herdsman CLI and Pi package, then check that the daemon is running:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npm install --global @dorokuma/herdsman
|
|
11
11
|
pi install npm:@dorokuma/herdsman-pi
|
|
12
|
-
herdsman daemon
|
|
12
|
+
herdsman daemon status
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
Herdsman has no CLI start/stop command. On the production host the daemon runs under the systemd unit `herdsman.service` and stays running (`systemctl restart herdsman.service`); for a development or throwaway environment, run the daemon entrypoint in the foreground with an explicit temporary data directory (`HERDSMAN_HOME=/tmp/<name> node <package>/dist/src/cli/herdsman-daemon.js`) instead of the production one.
|
|
16
|
+
|
|
15
17
|
When Pi runs inside Herdr, this extension connects to the Herdsman daemon and registers its exact Pi session path as presence identity. After register it sends `agent.ping` at least every 30 seconds so an idle owner is not dropped by the daemon heartbeat. It does not send per-turn tool-result or final-message telemetry.
|
|
16
18
|
|
|
17
19
|
Enter these commands in Pi, not in a shell:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dorokuma/herdsman-pi",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension bridge for Herdsman agent history.
|
|
3
|
+
"version": "0.13.2",
|
|
4
|
+
"description": "Pi extension bridge for Herdsman agent history.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
@@ -34,9 +34,6 @@
|
|
|
34
34
|
"@earendil-works/pi-tui": ">=0.80.6"
|
|
35
35
|
},
|
|
36
36
|
"author": "dorokuma",
|
|
37
|
-
"contributors": [
|
|
38
|
-
"Ryo Nakae (https://github.com/ryonakae)"
|
|
39
|
-
],
|
|
40
37
|
"bugs": {
|
|
41
38
|
"url": "https://github.com/dorokuma/herdsman/issues"
|
|
42
39
|
},
|
package/src/index.ts
CHANGED
|
@@ -113,7 +113,29 @@ type HerdsmanState = {
|
|
|
113
113
|
roleMutationInFlight: boolean;
|
|
114
114
|
sessionRef: AgentSessionRef | undefined;
|
|
115
115
|
subscriberId: string | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* Delivery queue: events handed to Pi whose acknowledgement is still
|
|
118
|
+
* outstanding. Id-keyed so a repeated id is stored exactly once (deliver
|
|
119
|
+
* once), and every consumer sorts by id. Entries are only removed by an
|
|
120
|
+
* acknowledgement (success or dead-letter) or by a role/scope reset that also
|
|
121
|
+
* clears `presentedEventIds`, so releasing a deferred wake can never lose an
|
|
122
|
+
* unconfirmed event.
|
|
123
|
+
*/
|
|
124
|
+
unackedDelivered: Map<number, AgentEventWireRecord>;
|
|
116
125
|
wakeDeferredUntilSettled: boolean;
|
|
126
|
+
/** Wall-clock start of the current bounded wake deferral, if any. */
|
|
127
|
+
wakeDeferredSince: number | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* Set once the hard deferral budget elapsed: the next pass injects the batch
|
|
130
|
+
* from the current state instead of deferring again.
|
|
131
|
+
*/
|
|
132
|
+
wakeForcedRelease: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Event content queued for a busy orchestrator. Injected through the
|
|
135
|
+
* `context` hook so the running turn sees the update without being
|
|
136
|
+
* interrupted.
|
|
137
|
+
*/
|
|
138
|
+
wakeContext: { content: string; eventIds: number[] } | undefined;
|
|
117
139
|
wakeRequested: boolean;
|
|
118
140
|
wakeRequestedThroughEventId: number;
|
|
119
141
|
wakeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -170,6 +192,15 @@ const RECONNECTING_MESSAGE = "Herdsman is reconnecting · try again shortly";
|
|
|
170
192
|
export const MAX_ACK_ATTEMPTS = 5;
|
|
171
193
|
export const ACK_BACKOFF_CAP_MS = 30_000;
|
|
172
194
|
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
195
|
+
/** Retry interval used while a wake cannot be injected (busy orchestrator). */
|
|
196
|
+
export const WAKE_BUSY_SPIN_MS = 100;
|
|
197
|
+
/**
|
|
198
|
+
* Hard upper bound for every deferred wake. Once it elapses the scheduler stops
|
|
199
|
+
* waiting for the orchestrator to become idle or for a settlement to arrive and
|
|
200
|
+
* forces the injection decision from the current state, so a wake can never be
|
|
201
|
+
* parked forever.
|
|
202
|
+
*/
|
|
203
|
+
export const WAKE_DEFERRED_TIMEOUT_MS = 5_000;
|
|
173
204
|
|
|
174
205
|
type AckFailureClass = "terminal" | "resync" | "transient";
|
|
175
206
|
|
|
@@ -252,7 +283,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
252
283
|
runActive: false,
|
|
253
284
|
sessionRef: undefined,
|
|
254
285
|
subscriberId: undefined,
|
|
286
|
+
unackedDelivered: new Map(),
|
|
255
287
|
wakeDeferredUntilSettled: false,
|
|
288
|
+
wakeDeferredSince: undefined,
|
|
289
|
+
wakeForcedRelease: false,
|
|
290
|
+
wakeContext: undefined,
|
|
256
291
|
wakeRequested: false,
|
|
257
292
|
wakeRequestedThroughEventId: 0,
|
|
258
293
|
wakeTimer: undefined,
|
|
@@ -295,12 +330,18 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
295
330
|
if (state.wakeTimer) clearTimeout(state.wakeTimer);
|
|
296
331
|
state.wakeTimer = undefined;
|
|
297
332
|
state.wakeDeferredUntilSettled = false;
|
|
333
|
+
state.wakeDeferredSince = undefined;
|
|
334
|
+
state.wakeForcedRelease = false;
|
|
298
335
|
};
|
|
299
336
|
|
|
300
337
|
const cancelWake = () => {
|
|
301
338
|
cancelWakeTimer();
|
|
302
339
|
state.wakeRequested = false;
|
|
303
340
|
state.wakeRequestedThroughEventId = 0;
|
|
341
|
+
// A wake queued for a busy orchestrator belongs to the role/scope that
|
|
342
|
+
// queued it: dropping it here keeps a stale event body out of the context
|
|
343
|
+
// of whatever session takes over next.
|
|
344
|
+
state.wakeContext = undefined;
|
|
304
345
|
};
|
|
305
346
|
|
|
306
347
|
const clearAgentContext = () => {
|
|
@@ -309,6 +350,41 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
309
350
|
state.runActive = false;
|
|
310
351
|
};
|
|
311
352
|
|
|
353
|
+
const unackedDeliveredAscending = (): AgentEventWireRecord[] =>
|
|
354
|
+
[...state.unackedDelivered.values()].sort((left, right) => left.id - right.id);
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Merges freshly injected events into the delivery queue.
|
|
358
|
+
*
|
|
359
|
+
* Invariants (Phase 1 completeness fix):
|
|
360
|
+
* - a new batch is merged into the queue, never substituted for it, so an
|
|
361
|
+
* unconfirmed batch keeps riding along with the next delivery instead of
|
|
362
|
+
* being stranded;
|
|
363
|
+
* - the result is id-ascending and id-deduped, so the same id is never
|
|
364
|
+
* delivered (or acknowledged) twice;
|
|
365
|
+
* - entries are only ever removed by `dropUnackedDelivered` (acknowledged or
|
|
366
|
+
* dead-lettered) or by a role/scope reset, so releasing the deferred wake
|
|
367
|
+
* cannot drop an unconfirmed event.
|
|
368
|
+
*/
|
|
369
|
+
const mergeUnackedDelivered = (
|
|
370
|
+
incoming: readonly AgentEventWireRecord[],
|
|
371
|
+
): AgentEventWireRecord[] => {
|
|
372
|
+
for (const event of incoming) {
|
|
373
|
+
if (!state.unackedDelivered.has(event.id)) state.unackedDelivered.set(event.id, event);
|
|
374
|
+
}
|
|
375
|
+
return unackedDeliveredAscending();
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Drops one event from the delivery queue. The only callers are the two
|
|
380
|
+
* acknowledgement outcomes (accepted, or terminally refused by the daemon)
|
|
381
|
+
* and the role/scope reset, which clears the whole stream together with
|
|
382
|
+
* `presentedEventIds`.
|
|
383
|
+
*/
|
|
384
|
+
const dropUnackedDelivered = (eventId: number): void => {
|
|
385
|
+
state.unackedDelivered.delete(eventId);
|
|
386
|
+
};
|
|
387
|
+
|
|
312
388
|
const pruneAcknowledgedEvents = (ackedEventId: number | undefined) => {
|
|
313
389
|
if (ackedEventId === undefined) return;
|
|
314
390
|
// Acknowledged events leave the pending projection but intentionally stay
|
|
@@ -320,6 +396,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
320
396
|
// set is bounded by the number of events presented per session and is
|
|
321
397
|
// cleared on role loss, scope change, and shutdown.
|
|
322
398
|
state.pendingEvents = state.pendingEvents.filter((event) => event.id > ackedEventId);
|
|
399
|
+
// The delivery queue follows the same watermark: an event covered by the
|
|
400
|
+
// advanced acknowledgement cursor is confirmed even when its own ack was
|
|
401
|
+
// superseded (the daemon acknowledges by watermark), so it leaves the
|
|
402
|
+
// queue and is never re-acknowledged.
|
|
403
|
+
for (const eventId of [...state.unackedDelivered.keys()]) {
|
|
404
|
+
if (eventId <= ackedEventId) state.unackedDelivered.delete(eventId);
|
|
405
|
+
}
|
|
323
406
|
};
|
|
324
407
|
|
|
325
408
|
const isWakeableEvent = (event: AgentEventWireRecord | undefined) =>
|
|
@@ -347,6 +430,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
347
430
|
})) as { ackedEventId?: number; state?: { ackedEventId?: number } } | undefined;
|
|
348
431
|
pruneAcknowledgedEvents(ackResponse?.ackedEventId ?? ackResponse?.state?.ackedEventId);
|
|
349
432
|
state.pendingEvents = state.pendingEvents.filter((pending) => pending.id !== event.id);
|
|
433
|
+
// The event is confirmed: it leaves the delivery queue for good, which
|
|
434
|
+
// is what keeps the acknowledgement watermark monotonic (ids are acked
|
|
435
|
+
// in ascending order and never re-issued).
|
|
436
|
+
dropUnackedDelivered(event.id);
|
|
350
437
|
// The id intentionally stays in presentedEventIds: the event was
|
|
351
438
|
// already presented this session and must not be injected again even
|
|
352
439
|
// if the daemon replays it (for example after a reconnect
|
|
@@ -357,7 +444,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
357
444
|
} catch (error) {
|
|
358
445
|
const failureCode = ackFailureCode(error);
|
|
359
446
|
const classification = classifyAckFailure(error);
|
|
360
|
-
|
|
447
|
+
// The attempt counter is read from the live projection, not from the
|
|
448
|
+
// delivery-queue snapshot the caller iterated over: a stale base would
|
|
449
|
+
// pin the counter at 1 for good and make the MAX_ACK_ATTEMPTS
|
|
450
|
+
// dead-letter branch unreachable for every event retried here.
|
|
451
|
+
const attempts =
|
|
452
|
+
(state.pendingEvents.find((pending) => pending.id === event.id)?.attempts ??
|
|
453
|
+
event.attempts ??
|
|
454
|
+
0) + 1;
|
|
361
455
|
const attemptedAt = Date.now();
|
|
362
456
|
const updatedEvent = {
|
|
363
457
|
...event,
|
|
@@ -368,9 +462,20 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
368
462
|
state.pendingEvents = state.pendingEvents.map((pending) =>
|
|
369
463
|
pending.id === event.id ? updatedEvent : pending,
|
|
370
464
|
);
|
|
465
|
+
// Keep the delivery-queue entry in step with the live accounting: the
|
|
466
|
+
// same event may be retried later (another settlement, or a redelivery
|
|
467
|
+
// that re-attaches it to a new batch), and that attempt must resume
|
|
468
|
+
// from this counter instead of restarting at 1.
|
|
469
|
+
if (state.unackedDelivered.has(event.id)) {
|
|
470
|
+
state.unackedDelivered.set(event.id, updatedEvent);
|
|
471
|
+
}
|
|
371
472
|
|
|
372
473
|
if (classification === "terminal") {
|
|
373
474
|
state.pendingEvents = state.pendingEvents.filter((pending) => pending.id !== event.id);
|
|
475
|
+
// A terminally refused event is dead-lettered by the daemon
|
|
476
|
+
// (failedWakeThroughEventId is the dead-letter barrier), so it can
|
|
477
|
+
// never be confirmed later; it leaves the delivery queue as well.
|
|
478
|
+
dropUnackedDelivered(event.id);
|
|
374
479
|
state.failedWakeThroughEventId = Math.max(state.failedWakeThroughEventId, event.id);
|
|
375
480
|
if (/Only the current orchestrator can acknowledge notifications/i.test(failureCode)) {
|
|
376
481
|
state.isOrchestrator = false;
|
|
@@ -390,6 +495,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
390
495
|
|
|
391
496
|
if (attempts >= MAX_ACK_ATTEMPTS) {
|
|
392
497
|
state.pendingEvents = state.pendingEvents.filter((pending) => pending.id !== event.id);
|
|
498
|
+
// Dead-lettered by this client: the id is behind the dead-letter
|
|
499
|
+
// barrier from now on, so it can never be confirmed later and leaves
|
|
500
|
+
// the delivery queue with the pending projection.
|
|
501
|
+
dropUnackedDelivered(event.id);
|
|
393
502
|
state.failedWakeThroughEventId = Math.max(state.failedWakeThroughEventId, event.id);
|
|
394
503
|
logHerdsmanPi(
|
|
395
504
|
"warn",
|
|
@@ -498,6 +607,36 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
498
607
|
}, WAKE_SETTLE_MS);
|
|
499
608
|
};
|
|
500
609
|
|
|
610
|
+
/**
|
|
611
|
+
* Arms the bounded deferral for a wake that cannot be injected right now.
|
|
612
|
+
*
|
|
613
|
+
* The retry spins every `WAKE_BUSY_SPIN_MS` while the orchestrator is busy
|
|
614
|
+
* and flips `wakeForcedRelease` once `WAKE_DEFERRED_TIMEOUT_MS` has elapsed,
|
|
615
|
+
* so the next pass injects the batch from the current state (as a queued,
|
|
616
|
+
* non-triggering follow-up) instead of waiting for an idle signal or a
|
|
617
|
+
* settlement that may never arrive.
|
|
618
|
+
*/
|
|
619
|
+
const scheduleDeferredWake = (ctx: PiContext) => {
|
|
620
|
+
state.wakeDeferredUntilSettled = true;
|
|
621
|
+
const since = state.wakeDeferredSince ?? Date.now();
|
|
622
|
+
state.wakeDeferredSince = since;
|
|
623
|
+
const remaining = WAKE_DEFERRED_TIMEOUT_MS - (Date.now() - since);
|
|
624
|
+
if (remaining <= 0) state.wakeForcedRelease = true;
|
|
625
|
+
if (state.wakeTimer) return;
|
|
626
|
+
state.wakeTimer = setTimeout(() => {
|
|
627
|
+
state.wakeTimer = undefined;
|
|
628
|
+
if (state.wakeForcedRelease && state.deliveredBatch && ctx.isIdle?.() !== false) {
|
|
629
|
+
// The hard deadline only releases the delivery — it never discards an
|
|
630
|
+
// unconfirmed event. The batch record is dropped so a wake turn that
|
|
631
|
+
// is no longer running cannot gate later wakes, but its events stay in
|
|
632
|
+
// the delivery queue (`unackedDelivered`) and are re-attached to the
|
|
633
|
+
// batch injected right below, which acknowledges them once it settles.
|
|
634
|
+
state.deliveredBatch = undefined;
|
|
635
|
+
}
|
|
636
|
+
scheduleWake(ctx);
|
|
637
|
+
}, Math.max(0, Math.min(WAKE_BUSY_SPIN_MS, remaining)));
|
|
638
|
+
};
|
|
639
|
+
|
|
501
640
|
const scheduleWake = (ctx: PiContext | undefined) => {
|
|
502
641
|
if (!ctx || !state.isOrchestrator || !state.currentScope || !pi.sendMessage) return;
|
|
503
642
|
if (state.wakeTimer || state.wakeRequested) return;
|
|
@@ -551,8 +690,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
551
690
|
return;
|
|
552
691
|
}
|
|
553
692
|
|
|
554
|
-
|
|
555
|
-
|
|
693
|
+
// An in-flight batch owns the ack cursor and a busy orchestrator must not
|
|
694
|
+
// be interrupted, so neither is woken immediately — but both are deferred
|
|
695
|
+
// on a bounded spin (never parked until an event that may never come).
|
|
696
|
+
const inFlight = state.deliveredBatch !== undefined || state.ackInFlight;
|
|
697
|
+
if (!state.wakeForcedRelease && (inFlight || ctx.isIdle?.() === false)) {
|
|
698
|
+
scheduleDeferredWake(ctx);
|
|
556
699
|
return;
|
|
557
700
|
}
|
|
558
701
|
const generation = wakeGeneration;
|
|
@@ -571,9 +714,9 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
571
714
|
state.wakeTimer = undefined;
|
|
572
715
|
return;
|
|
573
716
|
}
|
|
574
|
-
if (ctx.isIdle?.() === false) {
|
|
717
|
+
if (ctx.isIdle?.() === false && !state.wakeForcedRelease) {
|
|
575
718
|
state.wakeTimer = undefined;
|
|
576
|
-
|
|
719
|
+
scheduleDeferredWake(ctx);
|
|
577
720
|
return;
|
|
578
721
|
}
|
|
579
722
|
|
|
@@ -608,9 +751,9 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
608
751
|
state.wakeTimer = undefined;
|
|
609
752
|
return;
|
|
610
753
|
}
|
|
611
|
-
if (ctx.isIdle?.() === false) {
|
|
754
|
+
if (ctx.isIdle?.() === false && !state.wakeForcedRelease) {
|
|
612
755
|
state.wakeTimer = undefined;
|
|
613
|
-
|
|
756
|
+
scheduleDeferredWake(ctx);
|
|
614
757
|
return;
|
|
615
758
|
}
|
|
616
759
|
|
|
@@ -628,24 +771,24 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
628
771
|
return;
|
|
629
772
|
}
|
|
630
773
|
const current = batchOutcomes;
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
batchOutcomes.some((outcome) => outcome.eventId === event.id),
|
|
636
|
-
),
|
|
637
|
-
hasSubstantiveWork: false,
|
|
638
|
-
invalidated: false,
|
|
639
|
-
ownerTerminalId,
|
|
640
|
-
herdsmanTriggered: true,
|
|
641
|
-
};
|
|
774
|
+
const incomingEvents = batchEvents.filter((event) =>
|
|
775
|
+
batchOutcomes.some((outcome) => outcome.eventId === event.id),
|
|
776
|
+
);
|
|
777
|
+
const previousBatch = state.deliveredBatch;
|
|
642
778
|
state.wakeTimer = undefined;
|
|
643
779
|
state.wakeRequested = true;
|
|
644
780
|
state.wakeRequestedThroughEventId = current.at(-1)?.eventId ?? 0;
|
|
781
|
+
// Dual-track injection: an idle orchestrator gets a triggered
|
|
782
|
+
// follow-up turn (immediate delivery), while a busy one is not
|
|
783
|
+
// interrupted — the same content is queued as a non-triggering
|
|
784
|
+
// follow-up and additionally exposed through the `context` hook so the
|
|
785
|
+
// running turn can already see it.
|
|
786
|
+
const orchestratorBusy = ctx.isIdle?.() === false;
|
|
787
|
+
const wakeContent = formatAgentOutcomeUpdates(batchOutcomes);
|
|
645
788
|
try {
|
|
646
789
|
pi.sendMessage?.(
|
|
647
790
|
{
|
|
648
|
-
content:
|
|
791
|
+
content: wakeContent,
|
|
649
792
|
customType: "herdsman-wake-context",
|
|
650
793
|
// Suppressed upstream errors are dropped from the injected
|
|
651
794
|
// context, but every other pending id stays listed so the
|
|
@@ -657,11 +800,44 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
657
800
|
},
|
|
658
801
|
display: false,
|
|
659
802
|
},
|
|
660
|
-
|
|
803
|
+
orchestratorBusy
|
|
804
|
+
? { deliverAs: "followUp", triggerTurn: false }
|
|
805
|
+
: { deliverAs: "followUp", triggerTurn: true },
|
|
661
806
|
);
|
|
807
|
+
// A queued (non-triggering) delivery keeps the content available to
|
|
808
|
+
// the current turn through the context hook until it is settled or
|
|
809
|
+
// superseded by the next injection.
|
|
810
|
+
state.wakeContext = orchestratorBusy
|
|
811
|
+
? { content: wakeContent, eventIds: batchOutcomes.map((outcome) => outcome.eventId) }
|
|
812
|
+
: undefined;
|
|
813
|
+
state.wakeForcedRelease = false;
|
|
814
|
+
state.wakeDeferredSince = undefined;
|
|
662
815
|
// Only expose the batch after the hidden context was accepted by pi. This
|
|
663
816
|
// keeps an injection failure eligible for daemon redelivery.
|
|
664
|
-
|
|
817
|
+
//
|
|
818
|
+
// The batch is the delivery queue plus this injection: previously
|
|
819
|
+
// unconfirmed events are merged (never replaced) so that a forced
|
|
820
|
+
// release always leaves both the old and the new events deliverable
|
|
821
|
+
// and acknowledgeable in id order. The turn-consumption flags of a
|
|
822
|
+
// still-running previous batch are carried over, because they
|
|
823
|
+
// describe whether the content already reached the orchestrator.
|
|
824
|
+
//
|
|
825
|
+
// `hasSubstantiveWork` is the sole gate that decides whether an
|
|
826
|
+
// ownership/scope change may abort the in-flight turn (see loseRole
|
|
827
|
+
// and resetForScopeChange), and aborting is only ever allowed for a
|
|
828
|
+
// *pure* Herdsman wake turn. A busy orchestrator gets the batch as a
|
|
829
|
+
// non-triggering queued follow-up, which rides the user's own turn:
|
|
830
|
+
// that turn is not a Herdsman wake turn, so it must never be aborted
|
|
831
|
+
// on our behalf and the flag is set here.
|
|
832
|
+
state.deliveredBatch = {
|
|
833
|
+
abortedByUser: previousBatch?.abortedByUser ?? false,
|
|
834
|
+
assistantFinalSucceeded: previousBatch?.assistantFinalSucceeded ?? false,
|
|
835
|
+
events: mergeUnackedDelivered(incomingEvents),
|
|
836
|
+
hasSubstantiveWork: orchestratorBusy || (previousBatch?.hasSubstantiveWork ?? false),
|
|
837
|
+
invalidated: false,
|
|
838
|
+
ownerTerminalId,
|
|
839
|
+
herdsmanTriggered: true,
|
|
840
|
+
};
|
|
665
841
|
state.wakeRequested = false;
|
|
666
842
|
state.wakeRequestedThroughEventId = 0;
|
|
667
843
|
// Record the presentation so a reclaim redelivery of the same id is
|
|
@@ -718,7 +894,13 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
718
894
|
// A transient disconnect (reconnect) keeps the presentation guard so an
|
|
719
895
|
// event already presented in this scope session is not presented again;
|
|
720
896
|
// only a genuine role/scope loss or shutdown resets it.
|
|
721
|
-
if (!options.preservePresented)
|
|
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
|
+
}
|
|
722
904
|
state.reconnectingFromOn = false;
|
|
723
905
|
setHerdsmanUi(ctx);
|
|
724
906
|
};
|
|
@@ -749,6 +931,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
749
931
|
state.failedWakeThroughEventId = 0;
|
|
750
932
|
state.pendingEvents = [];
|
|
751
933
|
state.presentedEventIds.clear();
|
|
934
|
+
state.unackedDelivered.clear();
|
|
752
935
|
setHerdsmanUi(ctx);
|
|
753
936
|
};
|
|
754
937
|
|
|
@@ -866,6 +1049,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
866
1049
|
// already acked events are covered by the server cursor
|
|
867
1050
|
// (pruneAcknowledgedEvents below).
|
|
868
1051
|
state.deliveredBatch = undefined;
|
|
1052
|
+
state.wakeContext = undefined;
|
|
869
1053
|
}
|
|
870
1054
|
// Otherwise the batch's wake turn is still in flight: keep it so the
|
|
871
1055
|
// settlement acknowledges it and the events are not re-presented.
|
|
@@ -1107,6 +1291,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1107
1291
|
loseRole(activeContext);
|
|
1108
1292
|
state.deliveredBatch = undefined;
|
|
1109
1293
|
state.presentedEventIds.clear();
|
|
1294
|
+
state.unackedDelivered.clear();
|
|
1110
1295
|
state.client?.close();
|
|
1111
1296
|
state.client = undefined;
|
|
1112
1297
|
activeContext = undefined;
|
|
@@ -1190,23 +1375,43 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1190
1375
|
|
|
1191
1376
|
pi.on("context", (event: { messages: PiAgentMessage[] }) => {
|
|
1192
1377
|
const messages = event.messages.filter((message) => !isNormalHerdsmanContext(message));
|
|
1378
|
+
const additions: PiAgentMessage[] = [];
|
|
1193
1379
|
const snapshot = state.pinnedContext;
|
|
1194
|
-
if (
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1380
|
+
if (snapshot && snapshot.agents.length > 0) {
|
|
1381
|
+
additions.push({
|
|
1382
|
+
content: formatHiddenAgentContext({
|
|
1383
|
+
agents: snapshot.agents,
|
|
1384
|
+
workspaceId: snapshot.workspaceId,
|
|
1385
|
+
}),
|
|
1386
|
+
customType: "herdsman-agent-context",
|
|
1387
|
+
display: false,
|
|
1388
|
+
role: "custom",
|
|
1389
|
+
timestamp: Date.now(),
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
// A wake queued for a busy orchestrator is not allowed to interrupt the
|
|
1393
|
+
// running tool chain, so its content is additionally pinned to the current
|
|
1394
|
+
// context: the orchestrator sees the child-agent outcome in this turn
|
|
1395
|
+
// without a triggered follow-up. The entry is dropped again by
|
|
1396
|
+
// isNormalHerdsmanContext, so at most one copy is present per call.
|
|
1397
|
+
//
|
|
1398
|
+
// `eventIds` mirrors what this turn actually presents (the freshly injected
|
|
1399
|
+
// outcomes): events carried over in the delivery queue were already shown
|
|
1400
|
+
// to the orchestrator in the turn that presented them, so they are not
|
|
1401
|
+
// re-listed here. The queued follow-up message itself keeps the wider
|
|
1402
|
+
// `details.eventIds` set (everything still unconfirmed).
|
|
1403
|
+
const queuedWake = state.wakeContext;
|
|
1404
|
+
if (queuedWake) {
|
|
1405
|
+
additions.push({
|
|
1406
|
+
content: queuedWake.content,
|
|
1407
|
+
customType: "herdsman-wake-queued",
|
|
1408
|
+
details: { eventIds: queuedWake.eventIds },
|
|
1409
|
+
display: false,
|
|
1410
|
+
role: "custom",
|
|
1411
|
+
timestamp: Date.now(),
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
return additions.length === 0 ? { messages } : { messages: [...messages, ...additions] };
|
|
1210
1415
|
});
|
|
1211
1416
|
|
|
1212
1417
|
pi.on("agent_settled", async (_event: unknown, ctx: PiContext) => {
|
|
@@ -1231,10 +1436,41 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1231
1436
|
const finishBatch = () => {
|
|
1232
1437
|
state.ackInFlight = false;
|
|
1233
1438
|
state.wakeDeferredUntilSettled = false;
|
|
1439
|
+
state.wakeDeferredSince = undefined;
|
|
1440
|
+
state.wakeForcedRelease = false;
|
|
1441
|
+
state.wakeContext = undefined;
|
|
1234
1442
|
setHerdsmanUi(ctx);
|
|
1235
1443
|
scheduleWake(ctx);
|
|
1236
1444
|
};
|
|
1237
1445
|
|
|
1446
|
+
// The delivery queue — not the injection snapshot — is what gets
|
|
1447
|
+
// acknowledged: it holds every event handed to Pi that is still
|
|
1448
|
+
// unconfirmed (a release merges batches instead of replacing them), is
|
|
1449
|
+
// id-ascending, and an id leaves it only when the daemon accepts it.
|
|
1450
|
+
//
|
|
1451
|
+
// An event whose own acknowledgement already failed is left out here: it is
|
|
1452
|
+
// never retried by this path (the daemon's cursor advance sweeps it, and a
|
|
1453
|
+
// retry would reset its attempt/backoff accounting), but it stays in the
|
|
1454
|
+
// queue until that cursor or a scope reset confirms it. A failed or
|
|
1455
|
+
// dead-lettered acknowledgement thus never depends on the timing of the
|
|
1456
|
+
// release to stay recoverable.
|
|
1457
|
+
//
|
|
1458
|
+
// Restricting to a *live* row with `attempts === 0` has two holes on
|
|
1459
|
+
// purpose. `?? 0` covers an event the live projection no longer holds at
|
|
1460
|
+
// all (the server stopped listing it, or `failedWakeThroughEventId`
|
|
1461
|
+
// filters it out): the projection cannot tell us it already failed, so the
|
|
1462
|
+
// event gets one more attempt — a deliberate self-healing opportunity that
|
|
1463
|
+
// then accumulates on the queue copy's counter and can reach
|
|
1464
|
+
// MAX_ACK_ATTEMPTS instead of restarting at 1 every round.
|
|
1465
|
+
const ackable = unackedDeliveredAscending().filter(
|
|
1466
|
+
(event) =>
|
|
1467
|
+
(state.pendingEvents.find((pending) => pending.id === event.id)?.attempts ?? 0) === 0,
|
|
1468
|
+
);
|
|
1469
|
+
if (ackable.length === 0) {
|
|
1470
|
+
finishBatch();
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1238
1474
|
if (
|
|
1239
1475
|
(!batch.assistantFinalSucceeded && !batch.abortedByUser) ||
|
|
1240
1476
|
batch.invalidated ||
|
|
@@ -1247,7 +1483,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1247
1483
|
return;
|
|
1248
1484
|
}
|
|
1249
1485
|
|
|
1250
|
-
await acknowledgeEventIds(
|
|
1486
|
+
await acknowledgeEventIds(ackable, { notify: true }, ctx);
|
|
1251
1487
|
finishBatch();
|
|
1252
1488
|
});
|
|
1253
1489
|
|
|
@@ -1334,6 +1570,7 @@ export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string
|
|
|
1334
1570
|
function isNormalHerdsmanContext(message: PiAgentMessage): boolean {
|
|
1335
1571
|
return (
|
|
1336
1572
|
message.customType === "herdsman-agent-context" ||
|
|
1573
|
+
message.customType === "herdsman-wake-queued" ||
|
|
1337
1574
|
contentIncludesMarker(message.content, "[HERDSMAN AGENT CONTEXT]")
|
|
1338
1575
|
);
|
|
1339
1576
|
}
|
package/src/wake.ts
CHANGED
|
@@ -3,7 +3,10 @@ import { agentIdentityLabel } from "./agent-display.js";
|
|
|
3
3
|
import type { AgentEventWireRecord } from "./daemon-client.js";
|
|
4
4
|
import { DEFAULT_WAKE_FILTER_CONFIG, isUpstreamModelError, type WakeFilterConfig } from "./upstream-error.js";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// 0ms: once the orchestrator is known to be idle the wake is injected on the
|
|
7
|
+
// next microtask (0ms timer) instead of waiting out a settle window. Delivery
|
|
8
|
+
// latency is owned by the bounded deferral in the extension, not by this delay.
|
|
9
|
+
export const WAKE_SETTLE_MS = 0;
|
|
7
10
|
|
|
8
11
|
export type AgentOutcome = {
|
|
9
12
|
agent: string;
|
|
@@ -43,13 +46,17 @@ function outcomeKind(event: AgentEventWireRecord): AgentOutcome["kind"] | undefi
|
|
|
43
46
|
// Backward compatibility filter for legacy pre-upgrade failed rows with PLAN_WAITING_HISTORY
|
|
44
47
|
// and degraded retries that exceeded the bounded retry budget.
|
|
45
48
|
if (reason === "PLAN_WAITING_HISTORY" || reason === "degraded") {
|
|
49
|
+
if (payload.fallbackOutcome === true) {
|
|
50
|
+
return "failed";
|
|
51
|
+
}
|
|
46
52
|
return undefined;
|
|
47
53
|
}
|
|
48
54
|
return "failed";
|
|
49
55
|
}
|
|
50
56
|
if (event.type === "agent.discarded") {
|
|
51
|
-
//
|
|
52
|
-
|
|
57
|
+
// 观察者放弃等待也是终态失败:结果永远不会到达,必须能在编排者对话里被唤醒。
|
|
58
|
+
// 压制/死信语义保持既有实现(上游错误抑制、pane 级 fallback 压制、seen 去重)。
|
|
59
|
+
return "failed";
|
|
53
60
|
}
|
|
54
61
|
const payload = asRecord(event.payload);
|
|
55
62
|
if (event.type === "agent.idle" && payload.from === "working") return "completed";
|
|
@@ -65,11 +72,29 @@ function project(
|
|
|
65
72
|
const rawEvents = [...uniqueEvents.values()].sort((left, right) => left.id - right.id);
|
|
66
73
|
const outcomes: AgentOutcome[] = [];
|
|
67
74
|
const suppressedUpstreamErrorEventIds: number[] = [];
|
|
75
|
+
|
|
76
|
+
// 维护单次扫描中已具备成功完成态的 pane 集合
|
|
77
|
+
const completedPaneIds = new Set<string>();
|
|
78
|
+
for (const event of rawEvents) {
|
|
79
|
+
if (
|
|
80
|
+
event.paneId &&
|
|
81
|
+
(event.type === "agent.done" || (event.type === "agent.idle" && asRecord(event.payload).from === "working"))
|
|
82
|
+
) {
|
|
83
|
+
completedPaneIds.add(event.paneId);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
for (const event of rawEvents) {
|
|
69
88
|
const kind = outcomeKind(event);
|
|
70
89
|
if (!kind || !event.terminalId) continue;
|
|
71
90
|
const payload = asRecord(event.payload);
|
|
72
91
|
const paneId = event.paneId ?? null;
|
|
92
|
+
|
|
93
|
+
// 核心噪音门禁:若当前事件为 fallbackOutcome,但该 pane 存在任意成功的完成事件,直接压制
|
|
94
|
+
if (payload.fallbackOutcome === true && event.paneId && completedPaneIds.has(event.paneId)) {
|
|
95
|
+
continue; // 压制噪音,不产生 outcome
|
|
96
|
+
}
|
|
97
|
+
|
|
73
98
|
const text = normalizeExcerpt(event.compactHistory?.lastAssistantMessage?.text);
|
|
74
99
|
const reason = kind === "failed" ? normalizeExcerpt(payload.reason) : undefined;
|
|
75
100
|
// Upstream model errors are transient provider failures, not agent results:
|