@arhen/pi-core-subagent 1.3.66 → 1.3.67
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/README.md +1 -1
- package/package.json +3 -9
- package/src/delivery.ts +744 -0
- package/src/manager.ts +76 -1022
- package/src/outcome.ts +58 -0
- package/src/run-widget.ts +122 -0
- package/src/runtime.ts +136 -0
- package/src/types.ts +9 -0
package/src/delivery.ts
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { makeAskNotice, makeNotice, makeTaskArtifactNotice, makeTaskNotice } from "./format.ts";
|
|
3
|
+
import { artifactFingerprint, outcomeFingerprint, textFingerprint } from "./notification-state.ts";
|
|
4
|
+
import { type ParkedMsg, type RunSnapshot, type TaskSnapshot, TERMINAL } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** A submitted notice missing from the canonical context this long is treated as lost. */
|
|
7
|
+
const RECEIPT_GRACE_MS = 3_000;
|
|
8
|
+
/** Minimum wait before retrying a failed submission, so one flush cannot burn every attempt. */
|
|
9
|
+
const RETRY_BACKOFF_MS = 1_000;
|
|
10
|
+
/** Total submission attempts before a notice is declared lost. */
|
|
11
|
+
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
12
|
+
|
|
13
|
+
interface LeaderMessageReceipt {
|
|
14
|
+
runId: string;
|
|
15
|
+
taskIds: string[];
|
|
16
|
+
kind: "update" | "final" | "terminal";
|
|
17
|
+
deliverAs: "steer" | "followUp";
|
|
18
|
+
delivered: boolean;
|
|
19
|
+
submitted: boolean;
|
|
20
|
+
lost: boolean;
|
|
21
|
+
submittedAt: number;
|
|
22
|
+
attempts: number;
|
|
23
|
+
/** Agent runs started when this receipt was submitted; a later run is needed to prove loss. */
|
|
24
|
+
submittedRunCount: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface OutboxEntry {
|
|
28
|
+
runId: string;
|
|
29
|
+
taskIds: string[];
|
|
30
|
+
kind: "update" | "final" | "terminal";
|
|
31
|
+
deliverAs: "steer" | "followUp";
|
|
32
|
+
attempts?: number;
|
|
33
|
+
attemptedAt?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface TerminalNotice {
|
|
37
|
+
body: string;
|
|
38
|
+
fingerprint: string;
|
|
39
|
+
delivery: "pending" | "received" | "lost";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What delivery needs from the manager that owns runs, live children, parking and persistence. */
|
|
43
|
+
export interface DeliveryHost {
|
|
44
|
+
readonly pi: ExtensionAPI;
|
|
45
|
+
isCleared(): boolean;
|
|
46
|
+
findRun(runId: string): RunSnapshot | undefined;
|
|
47
|
+
/** `runId:taskId` of a child session that is still running. */
|
|
48
|
+
isLive(key: string): boolean;
|
|
49
|
+
persist(ctx: ExtensionContext): void;
|
|
50
|
+
emit(type: string, payload: Record<string, unknown>): void;
|
|
51
|
+
collectParked(runId: string, msg: ParkedMsg): boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Leader-facing notifications: per-task and aggregate notices, held follow-ups (outbox), submitted
|
|
56
|
+
* receipts confirmed against the canonical leader context, loss detection with bounded retries, and
|
|
57
|
+
* de-duplication against final parent reports and terminal awaits.
|
|
58
|
+
*/
|
|
59
|
+
export class LeaderDelivery {
|
|
60
|
+
private leaderMessages = new Map<string, LeaderMessageReceipt>();
|
|
61
|
+
private outbox = new Map<string, OutboxEntry>();
|
|
62
|
+
private parkedBodies = new Map<string, Set<string>>();
|
|
63
|
+
private agentRunCount = 0;
|
|
64
|
+
private terminalNotices = new Map<string, TerminalNotice>();
|
|
65
|
+
private awaitedTaskNotices = new Map<string, string>();
|
|
66
|
+
private pendingTaskNotices = new Map<string, { run: RunSnapshot; task: TaskSnapshot }>();
|
|
67
|
+
private pendingRunNotices = new Map<string, RunSnapshot>();
|
|
68
|
+
private notificationRetries = new Set<string>();
|
|
69
|
+
private notificationFlushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
notificationContext: ExtensionContext | undefined;
|
|
71
|
+
|
|
72
|
+
constructor(private readonly host: DeliveryHost) {}
|
|
73
|
+
|
|
74
|
+
/** Session end: forget every notice, receipt and pending flush. */
|
|
75
|
+
reset(): void {
|
|
76
|
+
this.leaderMessages.clear();
|
|
77
|
+
this.outbox.clear();
|
|
78
|
+
this.parkedBodies.clear();
|
|
79
|
+
this.terminalNotices.clear();
|
|
80
|
+
this.awaitedTaskNotices.clear();
|
|
81
|
+
this.pendingTaskNotices.clear();
|
|
82
|
+
this.pendingRunNotices.clear();
|
|
83
|
+
this.notificationRetries.clear();
|
|
84
|
+
if (this.notificationFlushTimer) clearTimeout(this.notificationFlushTimer);
|
|
85
|
+
this.notificationFlushTimer = undefined;
|
|
86
|
+
this.notificationContext = undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A resumed task starts a fresh outcome: drop its notices so the next settle reports it again. */
|
|
90
|
+
forgetTask(run: RunSnapshot, task: TaskSnapshot): void {
|
|
91
|
+
const scope = `${run.id}:${task.id}`;
|
|
92
|
+
this.terminalNotices.delete(scope);
|
|
93
|
+
this.pendingTaskNotices.delete(scope);
|
|
94
|
+
this.pendingRunNotices.delete(run.id);
|
|
95
|
+
this.notificationRetries.delete(run.id);
|
|
96
|
+
for (const [body, receipt] of this.leaderMessages) {
|
|
97
|
+
if (receipt.runId !== run.id || !receipt.taskIds.includes(task.id)) continue;
|
|
98
|
+
if (receipt.taskIds.length === 1) {
|
|
99
|
+
this.leaderMessages.delete(body);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// A shared aggregate stays for its remaining tasks; the resumed task's slice is published
|
|
103
|
+
// again by the run's next settle.
|
|
104
|
+
receipt.taskIds = receipt.taskIds.filter((id) => id !== task.id);
|
|
105
|
+
}
|
|
106
|
+
for (const [body, entry] of this.outbox) {
|
|
107
|
+
if (entry.runId !== run.id || !entry.taskIds.includes(task.id)) continue;
|
|
108
|
+
if (entry.taskIds.length === 1) {
|
|
109
|
+
this.outbox.delete(body);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
this.outbox.delete(body);
|
|
113
|
+
for (const otherId of entry.taskIds) {
|
|
114
|
+
if (otherId === task.id) continue;
|
|
115
|
+
const notice = this.terminalNotices.get(`${run.id}:${otherId}`);
|
|
116
|
+
if (notice) notice.delivery = "lost";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
this.awaitedTaskNotices.delete(scope);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private finalReportMatches(task: TaskSnapshot): boolean {
|
|
123
|
+
const report = task.finalParentReport;
|
|
124
|
+
if (!report || report.toolCalls !== task.toolCalls) return false;
|
|
125
|
+
const fingerprint = task.finalTextFingerprint ?? textFingerprint(task.finalText ?? "");
|
|
126
|
+
return report.fingerprint === fingerprint;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private hasArtifactDelta(task: TaskSnapshot): boolean {
|
|
130
|
+
return artifactFingerprint(task) !== task.finalParentReport?.artifacts;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private leaderMessageText(content: unknown): string {
|
|
134
|
+
return typeof content === "string"
|
|
135
|
+
? content
|
|
136
|
+
: Array.isArray(content)
|
|
137
|
+
? content
|
|
138
|
+
.filter((part) => part?.type === "text")
|
|
139
|
+
.map((part) => part.text ?? "")
|
|
140
|
+
.join("\n")
|
|
141
|
+
: "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* One pass over the finalized leader projection. Returns undefined when the pinned pi build
|
|
146
|
+
* cannot project (never treat that as loss, or every receipt would be replayed).
|
|
147
|
+
*/
|
|
148
|
+
private projectionBody(ctx: ExtensionContext): string | undefined {
|
|
149
|
+
try {
|
|
150
|
+
const manager = ctx.sessionManager as unknown as {
|
|
151
|
+
buildSessionProjection?: () => { messages: readonly { role?: string; content?: unknown }[] };
|
|
152
|
+
};
|
|
153
|
+
if (typeof manager.buildSessionProjection !== "function") return undefined;
|
|
154
|
+
const messages = manager.buildSessionProjection().messages;
|
|
155
|
+
const parts: string[] = [];
|
|
156
|
+
for (const message of messages) {
|
|
157
|
+
if (message.role !== "user" && message.role !== "custom") continue;
|
|
158
|
+
parts.push(this.leaderMessageText(message.content));
|
|
159
|
+
}
|
|
160
|
+
return parts.join("\n");
|
|
161
|
+
} catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** An agent run started; a receipt can only be declared lost after one full later run. */
|
|
167
|
+
noteAgentStart(): void {
|
|
168
|
+
this.agentRunCount += 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True when a terminal await already rendered this task's outcome. */
|
|
172
|
+
private coveredByAwait(runId: string, taskId: string, fingerprint: string): boolean {
|
|
173
|
+
return this.awaitedTaskNotices.get(`${runId}:${taskId}`) === fingerprint;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Record what a terminal await actually rendered, so held notices are dropped instead of echoed. */
|
|
177
|
+
markAwaitCoverage(run: RunSnapshot, coveredTaskIds?: ReadonlySet<string>): void {
|
|
178
|
+
for (const task of run.tasks) {
|
|
179
|
+
if (!TERMINAL.includes(task.status)) continue;
|
|
180
|
+
if (coveredTaskIds && !coveredTaskIds.has(task.id)) continue;
|
|
181
|
+
const scope = `${run.id}:${task.id}`;
|
|
182
|
+
this.awaitedTaskNotices.set(scope, outcomeFingerprint(task));
|
|
183
|
+
this.pendingTaskNotices.delete(scope);
|
|
184
|
+
}
|
|
185
|
+
for (const [body, entry] of [...this.outbox]) {
|
|
186
|
+
if (entry.runId !== run.id) continue;
|
|
187
|
+
if (!this.outboxCovered(entry)) continue;
|
|
188
|
+
const task = run.tasks.find((t) => t.id === entry.taskIds[0]);
|
|
189
|
+
// An update is not part of the rendered summary; only terminal/aggregate entries are safe to drop.
|
|
190
|
+
if (entry.kind === "update") continue;
|
|
191
|
+
if (entry.kind === "final" && (!task || !this.finalReportMatches(task))) continue;
|
|
192
|
+
this.dropOutbox(body, entry, "awaited");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private outboxCovered(entry: OutboxEntry): boolean {
|
|
197
|
+
const run = this.host.findRun(entry.runId);
|
|
198
|
+
if (!run) return false;
|
|
199
|
+
return entry.taskIds.every((taskId) => {
|
|
200
|
+
const task = run.tasks.find((t) => t.id === taskId);
|
|
201
|
+
if (!task || !TERMINAL.includes(task.status)) return false;
|
|
202
|
+
return this.coveredByAwait(entry.runId, taskId, outcomeFingerprint(task));
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private dropOutbox(body: string, entry: OutboxEntry, delivery: string): void {
|
|
207
|
+
this.outbox.delete(body);
|
|
208
|
+
const task = this.host.findRun(entry.runId)?.tasks.find((t) => t.id === entry.taskIds[0]);
|
|
209
|
+
if (entry.kind === "final" && task?.finalParentReport?.body === body) task.finalParentReport.delivery = "awaited";
|
|
210
|
+
if (entry.kind === "terminal") {
|
|
211
|
+
for (const taskId of entry.taskIds) {
|
|
212
|
+
const notice = this.terminalNotices.get(`${entry.runId}:${taskId}`);
|
|
213
|
+
if (notice?.body === body) notice.delivery = "received";
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
this.host.emit("subagent:notification", {
|
|
217
|
+
runId: entry.runId,
|
|
218
|
+
taskId: entry.taskIds[0],
|
|
219
|
+
kind: "completed",
|
|
220
|
+
body: "",
|
|
221
|
+
suppressed: true,
|
|
222
|
+
delivery,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private enqueueNotification(body: string, entry: OutboxEntry, ctx?: ExtensionContext): void {
|
|
227
|
+
this.outbox.set(body, entry);
|
|
228
|
+
const flushContext = ctx ?? this.notificationContext;
|
|
229
|
+
if (flushContext) this.scheduleNotificationFlush(flushContext);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private noteTerminal(runId: string, taskId: string, body: string, fingerprint: string): void {
|
|
233
|
+
this.terminalNotices.set(`${runId}:${taskId}`, { body, fingerprint, delivery: "pending" });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
submitLeaderReport(
|
|
237
|
+
run: RunSnapshot,
|
|
238
|
+
task: TaskSnapshot,
|
|
239
|
+
message: string,
|
|
240
|
+
level: "info" | "warning" | "error",
|
|
241
|
+
final: boolean,
|
|
242
|
+
ctx: ExtensionContext,
|
|
243
|
+
): boolean {
|
|
244
|
+
if (this.host.isCleared()) return false;
|
|
245
|
+
this.notificationContext = ctx;
|
|
246
|
+
const body = `[Subagent ${task.agent} (${task.id}, ${run.id})]${final ? " Final result:" : ""}${level === "info" ? "" : ` [${level}]`} ${message}`;
|
|
247
|
+
const previous = this.leaderMessages.get(body);
|
|
248
|
+
if ((previous && !previous.lost) || this.outbox.has(body)) {
|
|
249
|
+
if (final) {
|
|
250
|
+
task.finalParentReport = {
|
|
251
|
+
body,
|
|
252
|
+
message,
|
|
253
|
+
toolCalls: task.toolCalls,
|
|
254
|
+
fingerprint: textFingerprint(message),
|
|
255
|
+
artifacts: artifactFingerprint(task),
|
|
256
|
+
delivery: previous?.delivered ? "message" : "pending",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
if (previous?.lost) this.leaderMessages.delete(body);
|
|
262
|
+
const report: TaskSnapshot["finalParentReport"] = final
|
|
263
|
+
? {
|
|
264
|
+
body,
|
|
265
|
+
message,
|
|
266
|
+
toolCalls: task.toolCalls,
|
|
267
|
+
fingerprint: textFingerprint(message),
|
|
268
|
+
artifacts: artifactFingerprint(task),
|
|
269
|
+
delivery: "pending",
|
|
270
|
+
}
|
|
271
|
+
: undefined;
|
|
272
|
+
if (report) task.finalParentReport = report;
|
|
273
|
+
this.host.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message, final });
|
|
274
|
+
if (this.host.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message, final })) {
|
|
275
|
+
let parked = this.parkedBodies.get(run.id);
|
|
276
|
+
if (!parked) {
|
|
277
|
+
parked = new Set();
|
|
278
|
+
this.parkedBodies.set(run.id, parked);
|
|
279
|
+
}
|
|
280
|
+
if (parked.has(body)) return true;
|
|
281
|
+
parked.add(body);
|
|
282
|
+
task.notifiedParent = true;
|
|
283
|
+
if (report) report.delivery = "parked";
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
this.enqueueNotification(body, {
|
|
287
|
+
runId: run.id,
|
|
288
|
+
taskIds: [task.id],
|
|
289
|
+
kind: final ? "final" : "update",
|
|
290
|
+
deliverAs: "followUp",
|
|
291
|
+
});
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private hasPendingSuccessMessages(run: RunSnapshot): boolean {
|
|
296
|
+
return run.tasks.some((task) => {
|
|
297
|
+
const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
|
|
298
|
+
if (notice?.delivery !== "pending") return false;
|
|
299
|
+
const receipt = this.leaderMessages.get(notice.body);
|
|
300
|
+
return receipt ? !receipt.delivered && !receipt.lost : this.outbox.has(notice.body);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
confirmLeaderMessages(ctx: ExtensionContext): void {
|
|
305
|
+
if (this.host.isCleared()) return;
|
|
306
|
+
const projection = this.projectionBody(ctx);
|
|
307
|
+
let changed = false;
|
|
308
|
+
for (const [body, receipt] of [...this.leaderMessages]) {
|
|
309
|
+
if (receipt.delivered || receipt.lost || projection === undefined || !projection.includes(body)) continue;
|
|
310
|
+
receipt.delivered = true;
|
|
311
|
+
changed = true;
|
|
312
|
+
const run = this.host.findRun(receipt.runId);
|
|
313
|
+
for (const taskId of receipt.taskIds) {
|
|
314
|
+
const task = run?.tasks.find((t) => t.id === taskId);
|
|
315
|
+
if (!task) continue;
|
|
316
|
+
task.notifiedParent = true;
|
|
317
|
+
if (task.finalParentReport?.body === body) task.finalParentReport.delivery = "message";
|
|
318
|
+
const key = `${receipt.runId}:${taskId}`;
|
|
319
|
+
const notice = this.terminalNotices.get(key);
|
|
320
|
+
if (notice?.body === body) notice.delivery = "received";
|
|
321
|
+
const pending = this.pendingTaskNotices.get(key);
|
|
322
|
+
if (pending && this.finalReportMatches(task)) {
|
|
323
|
+
this.pendingTaskNotices.delete(key);
|
|
324
|
+
if (this.hasArtifactDelta(task)) this.notifyTask(pending.run, task, "completed");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (run && !this.hasPendingSuccessMessages(run)) this.pendingRunNotices.delete(run.id);
|
|
328
|
+
}
|
|
329
|
+
if (changed) this.host.persist(ctx);
|
|
330
|
+
for (const [body, receipt] of this.leaderMessages) {
|
|
331
|
+
if (!receipt.delivered && !receipt.lost) continue;
|
|
332
|
+
const run = this.host.findRun(receipt.runId);
|
|
333
|
+
const tasks = run?.tasks.filter((task) => receipt.taskIds.includes(task.id)) ?? [];
|
|
334
|
+
if (
|
|
335
|
+
tasks.length > 0 &&
|
|
336
|
+
tasks.every((task) => TERMINAL.includes(task.status) && !this.host.isLive(`${receipt.runId}:${task.id}`))
|
|
337
|
+
)
|
|
338
|
+
this.leaderMessages.delete(body);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* A submitted receipt that never appears in the canonical context after a grace period is lost.
|
|
344
|
+
* The grace period keeps the check from racing Pi's input/auth/start hooks, which run before the
|
|
345
|
+
* message is appended to the session and can be slow while the session still reports idle.
|
|
346
|
+
*/
|
|
347
|
+
private markLostReceipts(ctx: ExtensionContext, existing: ReadonlySet<string>): void {
|
|
348
|
+
const now = Date.now();
|
|
349
|
+
const projection = this.projectionBody(ctx);
|
|
350
|
+
if (projection === undefined) return;
|
|
351
|
+
for (const [body, receipt] of [...this.leaderMessages]) {
|
|
352
|
+
if (receipt.delivered || receipt.lost || !existing.has(body)) continue;
|
|
353
|
+
if (this.outbox.has(body)) continue;
|
|
354
|
+
if (now - receipt.submittedAt < RECEIPT_GRACE_MS) continue;
|
|
355
|
+
// A later agent run proves the submitted prompt had its turn; a message that never runs
|
|
356
|
+
// at all is caught by the longer bound. While a prompt is still queued or in preflight
|
|
357
|
+
// (deferred settle, slow input/auth hooks) the session can report idle without the
|
|
358
|
+
// message being appended yet, so the short window alone must not declare loss.
|
|
359
|
+
const laterRun = this.agentRunCount > receipt.submittedRunCount;
|
|
360
|
+
const longWait = now - receipt.submittedAt >= RECEIPT_GRACE_MS * 2;
|
|
361
|
+
if (!laterRun && !longWait) continue;
|
|
362
|
+
if (projection.includes(body)) continue;
|
|
363
|
+
if (receipt.attempts < MAX_DELIVERY_ATTEMPTS) {
|
|
364
|
+
this.leaderMessages.delete(body);
|
|
365
|
+
this.outbox.set(body, {
|
|
366
|
+
runId: receipt.runId,
|
|
367
|
+
taskIds: receipt.taskIds,
|
|
368
|
+
kind: receipt.kind,
|
|
369
|
+
deliverAs: receipt.deliverAs,
|
|
370
|
+
attempts: receipt.attempts,
|
|
371
|
+
attemptedAt: now,
|
|
372
|
+
});
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
receipt.lost = true;
|
|
376
|
+
for (const taskId of receipt.taskIds) {
|
|
377
|
+
const key = `${receipt.runId}:${taskId}`;
|
|
378
|
+
const notice = this.terminalNotices.get(key);
|
|
379
|
+
if (notice?.body === body) notice.delivery = "lost";
|
|
380
|
+
const task = this.host.findRun(receipt.runId)?.tasks.find((t) => t.id === taskId);
|
|
381
|
+
if (task?.finalParentReport?.body === body) task.finalParentReport.delivery = "lost";
|
|
382
|
+
}
|
|
383
|
+
this.host.emit("subagent:notification", {
|
|
384
|
+
runId: receipt.runId,
|
|
385
|
+
taskId: receipt.taskIds[0],
|
|
386
|
+
kind: "completed",
|
|
387
|
+
body,
|
|
388
|
+
delivery: "lost",
|
|
389
|
+
});
|
|
390
|
+
// A lost final report must still reach the leader: per-task hold or a direct full fallback.
|
|
391
|
+
if (receipt.kind === "final") {
|
|
392
|
+
const run = this.host.findRun(receipt.runId);
|
|
393
|
+
for (const taskId of receipt.taskIds) {
|
|
394
|
+
if (this.pendingTaskNotices.has(`${receipt.runId}:${taskId}`)) continue;
|
|
395
|
+
const task = run?.tasks.find((t) => t.id === taskId);
|
|
396
|
+
if (run && task) this.notifyTask(run, task, "completed");
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private flushOutbox(): void {
|
|
403
|
+
for (const [body, entry] of [...this.outbox]) {
|
|
404
|
+
if (this.outboxCovered(entry)) {
|
|
405
|
+
this.dropOutbox(body, entry, "awaited");
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (entry.attemptedAt && Date.now() - entry.attemptedAt < RETRY_BACKOFF_MS) continue;
|
|
409
|
+
this.outbox.delete(body);
|
|
410
|
+
try {
|
|
411
|
+
this.host.pi.sendUserMessage(body, { deliverAs: entry.deliverAs });
|
|
412
|
+
this.leaderMessages.set(body, {
|
|
413
|
+
runId: entry.runId,
|
|
414
|
+
taskIds: entry.taskIds,
|
|
415
|
+
kind: entry.kind,
|
|
416
|
+
deliverAs: entry.deliverAs,
|
|
417
|
+
delivered: false,
|
|
418
|
+
submitted: true,
|
|
419
|
+
lost: false,
|
|
420
|
+
submittedAt: Date.now(),
|
|
421
|
+
attempts: (entry.attempts ?? 0) + 1,
|
|
422
|
+
submittedRunCount: this.agentRunCount,
|
|
423
|
+
});
|
|
424
|
+
if (entry.kind === "terminal") {
|
|
425
|
+
for (const taskId of entry.taskIds) {
|
|
426
|
+
const notice = this.terminalNotices.get(`${entry.runId}:${taskId}`);
|
|
427
|
+
if (notice?.body === body) notice.delivery = "pending";
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
this.host.emit("subagent:notification", {
|
|
431
|
+
runId: entry.runId,
|
|
432
|
+
taskId: entry.taskIds[0],
|
|
433
|
+
kind: "completed",
|
|
434
|
+
body,
|
|
435
|
+
delivery: "submitted",
|
|
436
|
+
});
|
|
437
|
+
} catch {
|
|
438
|
+
const attempts = (entry.attempts ?? 0) + 1;
|
|
439
|
+
if (attempts < MAX_DELIVERY_ATTEMPTS) {
|
|
440
|
+
this.outbox.set(body, { ...entry, attempts, attemptedAt: Date.now() });
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
this.host.emit("subagent:notification", {
|
|
444
|
+
runId: entry.runId,
|
|
445
|
+
taskId: entry.taskIds[0],
|
|
446
|
+
kind: "completed",
|
|
447
|
+
body,
|
|
448
|
+
delivery: "failed",
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
flushPendingNotifications(ctx: ExtensionContext): void {
|
|
455
|
+
const existing = new Set([...this.leaderMessages.keys(), ...this.outbox.keys()]);
|
|
456
|
+
this.confirmLeaderMessages(ctx);
|
|
457
|
+
if (this.host.isCleared()) return;
|
|
458
|
+
let idle = false;
|
|
459
|
+
try {
|
|
460
|
+
idle = ctx.isIdle() && !ctx.hasPendingMessages();
|
|
461
|
+
} catch {
|
|
462
|
+
idle = false;
|
|
463
|
+
}
|
|
464
|
+
if (idle) this.markLostReceipts(ctx, existing);
|
|
465
|
+
const outstandingReceipts = () =>
|
|
466
|
+
[...this.leaderMessages.values()].some((receipt) => !receipt.delivered && !receipt.lost);
|
|
467
|
+
if (!this.pendingTaskNotices.size && !this.pendingRunNotices.size && !this.outbox.size && !outstandingReceipts())
|
|
468
|
+
return;
|
|
469
|
+
if (idle) {
|
|
470
|
+
this.flushOutbox();
|
|
471
|
+
for (const [key, pending] of [...this.pendingTaskNotices]) {
|
|
472
|
+
const report = pending.task.finalParentReport;
|
|
473
|
+
if (report?.delivery === "lost") {
|
|
474
|
+
this.pendingTaskNotices.delete(key);
|
|
475
|
+
pending.task.finalParentReport = undefined;
|
|
476
|
+
this.notifyTask(pending.run, pending.task, "completed");
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (report?.delivery === "pending") {
|
|
480
|
+
const outstanding = this.leaderMessages.has(report.body) || this.outbox.has(report.body);
|
|
481
|
+
if (outstanding) continue;
|
|
482
|
+
this.pendingTaskNotices.delete(key);
|
|
483
|
+
pending.task.finalParentReport = undefined;
|
|
484
|
+
this.notifyTask(pending.run, pending.task, "completed");
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
this.pendingTaskNotices.delete(key);
|
|
488
|
+
if (report && this.finalReportMatches(pending.task) && this.hasArtifactDelta(pending.task))
|
|
489
|
+
this.notifyTask(pending.run, pending.task, "completed");
|
|
490
|
+
}
|
|
491
|
+
for (const [key, run] of [...this.pendingRunNotices]) {
|
|
492
|
+
const notices = run.tasks
|
|
493
|
+
.map((task) => this.terminalNotices.get(`${run.id}:${task.id}`))
|
|
494
|
+
.filter((notice): notice is TerminalNotice => Boolean(notice));
|
|
495
|
+
const waiting = notices.some((notice) => {
|
|
496
|
+
const receipt = this.leaderMessages.get(notice.body);
|
|
497
|
+
return notice.delivery === "pending" && (!receipt || (!receipt.delivered && !receipt.lost));
|
|
498
|
+
});
|
|
499
|
+
if (waiting) continue;
|
|
500
|
+
this.pendingRunNotices.delete(key);
|
|
501
|
+
const lost = notices.some((notice) => notice.delivery === "lost");
|
|
502
|
+
if (!lost || this.notificationRetries.has(run.id)) continue;
|
|
503
|
+
this.notificationRetries.add(run.id);
|
|
504
|
+
this.notifyParent(run, run.status === "aborted" ? "aborted" : run.status === "failed" ? "failed" : "completed");
|
|
505
|
+
}
|
|
506
|
+
this.flushOutbox();
|
|
507
|
+
}
|
|
508
|
+
// Anything still unresolved (including a retry backoff) must be revisited without unrelated activity.
|
|
509
|
+
this.scheduleNotificationFlush(ctx, RETRY_BACKOFF_MS);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
scheduleNotificationFlush(ctx: ExtensionContext, delayMs = 100): void {
|
|
513
|
+
if (
|
|
514
|
+
this.notificationFlushTimer ||
|
|
515
|
+
this.host.isCleared() ||
|
|
516
|
+
(!this.pendingTaskNotices.size &&
|
|
517
|
+
!this.pendingRunNotices.size &&
|
|
518
|
+
!this.outbox.size &&
|
|
519
|
+
![...this.leaderMessages.values()].some((receipt) => !receipt.delivered && !receipt.lost))
|
|
520
|
+
)
|
|
521
|
+
return;
|
|
522
|
+
this.notificationFlushTimer = setTimeout(() => {
|
|
523
|
+
this.notificationFlushTimer = undefined;
|
|
524
|
+
this.flushPendingNotifications(ctx);
|
|
525
|
+
}, delayMs);
|
|
526
|
+
this.notificationFlushTimer.unref?.();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
|
|
530
|
+
const key = `${run.id}:${task.id}`;
|
|
531
|
+
const outcome = outcomeFingerprint(task);
|
|
532
|
+
const final = kind === "completed" && this.finalReportMatches(task);
|
|
533
|
+
const report = task.finalParentReport;
|
|
534
|
+
if (final && report?.delivery === "pending") {
|
|
535
|
+
this.pendingTaskNotices.set(key, { run, task });
|
|
536
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
537
|
+
this.host.emit("subagent:notification", {
|
|
538
|
+
runId: run.id,
|
|
539
|
+
taskId: task.id,
|
|
540
|
+
kind,
|
|
541
|
+
body: "",
|
|
542
|
+
suppressed: true,
|
|
543
|
+
delivery: "pending",
|
|
544
|
+
});
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (
|
|
548
|
+
final &&
|
|
549
|
+
report &&
|
|
550
|
+
report.delivery !== "lost" &&
|
|
551
|
+
report.delivery !== "pending" &&
|
|
552
|
+
!this.hasArtifactDelta(task)
|
|
553
|
+
) {
|
|
554
|
+
this.host.emit("subagent:notification", {
|
|
555
|
+
runId: run.id,
|
|
556
|
+
taskId: task.id,
|
|
557
|
+
kind,
|
|
558
|
+
body: "",
|
|
559
|
+
suppressed: true,
|
|
560
|
+
delivery: report.delivery,
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const covered = this.terminalNotices.get(key);
|
|
565
|
+
if (covered?.fingerprint === outcome && covered.delivery !== "lost") {
|
|
566
|
+
this.host.emit("subagent:notification", {
|
|
567
|
+
runId: run.id,
|
|
568
|
+
taskId: task.id,
|
|
569
|
+
kind,
|
|
570
|
+
body: "",
|
|
571
|
+
suppressed: true,
|
|
572
|
+
delivery: covered.delivery,
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (this.coveredByAwait(run.id, task.id, outcome)) {
|
|
577
|
+
this.host.emit("subagent:notification", {
|
|
578
|
+
runId: run.id,
|
|
579
|
+
taskId: task.id,
|
|
580
|
+
kind,
|
|
581
|
+
body: "",
|
|
582
|
+
suppressed: true,
|
|
583
|
+
delivery: "awaited",
|
|
584
|
+
});
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const artifactOnly = final && report?.delivery !== "lost";
|
|
588
|
+
const body = artifactOnly ? makeTaskArtifactNotice(run, task) : makeTaskNotice(run, task, kind);
|
|
589
|
+
if (this.host.collectParked(run.id, { kind: "done", taskId: task.id, agent: task.agent, text: body })) {
|
|
590
|
+
this.noteTerminal(run.id, task.id, body, outcome);
|
|
591
|
+
this.terminalNotices.get(key)!.delivery = "received";
|
|
592
|
+
this.host.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "parked" });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (kind === "failed") {
|
|
596
|
+
this.noteTerminal(run.id, task.id, body, outcome);
|
|
597
|
+
try {
|
|
598
|
+
this.host.pi.sendUserMessage(body, { deliverAs: "steer" });
|
|
599
|
+
this.leaderMessages.set(body, {
|
|
600
|
+
runId: run.id,
|
|
601
|
+
taskIds: [task.id],
|
|
602
|
+
kind: "terminal",
|
|
603
|
+
deliverAs: "steer",
|
|
604
|
+
delivered: false,
|
|
605
|
+
submitted: true,
|
|
606
|
+
lost: false,
|
|
607
|
+
submittedAt: Date.now(),
|
|
608
|
+
attempts: 0,
|
|
609
|
+
submittedRunCount: this.agentRunCount,
|
|
610
|
+
});
|
|
611
|
+
this.host.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "submitted" });
|
|
612
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext, RECEIPT_GRACE_MS);
|
|
613
|
+
} catch {
|
|
614
|
+
this.terminalNotices.get(key)!.delivery = "lost";
|
|
615
|
+
this.host.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "failed" });
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
this.noteTerminal(run.id, task.id, body, outcome);
|
|
620
|
+
this.enqueueNotification(body, {
|
|
621
|
+
runId: run.id,
|
|
622
|
+
taskIds: [task.id],
|
|
623
|
+
kind: "terminal",
|
|
624
|
+
deliverAs: "followUp",
|
|
625
|
+
});
|
|
626
|
+
this.host.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "held" });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
notifyParent(
|
|
630
|
+
run: RunSnapshot,
|
|
631
|
+
kind: "completed" | "failed" | "aborted" | "asked",
|
|
632
|
+
extra?: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
|
|
633
|
+
): void {
|
|
634
|
+
if (kind !== "asked" && run.completionAwaited) return;
|
|
635
|
+
let tasks = run.tasks;
|
|
636
|
+
if (kind !== "asked") {
|
|
637
|
+
tasks = tasks.filter((task) => {
|
|
638
|
+
const key = `${run.id}:${task.id}`;
|
|
639
|
+
const outcome = outcomeFingerprint(task);
|
|
640
|
+
const covered = this.terminalNotices.get(key);
|
|
641
|
+
if (covered?.fingerprint === outcome && covered.delivery !== "lost") return false;
|
|
642
|
+
if (this.pendingTaskNotices.has(key)) return false;
|
|
643
|
+
if (this.coveredByAwait(run.id, task.id, outcome)) return false;
|
|
644
|
+
if (
|
|
645
|
+
kind === "completed" &&
|
|
646
|
+
task.finalParentReport?.delivery !== "lost" &&
|
|
647
|
+
this.finalReportMatches(task) &&
|
|
648
|
+
!this.hasArtifactDelta(task)
|
|
649
|
+
)
|
|
650
|
+
return false;
|
|
651
|
+
return true;
|
|
652
|
+
});
|
|
653
|
+
if (tasks.length === 0) {
|
|
654
|
+
if (!this.notificationRetries.has(run.id) && this.hasPendingSuccessMessages(run)) {
|
|
655
|
+
this.pendingRunNotices.set(run.id, run);
|
|
656
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
657
|
+
}
|
|
658
|
+
this.host.emit("subagent:notification", { runId: run.id, kind, body: "", suppressed: true });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// Aggregate-only mode has no per-task hold: keep a run-level watch so a pending report
|
|
662
|
+
// that later goes lost still produces a fallback.
|
|
663
|
+
if (
|
|
664
|
+
!this.notificationRetries.has(run.id) &&
|
|
665
|
+
!this.pendingRunNotices.has(run.id) &&
|
|
666
|
+
run.tasks.some((task) => task.finalParentReport?.delivery === "pending")
|
|
667
|
+
) {
|
|
668
|
+
this.pendingRunNotices.set(run.id, run);
|
|
669
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (kind === "asked") {
|
|
673
|
+
const body = makeAskNotice(run, extra ?? {});
|
|
674
|
+
try {
|
|
675
|
+
this.host.pi.sendUserMessage(body, { deliverAs: "steer" });
|
|
676
|
+
this.host.emit("subagent:notification", {
|
|
677
|
+
runId: run.id,
|
|
678
|
+
taskId: extra?.taskId,
|
|
679
|
+
kind,
|
|
680
|
+
body,
|
|
681
|
+
delivery: "submitted",
|
|
682
|
+
});
|
|
683
|
+
} catch {
|
|
684
|
+
this.host.emit("subagent:notification", {
|
|
685
|
+
runId: run.id,
|
|
686
|
+
taskId: extra?.taskId,
|
|
687
|
+
kind,
|
|
688
|
+
body,
|
|
689
|
+
delivery: "failed",
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const body = makeNotice(run, kind, tasks);
|
|
695
|
+
const deliverAs = kind === "failed" ? "steer" : "followUp";
|
|
696
|
+
for (const task of tasks) this.noteTerminal(run.id, task.id, body, outcomeFingerprint(task));
|
|
697
|
+
if (deliverAs === "steer") {
|
|
698
|
+
try {
|
|
699
|
+
this.host.pi.sendUserMessage(body, { deliverAs });
|
|
700
|
+
this.leaderMessages.set(body, {
|
|
701
|
+
runId: run.id,
|
|
702
|
+
taskIds: tasks.map((t) => t.id),
|
|
703
|
+
kind: "terminal",
|
|
704
|
+
deliverAs,
|
|
705
|
+
delivered: false,
|
|
706
|
+
submitted: true,
|
|
707
|
+
lost: false,
|
|
708
|
+
submittedAt: Date.now(),
|
|
709
|
+
attempts: 0,
|
|
710
|
+
submittedRunCount: this.agentRunCount,
|
|
711
|
+
});
|
|
712
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext, RECEIPT_GRACE_MS);
|
|
713
|
+
} catch {
|
|
714
|
+
for (const task of tasks) {
|
|
715
|
+
const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
|
|
716
|
+
if (notice?.body === body) notice.delivery = "lost";
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
this.host.emit("subagent:notification", { runId: run.id, kind, body, delivery: "submitted" });
|
|
720
|
+
this.holdRunFallback(run, tasks);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
this.enqueueNotification(body, {
|
|
724
|
+
runId: run.id,
|
|
725
|
+
taskIds: tasks.map((t) => t.id),
|
|
726
|
+
kind: "terminal",
|
|
727
|
+
deliverAs,
|
|
728
|
+
});
|
|
729
|
+
this.host.emit("subagent:notification", { runId: run.id, kind, body, delivery: "held" });
|
|
730
|
+
this.holdRunFallback(run, tasks);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Keep one bounded retry for aggregates whose per-task receipts never arrived. */
|
|
734
|
+
private holdRunFallback(run: RunSnapshot, tasks: TaskSnapshot[]): void {
|
|
735
|
+
if (this.notificationRetries.has(run.id)) return;
|
|
736
|
+
const covered = tasks.filter((task) => {
|
|
737
|
+
const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
|
|
738
|
+
return Boolean(notice && notice.body !== "");
|
|
739
|
+
});
|
|
740
|
+
if (covered.length === 0) return;
|
|
741
|
+
this.pendingRunNotices.set(run.id, run);
|
|
742
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
743
|
+
}
|
|
744
|
+
}
|