@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
|
@@ -14,37 +14,87 @@ export function validateIntercomMessageSize(msg) {
|
|
|
14
14
|
* Format: 4-byte big-endian length + JSON payload
|
|
15
15
|
*/
|
|
16
16
|
export function writeMessage(socket, msg) {
|
|
17
|
-
const tooLarge = validateIntercomMessageSize(msg);
|
|
18
|
-
if (tooLarge) {
|
|
19
|
-
throw tooLarge;
|
|
20
|
-
}
|
|
21
17
|
const json = JSON.stringify(msg);
|
|
18
|
+
const size = Buffer.byteLength(json, "utf-8");
|
|
19
|
+
if (size > MAX_FRAME_SIZE_BYTES) {
|
|
20
|
+
throw new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`);
|
|
21
|
+
}
|
|
22
22
|
const payload = Buffer.from(json, "utf-8");
|
|
23
23
|
const header = Buffer.alloc(4);
|
|
24
24
|
header.writeUInt32BE(payload.length, 0);
|
|
25
25
|
socket.write(Buffer.concat([header, payload]));
|
|
26
26
|
}
|
|
27
|
+
/** Retain fragments until a header or payload is complete; copy each fragmented value once. */
|
|
28
|
+
class MessageBuffer {
|
|
29
|
+
chunks = [];
|
|
30
|
+
index = 0;
|
|
31
|
+
length = 0;
|
|
32
|
+
append(data) {
|
|
33
|
+
if (data.length > 0) {
|
|
34
|
+
this.chunks.push(data);
|
|
35
|
+
this.length += data.length;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
clear() {
|
|
39
|
+
this.chunks = [];
|
|
40
|
+
this.index = 0;
|
|
41
|
+
this.length = 0;
|
|
42
|
+
}
|
|
43
|
+
read(size) {
|
|
44
|
+
const first = this.chunks[this.index];
|
|
45
|
+
if (size === 0) {
|
|
46
|
+
return Buffer.alloc(0);
|
|
47
|
+
}
|
|
48
|
+
const result = first.length >= size ? first.subarray(0, size) : Buffer.allocUnsafe(size);
|
|
49
|
+
let written = 0;
|
|
50
|
+
while (written < size) {
|
|
51
|
+
const chunk = this.chunks[this.index];
|
|
52
|
+
const count = Math.min(chunk.length, size - written);
|
|
53
|
+
if (first.length < size) {
|
|
54
|
+
chunk.copy(result, written, 0, count);
|
|
55
|
+
}
|
|
56
|
+
written += count;
|
|
57
|
+
this.length -= count;
|
|
58
|
+
if (count === chunk.length) {
|
|
59
|
+
this.index++;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
this.chunks[this.index] = chunk.subarray(count);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (this.index === this.chunks.length) {
|
|
66
|
+
this.clear();
|
|
67
|
+
}
|
|
68
|
+
else if (this.index * 2 >= this.chunks.length) {
|
|
69
|
+
this.chunks = this.chunks.slice(this.index);
|
|
70
|
+
this.index = 0;
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
27
75
|
/**
|
|
28
76
|
* Create a message reader that handles partial reads.
|
|
29
77
|
* Calls onMessage for each complete message received.
|
|
30
78
|
* Protocol or handler errors are reported to onError so the caller can close the socket.
|
|
31
79
|
*/
|
|
32
80
|
export function createMessageReader(onMessage, onError) {
|
|
33
|
-
|
|
81
|
+
const buffer = new MessageBuffer();
|
|
82
|
+
let length;
|
|
34
83
|
return (data) => {
|
|
35
|
-
buffer
|
|
36
|
-
while (buffer.length >= 4) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
84
|
+
buffer.append(data);
|
|
85
|
+
while (buffer.length >= (length ?? 4)) {
|
|
86
|
+
if (length === undefined) {
|
|
87
|
+
length = buffer.read(4).readUInt32BE(0);
|
|
88
|
+
if (length > MAX_FRAME_SIZE_BYTES) {
|
|
89
|
+
onError(new Error(`Intercom frame too large (${length} bytes; max ${MAX_FRAME_SIZE_BYTES})`));
|
|
90
|
+
buffer.clear();
|
|
91
|
+
length = undefined;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
45
95
|
}
|
|
46
|
-
const payload = buffer.
|
|
47
|
-
|
|
96
|
+
const payload = buffer.read(length);
|
|
97
|
+
length = undefined;
|
|
48
98
|
let msg;
|
|
49
99
|
try {
|
|
50
100
|
msg = JSON.parse(payload.toString("utf-8"));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isRecord, isUnknownArray } from "../../shared/unknown.js";
|
|
2
|
-
import { normalizeSessionInfo } from "../types.js";
|
|
2
|
+
import { normalizeSessionInfo, } from "../types.js";
|
|
3
3
|
function optionalReceiptFields(value) {
|
|
4
4
|
if ((value.queued !== undefined && typeof value.queued !== "boolean") ||
|
|
5
5
|
(value.reason !== undefined && typeof value.reason !== "string")) {
|
|
@@ -36,6 +36,16 @@ function receipt(value) {
|
|
|
36
36
|
...optional,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
|
+
function appendRows(previous, rows, supplied) {
|
|
40
|
+
if (rows === undefined) {
|
|
41
|
+
return supplied ? undefined : previous;
|
|
42
|
+
}
|
|
43
|
+
const result = previous ?? [];
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
result.push(row);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
39
49
|
/** Multipart frames build a private cache; only a complete validated snapshot escapes. */
|
|
40
50
|
export class SnapshotReader {
|
|
41
51
|
sessions = new Map();
|
|
@@ -48,17 +58,20 @@ export class SnapshotReader {
|
|
|
48
58
|
const subscriptions = optionalArray(row.subscriptions);
|
|
49
59
|
const previous = this.sessions.get(row.id);
|
|
50
60
|
const info = normalizeSessionInfo({
|
|
51
|
-
...previous,
|
|
61
|
+
...previous?.info,
|
|
52
62
|
...row,
|
|
53
|
-
...(topics !== undefined ? { topics
|
|
54
|
-
...(subscriptions !== undefined
|
|
55
|
-
? { subscriptions: [...(previous?.subscriptions ?? []), ...subscriptions] }
|
|
56
|
-
: {}),
|
|
63
|
+
...(topics !== undefined ? { topics } : {}),
|
|
64
|
+
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
57
65
|
});
|
|
58
66
|
if (!info) {
|
|
59
67
|
throw new Error("Invalid session snapshot");
|
|
60
68
|
}
|
|
61
|
-
|
|
69
|
+
const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
|
|
70
|
+
this.sessions.set(info.id, {
|
|
71
|
+
info: identity,
|
|
72
|
+
topics: appendRows(previous?.topics, newTopics, "topics" in row),
|
|
73
|
+
subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
|
|
74
|
+
});
|
|
62
75
|
}
|
|
63
76
|
append(sessions, receipts) {
|
|
64
77
|
for (const row of sessions) {
|
|
@@ -73,6 +86,14 @@ export class SnapshotReader {
|
|
|
73
86
|
this.receipts.push(...receipts.map(receipt));
|
|
74
87
|
}
|
|
75
88
|
finish() {
|
|
76
|
-
|
|
89
|
+
const sessions = [];
|
|
90
|
+
for (const { info, topics, subscriptions } of this.sessions.values()) {
|
|
91
|
+
sessions.push({
|
|
92
|
+
...info,
|
|
93
|
+
...(topics !== undefined ? { topics } : {}),
|
|
94
|
+
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return { sessions, receipts: this.receipts };
|
|
77
98
|
}
|
|
78
99
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { isRecord } from "../../shared/unknown.js";
|
|
3
3
|
import { isTopicUpdate, isTopicSubscription, } from "../types.js";
|
|
4
|
-
import { validateIntercomMessageSize } from "./framing.js";
|
|
4
|
+
import { intercomMessageSizeBytes, MAX_FRAME_SIZE_BYTES, validateIntercomMessageSize, } from "./framing.js";
|
|
5
5
|
/** Direct messages and old clients receive identity, not potentially large topic state. */
|
|
6
6
|
export function messageSender(info) {
|
|
7
7
|
const { topics: _topics, subscriptions: _subscriptions, ...identity } = info;
|
|
@@ -91,60 +91,163 @@ function publicationUpdate(topic, previous, publish) {
|
|
|
91
91
|
}
|
|
92
92
|
return { ...topic, revision };
|
|
93
93
|
}
|
|
94
|
-
class
|
|
95
|
-
|
|
94
|
+
class SnapshotRecords {
|
|
95
|
+
records = new Map();
|
|
96
|
+
largest = 0;
|
|
97
|
+
initial;
|
|
98
|
+
present;
|
|
99
|
+
constructor(values) {
|
|
100
|
+
this.initial = values;
|
|
101
|
+
this.present = values !== undefined;
|
|
102
|
+
for (const value of values ?? []) {
|
|
103
|
+
this.set(value, intercomMessageSizeBytes(value));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
get(topic, first = false) {
|
|
107
|
+
if (first && this.initial !== undefined) {
|
|
108
|
+
return this.initial.find((record) => record.topic === topic);
|
|
109
|
+
}
|
|
110
|
+
return this.records.get(topic)?.value;
|
|
111
|
+
}
|
|
112
|
+
maximumAfter(topic, bytes = 0) {
|
|
113
|
+
const previous = topic === undefined ? undefined : this.records.get(topic);
|
|
114
|
+
if (previous?.bytes !== this.largest || bytes >= this.largest) {
|
|
115
|
+
return Math.max(this.largest, bytes);
|
|
116
|
+
}
|
|
117
|
+
// ponytail: shrinking the largest record scans cached sizes; index sizes if churn warrants it.
|
|
118
|
+
let largest = bytes;
|
|
119
|
+
for (const [key, record] of this.records) {
|
|
120
|
+
if (key !== topic) {
|
|
121
|
+
largest = Math.max(largest, record.bytes);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return largest;
|
|
125
|
+
}
|
|
126
|
+
set(value, bytes) {
|
|
127
|
+
this.largest = this.maximumAfter(value.topic, bytes);
|
|
128
|
+
this.records.set(value.topic, { value, bytes });
|
|
129
|
+
}
|
|
130
|
+
delete(topic) {
|
|
131
|
+
this.largest = this.maximumAfter(topic);
|
|
132
|
+
this.records.delete(topic);
|
|
133
|
+
}
|
|
134
|
+
accept() {
|
|
135
|
+
this.initial = undefined;
|
|
136
|
+
this.present = true;
|
|
137
|
+
}
|
|
138
|
+
valuesAfter(topic, value, changed = false) {
|
|
139
|
+
if (!changed) {
|
|
140
|
+
if (this.initial !== undefined) {
|
|
141
|
+
return this.initial;
|
|
142
|
+
}
|
|
143
|
+
if (!this.present) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const values = [];
|
|
148
|
+
for (const [key, record] of this.records) {
|
|
149
|
+
const next = key === topic ? value : record.value;
|
|
150
|
+
if (next !== undefined) {
|
|
151
|
+
values.push(next);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (value !== undefined && !this.records.has(value.topic)) {
|
|
155
|
+
values.push(value);
|
|
156
|
+
}
|
|
157
|
+
return values;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const UNCHANGED = { restore: false, changed: false, bytes: 0 };
|
|
161
|
+
/** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
|
|
162
|
+
export class TopicSnapshot {
|
|
96
163
|
topics;
|
|
97
164
|
subscriptions;
|
|
98
165
|
constructor(info) {
|
|
99
|
-
this.
|
|
100
|
-
this.
|
|
101
|
-
this.subscriptions = new Map((info.subscriptions ?? []).map((entry) => [entry.topic, entry]));
|
|
166
|
+
this.topics = new SnapshotRecords(info.topics);
|
|
167
|
+
this.subscriptions = new SnapshotRecords(info.subscriptions);
|
|
102
168
|
}
|
|
103
|
-
|
|
169
|
+
subscription(topic) {
|
|
170
|
+
return this.subscriptions.get(topic, true);
|
|
171
|
+
}
|
|
172
|
+
prepare(change, supported) {
|
|
173
|
+
if (change === undefined) {
|
|
174
|
+
return UNCHANGED;
|
|
175
|
+
}
|
|
176
|
+
if (!supported || !isRecord(change)) {
|
|
177
|
+
throw new Error("Topic snapshots unavailable for this client");
|
|
178
|
+
}
|
|
179
|
+
const mutation = topicMutation(change);
|
|
180
|
+
const base = { restore: change.action === "restore", changed: true, bytes: 0 };
|
|
104
181
|
switch (mutation.kind) {
|
|
105
|
-
case "publication":
|
|
106
|
-
|
|
107
|
-
const update = publicationUpdate(mutation.topic, previous, mutation.publish);
|
|
108
|
-
if (mutation.publish || (previous?.revision ?? 0) < update.revision) {
|
|
109
|
-
this.topics.set(update.topic, update);
|
|
110
|
-
}
|
|
111
|
-
if (mutation.publish) {
|
|
112
|
-
return update;
|
|
113
|
-
}
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
182
|
+
case "publication":
|
|
183
|
+
return this.preparePublication(mutation, base);
|
|
116
184
|
case "subscription":
|
|
117
|
-
|
|
118
|
-
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
subscription: mutation.subscription,
|
|
188
|
+
bytes: intercomMessageSizeBytes(mutation.subscription),
|
|
189
|
+
};
|
|
119
190
|
case "unsubscribe":
|
|
120
|
-
|
|
121
|
-
return;
|
|
191
|
+
return { ...base, unsubscribe: mutation.topic };
|
|
122
192
|
}
|
|
123
193
|
}
|
|
124
|
-
|
|
194
|
+
preparePublication(mutation, base) {
|
|
195
|
+
const previous = this.topics.get(mutation.topic.topic);
|
|
196
|
+
const update = publicationUpdate(mutation.topic, previous, mutation.publish);
|
|
197
|
+
if (!mutation.publish && (previous?.revision ?? 0) >= update.revision) {
|
|
198
|
+
return base;
|
|
199
|
+
}
|
|
125
200
|
return {
|
|
126
|
-
...
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
...(
|
|
201
|
+
...base,
|
|
202
|
+
topic: update,
|
|
203
|
+
bytes: intercomMessageSizeBytes(update),
|
|
204
|
+
...(mutation.publish ? { publication: update } : {}),
|
|
130
205
|
};
|
|
131
206
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
207
|
+
snapshot(info, prepared = UNCHANGED) {
|
|
208
|
+
const topics = this.topics.valuesAfter(prepared.topic?.topic, prepared.topic, prepared.changed);
|
|
209
|
+
const subscriptions = this.subscriptions.valuesAfter(prepared.subscription?.topic ?? prepared.unsubscribe, prepared.subscription, prepared.changed);
|
|
210
|
+
return {
|
|
211
|
+
...info,
|
|
212
|
+
...(topics !== undefined ? { topics } : {}),
|
|
213
|
+
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
214
|
+
...(prepared.publication ? { lastIntercomActivity: Date.now() } : {}),
|
|
215
|
+
};
|
|
137
216
|
}
|
|
138
|
-
|
|
139
|
-
|
|
217
|
+
validateRestore(info, prepared, requestId) {
|
|
218
|
+
snapshotFrames(requestId, [{ ...info, topics: [], subscriptions: [] }], true);
|
|
219
|
+
const topicBytes = this.topics.maximumAfter(prepared.topic?.topic, prepared.topic ? prepared.bytes : 0);
|
|
220
|
+
const subscriptionBytes = this.subscriptions.maximumAfter(prepared.subscription?.topic, prepared.subscription ? prepared.bytes : 0);
|
|
221
|
+
for (const [field, bytes] of [
|
|
222
|
+
["topics", topicBytes],
|
|
223
|
+
["subscriptions", subscriptionBytes],
|
|
224
|
+
]) {
|
|
225
|
+
const size = intercomMessageSizeBytes({
|
|
226
|
+
type: "sessions",
|
|
227
|
+
requestId,
|
|
228
|
+
sessions: [{ id: info.id, [field]: [] }],
|
|
229
|
+
more: true,
|
|
230
|
+
}) + bytes;
|
|
231
|
+
if (size > MAX_FRAME_SIZE_BYTES) {
|
|
232
|
+
throw new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
commit(prepared) {
|
|
237
|
+
if (prepared.changed) {
|
|
238
|
+
this.topics.accept();
|
|
239
|
+
this.subscriptions.accept();
|
|
240
|
+
}
|
|
241
|
+
if (prepared.topic) {
|
|
242
|
+
this.topics.set(prepared.topic, prepared.bytes);
|
|
243
|
+
}
|
|
244
|
+
else if (prepared.subscription) {
|
|
245
|
+
this.subscriptions.set(prepared.subscription, prepared.bytes);
|
|
246
|
+
}
|
|
247
|
+
else if (prepared.unsubscribe !== undefined) {
|
|
248
|
+
this.subscriptions.delete(prepared.unsubscribe);
|
|
249
|
+
}
|
|
140
250
|
}
|
|
141
|
-
const pending = new TopicSnapshot(info);
|
|
142
|
-
const publication = pending.apply(topicMutation(change));
|
|
143
|
-
return {
|
|
144
|
-
info: pending.snapshot(publication),
|
|
145
|
-
restore: change.action === "restore",
|
|
146
|
-
...(publication ? { publication } : {}),
|
|
147
|
-
};
|
|
148
251
|
}
|
|
149
252
|
export function deliveryAcknowledgement(receipt) {
|
|
150
253
|
if (!receipt.accepted) {
|
|
@@ -3,12 +3,12 @@ import * as path from "node:path";
|
|
|
3
3
|
import { RESULTS_DIR } from "../../shared/types.js";
|
|
4
4
|
import { runSynchronously } from "../../shared/cooperative.js";
|
|
5
5
|
import { asyncRunRoots, readAsyncRunRecord } from "./async-resume.js";
|
|
6
|
-
import {
|
|
6
|
+
import { readQuestionOwner } from "../shared/supervisor-questions.js";
|
|
7
7
|
import { findNestedRouteForRootId } from "../shared/nested-events.js";
|
|
8
8
|
import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.js";
|
|
9
9
|
import { validateStatusForSummary } from "./async-status-validation.js";
|
|
10
10
|
import { asyncStatusToSummary } from "./async-status-projection.js";
|
|
11
|
-
import { errorCode, errorMessage, hasText
|
|
11
|
+
import { errorCode, errorMessage, hasText } from "./async-value.js";
|
|
12
12
|
function directory(root, entry) {
|
|
13
13
|
const entryPath = path.join(root, entry);
|
|
14
14
|
try {
|
|
@@ -86,10 +86,8 @@ class AsyncRunDiscovery {
|
|
|
86
86
|
let owner = this.owners.get(entry);
|
|
87
87
|
if (!hasText(owner) && hasText(this.ownerSessionId)) {
|
|
88
88
|
try {
|
|
89
|
-
const saved =
|
|
90
|
-
if (
|
|
91
|
-
typeof saved.sessionId === "string" &&
|
|
92
|
-
saved.sessionId.trim().length > 0) {
|
|
89
|
+
const saved = readQuestionOwner(entry);
|
|
90
|
+
if (saved?.sessionId !== undefined && saved.sessionId.trim().length > 0) {
|
|
93
91
|
owner = saved.sessionId;
|
|
94
92
|
this.owners.set(entry, owner);
|
|
95
93
|
}
|
|
@@ -1,18 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { isRecord, hasText } from "./async-value.js";
|
|
3
|
-
function asNonEmptyString(value) {
|
|
4
|
-
if (typeof value !== "string") {
|
|
5
|
-
return undefined;
|
|
6
|
-
}
|
|
7
|
-
const trimmed = value.trim();
|
|
8
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
9
|
-
}
|
|
10
|
-
function asFiniteNumber(value) {
|
|
11
|
-
if (typeof value !== "number") {
|
|
12
|
-
return undefined;
|
|
13
|
-
}
|
|
14
|
-
return Number.isFinite(value) ? value : undefined;
|
|
15
|
-
}
|
|
16
3
|
function stableStringify(value) {
|
|
17
4
|
if (value === null || typeof value !== "object") {
|
|
18
5
|
return JSON.stringify(value);
|
|
@@ -32,32 +19,11 @@ function stableStringify(value) {
|
|
|
32
19
|
function payloadDigest(value) {
|
|
33
20
|
return createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16);
|
|
34
21
|
}
|
|
35
|
-
export function buildCompletionKey(data
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return `completion:${completionId}`;
|
|
39
|
-
}
|
|
40
|
-
const id = asNonEmptyString(data.id);
|
|
41
|
-
if (hasText(id)) {
|
|
42
|
-
return `id:${id}:${payloadDigest(data)}`;
|
|
22
|
+
export function buildCompletionKey(data) {
|
|
23
|
+
if (hasText(data.completionId)) {
|
|
24
|
+
return `completion:${data.completionId.trim()}`;
|
|
43
25
|
}
|
|
44
|
-
|
|
45
|
-
const agent = asNonEmptyString(data.agent) ?? "unknown";
|
|
46
|
-
const timestamp = asFiniteNumber(data.timestamp);
|
|
47
|
-
const taskIndex = asFiniteNumber(data.taskIndex);
|
|
48
|
-
const totalTasks = asFiniteNumber(data.totalTasks);
|
|
49
|
-
const success = data.success === true ? "1" : "?";
|
|
50
|
-
const successIdentity = data.success === false ? "0" : success;
|
|
51
|
-
return [
|
|
52
|
-
"meta",
|
|
53
|
-
sessionId,
|
|
54
|
-
agent,
|
|
55
|
-
timestamp !== undefined ? String(timestamp) : "no-ts",
|
|
56
|
-
taskIndex !== undefined ? String(taskIndex) : "-",
|
|
57
|
-
totalTasks !== undefined ? String(totalTasks) : "-",
|
|
58
|
-
successIdentity,
|
|
59
|
-
fallback,
|
|
60
|
-
].join(":");
|
|
26
|
+
return `id:${data.id.trim()}:${payloadDigest(data)}`;
|
|
61
27
|
}
|
|
62
28
|
function pruneSeenMap(seen, now, ttlMs) {
|
|
63
29
|
for (const [key, ts] of seen.entries()) {
|
|
@@ -74,20 +40,3 @@ export function markSeenWithTtl(seen, key, now, ttlMs) {
|
|
|
74
40
|
seen.set(key, now);
|
|
75
41
|
return false;
|
|
76
42
|
}
|
|
77
|
-
function isSeenMap(value) {
|
|
78
|
-
if (!(value instanceof Map)) {
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
const entries = value;
|
|
82
|
-
return [...entries].every(([key, timestamp]) => typeof key === "string" && typeof timestamp === "number");
|
|
83
|
-
}
|
|
84
|
-
export function getGlobalSeenMap(storeKey) {
|
|
85
|
-
const globalStore = globalThis;
|
|
86
|
-
const existing = globalStore[storeKey];
|
|
87
|
-
if (isSeenMap(existing)) {
|
|
88
|
-
return existing;
|
|
89
|
-
}
|
|
90
|
-
const map = new Map();
|
|
91
|
-
globalStore[storeKey] = map;
|
|
92
|
-
return map;
|
|
93
|
-
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { buildCompletionKey, getGlobalSeenMap, markSeenWithTtl } from "./completion-dedupe.js";
|
|
2
1
|
import { SUBAGENT_ASYNC_COMPLETE_EVENT } from "../../shared/types.js";
|
|
3
2
|
import { hasText, isRecord } from "./async-value.js";
|
|
4
3
|
function isResult(value) {
|
|
@@ -95,22 +94,15 @@ export default function registerSubagentNotify(pi, onQueued) {
|
|
|
95
94
|
// Best effort cleanup for stale handlers from an older reload.
|
|
96
95
|
}
|
|
97
96
|
}
|
|
98
|
-
const seen = getGlobalSeenMap("__pi_subagents_notify_seen__");
|
|
99
97
|
const handleComplete = (data) => {
|
|
100
98
|
if (!isResult(data) ||
|
|
101
99
|
data.intercomResultDelivered === true ||
|
|
102
100
|
data.suppressNotification === true) {
|
|
103
101
|
return;
|
|
104
102
|
}
|
|
105
|
-
const key = buildCompletionKey(data, "notify");
|
|
106
|
-
// Owned completion keys are governed by the shared queue/journal lifecycle, not a TTL.
|
|
107
|
-
if ((onQueued === undefined || !hasText(data.completionKey)) &&
|
|
108
|
-
markSeenWithTtl(seen, key, Date.now(), 10 * 60 * 1000)) {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
103
|
pi.sendMessage(buildNotification(data), { triggerTurn: true });
|
|
112
104
|
if (hasText(data.completionKey)) {
|
|
113
|
-
onQueued
|
|
105
|
+
onQueued(data.completionKey, data.runId);
|
|
114
106
|
}
|
|
115
107
|
};
|
|
116
108
|
const unsubscribe = pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete);
|
|
@@ -215,7 +215,7 @@ class ResultWatcher {
|
|
|
215
215
|
if (this.foreign(envelope)) {
|
|
216
216
|
return;
|
|
217
217
|
}
|
|
218
|
-
const key = buildCompletionKey({ ...envelope.data, id: envelope.runId }
|
|
218
|
+
const key = buildCompletionKey({ ...envelope.data, id: envelope.runId });
|
|
219
219
|
if (this.alreadyHandled(envelope, key)) {
|
|
220
220
|
return;
|
|
221
221
|
}
|
|
@@ -31,17 +31,20 @@ export class RunnerChildObserver {
|
|
|
31
31
|
this.id = config.id;
|
|
32
32
|
this.hooks = hooks;
|
|
33
33
|
this.mutatingFailureStates = statusPayload.steps.map(() => createMutatingFailureState());
|
|
34
|
+
this.syncTopLevelCurrentTool();
|
|
34
35
|
}
|
|
35
36
|
expandChildren(flatIndex, count) {
|
|
36
37
|
this.mutatingFailureStates.splice(flatIndex, 1, ...Array.from({ length: count }, () => createMutatingFailureState()));
|
|
37
38
|
}
|
|
38
39
|
syncTopLevelCurrentTool() {
|
|
39
|
-
const active = this.statusPayload.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
const active = this.statusPayload.state === "running" || this.statusPayload.state === "queued"
|
|
41
|
+
? this.statusPayload.steps
|
|
42
|
+
.filter((step) => step.status === "running" &&
|
|
43
|
+
typeof step.currentTool === "string" &&
|
|
44
|
+
step.currentTool.length > 0)
|
|
45
|
+
.sort((left, right) => (right.currentToolStartedAt ?? 0) - (left.currentToolStartedAt ?? 0))
|
|
46
|
+
.at(0)
|
|
47
|
+
: undefined;
|
|
45
48
|
this.statusPayload.currentTool = active?.currentTool;
|
|
46
49
|
this.statusPayload.currentToolStartedAt = active?.currentToolStartedAt;
|
|
47
50
|
this.statusPayload.currentPath = active?.currentPath;
|
|
@@ -62,7 +65,9 @@ export class RunnerChildObserver {
|
|
|
62
65
|
}
|
|
63
66
|
step.streamingText = updateStreamingText(step.streamingText, event);
|
|
64
67
|
this.recordChildActivity(flatIndex, event, toolSnapshot, now);
|
|
65
|
-
|
|
68
|
+
if (event.type === "tool_execution_start" || event.type === "tool_execution_end") {
|
|
69
|
+
this.syncTopLevelCurrentTool();
|
|
70
|
+
}
|
|
66
71
|
step.lastActivityAt = now;
|
|
67
72
|
this.statusPayload.lastActivityAt = now;
|
|
68
73
|
this.statusPayload.lastUpdate = now;
|
|
@@ -120,6 +120,8 @@ export class RunnerMonitor {
|
|
|
120
120
|
clearTimeout(this.statusWriteTimer);
|
|
121
121
|
this.statusWriteTimer = undefined;
|
|
122
122
|
}
|
|
123
|
+
// Step completion, interruption and expansion can change activity without a tool-end event.
|
|
124
|
+
this.childObserver.syncTopLevelCurrentTool();
|
|
123
125
|
this.refreshWorkflowGraph();
|
|
124
126
|
writeAtomicJson(this.statusPath, this.statusPayload);
|
|
125
127
|
if (this.config.runtimeVersion !== 2) {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { inspect } from "node:util";
|
|
3
3
|
import { appendJsonl } from "../../shared/artifacts.js";
|
|
4
|
-
import { writeAtomicJson } from "../../shared/atomic-json.js";
|
|
5
4
|
import { writeInitialProgressFile } from "../../shared/settings.js";
|
|
6
5
|
import { completeWorkflowStep, runParallelTasks } from "../shared/workflow-policy.js";
|
|
7
6
|
import { createWorktrees, cleanupWorktrees, findWorktreeTaskCwdConflict, formatWorktreeTaskCwdConflict, formatParallelWorktreeSummary, WorktreeCleanupError, } from "../shared/worktree.js";
|
|
@@ -129,7 +128,7 @@ export class RunnerParallelGroup {
|
|
|
129
128
|
status.currentStep = position.flatIndex;
|
|
130
129
|
status.lastUpdate = failedAt;
|
|
131
130
|
status.outputFile = path.join(this.config.asyncDir, `output-${position.flatIndex}.log`);
|
|
132
|
-
|
|
131
|
+
this.monitor.writeStatusPayload();
|
|
133
132
|
this.append({
|
|
134
133
|
type: "subagent.parallel.completed",
|
|
135
134
|
ts: failedAt,
|
|
@@ -158,7 +157,7 @@ export class RunnerParallelGroup {
|
|
|
158
157
|
status.lastActivityAt = startedAt;
|
|
159
158
|
status.lastUpdate = startedAt;
|
|
160
159
|
status.outputFile = path.join(this.config.asyncDir, `output-${position.flatIndex}.log`);
|
|
161
|
-
|
|
160
|
+
this.monitor.writeStatusPayload();
|
|
162
161
|
this.append({
|
|
163
162
|
type: "subagent.parallel.started",
|
|
164
163
|
ts: startedAt,
|
|
@@ -37,8 +37,11 @@ export class ChildObservationSpool {
|
|
|
37
37
|
fs.writeFileSync(this.stderr, text);
|
|
38
38
|
}
|
|
39
39
|
resetText() {
|
|
40
|
-
this.textOffset
|
|
40
|
+
if (this.textOffset === 0) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
41
43
|
fs.ftruncateSync(this.text, 0);
|
|
44
|
+
this.textOffset = 0;
|
|
42
45
|
}
|
|
43
46
|
appendText(text) {
|
|
44
47
|
const bytes = Buffer.from(text);
|
|
@@ -52,13 +55,15 @@ export class ChildObservationSpool {
|
|
|
52
55
|
this.textOffset += bytes.length;
|
|
53
56
|
}
|
|
54
57
|
replayText(consume) {
|
|
55
|
-
|
|
58
|
+
if (this.textOffset > 0) {
|
|
59
|
+
this.decode(this.text, 0, this.textOffset, consume);
|
|
60
|
+
}
|
|
56
61
|
}
|
|
57
62
|
replayWire(start, end, consume) {
|
|
58
63
|
this.decode(this.wire, start, end, consume);
|
|
59
64
|
}
|
|
60
65
|
copy(fd, start, end, consume) {
|
|
61
|
-
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
66
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, end - start));
|
|
62
67
|
for (let position = start; position < end;) {
|
|
63
68
|
const count = fs.readSync(fd, buffer, 0, Math.min(buffer.length, end - position), position);
|
|
64
69
|
if (count === 0) {
|