@dorokuma/herdsman-pi 0.14.7 → 0.15.0

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.7",
3
+ "version": "0.15.0",
4
4
  "description": "Pi extension bridge for Herdsman agent history.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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";
@@ -95,6 +97,7 @@ type DeliveredBatch = {
95
97
  };
96
98
 
97
99
  type HerdsmanState = {
100
+ autoClaimAttempted: boolean;
98
101
  client: HerdsmanDaemonClient | undefined;
99
102
  connected: boolean;
100
103
  currentScope: CurrentScope | undefined;
@@ -241,6 +244,8 @@ type HerdsmanState = {
241
244
  wakeRequested: boolean;
242
245
  wakeRequestedThroughEventId: number;
243
246
  wakeTimer: ReturnType<typeof setTimeout> | undefined;
247
+ /** Completion evidence retained after its event leaves the pending window. */
248
+ completedPaneKeys: Set<string>;
244
249
  };
245
250
 
246
251
  type PiContext = {
@@ -446,6 +451,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
446
451
  const wakeFilter = options.wakeFilter ?? loadWakeFilterConfig();
447
452
 
448
453
  const state: HerdsmanState = {
454
+ autoClaimAttempted: false,
449
455
  client: undefined,
450
456
  connected: false,
451
457
  currentScope: undefined,
@@ -476,6 +482,18 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
476
482
  wakeRequestedThroughEventId: 0,
477
483
  wakeTimer: undefined,
478
484
  ackInFlight: false,
485
+ completedPaneKeys: new Set(),
486
+ };
487
+ // Completion evidence is the only part of the projection that must outlive a
488
+ // single batch; the dedup set stays per call exactly as before.
489
+ let completedPanes = new Map<CompletedPaneKey, number>();
490
+ const syncCompletedPaneKeys = () => {
491
+ state.completedPaneKeys = new Set(completedPanes.keys());
492
+ };
493
+ const projectPendingOutcomes = (events: AgentEventWireRecord[]) => {
494
+ const projection = projectAgentOutcomes(events, wakeFilter, completedPanes);
495
+ syncCompletedPaneKeys();
496
+ return projection;
479
497
  };
480
498
  options.onStateExposed?.(state);
481
499
  let activeContext: PiContext | undefined;
@@ -503,7 +521,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
503
521
 
504
522
  const setHerdsmanUi = (ctx: PiContext | undefined) => {
505
523
  if (!ctx) return;
506
- const pendingOutcomeCount = projectAgentOutcomes(state.pendingEvents, wakeFilter).outcomes.length;
524
+ const pendingOutcomeCount = projectAgentOutcomes(
525
+ state.pendingEvents,
526
+ wakeFilter,
527
+ new Map(completedPanes),
528
+ ).outcomes.length;
507
529
  const footerState: HerdsmanFooterState = state.reconnectingFromOn
508
530
  ? { kind: "reconnecting" }
509
531
  : state.isOrchestrator
@@ -667,6 +689,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
667
689
  // set is bounded by the number of events presented per session and is
668
690
  // cleared on role loss, scope change, and shutdown.
669
691
  state.pendingEvents = state.pendingEvents.filter((event) => event.id > ackedEventId);
692
+ completedPanes = pruneCompletedPanes(completedPanes, ackedEventId);
693
+ syncCompletedPaneKeys();
670
694
  // The delivery queue follows the same watermark: an event covered by the
671
695
  // advanced acknowledgement cursor is confirmed even when its own ack was
672
696
  // superseded (the daemon acknowledges by watermark), so it leaves the
@@ -1370,7 +1394,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1370
1394
  // deferral there would let a second injection ride in behind it.
1371
1395
  return;
1372
1396
  }
1373
- const projection = projectAgentOutcomes(state.pendingEvents, wakeFilter);
1397
+ const projection = projectPendingOutcomes(state.pendingEvents);
1374
1398
  const outcomes = projection.outcomes.filter(
1375
1399
  (outcome) =>
1376
1400
  outcome.eventId > state.failedWakeThroughEventId &&
@@ -1527,7 +1551,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1527
1551
  }
1528
1552
 
1529
1553
  const batchEvents = [...state.pendingEvents].sort((left, right) => left.id - right.id);
1530
- const batchProjection = projectAgentOutcomes(batchEvents, wakeFilter);
1554
+ const batchProjection = projectPendingOutcomes(batchEvents);
1531
1555
  const batchSuppressedIds = new Set(batchProjection.suppressedEventIds);
1532
1556
  const projectableBatchOutcomes = batchProjection.outcomes.filter(
1533
1557
  (outcome) =>
@@ -1741,6 +1765,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1741
1765
  state.failedWakeThroughEventId = 0;
1742
1766
  state.pendingEvents = [];
1743
1767
  clearDeliveryBookkeeping();
1768
+ completedPanes = new Map();
1769
+ syncCompletedPaneKeys();
1744
1770
  setHerdsmanUi(ctx);
1745
1771
  };
1746
1772
 
@@ -1754,7 +1780,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1754
1780
  // on every orchestrator.get; treating one as new would cancel the in-flight
1755
1781
  // wake (wakeGeneration++) on every get and stall the whole stream forever.
1756
1782
  if (!previous && event.id > state.failedWakeThroughEventId) addedNewEvent = true;
1757
- byId.set(event.id, previous ? { ...event, ...previous } : event);
1783
+ // Server fields win; the local snapshot only fills keys the page omitted.
1784
+ byId.set(event.id, previous ? { ...previous, ...event } : event);
1758
1785
  }
1759
1786
  state.pendingEvents = [...byId.values()]
1760
1787
  .filter((event) => event.id > state.failedWakeThroughEventId)
@@ -1767,10 +1794,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1767
1794
  const syncPendingEventsFromServer = (
1768
1795
  serverEvents: AgentEventWireRecord[],
1769
1796
  ctx: PiContext | undefined,
1797
+ serverAckedEventId = 0,
1770
1798
  ) => {
1771
1799
  const serverEventById = new Map(serverEvents.map((event) => [event.id, event]));
1772
1800
  const inFlightIds = new Set(state.deliveredBatch?.events.map((event) => event.id) ?? []);
1773
1801
  const previousById = new Map(state.pendingEvents.map((event) => [event.id, event]));
1802
+ // The daemon's pending page is hard-capped at 100, so a page of exactly
1803
+ // 100 cannot tell "exhausted" from "more remain". Keep any higher local id
1804
+ // rather than drop it: a later shorter page is what proves the stream ended.
1774
1805
  const isTruncated = serverEvents.length >= 100;
1775
1806
  const maxServerEventId = serverEvents.reduce((max, event) => Math.max(max, event.id), 0);
1776
1807
 
@@ -1780,14 +1811,29 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1780
1811
  for (const serverEvent of serverEvents) {
1781
1812
  const previous = previousById.get(serverEvent.id);
1782
1813
  if (!previous && serverEvent.id > state.failedWakeThroughEventId) addedNewEvent = true;
1783
- nextPending.push(previous ? { ...serverEvent, ...previous } : serverEvent);
1814
+ // Server fields win; the local snapshot only fills keys the page omitted.
1815
+ nextPending.push(previous ? { ...previous, ...serverEvent } : serverEvent);
1784
1816
  }
1785
1817
 
1786
1818
  for (const [previousId, previousEvent] of previousById) {
1787
1819
  if (serverEventById.has(previousId)) continue;
1788
- if (inFlightIds.has(previousId)) {
1820
+ const barrier =
1821
+ state.unackedDelivered.has(previousId) &&
1822
+ (state.wakeAwaitingConsumption.has(previousId) ||
1823
+ state.wakeRetryableEventIds.has(previousId));
1824
+ // Only the server cursor settles an absent barrier. A local status of
1825
+ // acked/failed is a stale snapshot (and, when merged, the server's own
1826
+ // status wins), so it must not reap a watermark barrier by itself.
1827
+ const settledByServer = previousId <= serverAckedEventId;
1828
+ // Absence from pending() is not settlement: generation, reclaim, and
1829
+ // backoff filters hide events that are still deliverable. A local
1830
+ // watermark barrier stays until the server cursor covers it.
1831
+ if (barrier && !settledByServer) {
1789
1832
  nextPending.push(previousEvent);
1790
- } else if (isTruncated && previousId > maxServerEventId) {
1833
+ continue;
1834
+ }
1835
+ if (settledByServer) continue;
1836
+ if (inFlightIds.has(previousId) || (isTruncated && previousId > maxServerEventId)) {
1791
1837
  nextPending.push(previousEvent);
1792
1838
  }
1793
1839
  }
@@ -1864,8 +1910,14 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1864
1910
  }
1865
1911
  applyOwnerContext(response);
1866
1912
  setHerdsmanUi(ctx);
1867
- syncPendingEventsFromServer(response.events ?? [], ctx);
1913
+ // The watermark first: a barrier the server has already settled is reaped
1914
+ // inside the sync instead of being kept as a barrier by it.
1868
1915
  pruneAcknowledgedEvents(response.state?.ackedEventId ?? response.ackedEventId);
1916
+ syncPendingEventsFromServer(
1917
+ response.events ?? [],
1918
+ ctx,
1919
+ response.state?.ackedEventId ?? response.ackedEventId ?? 0,
1920
+ );
1869
1921
  if (state.pendingEvents.length === 0 && state.wakeTimer) cancelWakeTimer();
1870
1922
  setHerdsmanUi(ctx);
1871
1923
  scheduleWake(ctx);
@@ -1971,10 +2023,32 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
1971
2023
  subscriberKind: "pi",
1972
2024
  workspaceId: state.currentScope?.workspaceId ?? launchIdentity.workspaceId,
1973
2025
  })
