@dorokuma/herdsman-pi 0.14.6 → 0.14.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorokuma/herdsman-pi",
3
- "version": "0.14.6",
3
+ "version": "0.14.9",
4
4
  "description": "Pi extension bridge for Herdsman agent history.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -21,9 +21,6 @@
21
21
  "url": "git+https://github.com/dorokuma/herdsman.git",
22
22
  "directory": "packages/herdsman-pi"
23
23
  },
24
- "scripts": {
25
- "typecheck": "tsc --noEmit -p tsconfig.json"
26
- },
27
24
  "pi": {
28
25
  "extensions": [
29
26
  "./src/index.ts"
@@ -37,5 +34,8 @@
37
34
  "bugs": {
38
35
  "url": "https://github.com/dorokuma/herdsman/issues"
39
36
  },
40
- "homepage": "https://github.com/dorokuma/herdsman#readme"
41
- }
37
+ "homepage": "https://github.com/dorokuma/herdsman#readme",
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit -p tsconfig.json"
40
+ }
41
+ }
@@ -11,8 +11,10 @@ export type AgentEventWireRecord = {
11
11
  createdAt?: string;
12
12
  herdrSessionName?: string;
13
13
  id: number;
14
+ paneGeneration?: string | null;
14
15
  paneId?: string | null;
15
16
  payload: unknown;
17
+ status?: "pending" | "delivered" | "acked" | "invalidated" | "failed";
16
18
  terminalId?: string | null;
17
19
  type: string;
18
20
  workspaceId?: string | null;
package/src/index.ts CHANGED
@@ -21,9 +21,11 @@ import {
21
21
  type HerdsmanFooterState,
22
22
  } from "./agent-update-ui.js";
23
23
  import {
24
- projectAgentOutcomes,
25
24
  formatAgentOutcomeUpdates,
25
+ projectAgentOutcomes,
26
+ pruneCompletedPanes,
26
27
  type AgentOutcome,
28
+ type CompletedPaneKey,
27
29
  WAKE_SETTLE_MS,
28
30
  } from "./wake.js";
29
31
  import { loadWakeFilterConfig } from "./wake-filter-config.js";
@@ -241,6 +243,8 @@ type HerdsmanState = {
241
243
  wakeRequested: boolean;
242
244
  wakeRequestedThroughEventId: number;
243
245
  wakeTimer: ReturnType<typeof setTimeout> | undefined;
246
+ /** Completion evidence retained after its event leaves the pending window. */
247
+ completedPaneKeys: Set<string>;
244
248
  };
245
249
 
246
250
  type PiContext = {
@@ -476,6 +480,18 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
476
480
  wakeRequestedThroughEventId: 0,
477
481
  wakeTimer: undefined,
478
482
  ackInFlight: false,
483
+ completedPaneKeys: new Set(),
484
+ };
485
+ // Completion evidence is the only part of the projection that must outlive a
486
+ // single batch; the dedup set stays per call exactly as before.
487
+ let completedPanes = new Map<CompletedPaneKey, number>();
488
+ const syncCompletedPaneKeys = () => {
489
+ state.completedPaneKeys = new Set(completedPanes.keys());
490
+ };
491
+ const projectPendingOutcomes = (events: AgentEventWireRecord[]) => {
492
+ const projection = projectAgentOutcomes(events, wakeFilter, completedPanes);
493
+ syncCompletedPaneKeys();
494
+ return projection;
479
495
  };
480
496
  options.onStateExposed?.(state);
481
497
  let activeContext: PiContext | undefined;
@@ -503,7 +519,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
503
519
 
504
520
  const setHerdsmanUi = (ctx: PiContext | undefined) => {
505
521
  if (!ctx) return;
506
- const pendingOutcomeCount = projectAgentOutcomes(state.pendingEvents, wakeFilter).outcomes.length;
522
+ const pendingOutcomeCount = projectAgentOutcomes(
523
+ state.pendingEvents,
524
+ wakeFilter,
525
+ new Map(completedPanes),
526
+ ).outcomes.length;
507
527
  const footerState: HerdsmanFooterState = state.reconnectingFromOn
508
528
  ? { kind: "reconnecting" }
509
529
  : state.isOrchestrator
@@ -667,6 +687,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
667
687
  // set is bounded by the number of events presented per session and is
668
688
  // cleared on role loss, scope change, and shutdown.
669
689
  state.pendingEvents = state.pendingEvents.filter((event) => event.id > ackedEventId);
690
+ completedPanes = pruneCompletedPanes(completedPanes, ackedEventId);
691
+ syncCompletedPaneKeys();
670
692
  // The delivery queue follows the same watermark: an event covered by the
671
693
  // advanced acknowledgement cursor is confirmed even when its own ack was
672
694
  // superseded (the daemon acknowledges by watermark), so it leaves the
@@ -1370,7 +1392,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1370
1392
  // deferral there would let a second injection ride in behind it.
1371
1393
  return;
1372
1394
  }
1373
- const projection = projectAgentOutcomes(state.pendingEvents, wakeFilter);
1395
+ const projection = projectPendingOutcomes(state.pendingEvents);
1374
1396
  const outcomes = projection.outcomes.filter(
1375
1397
  (outcome) =>
1376
1398
  outcome.eventId > state.failedWakeThroughEventId &&
@@ -1527,7 +1549,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1527
1549
  }
1528
1550
 
1529
1551
  const batchEvents = [...state.pendingEvents].sort((left, right) => left.id - right.id);
1530
- const batchProjection = projectAgentOutcomes(batchEvents, wakeFilter);
1552
+ const batchProjection = projectPendingOutcomes(batchEvents);
1531
1553
  const batchSuppressedIds = new Set(batchProjection.suppressedEventIds);
1532
1554
  const projectableBatchOutcomes = batchProjection.outcomes.filter(
1533
1555
  (outcome) =>
@@ -1741,6 +1763,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1741
1763
  state.failedWakeThroughEventId = 0;
1742
1764
  state.pendingEvents = [];
1743
1765
  clearDeliveryBookkeeping();
1766
+ completedPanes = new Map();
1767
+ syncCompletedPaneKeys();
1744
1768
  setHerdsmanUi(ctx);
1745
1769
  };
1746
1770
 
@@ -1754,7 +1778,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1754
1778
  // on every orchestrator.get; treating one as new would cancel the in-flight
1755
1779
  // wake (wakeGeneration++) on every get and stall the whole stream forever.
1756
1780
  if (!previous && event.id > state.failedWakeThroughEventId) addedNewEvent = true;
1757
- byId.set(event.id, previous ? { ...event, ...previous } : event);
1781
+ // Server fields win; the local snapshot only fills keys the page omitted.
1782
+ byId.set(event.id, previous ? { ...previous, ...event } : event);
1758
1783
  }
