@fitchmultz/pi-subagents 0.46.1 → 0.46.2
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/CHANGELOG.md +16 -0
- package/dist/agents/agent-management.js +8 -2
- package/dist/agents/agents.js +8 -8
- package/dist/extension/build-info.js +1 -1
- package/dist/extension/index.js +11 -5
- package/dist/extension/result-snapshot.js +0 -18
- package/dist/history/ingest.js +40 -31
- package/dist/history/prune.js +1 -1
- package/dist/history/run-projection.js +1 -2
- package/dist/history/search-queries.js +8 -2
- package/dist/history/store.js +20 -4
- package/dist/history/text-chunks.js +1 -2
- package/dist/history/transcript-queries.js +12 -6
- package/dist/intercom/result-intercom.js +0 -74
- package/dist/pi-intercom/broker/broker.js +33 -16
- package/dist/pi-intercom/broker/framing.js +67 -17
- package/dist/pi-intercom/broker/snapshot-reader.js +29 -8
- package/dist/pi-intercom/broker/snapshots.js +144 -41
- package/dist/pi-intercom/session-targets.js +0 -1
- package/dist/runs/background/async-run-discovery.js +4 -6
- package/dist/runs/background/completion-dedupe.js +4 -55
- package/dist/runs/background/notify.js +1 -9
- package/dist/runs/background/result-watcher.js +1 -1
- package/dist/runs/background/runner-child-observer.js +12 -7
- package/dist/runs/background/runner-monitor.js +2 -0
- package/dist/runs/background/runner-parallel.js +2 -3
- package/dist/runs/shared/child-observation-spool.js +8 -3
- package/dist/runs/shared/run-restoration.js +4 -5
- package/dist/runs/shared/supervisor-questions.js +7 -4
- package/dist/shared/journal-frames.js +1 -1
- package/dist/slash/command-discovery.js +13 -12
- package/dist/tui/agent-history.js +0 -14
- package/dist/tui/agent-view.js +0 -1
- package/dist/tui/conversation-history.js +10 -1
- package/dist/tui/history-cards.js +8 -22
- package/docs/intercom.md +5 -1
- package/docs/performance.md +72 -3
- package/package.json +1 -1
- package/src/agents/agent-management.ts +8 -4
- package/src/agents/agents.ts +17 -12
- package/src/extension/index.ts +12 -5
- package/src/extension/result-snapshot.ts +1 -27
- package/src/history/ingest.ts +57 -49
- package/src/history/prune.ts +1 -1
- package/src/history/run-projection.ts +2 -4
- package/src/history/search-queries.ts +9 -2
- package/src/history/store.ts +21 -5
- package/src/history/text-chunks.ts +7 -2
- package/src/history/transcript-queries.ts +16 -10
- package/src/intercom/result-intercom.ts +0 -103
- package/src/pi-intercom/broker/broker.ts +34 -20
- package/src/pi-intercom/broker/framing.ts +71 -20
- package/src/pi-intercom/broker/snapshot-reader.ts +47 -9
- package/src/pi-intercom/broker/snapshots.ts +184 -51
- package/src/pi-intercom/session-targets.ts +0 -2
- package/src/runs/background/async-run-discovery.ts +4 -10
- package/src/runs/background/completion-dedupe.ts +6 -69
- package/src/runs/background/notify.ts +2 -12
- package/src/runs/background/result-watcher.ts +1 -1
- package/src/runs/background/runner-child-observer.ts +19 -11
- package/src/runs/background/runner-monitor.ts +2 -0
- package/src/runs/background/runner-parallel.ts +2 -3
- package/src/runs/shared/child-observation-spool.ts +8 -3
- package/src/runs/shared/run-restoration.ts +4 -4
- package/src/runs/shared/supervisor-questions.ts +10 -10
- package/src/shared/journal-frames.ts +1 -1
- package/src/slash/command-discovery.ts +13 -12
- package/src/tui/agent-history.ts +1 -18
- package/src/tui/agent-view.ts +0 -1
- package/src/tui/conversation-history.ts +15 -1
- package/src/tui/history-cards.ts +8 -26
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { isRecord, isUnknownArray, type UnknownRecord } from "../../shared/unknown.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
normalizeSessionInfo,
|
|
4
|
+
type SessionInfo,
|
|
5
|
+
type SessionSnapshot,
|
|
6
|
+
type TopicSubscription,
|
|
7
|
+
type TopicUpdate,
|
|
8
|
+
} from "../types.ts";
|
|
3
9
|
|
|
4
10
|
type Receipt = SessionSnapshot["receipts"][number];
|
|
5
11
|
|
|
@@ -46,9 +52,30 @@ function receipt(value: unknown): Receipt {
|
|
|
46
52
|
};
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
interface SessionParts {
|
|
56
|
+
readonly info: SessionInfo;
|
|
57
|
+
readonly topics: TopicUpdate[] | undefined;
|
|
58
|
+
readonly subscriptions: TopicSubscription[] | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function appendRows<T>(
|
|
62
|
+
previous: T[] | undefined,
|
|
63
|
+
rows: readonly T[] | undefined,
|
|
64
|
+
supplied: boolean,
|
|
65
|
+
): T[] | undefined {
|
|
66
|
+
if (rows === undefined) {
|
|
67
|
+
return supplied ? undefined : previous;
|
|
68
|
+
}
|
|
69
|
+
const result = previous ?? [];
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
result.push(row);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
/** Multipart frames build a private cache; only a complete validated snapshot escapes. */
|
|
50
77
|
export class SnapshotReader {
|
|
51
|
-
private readonly sessions = new Map<string,
|
|
78
|
+
private readonly sessions = new Map<string, SessionParts>();
|
|
52
79
|
private readonly receipts: Receipt[] = [];
|
|
53
80
|
|
|
54
81
|
private addSession(row: unknown): void {
|
|
@@ -59,17 +86,20 @@ export class SnapshotReader {
|
|
|
59
86
|
const subscriptions = optionalArray(row.subscriptions);
|
|
60
87
|
const previous = this.sessions.get(row.id);
|
|
61
88
|
const info = normalizeSessionInfo({
|
|
62
|
-
...previous,
|
|
89
|
+
...previous?.info,
|
|
63
90
|
...row,
|
|
64
|
-
...(topics !== undefined ? { topics
|
|
65
|
-
...(subscriptions !== undefined
|
|
66
|
-
? { subscriptions: [...(previous?.subscriptions ?? []), ...subscriptions] }
|
|
67
|
-
: {}),
|
|
91
|
+
...(topics !== undefined ? { topics } : {}),
|
|
92
|
+
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
68
93
|
});
|
|
69
94
|
if (!info) {
|
|
70
95
|
throw new Error("Invalid session snapshot");
|
|
71
96
|
}
|
|
72
|
-
|
|
97
|
+
const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
|
|
98
|
+
this.sessions.set(info.id, {
|
|
99
|
+
info: identity,
|
|
100
|
+
topics: appendRows(previous?.topics, newTopics, "topics" in row),
|
|
101
|
+
subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
|
|
102
|
+
});
|
|
73
103
|
}
|
|
74
104
|
|
|
75
105
|
append(sessions: readonly unknown[], receipts: unknown): void {
|
|
@@ -86,6 +116,14 @@ export class SnapshotReader {
|
|
|
86
116
|
}
|
|
87
117
|
|
|
88
118
|
finish(): SessionSnapshot {
|
|
89
|
-
|
|
119
|
+
const sessions: SessionInfo[] = [];
|
|
120
|
+
for (const { info, topics, subscriptions } of this.sessions.values()) {
|
|
121
|
+
sessions.push({
|
|
122
|
+
...info,
|
|
123
|
+
...(topics !== undefined ? { topics } : {}),
|
|
124
|
+
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return { sessions, receipts: this.receipts };
|
|
90
128
|
}
|
|
91
129
|
}
|
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
type TopicSubscription,
|
|
11
11
|
type SendResult,
|
|
12
12
|
} from "../types.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
intercomMessageSizeBytes,
|
|
15
|
+
MAX_FRAME_SIZE_BYTES,
|
|
16
|
+
validateIntercomMessageSize,
|
|
17
|
+
} from "./framing.ts";
|
|
14
18
|
|
|
15
19
|
/** Direct messages and old clients receive identity, not potentially large topic state. */
|
|
16
20
|
export function messageSender(info: SessionInfo): SessionInfo {
|
|
@@ -124,72 +128,201 @@ function publicationUpdate(
|
|
|
124
128
|
return { ...topic, revision };
|
|
125
129
|
}
|
|
126
130
|
|
|
127
|
-
class
|
|
128
|
-
private readonly
|
|
129
|
-
private
|
|
130
|
-
private readonly
|
|
131
|
+
class SnapshotRecords<T extends { readonly topic: string }> {
|
|
132
|
+
private readonly records = new Map<string, { readonly value: T; readonly bytes: number }>();
|
|
133
|
+
private largest = 0;
|
|
134
|
+
private initial: readonly T[] | undefined;
|
|
135
|
+
private present: boolean;
|
|
131
136
|
|
|
132
|
-
constructor(
|
|
133
|
-
this.
|
|
134
|
-
this.
|
|
135
|
-
|
|
137
|
+
constructor(values: readonly T[] | undefined) {
|
|
138
|
+
this.initial = values;
|
|
139
|
+
this.present = values !== undefined;
|
|
140
|
+
for (const value of values ?? []) {
|
|
141
|
+
this.set(value, intercomMessageSizeBytes(value));
|
|
142
|
+
}
|
|
136
143
|
}
|
|
137
144
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
get(topic: string, first = false): T | undefined {
|
|
146
|
+
if (first && this.initial !== undefined) {
|
|
147
|
+
return this.initial.find((record) => record.topic === topic);
|
|
148
|
+
}
|
|
149
|
+
return this.records.get(topic)?.value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
maximumAfter(topic?: string, bytes = 0): number {
|
|
153
|
+
const previous = topic === undefined ? undefined : this.records.get(topic);
|
|
154
|
+
if (previous?.bytes !== this.largest || bytes >= this.largest) {
|
|
155
|
+
return Math.max(this.largest, bytes);
|
|
156
|
+
}
|
|
157
|
+
// ponytail: shrinking the largest record scans cached sizes; index sizes if churn warrants it.
|
|
158
|
+
let largest = bytes;
|
|
159
|
+
for (const [key, record] of this.records) {
|
|
160
|
+
if (key !== topic) {
|
|
161
|
+
largest = Math.max(largest, record.bytes);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return largest;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
set(value: T, bytes: number): void {
|
|
168
|
+
this.largest = this.maximumAfter(value.topic, bytes);
|
|
169
|
+
this.records.set(value.topic, { value, bytes });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
delete(topic: string): void {
|
|
173
|
+
this.largest = this.maximumAfter(topic);
|
|
174
|
+
this.records.delete(topic);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
accept(): void {
|
|
178
|
+
this.initial = undefined;
|
|
179
|
+
this.present = true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
valuesAfter(topic?: string, value?: T, changed = false): readonly T[] | undefined {
|
|
183
|
+
if (!changed) {
|
|
184
|
+
if (this.initial !== undefined) {
|
|
185
|
+
return this.initial;
|
|
186
|
+
}
|
|
187
|
+
if (!this.present) {
|
|
149
188
|
return;
|
|
150
189
|
}
|
|
190
|
+
}
|
|
191
|
+
const values: T[] = [];
|
|
192
|
+
for (const [key, record] of this.records) {
|
|
193
|
+
const next = key === topic ? value : record.value;
|
|
194
|
+
if (next !== undefined) {
|
|
195
|
+
values.push(next);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (value !== undefined && !this.records.has(value.topic)) {
|
|
199
|
+
values.push(value);
|
|
200
|
+
}
|
|
201
|
+
return values;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface PreparedTopicChange {
|
|
206
|
+
readonly topic?: TopicUpdate;
|
|
207
|
+
readonly subscription?: TopicSubscription;
|
|
208
|
+
readonly unsubscribe?: string;
|
|
209
|
+
readonly publication?: TopicUpdate;
|
|
210
|
+
readonly restore: boolean;
|
|
211
|
+
readonly changed: boolean;
|
|
212
|
+
readonly bytes: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const UNCHANGED: PreparedTopicChange = { restore: false, changed: false, bytes: 0 };
|
|
216
|
+
|
|
217
|
+
/** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
|
|
218
|
+
export class TopicSnapshot {
|
|
219
|
+
private readonly topics: SnapshotRecords<TopicUpdate>;
|
|
220
|
+
private readonly subscriptions: SnapshotRecords<TopicSubscription>;
|
|
221
|
+
|
|
222
|
+
constructor(info: SessionInfo) {
|
|
223
|
+
this.topics = new SnapshotRecords(info.topics);
|
|
224
|
+
this.subscriptions = new SnapshotRecords(info.subscriptions);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
subscription(topic: string): TopicSubscription | undefined {
|
|
228
|
+
return this.subscriptions.get(topic, true);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
prepare(change: unknown, supported: boolean): PreparedTopicChange {
|
|
232
|
+
if (change === undefined) {
|
|
233
|
+
return UNCHANGED;
|
|
234
|
+
}
|
|
235
|
+
if (!supported || !isRecord(change)) {
|
|
236
|
+
throw new Error("Topic snapshots unavailable for this client");
|
|
237
|
+
}
|
|
238
|
+
const mutation = topicMutation(change);
|
|
239
|
+
const base = { restore: change.action === "restore", changed: true, bytes: 0 };
|
|
240
|
+
switch (mutation.kind) {
|
|
241
|
+
case "publication":
|
|
242
|
+
return this.preparePublication(mutation, base);
|
|
151
243
|
case "subscription":
|
|
152
|
-
|
|
153
|
-
|
|
244
|
+
return {
|
|
245
|
+
...base,
|
|
246
|
+
subscription: mutation.subscription,
|
|
247
|
+
bytes: intercomMessageSizeBytes(mutation.subscription),
|
|
248
|
+
};
|
|
154
249
|
case "unsubscribe":
|
|
155
|
-
|
|
156
|
-
|
|
250
|
+
return { ...base, unsubscribe: mutation.topic };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private preparePublication(
|
|
255
|
+
mutation: Extract<TopicMutation, { readonly kind: "publication" }>,
|
|
256
|
+
base: PreparedTopicChange,
|
|
257
|
+
): PreparedTopicChange {
|
|
258
|
+
const previous = this.topics.get(mutation.topic.topic);
|
|
259
|
+
const update = publicationUpdate(mutation.topic, previous, mutation.publish);
|
|
260
|
+
if (!mutation.publish && (previous?.revision ?? 0) >= update.revision) {
|
|
261
|
+
return base;
|
|
157
262
|
}
|
|
263
|
+
return {
|
|
264
|
+
...base,
|
|
265
|
+
topic: update,
|
|
266
|
+
bytes: intercomMessageSizeBytes(update),
|
|
267
|
+
...(mutation.publish ? { publication: update } : {}),
|
|
268
|
+
};
|
|
158
269
|
}
|
|
159
270
|
|
|
160
|
-
snapshot(
|
|
271
|
+
snapshot(info: SessionInfo, prepared = UNCHANGED): SessionInfo {
|
|
272
|
+
const topics = this.topics.valuesAfter(prepared.topic?.topic, prepared.topic, prepared.changed);
|
|
273
|
+
const subscriptions = this.subscriptions.valuesAfter(
|
|
274
|
+
prepared.subscription?.topic ?? prepared.unsubscribe,
|
|
275
|
+
prepared.subscription,
|
|
276
|
+
prepared.changed,
|
|
277
|
+
);
|
|
161
278
|
return {
|
|
162
|
-
...
|
|
163
|
-
topics:
|
|
164
|
-
subscriptions:
|
|
165
|
-
...(publication ? { lastIntercomActivity: Date.now() } : {}),
|
|
279
|
+
...info,
|
|
280
|
+
...(topics !== undefined ? { topics } : {}),
|
|
281
|
+
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
282
|
+
...(prepared.publication ? { lastIntercomActivity: Date.now() } : {}),
|
|
166
283
|
};
|
|
167
284
|
}
|
|
168
|
-
}
|
|
169
285
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
286
|
+
validateRestore(info: SessionInfo, prepared: PreparedTopicChange, requestId: string): void {
|
|
287
|
+
snapshotFrames(requestId, [{ ...info, topics: [], subscriptions: [] }], true);
|
|
288
|
+
const topicBytes = this.topics.maximumAfter(
|
|
289
|
+
prepared.topic?.topic,
|
|
290
|
+
prepared.topic ? prepared.bytes : 0,
|
|
291
|
+
);
|
|
292
|
+
const subscriptionBytes = this.subscriptions.maximumAfter(
|
|
293
|
+
prepared.subscription?.topic,
|
|
294
|
+
prepared.subscription ? prepared.bytes : 0,
|
|
295
|
+
);
|
|
296
|
+
for (const [field, bytes] of [
|
|
297
|
+
["topics", topicBytes],
|
|
298
|
+
["subscriptions", subscriptionBytes],
|
|
299
|
+
] as const) {
|
|
300
|
+
const size =
|
|
301
|
+
intercomMessageSizeBytes({
|
|
302
|
+
type: "sessions",
|
|
303
|
+
requestId,
|
|
304
|
+
sessions: [{ id: info.id, [field]: [] }],
|
|
305
|
+
more: true,
|
|
306
|
+
}) + bytes;
|
|
307
|
+
if (size > MAX_FRAME_SIZE_BYTES) {
|
|
308
|
+
throw new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
182
311
|
}
|
|
183
|
-
|
|
184
|
-
|
|
312
|
+
|
|
313
|
+
commit(prepared: PreparedTopicChange): void {
|
|
314
|
+
if (prepared.changed) {
|
|
315
|
+
this.topics.accept();
|
|
316
|
+
this.subscriptions.accept();
|
|
317
|
+
}
|
|
318
|
+
if (prepared.topic) {
|
|
319
|
+
this.topics.set(prepared.topic, prepared.bytes);
|
|
320
|
+
} else if (prepared.subscription) {
|
|
321
|
+
this.subscriptions.set(prepared.subscription, prepared.bytes);
|
|
322
|
+
} else if (prepared.unsubscribe !== undefined) {
|
|
323
|
+
this.subscriptions.delete(prepared.unsubscribe);
|
|
324
|
+
}
|
|
185
325
|
}
|
|
186
|
-
const pending = new TopicSnapshot(info);
|
|
187
|
-
const publication = pending.apply(topicMutation(change));
|
|
188
|
-
return {
|
|
189
|
-
info: pending.snapshot(publication),
|
|
190
|
-
restore: change.action === "restore",
|
|
191
|
-
...(publication ? { publication } : {}),
|
|
192
|
-
};
|
|
193
326
|
}
|
|
194
327
|
|
|
195
328
|
export function deliveryAcknowledgement(receipt: SendResult): BrokerMessage {
|
|
@@ -18,7 +18,6 @@ export interface TargetResolution<T extends TargetIdentity> {
|
|
|
18
18
|
readonly status: "none" | "found" | "ambiguous" | "prefix_too_short";
|
|
19
19
|
readonly target?: T;
|
|
20
20
|
readonly matches: readonly T[];
|
|
21
|
-
readonly minLength?: number;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
export function shortSessionId(sessionId: string): string {
|
|
@@ -180,7 +179,6 @@ function shortTargetResolution<T extends TargetIdentity>(
|
|
|
180
179
|
return {
|
|
181
180
|
status: "prefix_too_short",
|
|
182
181
|
matches: prefixes,
|
|
183
|
-
minLength: MIN_SESSION_TARGET_PREFIX_LENGTH,
|
|
184
182
|
};
|
|
185
183
|
}
|
|
186
184
|
const byId = new Map<string, T>();
|
|
@@ -6,12 +6,12 @@ import { runSynchronously } from "../../shared/cooperative.ts";
|
|
|
6
6
|
import { asyncRunRoots, readAsyncRunRecord } from "./async-resume.ts";
|
|
7
7
|
import type { AsyncRunRecord } from "./async-run-record.ts";
|
|
8
8
|
import type { AsyncRunSummary } from "./async-run-summary.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { readQuestionOwner } from "../shared/supervisor-questions.ts";
|
|
10
10
|
import { findNestedRouteForRootId } from "../shared/nested-events.ts";
|
|
11
11
|
import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
|
|
12
12
|
import { validateStatusForSummary } from "./async-status-validation.ts";
|
|
13
13
|
import { asyncStatusToSummary } from "./async-status-projection.ts";
|
|
14
|
-
import { errorCode, errorMessage, hasText
|
|
14
|
+
import { errorCode, errorMessage, hasText } from "./async-value.ts";
|
|
15
15
|
|
|
16
16
|
interface AsyncRunListOptions {
|
|
17
17
|
readonly states?: readonly AsyncRunSummary["state"][];
|
|
@@ -112,14 +112,8 @@ class AsyncRunDiscovery {
|
|
|
112
112
|
let owner = this.owners.get(entry);
|
|
113
113
|
if (!hasText(owner) && hasText(this.ownerSessionId)) {
|
|
114
114
|
try {
|
|
115
|
-
const saved
|
|
116
|
-
|
|
117
|
-
);
|
|
118
|
-
if (
|
|
119
|
-
isRecord(saved) &&
|
|
120
|
-
typeof saved.sessionId === "string" &&
|
|
121
|
-
saved.sessionId.trim().length > 0
|
|
122
|
-
) {
|
|
115
|
+
const saved = readQuestionOwner(entry);
|
|
116
|
+
if (saved?.sessionId !== undefined && saved.sessionId.trim().length > 0) {
|
|
123
117
|
owner = saved.sessionId;
|
|
124
118
|
this.owners.set(entry, owner);
|
|
125
119
|
}
|
|
@@ -2,29 +2,8 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { isRecord, hasText } from "./async-value.ts";
|
|
3
3
|
|
|
4
4
|
interface CompletionDataLike {
|
|
5
|
-
completionId?:
|
|
6
|
-
id
|
|
7
|
-
agent?: unknown;
|
|
8
|
-
timestamp?: unknown;
|
|
9
|
-
sessionId?: unknown;
|
|
10
|
-
taskIndex?: unknown;
|
|
11
|
-
totalTasks?: unknown;
|
|
12
|
-
success?: unknown;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function asNonEmptyString(value: unknown): string | undefined {
|
|
16
|
-
if (typeof value !== "string") {
|
|
17
|
-
return undefined;
|
|
18
|
-
}
|
|
19
|
-
const trimmed = value.trim();
|
|
20
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function asFiniteNumber(value: unknown): number | undefined {
|
|
24
|
-
if (typeof value !== "number") {
|
|
25
|
-
return undefined;
|
|
26
|
-
}
|
|
27
|
-
return Number.isFinite(value) ? value : undefined;
|
|
5
|
+
completionId?: string;
|
|
6
|
+
id: string;
|
|
28
7
|
}
|
|
29
8
|
|
|
30
9
|
function stableStringify(value: unknown): string {
|
|
@@ -48,32 +27,11 @@ function payloadDigest(value: unknown): string {
|
|
|
48
27
|
return createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16);
|
|
49
28
|
}
|
|
50
29
|
|
|
51
|
-
export function buildCompletionKey(data: Readonly<CompletionDataLike
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return `completion:${completionId}`;
|
|
55
|
-
}
|
|
56
|
-
const id = asNonEmptyString(data.id);
|
|
57
|
-
if (hasText(id)) {
|
|
58
|
-
return `id:${id}:${payloadDigest(data)}`;
|
|
30
|
+
export function buildCompletionKey(data: Readonly<CompletionDataLike>): string {
|
|
31
|
+
if (hasText(data.completionId)) {
|
|
32
|
+
return `completion:${data.completionId.trim()}`;
|
|
59
33
|
}
|
|
60
|
-
|
|
61
|
-
const agent = asNonEmptyString(data.agent) ?? "unknown";
|
|
62
|
-
const timestamp = asFiniteNumber(data.timestamp);
|
|
63
|
-
const taskIndex = asFiniteNumber(data.taskIndex);
|
|
64
|
-
const totalTasks = asFiniteNumber(data.totalTasks);
|
|
65
|
-
const success = data.success === true ? "1" : "?";
|
|
66
|
-
const successIdentity = data.success === false ? "0" : success;
|
|
67
|
-
return [
|
|
68
|
-
"meta",
|
|
69
|
-
sessionId,
|
|
70
|
-
agent,
|
|
71
|
-
timestamp !== undefined ? String(timestamp) : "no-ts",
|
|
72
|
-
taskIndex !== undefined ? String(taskIndex) : "-",
|
|
73
|
-
totalTasks !== undefined ? String(totalTasks) : "-",
|
|
74
|
-
successIdentity,
|
|
75
|
-
fallback,
|
|
76
|
-
].join(":");
|
|
34
|
+
return `id:${data.id.trim()}:${payloadDigest(data)}`;
|
|
77
35
|
}
|
|
78
36
|
|
|
79
37
|
/** Mutable identity store owned by the completion-admission lifecycle. */
|
|
@@ -105,24 +63,3 @@ export function markSeenWithTtl(
|
|
|
105
63
|
seen.set(key, now);
|
|
106
64
|
return false;
|
|
107
65
|
}
|
|
108
|
-
|
|
109
|
-
function isSeenMap(value: unknown): value is Map<string, number> {
|
|
110
|
-
if (!(value instanceof Map)) {
|
|
111
|
-
return false;
|
|
112
|
-
}
|
|
113
|
-
const entries: Map<unknown, unknown> = value;
|
|
114
|
-
return [...entries].every(
|
|
115
|
-
([key, timestamp]) => typeof key === "string" && typeof timestamp === "number",
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function getGlobalSeenMap(storeKey: string): Map<string, number> {
|
|
120
|
-
const globalStore = globalThis as Record<string, unknown>;
|
|
121
|
-
const existing = globalStore[storeKey];
|
|
122
|
-
if (isSeenMap(existing)) {
|
|
123
|
-
return existing;
|
|
124
|
-
}
|
|
125
|
-
const map = new Map<string, number>();
|
|
126
|
-
globalStore[storeKey] = map;
|
|
127
|
-
return map;
|
|
128
|
-
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/** Subagent completion notifications. */
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { buildCompletionKey, getGlobalSeenMap, markSeenWithTtl } from "./completion-dedupe.ts";
|
|
4
3
|
import { SUBAGENT_ASYNC_COMPLETE_EVENT } from "../../shared/types.ts";
|
|
5
4
|
import { hasText, isRecord } from "./async-value.ts";
|
|
6
5
|
import type { ReadonlyInput } from "../../shared/types/inputs.ts";
|
|
@@ -144,7 +143,7 @@ function isUnsubscribe(value: unknown): value is () => void {
|
|
|
144
143
|
|
|
145
144
|
export default function registerSubagentNotify(
|
|
146
145
|
pi: ReadonlyInput<ExtensionAPI>,
|
|
147
|
-
onQueued
|
|
146
|
+
onQueued: (completionKey: string, runId?: string) => void,
|
|
148
147
|
): () => void {
|
|
149
148
|
const unsubscribeStoreKey = "__pi_subagents_notify_unsubscribe__";
|
|
150
149
|
const globalStore = globalThis as Record<string, unknown>;
|
|
@@ -156,7 +155,6 @@ export default function registerSubagentNotify(
|
|
|
156
155
|
// Best effort cleanup for stale handlers from an older reload.
|
|
157
156
|
}
|
|
158
157
|
}
|
|
159
|
-
const seen = getGlobalSeenMap("__pi_subagents_notify_seen__");
|
|
160
158
|
const handleComplete = (data: unknown): void => {
|
|
161
159
|
if (
|
|
162
160
|
!isResult(data) ||
|
|
@@ -165,17 +163,9 @@ export default function registerSubagentNotify(
|
|
|
165
163
|
) {
|
|
166
164
|
return;
|
|
167
165
|
}
|
|
168
|
-
const key = buildCompletionKey(data, "notify");
|
|
169
|
-
// Owned completion keys are governed by the shared queue/journal lifecycle, not a TTL.
|
|
170
|
-
if (
|
|
171
|
-
(onQueued === undefined || !hasText(data.completionKey)) &&
|
|
172
|
-
markSeenWithTtl(seen, key, Date.now(), 10 * 60 * 1000)
|
|
173
|
-
) {
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
166
|
pi.sendMessage(buildNotification(data), { triggerTurn: true });
|
|
177
167
|
if (hasText(data.completionKey)) {
|
|
178
|
-
onQueued
|
|
168
|
+
onQueued(data.completionKey, data.runId);
|
|
179
169
|
}
|
|
180
170
|
};
|
|
181
171
|
const unsubscribe = pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete);
|
|
@@ -292,7 +292,7 @@ class ResultWatcher {
|
|
|
292
292
|
if (this.foreign(envelope)) {
|
|
293
293
|
return;
|
|
294
294
|
}
|
|
295
|
-
const key = buildCompletionKey({ ...envelope.data, id: envelope.runId }
|
|
295
|
+
const key = buildCompletionKey({ ...envelope.data, id: envelope.runId });
|
|
296
296
|
if (this.alreadyHandled(envelope, key)) {
|
|
297
297
|
return;
|
|
298
298
|
}
|
|
@@ -59,6 +59,7 @@ export class RunnerChildObserver {
|
|
|
59
59
|
this.id = config.id;
|
|
60
60
|
this.hooks = hooks;
|
|
61
61
|
this.mutatingFailureStates = statusPayload.steps.map(() => createMutatingFailureState());
|
|
62
|
+
this.syncTopLevelCurrentTool();
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
expandChildren(flatIndex: number, count: number): void {
|
|
@@ -69,16 +70,21 @@ export class RunnerChildObserver {
|
|
|
69
70
|
);
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
const active =
|
|
74
|
-
.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
73
|
+
syncTopLevelCurrentTool(): void {
|
|
74
|
+
const active =
|
|
75
|
+
this.statusPayload.state === "running" || this.statusPayload.state === "queued"
|
|
76
|
+
? this.statusPayload.steps
|
|
77
|
+
.filter(
|
|
78
|
+
(step) =>
|
|
79
|
+
step.status === "running" &&
|
|
80
|
+
typeof step.currentTool === "string" &&
|
|
81
|
+
step.currentTool.length > 0,
|
|
82
|
+
)
|
|
83
|
+
.sort(
|
|
84
|
+
(left, right) => (right.currentToolStartedAt ?? 0) - (left.currentToolStartedAt ?? 0),
|
|
85
|
+
)
|
|
86
|
+
.at(0)
|
|
87
|
+
: undefined;
|
|
82
88
|
this.statusPayload.currentTool = active?.currentTool;
|
|
83
89
|
this.statusPayload.currentToolStartedAt = active?.currentToolStartedAt;
|
|
84
90
|
this.statusPayload.currentPath = active?.currentPath;
|
|
@@ -106,7 +112,9 @@ export class RunnerChildObserver {
|
|
|
106
112
|
}
|
|
107
113
|
step.streamingText = updateStreamingText(step.streamingText, event);
|
|
108
114
|
this.recordChildActivity(flatIndex, event, toolSnapshot, now);
|
|
109
|
-
|
|
115
|
+
if (event.type === "tool_execution_start" || event.type === "tool_execution_end") {
|
|
116
|
+
this.syncTopLevelCurrentTool();
|
|
117
|
+
}
|
|
110
118
|
step.lastActivityAt = now;
|
|
111
119
|
this.statusPayload.lastActivityAt = now;
|
|
112
120
|
this.statusPayload.lastUpdate = now;
|
|
@@ -166,6 +166,8 @@ export class RunnerMonitor {
|
|
|
166
166
|
clearTimeout(this.statusWriteTimer);
|
|
167
167
|
this.statusWriteTimer = undefined;
|
|
168
168
|
}
|
|
169
|
+
// Step completion, interruption and expansion can change activity without a tool-end event.
|
|
170
|
+
this.childObserver.syncTopLevelCurrentTool();
|
|
169
171
|
this.refreshWorkflowGraph();
|
|
170
172
|
writeAtomicJson(this.statusPath, this.statusPayload);
|
|
171
173
|
if (this.config.runtimeVersion !== 2) {
|
|
@@ -2,7 +2,6 @@ import * as path from "node:path";
|
|
|
2
2
|
import { inspect } from "node:util";
|
|
3
3
|
import type { ReadonlyDeep } from "type-fest";
|
|
4
4
|
import { appendJsonl } from "../../shared/artifacts.ts";
|
|
5
|
-
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
6
5
|
import { writeInitialProgressFile } from "../../shared/settings.ts";
|
|
7
6
|
import { completeWorkflowStep, runParallelTasks } from "../shared/workflow-policy.ts";
|
|
8
7
|
import {
|
|
@@ -194,7 +193,7 @@ export class RunnerParallelGroup {
|
|
|
194
193
|
status.currentStep = position.flatIndex;
|
|
195
194
|
status.lastUpdate = failedAt;
|
|
196
195
|
status.outputFile = path.join(this.config.asyncDir, `output-${position.flatIndex}.log`);
|
|
197
|
-
|
|
196
|
+
this.monitor.writeStatusPayload();
|
|
198
197
|
this.append({
|
|
199
198
|
type: "subagent.parallel.completed",
|
|
200
199
|
ts: failedAt,
|
|
@@ -224,7 +223,7 @@ export class RunnerParallelGroup {
|
|
|
224
223
|
status.lastActivityAt = startedAt;
|
|
225
224
|
status.lastUpdate = startedAt;
|
|
226
225
|
status.outputFile = path.join(this.config.asyncDir, `output-${position.flatIndex}.log`);
|
|
227
|
-
|
|
226
|
+
this.monitor.writeStatusPayload();
|
|
228
227
|
this.append({
|
|
229
228
|
type: "subagent.parallel.started",
|
|
230
229
|
ts: startedAt,
|
|
@@ -51,8 +51,11 @@ export class ChildObservationSpool {
|
|
|
51
51
|
fs.writeFileSync(this.stderr, text);
|
|
52
52
|
}
|
|
53
53
|
resetText(): void {
|
|
54
|
-
this.textOffset
|
|
54
|
+
if (this.textOffset === 0) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
55
57
|
fs.ftruncateSync(this.text, 0);
|
|
58
|
+
this.textOffset = 0;
|
|
56
59
|
}
|
|
57
60
|
appendText(text: string): void {
|
|
58
61
|
const bytes = Buffer.from(text);
|
|
@@ -72,14 +75,16 @@ export class ChildObservationSpool {
|
|
|
72
75
|
this.textOffset += bytes.length;
|
|
73
76
|
}
|
|
74
77
|
replayText(consume: (text: string) => void): void {
|
|
75
|
-
|
|
78
|
+
if (this.textOffset > 0) {
|
|
79
|
+
this.decode(this.text, 0, this.textOffset, consume);
|
|
80
|
+
}
|
|
76
81
|
}
|
|
77
82
|
replayWire(start: number, end: number, consume: (text: string) => void): void {
|
|
78
83
|
this.decode(this.wire, start, end, consume);
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
private copy(fd: number, start: number, end: number, consume: (bytes: Buffer) => void): void {
|
|
82
|
-
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
87
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, end - start));
|
|
83
88
|
for (let position = start; position < end;) {
|
|
84
89
|
const count = fs.readSync(fd, buffer, 0, Math.min(buffer.length, end - position), position);
|
|
85
90
|
if (count === 0) {
|