@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 +6 -6
- package/src/daemon-client.ts +2 -0
- package/src/index.ts +59 -9
- package/src/wake.ts +83 -16
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
|
@@ -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 ===
|
|
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
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
//
|
|
129
|
-
if (payload.fallbackOutcome === true &&
|
|
130
|
-
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;
|
|
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(
|
|
157
|
-
|
|
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 });
|