1759
1784
  state.pendingEvents = [...byId.values()]
1760
1785
  .filter((event) => event.id > state.failedWakeThroughEventId)
@@ -1767,10 +1792,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1767
1792
  const syncPendingEventsFromServer = (
1768
1793
  serverEvents: AgentEventWireRecord[],
1769
1794
  ctx: PiContext | undefined,
1795
+ serverAckedEventId = 0,
1770
1796
  ) => {
1771
1797
  const serverEventById = new Map(serverEvents.map((event) => [event.id, event]));
1772
1798
  const inFlightIds = new Set(state.deliveredBatch?.events.map((event) => event.id) ?? []);
1773
1799
  const previousById = new Map(state.pendingEvents.map((event) => [event.id, event]));
1800
+ // The daemon's pending page is hard-capped at 100, so a page of exactly
1801
+ // 100 cannot tell "exhausted" from "more remain". Keep any higher local id
1802
+ // rather than drop it: a later shorter page is what proves the stream ended.
1774
1803
  const isTruncated = serverEvents.length >= 100;
1775
1804
  const maxServerEventId = serverEvents.reduce((max, event) => Math.max(max, event.id), 0);
1776
1805
 
@@ -1780,14 +1809,29 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1780
1809
  for (const serverEvent of serverEvents) {
1781
1810
  const previous = previousById.get(serverEvent.id);
1782
1811
  if (!previous && serverEvent.id > state.failedWakeThroughEventId) addedNewEvent = true;
1783
- nextPending.push(previous ? { ...serverEvent, ...previous } : serverEvent);
1812
+ // Server fields win; the local snapshot only fills keys the page omitted.
1813
+ nextPending.push(previous ? { ...previous, ...serverEvent } : serverEvent);
1784
1814
  }
1785
1815
 
1786
1816
  for (const [previousId, previousEvent] of previousById) {
1787
1817
  if (serverEventById.has(previousId)) continue;
1788
- if (inFlightIds.has(previousId)) {
1818
+ const barrier =
1819
+ state.unackedDelivered.has(previousId) &&
1820
+ (state.wakeAwaitingConsumption.has(previousId) ||
1821
+ state.wakeRetryableEventIds.has(previousId));
1822
+ // Only the server cursor settles an absent barrier. A local status of
1823
+ // acked/failed is a stale snapshot (and, when merged, the server's own
1824
+ // status wins), so it must not reap a watermark barrier by itself.
1825
+ const settledByServer = previousId <= serverAckedEventId;
1826
+ // Absence from pending() is not settlement: generation, reclaim, and
1827
+ // backoff filters hide events that are still deliverable. A local
1828
+ // watermark barrier stays until the server cursor covers it.
1829
+ if (barrier && !settledByServer) {
1789
1830
  nextPending.push(previousEvent);
1790
- } else if (isTruncated && previousId > maxServerEventId) {
1831
+ continue;
1832
+ }
1833
+ if (settledByServer) continue;
1834
+ if (inFlightIds.has(previousId) || (isTruncated && previousId > maxServerEventId)) {
1791
1835
  nextPending.push(previousEvent);
1792
1836
  }
1793
1837
  }
@@ -1864,8 +1908,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1864
1908
  }
1865
1909
  applyOwnerContext(response);
1866
1910
  setHerdsmanUi(ctx);
1867
- syncPendingEventsFromServer(response.events ?? [], ctx);
1911
+ // The watermark first: a barrier the server has already settled is reaped
1912
+ // inside the sync instead of being kept as a barrier by it.
1868
1913
  pruneAcknowledgedEvents(response.state?.ackedEventId ?? response.ackedEventId);
1914
+ syncPendingEventsFromServer(
1915
+ response.events ?? [],
1916
+ ctx,
1917
+ response.state?.ackedEventId ?? response.ackedEventId ?? 0,
1918
+ );
1869
1919
  if (state.pendingEvents.length === 0 && state.wakeTimer) cancelWakeTimer();
1870
1920
  setHerdsmanUi(ctx);
1871
1921
  scheduleWake(ctx);
package/src/wake.ts CHANGED
@@ -7,6 +7,10 @@ import { DEFAULT_WAKE_FILTER_CONFIG, isUpstreamModelError, type WakeFilterConfig
7
7
  // next microtask (0ms timer) instead of waiting out a settle window. Delivery
8
8
  // latency is owned by the bounded deferral in the extension, not by this delay.
9
9
  export const WAKE_SETTLE_MS = 0;
10
+ // Mirrors `PLAN_WAITING_HISTORY` in `src/observability/contracts.ts` (the string
11
+ // value is the contract; duplicated here because `@dorokuma/herdsman-pi` ships
12
+ // standalone `src/` without root `src/`).
13
+ export const PLAN_WAITING_HISTORY = "PLAN_WAITING_HISTORY";
10
14
 
11
15
  export type AgentOutcome = {
12
16
  agent: string;
@@ -80,7 +84,7 @@ function outcomeKind(event: AgentEventWireRecord): AgentOutcome["kind"] | undefi
80
84
  const reason = stringValue(payload.reason);
81
85
  // Backward compatibility filter for legacy pre-upgrade failed rows with PLAN_WAITING_HISTORY
82
86
  // and degraded retries that exceeded the bounded retry budget.
83
- if (reason === "PLAN_WAITING_HISTORY" || reason === "degraded") {
87
+ if (reason === PLAN_WAITING_HISTORY || reason === "degraded") {
84
88
  if (payload.fallbackOutcome === true) {
85
89
  return "failed";
86
90
  }
@@ -97,10 +101,47 @@ function outcomeKind(event: AgentEventWireRecord): AgentOutcome["kind"] | undefi
97
101
  if (event.type === "agent.idle" && payload.from === "working") return "completed";
98
102
  return undefined;
99
103
  }
104
+ export type CompletedPaneKey = `${string}\u0000${string}`;
105
+
106
+ /** A completion without a pane generation is not evidence for any generation. */
107
+ export function completedPaneKey(
108
+ paneId: string,
109
+ paneGeneration: string | null | undefined,
110
+ ): CompletedPaneKey | undefined {
111
+ if (paneGeneration === null || paneGeneration === undefined || paneGeneration.length === 0) {
112
+ return undefined;
113
+ }
114
+ return `${paneId}\u0000${paneGeneration}`;
115
+ }
116
+
117
+ export type AgentOutcomeProjector = ((events: AgentEventWireRecord[]) => AgentOutcomeProjection) & {
118
+ completedPaneKeys(): readonly CompletedPaneKey[];
119
+ /** Drops completion evidence covered by the daemon's acknowledgement watermark. */
120
+ pruneCompletedPanes(ackedEventId: number | undefined): void;
121
+ };
122
+
123
+ function rememberCompletedPane(
124
+ completedPanes: Map<CompletedPaneKey, number>,
125
+ event: AgentEventWireRecord,
126
+ ): void {
127
+ if (!event.paneId) return;
128
+ if (
129
+ event.type !== "agent.done" &&
130
+ !(event.type === "agent.idle" && asRecord(event.payload).from === "working")
131
+ ) {
132
+ return;
133
+ }
134
+ const key = completedPaneKey(event.paneId, event.paneGeneration);
135
+ if (!key) return;
136
+ const current = completedPanes.get(key);
137
+ if (current === undefined || event.id > current) completedPanes.set(key, event.id);
138
+ }
139
+
100
140
  function project(
101
141
  events: AgentEventWireRecord[],
102
142
  seen: Set<number>,
103
143
  config: WakeFilterConfig,
144
+ completedPanes: Map<CompletedPaneKey, number> = new Map(),
104
145
  ): AgentOutcomeProjection {
105
146
  const uniqueEvents = new Map<number, AgentEventWireRecord>();
106
147
  for (const event of events) if (!seen.has(event.id) && !uniqueEvents.has(event.id)) uniqueEvents.set(event.id, event);
@@ -108,26 +149,23 @@ function project(
108
149
  const outcomes: AgentOutcome[] = [];
109
150
  const suppressedEventIds: number[] = [];
110
151
 
111
- // 维护单次扫描中已具备成功完成态的 pane 集合
112
- const completedPaneIds = new Set<string>();
113
- for (const event of rawEvents) {
114
- if (
115
- event.paneId &&
116
- (event.type === "agent.done" || (event.type === "agent.idle" && asRecord(event.payload).from === "working"))
117
- ) {
118
- completedPaneIds.add(event.paneId);
119
- }
120
- }
152
+ // Completion evidence survives the current batch: a later fallback for the
153
+ // same pane and the same generation stays suppressed after the completion
154
+ // itself has left the pending window. A missing generation proves nothing.
155
+ for (const event of rawEvents) rememberCompletedPane(completedPanes, event);
121
156
 
122
157
  for (const event of rawEvents) {
123
158
  const kind = outcomeKind(event);
124
159
  if (!kind || !event.terminalId) continue;
125
160
  const payload = asRecord(event.payload);
126
161
  const paneId = event.paneId ?? null;
162
+ const completedKey = event.paneId
163
+ ? completedPaneKey(event.paneId, event.paneGeneration)
164
+ : undefined;
127
165
 
128
- // 核心噪音门禁:若当前事件为 fallbackOutcome,但该 pane 存在任意成功的完成事件,直接压制
129
- if (payload.fallbackOutcome === true && event.paneId && completedPaneIds.has(event.paneId)) {
130
- continue; // 压制噪音,不产生 outcome
166
+ // A fallback is noise only when this exact pane generation already completed.
167
+ if (payload.fallbackOutcome === true && completedKey !== undefined && completedPanes.has(completedKey)) {
168
+ continue;
131
169
  }
132
170
 
133
171
  const text = normalizeExcerpt(event.compactHistory?.lastAssistantMessage?.text);
@@ -153,8 +191,37 @@ function project(
153
191
  for (const outcome of outcomes) seen.add(outcome.eventId);
154
192
  return { outcomes, rawEvents, suppressedEventIds };
155
193
  }
156
- export function projectAgentOutcomes(events: AgentEventWireRecord[], config: WakeFilterConfig = DEFAULT_WAKE_FILTER_CONFIG): AgentOutcomeProjection { return project(events, new Set(), config); }
157
- export function createAgentOutcomeProjector(config: WakeFilterConfig = DEFAULT_WAKE_FILTER_CONFIG): (events: AgentEventWireRecord[]) => AgentOutcomeProjection { const seen = new Set<number>(); return (events) => project(events, seen, config); }
194
+ export function projectAgentOutcomes(
195
+ events: AgentEventWireRecord[],
196
+ config: WakeFilterConfig = DEFAULT_WAKE_FILTER_CONFIG,
197
+ completedPanes?: Map<CompletedPaneKey, number>,
198
+ ): AgentOutcomeProjection {
199
+ return project(events, new Set(), config, completedPanes);
200
+ }
201
+
202
+ /** Drops completion evidence the daemon's acknowledgement watermark has covered. */
203
+ export function pruneCompletedPanes(
204
+ completedPanes: Map<CompletedPaneKey, number>,
205
+ ackedEventId: number | undefined,
206
+ ): Map<CompletedPaneKey, number> {
207
+ if (ackedEventId === undefined) return completedPanes;
208
+ for (const [key, eventId] of completedPanes) {
209
+ if (eventId <= ackedEventId) completedPanes.delete(key);
210
+ }
211
+ return completedPanes;
212
+ }
213
+
214
+ export function createAgentOutcomeProjector(config: WakeFilterConfig = DEFAULT_WAKE_FILTER_CONFIG): AgentOutcomeProjector {
215
+ const seen = new Set<number>();
216
+ const completedPanes = new Map<CompletedPaneKey, number>();
217
+ const projector = ((events: AgentEventWireRecord[]) =>
218
+ project(events, seen, config, completedPanes)) as AgentOutcomeProjector;
219
+ projector.completedPaneKeys = () => [...completedPanes.keys()];
220
+ projector.pruneCompletedPanes = (ackedEventId) => {
221
+ pruneCompletedPanes(completedPanes, ackedEventId);
222
+ };
223
+ return projector;
224
+ }
158
225
  export function formatAgentOutcomeUpdates(outcomes: AgentOutcome[]): string {
159
226
  const updates = outcomes.map((outcome) => {
160
227
  const identity = agentIdentityLabel({ agent: outcome.agent, name: outcome.name });