1974
- .then((response) => {
2026
+ .then(async (rawResponse) => {
1975
2027
  state.connected = true;
1976
2028
  startKeepalive(client);
1977
- applyConnectionStateResponse(response as ConnectionStateResponse, ctx, {
2029
+ let response = rawResponse as ConnectionStateResponse;
2030
+ const currentPaneId = state.currentScope?.paneId ?? launchIdentity.paneId;
2031
+ if (
2032
+ process.env.HERDSMAN_AUTO_ON === "1" &&
2033
+ !state.autoClaimAttempted &&
2034
+ isMainOrchestratorPane(currentPaneId) &&
2035
+ !isDispatchedRoleSession(sessionRef.value, process.env)
2036
+ ) {
2037
+ state.autoClaimAttempted = true;
2038
+ if (!isLocalOwner(response) && !response.state?.owner) {
2039
+ try {
2040
+ state.roleMutationInFlight = true;
2041
+ response = (await client.request("agent.orchestrator.set", {
2042
+ enabled: true,
2043
+ })) as ConnectionStateResponse;
2044
+ } catch {
2045
+ // Ignore race if another terminal claimed the workspace concurrently.
2046
+ } finally {
2047
+ state.roleMutationInFlight = false;
2048
+ }
2049
+ }
2050
+ }
2051
+ applyConnectionStateResponse(response, ctx, {
1978
2052
  notifyReconnectLoss: true,
1979
2053
  });
1980
2054
  })
@@ -2045,6 +2119,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
2045
2119
 
2046
2120
  pi.on("session_start", (_event: unknown, ctx: PiContext) => {
2047
2121
  activeContext = ctx;
2122
+ state.autoClaimAttempted = false;
2048
2123
  state.subscriberId = ctx.sessionManager.getSessionId();
2049
2124
  state.sessionRef = {
2050
2125
  agent: "pi",
@@ -2388,6 +2463,17 @@ function herdrLaunchIdentity(environment: NodeJS.ProcessEnv): LaunchIdentity | u
2388
2463
  return { herdrSocketPath, paneId, workspaceId };
2389
2464
  }
2390
2465
 
2466
+ function isMainOrchestratorPane(paneId: string): boolean {
2467
+ return paneId.trim().endsWith(":p1");
2468
+ }
2469
+
2470
+ function isDispatchedRoleSession(sessionPath: string, environment: NodeJS.ProcessEnv): boolean {
2471
+ if (typeof environment.CTXMODE_ROLE === "string" && environment.CTXMODE_ROLE.trim().length > 0) {
2472
+ return true;
2473
+ }
2474
+ return sessionPath.startsWith("/tmp/herdr-role-sessions/");
2475
+ }
2476
+
2391
2477
 
2392
2478
  function stringValue(value: unknown): string | null {
2393
2479
  return typeof value === "string" && value.length > 0 ? value : null;
package/src/wake.ts CHANGED
@@ -11,6 +11,7 @@ export const WAKE_SETTLE_MS = 0;
11
11
  // value is the contract; duplicated here because `@dorokuma/herdsman-pi` ships
12
12
  // standalone `src/` without root `src/`).
13
13
  export const PLAN_WAITING_HISTORY = "PLAN_WAITING_HISTORY";
14
+ export const PLAN_STALLED = "PLAN_STALLED";
14
15
 
15
16
  export type AgentOutcome = {
16
17
  agent: string;
@@ -93,18 +94,65 @@ function outcomeKind(event: AgentEventWireRecord): AgentOutcome["kind"] | undefi
93
94
  return "failed";
94
95
  }
95
96
  if (event.type === "agent.discarded") {
96
- // 观察者放弃等待也是终态失败:结果永远不会到达,必须能在编排者对话里被唤醒。
97
- // 压制/死信语义保持既有实现(上游错误抑制、pane 级 fallback 压制、seen 去重)。
97
+ const payload = asRecord(event.payload);
98
+ const agent = stringValue(payload.agent);
99
+ const isAgyOrEmpty = agent === undefined || agent === "agy";
100
+ if (isAgyOrEmpty) {
101
+ const reason = stringValue(payload.reason);
102
+ if (reason === PLAN_WAITING_HISTORY || reason === "degraded") {
103
+ if (payload.fallbackOutcome === true) {
104
+ return "failed";
105
+ }
106
+ return undefined;
107
+ }
108
+ }
98
109
  return "failed";
99
110
  }
100
111
  const payload = asRecord(event.payload);
101
112
  if (event.type === "agent.idle" && payload.from === "working") return "completed";
102
113
  return undefined;
103
114
  }
115
+ export type CompletedPaneKey = `${string}\u0000${string}`;
116
+
117
+ /** A completion without a pane generation is not evidence for any generation. */
118
+ export function completedPaneKey(
119
+ paneId: string,
120
+ paneGeneration: string | null | undefined,
121
+ ): CompletedPaneKey | undefined {
122
+ if (paneGeneration === null || paneGeneration === undefined || paneGeneration.length === 0) {
123
+ return undefined;
124
+ }
125
+ return `${paneId}\u0000${paneGeneration}`;
126
+ }
127
+
128
+ export type AgentOutcomeProjector = ((events: AgentEventWireRecord[]) => AgentOutcomeProjection) & {
129
+ completedPaneKeys(): readonly CompletedPaneKey[];
130
+ /** Drops completion evidence covered by the daemon's acknowledgement watermark. */
131
+ pruneCompletedPanes(ackedEventId: number | undefined): void;
132
+ };
133
+
134
+ function rememberCompletedPane(
135
+ completedPanes: Map<CompletedPaneKey, number>,
136
+ event: AgentEventWireRecord,
137
+ ): void {
138
+ if (!event.paneId) return;
139
+ if (
140
+ event.type !== "agent.done" &&
141
+ !(event.type === "agent.idle" && asRecord(event.payload).from === "working")
142
+ ) {
143
+ return;
144
+ }
145
+ const key = completedPaneKey(event.paneId, event.paneGeneration);
146
+ if (!key) return;
147
+ const current = completedPanes.get(key);
148
+ if (current === undefined || event.id > current) completedPanes.set(key, event.id);
149
+ }
150
+
104
151
  function project(
105
152
  events: AgentEventWireRecord[],
106
153
  seen: Set<number>,
107
154
  config: WakeFilterConfig,
155
+ completedPanes: Map<CompletedPaneKey, number> = new Map(),
108
156
  ): AgentOutcomeProjection {
109
157
  const uniqueEvents = new Map<number, AgentEventWireRecord>();
110
158
  for (const event of events) if (!seen.has(event.id) && !uniqueEvents.has(event.id)) uniqueEvents.set(event.id, event);
@@ -112,26 +160,23 @@ function project(
112
160
  const outcomes: AgentOutcome[] = [];
113
161
  const suppressedEventIds: number[] = [];
114
162
 
115
- // 维护单次扫描中已具备成功完成态的 pane 集合
116
- const completedPaneIds = new Set<string>();
117
- for (const event of rawEvents) {
118
- if (
119
- event.paneId &&
120
- (event.type === "agent.done" || (event.type === "agent.idle" && asRecord(event.payload).from === "working"))
121
- ) {
122
- completedPaneIds.add(event.paneId);
123
- }
124
- }
163
+ // Completion evidence survives the current batch: a later fallback for the
164
+ // same pane and the same generation stays suppressed after the completion
165
+ // itself has left the pending window. A missing generation proves nothing.
166
+ for (const event of rawEvents) rememberCompletedPane(completedPanes, event);
125
167
 
126
168
  for (const event of rawEvents) {
127
169
  const kind = outcomeKind(event);
128
170
  if (!kind || !event.terminalId) continue;
129
171
  const payload = asRecord(event.payload);
130
172
  const paneId = event.paneId ?? null;
173
+ const completedKey = event.paneId
174
+ ? completedPaneKey(event.paneId, event.paneGeneration)
175
+ : undefined;
131
176
 
132
- // 核心噪音门禁:若当前事件为 fallbackOutcome,但该 pane 存在任意成功的完成事件,直接压制
133
- if (payload.fallbackOutcome === true && event.paneId && completedPaneIds.has(event.paneId)) {
134
- continue; // 压制噪音,不产生 outcome
177
+ // A fallback is noise only when this exact pane generation already completed.
178
+ if (payload.fallbackOutcome === true && completedKey !== undefined && completedPanes.has(completedKey)) {
179
+ continue;
135
180
  }
136
181
 
137
182
  const text = normalizeExcerpt(event.compactHistory?.lastAssistantMessage?.text);
@@ -157,8 +202,37 @@ function project(
157
202
  for (const outcome of outcomes) seen.add(outcome.eventId);
158
203
  return { outcomes, rawEvents, suppressedEventIds };
159
204
  }
160
- export function projectAgentOutcomes(events: AgentEventWireRecord[], config: WakeFilterConfig = DEFAULT_WAKE_FILTER_CONFIG): AgentOutcomeProjection { return project(events, new Set(), config); }
161
- export function createAgentOutcomeProjector(config: WakeFilterConfig = DEFAULT_WAKE_FILTER_CONFIG): (events: AgentEventWireRecord[]) => AgentOutcomeProjection { const seen = new Set<number>(); return (events) => project(events, seen, config); }
205
+ export function projectAgentOutcomes(
206
+ events: AgentEventWireRecord[],
207
+ config: WakeFilterConfig = DEFAULT_WAKE_FILTER_CONFIG,
208
+ completedPanes?: Map<CompletedPaneKey, number>,
209
+ ): AgentOutcomeProjection {
210
+ return project(events, new Set(), config, completedPanes);
211
+ }
212
+
213
+ /** Drops completion evidence the daemon's acknowledgement watermark has covered. */
214
+ export function pruneCompletedPanes(
215
+ completedPanes: Map<CompletedPaneKey, number>,
216
+ ackedEventId: number | undefined,
217
+ ): Map<CompletedPaneKey, number> {
218
+ if (ackedEventId === undefined) return completedPanes;
219
+ for (const [key, eventId] of completedPanes) {
220
+ if (eventId <= ackedEventId) completedPanes.delete(key);
221
+ }
222
+ return completedPanes;
223
+ }
224
+
225
+ export function createAgentOutcomeProjector(config: WakeFilterConfig = DEFAULT_WAKE_FILTER_CONFIG): AgentOutcomeProjector {
226
+ const seen = new Set<number>();
227
+ const completedPanes = new Map<CompletedPaneKey, number>();
228
+ const projector = ((events: AgentEventWireRecord[]) =>
229
+ project(events, seen, config, completedPanes)) as AgentOutcomeProjector;
230
+ projector.completedPaneKeys = () => [...completedPanes.keys()];
231
+ projector.pruneCompletedPanes = (ackedEventId) => {
232
+ pruneCompletedPanes(completedPanes, ackedEventId);
233
+ };
234
+ return projector;
235
+ }
162
236
  export function formatAgentOutcomeUpdates(outcomes: AgentOutcome[]): string {
163
237
  const updates = outcomes.map((outcome) => {
164
238
  const identity = agentIdentityLabel({ agent: outcome.agent, name: outcome.name });