@dorokuma/herdsman-pi 0.14.7 → 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 +6 -6
- package/src/daemon-client.ts +2 -0
- package/src/index.ts +59 -9
- package/src/wake.ts +78 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dorokuma/herdsman-pi",
|
|
3
|
-
"version": "0.14.
|
|
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
|
+
}
|
package/src/daemon-client.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -101,10 +101,47 @@ function outcomeKind(event: AgentEventWireRecord): AgentOutcome["kind"] | undefi
|
|
|
101
101
|
if (event.type === "agent.idle" && payload.from === "working") return "completed";
|
|
102
102
|
return undefined;
|
|
103
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
|
+
|
|
104
140
|
function project(
|
|
105
141
|
events: AgentEventWireRecord[],
|
|
106
142
|
seen: Set<number>,
|
|
107
143
|
config: WakeFilterConfig,
|
|
144
|
+
completedPanes: Map<CompletedPaneKey, number> = new Map(),
|
|
108
145
|
): AgentOutcomeProjection {
|
|
109
146
|
const uniqueEvents = new Map<number, AgentEventWireRecord>();
|
|
110
147
|
for (const event of events) if (!seen.has(event.id) && !uniqueEvents.has(event.id)) uniqueEvents.set(event.id, event);
|
|
@@ -112,26 +149,23 @@ function project(
|
|
|
112
149
|
const outcomes: AgentOutcome[] = [];
|
|
113
150
|
const suppressedEventIds: number[] = [];
|
|
114
151
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
}
|
|
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);
|
|
125
156
|
|
|
126
157
|
for (const event of rawEvents) {
|
|
127
158
|
const kind = outcomeKind(event);
|
|
128
159
|
if (!kind || !event.terminalId) continue;
|
|
129
160
|
const payload = asRecord(event.payload);
|
|
130
161
|
const paneId = event.paneId ?? null;
|
|
162
|
+
const completedKey = event.paneId
|
|
163
|
+
? completedPaneKey(event.paneId, event.paneGeneration)
|
|
164
|
+
: undefined;
|
|
131
165
|
|
|
132
|
-
//
|
|
133
|
-
if (payload.fallbackOutcome === true &&
|
|
134
|
-
continue;
|
|
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;
|
|
135
169
|
}
|
|
136
170
|
|
|
137
171
|
const text = normalizeExcerpt(event.compactHistory?.lastAssistantMessage?.text);
|
|
@@ -157,8 +191,37 @@ function project(
|
|
|
157
191
|
for (const outcome of outcomes) seen.add(outcome.eventId);
|
|
158
192
|
return { outcomes, rawEvents, suppressedEventIds };
|
|
159
193
|
}
|
|
160
|
-
export function projectAgentOutcomes(
|
|
161
|
-
|
|
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
|
+
}
|
|
162
225
|
export function formatAgentOutcomeUpdates(outcomes: AgentOutcome[]): string {
|
|
163
226
|
const updates = outcomes.map((outcome) => {
|
|
164
227
|
const identity = agentIdentityLabel({ agent: outcome.agent, name: outcome.name });
|