@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 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 start the daemon:
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 start
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.12.1",
4
- "description": "Pi extension bridge for Herdsman agent history. Forked from @ryonakae/herdsman.",
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
- const attempts = (event.attempts ?? 0) + 1;
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
- if (state.deliveredBatch || state.ackInFlight || ctx.isIdle?.() === false) {
555
- state.wakeDeferredUntilSettled = true;
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
- state.wakeDeferredUntilSettled = true;
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
- state.wakeDeferredUntilSettled = true;
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 deliveredBatch: DeliveredBatch = {
632
- abortedByUser: false,
633
- assistantFinalSucceeded: false,
634
- events: batchEvents.filter((event) =>
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: formatAgentOutcomeUpdates(batchOutcomes),
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
- { deliverAs: "followUp", triggerTurn: true },
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
- state.deliveredBatch = deliveredBatch;
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) state.presentedEventIds.clear();
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 (!snapshot || snapshot.agents.length === 0) return { messages };
1195
- return {
1196
- messages: [
1197
- ...messages,
1198
- {
1199
- content: formatHiddenAgentContext({
1200
- agents: snapshot.agents,
1201
- workspaceId: snapshot.workspaceId,
1202
- }),
1203
- customType: "herdsman-agent-context",
1204
- display: false,
1205
- role: "custom",
1206
- timestamp: Date.now(),
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(batch.events, { notify: true }, ctx);
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
- export const WAKE_SETTLE_MS = 500;
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
- // 观察者放弃等待不唤醒编排者;真实故障由 agent.failed 负责唤醒,正常结束由 agent.done/idle 保底
52
- return undefined;
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: