@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 +1 -1
- package/src/daemon-client.ts +2 -0
- package/src/index.ts +97 -11
- package/src/wake.ts +91 -17
package/package.json
CHANGED
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";
|
|
@@ -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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
2026
|
+
.then(async (rawResponse) => {
|
|
1975
2027
|
state.connected = true;
|
|
1976
2028
|
startKeepalive(client);
|
|
1977
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
//
|
|
133
|
-
if (payload.fallbackOutcome === true &&
|
|
134
|
-
continue;
|
|
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(
|
|
161
|
-
|
|
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 });
|