@arhen/pi-core-subagent 1.3.65 → 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 +7 -4
- package/package.json +3 -9
- package/src/config.ts +60 -0
- package/src/delivery.ts +744 -0
- package/src/index.ts +1 -1
- package/src/manager.ts +79 -1026
- package/src/outcome.ts +58 -0
- package/src/presentation.ts +30 -28
- package/src/run-widget.ts +122 -0
- package/src/runtime.ts +136 -0
- package/src/types.ts +9 -0
package/src/manager.ts
CHANGED
|
@@ -2,13 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync
|
|
|
2
2
|
import { rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
4
4
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
5
|
-
import {
|
|
6
|
-
type Api,
|
|
7
|
-
type AssistantMessage,
|
|
8
|
-
clampThinkingLevel,
|
|
9
|
-
getSupportedThinkingLevels,
|
|
10
|
-
type Model,
|
|
11
|
-
} from "@earendil-works/pi-ai";
|
|
5
|
+
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
12
6
|
import {
|
|
13
7
|
type AgentSessionEvent,
|
|
14
8
|
createAgentSession,
|
|
@@ -16,32 +10,39 @@ import {
|
|
|
16
10
|
type ExtensionAPI,
|
|
17
11
|
type ExtensionContext,
|
|
18
12
|
getAgentDir,
|
|
19
|
-
ModelRuntime,
|
|
20
13
|
SessionManager,
|
|
21
14
|
type ToolDefinition,
|
|
22
15
|
} from "@earendil-works/pi-coding-agent";
|
|
23
|
-
import type { TUI } from "@earendil-works/pi-tui";
|
|
24
16
|
import { resolveAgentFile } from "./agentfile.ts";
|
|
25
17
|
import { CHILD_TALK_TOOLS, type ChildHandlers, createChildTools } from "./child.ts";
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
getFirstText,
|
|
30
|
-
isTalking,
|
|
31
|
-
makeAskNotice,
|
|
32
|
-
makeNotice,
|
|
33
|
-
makeTaskArtifactNotice,
|
|
34
|
-
makeTaskNotice,
|
|
35
|
-
SubagentsWidget,
|
|
36
|
-
truncateText,
|
|
37
|
-
} from "./format.ts";
|
|
18
|
+
import { readSubagentConfig, writeSubagentConfig } from "./config.ts";
|
|
19
|
+
import { LeaderDelivery } from "./delivery.ts";
|
|
20
|
+
import { activitySnippet, describeCall, getFirstText, truncateText } from "./format.ts";
|
|
38
21
|
import { applyUpstream, resolveNeeds, runWaveScheduler } from "./graph.ts";
|
|
39
22
|
import { createMailbox, type Mailbox } from "./mailbox.ts";
|
|
40
23
|
import { chooseModel, resolveChildModel } from "./models.ts";
|
|
41
|
-
import {
|
|
24
|
+
import { textFingerprint } from "./notification-state.ts";
|
|
25
|
+
import {
|
|
26
|
+
aggregateUsage,
|
|
27
|
+
classifyFailure,
|
|
28
|
+
cloneRun,
|
|
29
|
+
emptyUsage,
|
|
30
|
+
failureError,
|
|
31
|
+
lastAssistantFailure,
|
|
32
|
+
updateUsageFromMessage,
|
|
33
|
+
} from "./outcome.ts";
|
|
34
|
+
import { RunWidget } from "./run-widget.ts";
|
|
35
|
+
import {
|
|
36
|
+
clampResumeThinking,
|
|
37
|
+
codemodeFactories,
|
|
38
|
+
createChildModelRuntime,
|
|
39
|
+
ensureUsableModel,
|
|
40
|
+
validateThinking,
|
|
41
|
+
} from "./runtime.ts";
|
|
42
42
|
import type { SubagentParamsShape, TaskInput } from "./schemas.ts";
|
|
43
43
|
import {
|
|
44
44
|
MAX_TASKS,
|
|
45
|
+
type ParkedMsg,
|
|
45
46
|
type PendingReply,
|
|
46
47
|
type RunDetails,
|
|
47
48
|
type RunMode,
|
|
@@ -50,7 +51,6 @@ import {
|
|
|
50
51
|
type TaskSnapshot,
|
|
51
52
|
type TaskStatus,
|
|
52
53
|
TERMINAL,
|
|
53
|
-
type UsageStats,
|
|
54
54
|
} from "./types.ts";
|
|
55
55
|
import {
|
|
56
56
|
attachWorktree,
|
|
@@ -64,8 +64,9 @@ import {
|
|
|
64
64
|
type Worktree,
|
|
65
65
|
} from "./worktree.ts";
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
export type { ParkedMsg };
|
|
68
|
+
/** Legacy callers import these from here; their owners are `models.ts`, `outcome.ts`, `runtime.ts`. */
|
|
69
|
+
export { clampResumeThinking, classifyFailure, cloneRun, ensureUsableModel, resolveChildModel, validateThinking };
|
|
69
70
|
|
|
70
71
|
export const DEFAULT_CONCURRENCY = 3;
|
|
71
72
|
export const MAX_CONCURRENCY = 8;
|
|
@@ -77,51 +78,11 @@ const READONLY_TOOLS = ["read", "grep", "find", "ls", "codemode"];
|
|
|
77
78
|
const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write", "codemode"];
|
|
78
79
|
const WRITE_CAPABLE = ["bash", "edit", "write"];
|
|
79
80
|
const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
80
|
-
const WIDGET_THROTTLE_MS = 150;
|
|
81
|
-
/** A submitted notice missing from the canonical context this long is treated as lost. */
|
|
82
|
-
const RECEIPT_GRACE_MS = 3_000;
|
|
83
|
-
/** Minimum wait before retrying a failed submission, so one flush cannot burn every attempt. */
|
|
84
|
-
const RETRY_BACKOFF_MS = 1_000;
|
|
85
|
-
/** Total submission attempts before a notice is declared lost. */
|
|
86
|
-
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
87
81
|
|
|
88
82
|
function newId(prefix: string): string {
|
|
89
83
|
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
90
84
|
}
|
|
91
85
|
|
|
92
|
-
type ChildExtensionFactories = ConstructorParameters<typeof DefaultResourceLoader>[0]["extensionFactories"];
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Children run with `noExtensions`, so they get none of the configured extensions — codemode is the
|
|
96
|
-
* one exception, because it is how a child batches tool calls. `createCodemodeExtension()` is the
|
|
97
|
-
* supported factory (pi >= 1.0); hosts that do not export it simply give children no codemode.
|
|
98
|
-
*/
|
|
99
|
-
async function codemodeFactories(): Promise<ChildExtensionFactories> {
|
|
100
|
-
try {
|
|
101
|
-
const host = (await import("@earendil-works/pi-coding-agent")) as unknown as {
|
|
102
|
-
createCodemodeExtension?: () => unknown;
|
|
103
|
-
};
|
|
104
|
-
const factory = host.createCodemodeExtension?.();
|
|
105
|
-
return factory ? ([factory] as ChildExtensionFactories) : [];
|
|
106
|
-
} catch {
|
|
107
|
-
return [];
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
function emptyUsage(): UsageStats {
|
|
111
|
-
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
112
|
-
}
|
|
113
|
-
function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
|
|
114
|
-
const total = emptyUsage();
|
|
115
|
-
for (const task of tasks) {
|
|
116
|
-
total.input += task.usage.input;
|
|
117
|
-
total.output += task.usage.output;
|
|
118
|
-
total.cacheRead += task.usage.cacheRead;
|
|
119
|
-
total.cacheWrite += task.usage.cacheWrite;
|
|
120
|
-
total.cost += task.usage.cost;
|
|
121
|
-
total.turns += task.usage.turns;
|
|
122
|
-
}
|
|
123
|
-
return total;
|
|
124
|
-
}
|
|
125
86
|
function safeRealPath(path: string): string {
|
|
126
87
|
try {
|
|
127
88
|
return realpathSync(path);
|
|
@@ -136,150 +97,6 @@ function getParentSessionFile(ctx: ExtensionContext): string | undefined {
|
|
|
136
97
|
return undefined;
|
|
137
98
|
}
|
|
138
99
|
}
|
|
139
|
-
export function classifyFailure(
|
|
140
|
-
stopReason: string | undefined,
|
|
141
|
-
errorMessage?: string,
|
|
142
|
-
): { status: "failed" | "aborted"; message: string } | undefined {
|
|
143
|
-
if (!stopReason || stopReason === "stop" || stopReason === "end") return undefined;
|
|
144
|
-
if (stopReason === "aborted") return { status: "aborted", message: errorMessage || "Subagent was aborted." };
|
|
145
|
-
return { status: "failed", message: errorMessage || `Subagent ended with stopReason "${stopReason}".` };
|
|
146
|
-
}
|
|
147
|
-
function lastAssistantFailure(
|
|
148
|
-
messages: AssistantMessage[] | undefined,
|
|
149
|
-
): { status: "failed" | "aborted"; message: string } | undefined {
|
|
150
|
-
for (const message of [...(messages ?? [])].reverse()) {
|
|
151
|
-
if (message?.role !== "assistant") continue;
|
|
152
|
-
return classifyFailure(message.stopReason, message.errorMessage);
|
|
153
|
-
}
|
|
154
|
-
return undefined;
|
|
155
|
-
}
|
|
156
|
-
function failureError(failure: { status: "failed" | "aborted"; message: string }): Error {
|
|
157
|
-
const error = new Error(failure.message);
|
|
158
|
-
(error as Error & { subagentStatus?: string }).subagentStatus = failure.status;
|
|
159
|
-
return error;
|
|
160
|
-
}
|
|
161
|
-
function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage): void {
|
|
162
|
-
if (message?.role !== "assistant") return;
|
|
163
|
-
task.usage.turns += 1;
|
|
164
|
-
const usage = message.usage;
|
|
165
|
-
if (!usage) return;
|
|
166
|
-
task.usage.input += usage.input ?? 0;
|
|
167
|
-
task.usage.output += usage.output ?? 0;
|
|
168
|
-
task.usage.cacheRead += usage.cacheRead ?? 0;
|
|
169
|
-
task.usage.cacheWrite += usage.cacheWrite ?? 0;
|
|
170
|
-
task.usage.cost += usage.cost?.total ?? 0;
|
|
171
|
-
if (message.model && !task.model) task.model = message.model;
|
|
172
|
-
}
|
|
173
|
-
export function cloneRun(run: RunSnapshot): RunSnapshot {
|
|
174
|
-
return JSON.parse(JSON.stringify(run)) as RunSnapshot;
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* A resumed task keeps its stored thinking level, clamped to what the target model accepts — a
|
|
178
|
-
* resume that swaps model must not fail on an effort the new model does not define.
|
|
179
|
-
*/
|
|
180
|
-
export function clampResumeThinking(
|
|
181
|
-
model: Model<Api> | undefined,
|
|
182
|
-
thinking: ThinkingLevel | undefined,
|
|
183
|
-
): ThinkingLevel | undefined {
|
|
184
|
-
if (!thinking || !model) return thinking;
|
|
185
|
-
return clampThinkingLevel(model, thinking) as ThinkingLevel;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const PROBE_THINKING_LEVELS: ThinkingLevel[] = ["low", "minimal", "medium", "high", "xhigh", "max"];
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Thinking level for the usability probe: exactly what the child session will send — the clamped
|
|
192
|
-
* requested level, or the cheapest the model accepts when none was requested. Probing without a
|
|
193
|
-
* level makes adaptive-thinking providers reject the request (9router claude models answer
|
|
194
|
-
* "thinking.type.disabled is not supported"), which used to make every preflight fail and fall
|
|
195
|
-
* back to the session model.
|
|
196
|
-
*/
|
|
197
|
-
function probeThinking(model: Model<Api>, thinking?: string): ThinkingLevel | undefined {
|
|
198
|
-
if (!model.reasoning) return undefined;
|
|
199
|
-
const supported = getSupportedThinkingLevels(model);
|
|
200
|
-
if (thinking && thinking !== "off") return clampThinkingLevel(model, thinking as ThinkingLevel);
|
|
201
|
-
// the child clamps an unsupported "off" up to its cheapest level, so probe that instead
|
|
202
|
-
if (thinking === "off") return supported.includes("off") ? undefined : supported[0];
|
|
203
|
-
return PROBE_THINKING_LEVELS.find((level) => supported.includes(level));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function probeModel(
|
|
207
|
-
ctx: ExtensionContext,
|
|
208
|
-
model: Model<Api>,
|
|
209
|
-
signal: AbortSignal | undefined,
|
|
210
|
-
thinking?: string,
|
|
211
|
-
): Promise<string | undefined> {
|
|
212
|
-
try {
|
|
213
|
-
const reasoningEffort = probeThinking(model, thinking);
|
|
214
|
-
const reply = await ctx.modelRegistry.complete(
|
|
215
|
-
model,
|
|
216
|
-
{ messages: [{ role: "user", content: "ping", timestamp: Date.now() }] },
|
|
217
|
-
{ maxTokens: 16, signal, ...(reasoningEffort ? { reasoningEffort } : {}) },
|
|
218
|
-
);
|
|
219
|
-
return reply.stopReason === "error" ? (reply.errorMessage ?? "provider returned an error") : undefined;
|
|
220
|
-
} catch (err) {
|
|
221
|
-
return err instanceof Error ? err.message : String(err);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export async function ensureUsableModel(
|
|
226
|
-
ctx: ExtensionContext,
|
|
227
|
-
model: Model<Api> | undefined,
|
|
228
|
-
signal: AbortSignal | undefined,
|
|
229
|
-
thinking?: string,
|
|
230
|
-
): Promise<{ model: Model<Api> | undefined; note?: string }> {
|
|
231
|
-
const session = ctx.model;
|
|
232
|
-
if (!model || !ctx.modelRegistry) return { model };
|
|
233
|
-
if (session && model.provider === session.provider && model.id === session.id) return { model };
|
|
234
|
-
const error = await probeModel(ctx, model, signal, thinking);
|
|
235
|
-
if (!error) return { model };
|
|
236
|
-
if (model.provider === "opencode-go" && /MissingSessionID|x-opencode-session/i.test(error)) {
|
|
237
|
-
// ponytail: opencode-go rejects stateless probes but accepts AgentSession requests, which add the session header.
|
|
238
|
-
// Upgrade path: remove this exception when modelRegistry.complete can carry AgentSession request transforms.
|
|
239
|
-
return { model, note: `preflight unavailable (${error}); child session will validate the model` };
|
|
240
|
-
}
|
|
241
|
-
if (!session) throw new Error(`Model ${model.provider}/${model.id} is unusable: ${error}`);
|
|
242
|
-
return {
|
|
243
|
-
model: session,
|
|
244
|
-
note: `${model.provider}/${model.id} failed preflight (${error}); using session model ${session.provider}/${session.id}`,
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
async function createChildModelRuntime(ctx: ExtensionContext) {
|
|
249
|
-
const ids = ctx.modelRegistry.getRegisteredProviderIds?.() ?? [];
|
|
250
|
-
if (ids.length === 0) return undefined;
|
|
251
|
-
const agentDir = getAgentDir();
|
|
252
|
-
const runtime = await ModelRuntime.create({
|
|
253
|
-
authPath: join(agentDir, "auth.json"),
|
|
254
|
-
modelsPath: join(agentDir, "models.json"),
|
|
255
|
-
});
|
|
256
|
-
for (const id of ids) {
|
|
257
|
-
const native = ctx.modelRegistry.getRegisteredNativeProvider?.(id);
|
|
258
|
-
if (native) {
|
|
259
|
-
runtime.registerNativeProvider(native);
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
const config = ctx.modelRegistry.getRegisteredProviderConfig?.(id);
|
|
263
|
-
if (config) runtime.registerProvider(id, config);
|
|
264
|
-
}
|
|
265
|
-
await runtime.refresh({ allowNetwork: false });
|
|
266
|
-
return runtime;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export function validateThinking(model: Model<Api> | undefined, level: string | undefined): void {
|
|
270
|
-
if (!level || level === "off") return;
|
|
271
|
-
if (!model) return;
|
|
272
|
-
const map = model.thinkingLevelMap;
|
|
273
|
-
if (map && level in map && map[level as keyof typeof map] === null) {
|
|
274
|
-
const supported = Object.keys(map).filter((k) => map[k as keyof typeof map] !== null);
|
|
275
|
-
throw new Error(
|
|
276
|
-
`Thinking level "${level}" is not supported by ${model.provider}/${model.id}. Supported: ${supported.length ? supported.join(" | ") : 'none — use thinking: "off"'}.`,
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
if (!model.reasoning) {
|
|
280
|
-
throw new Error(`Model ${model.provider}/${model.id} does not support thinking. Use thinking: "off".`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
100
|
|
|
284
101
|
interface ResumeInput {
|
|
285
102
|
sessionFile: string;
|
|
@@ -293,43 +110,6 @@ interface ChildEventState {
|
|
|
293
110
|
childEndResolve?: () => void;
|
|
294
111
|
}
|
|
295
112
|
|
|
296
|
-
export interface ParkedMsg {
|
|
297
|
-
kind: "ask" | "notify" | "done";
|
|
298
|
-
taskId: string;
|
|
299
|
-
agent: string;
|
|
300
|
-
text: string;
|
|
301
|
-
final?: boolean;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
interface LeaderMessageReceipt {
|
|
305
|
-
runId: string;
|
|
306
|
-
taskIds: string[];
|
|
307
|
-
kind: "update" | "final" | "terminal";
|
|
308
|
-
deliverAs: "steer" | "followUp";
|
|
309
|
-
delivered: boolean;
|
|
310
|
-
submitted: boolean;
|
|
311
|
-
lost: boolean;
|
|
312
|
-
submittedAt: number;
|
|
313
|
-
attempts: number;
|
|
314
|
-
/** Agent runs started when this receipt was submitted; a later run is needed to prove loss. */
|
|
315
|
-
submittedRunCount: number;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
interface OutboxEntry {
|
|
319
|
-
runId: string;
|
|
320
|
-
taskIds: string[];
|
|
321
|
-
kind: "update" | "final" | "terminal";
|
|
322
|
-
deliverAs: "steer" | "followUp";
|
|
323
|
-
attempts?: number;
|
|
324
|
-
attemptedAt?: number;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
interface TerminalNotice {
|
|
328
|
-
body: string;
|
|
329
|
-
fingerprint: string;
|
|
330
|
-
delivery: "pending" | "received" | "lost";
|
|
331
|
-
}
|
|
332
|
-
|
|
333
113
|
export class SubagentManager {
|
|
334
114
|
private runs = new Map<string, RunSnapshot>();
|
|
335
115
|
private settlers = new Map<string, true>();
|
|
@@ -342,42 +122,38 @@ export class SubagentManager {
|
|
|
342
122
|
private mailboxes: Mailbox = createMailbox();
|
|
343
123
|
private liveWorktrees = new Map<string, Worktree>();
|
|
344
124
|
private runControllers = new Map<string, AbortController>();
|
|
345
|
-
private
|
|
346
|
-
private
|
|
125
|
+
private readonly widget = new RunWidget();
|
|
126
|
+
private readonly delivery: LeaderDelivery;
|
|
347
127
|
private eventSeq = 0;
|
|
348
128
|
private persistSeq = 0;
|
|
349
129
|
private persistedSeq = 0;
|
|
350
130
|
private persistChain: Promise<unknown> = Promise.resolve();
|
|
351
131
|
private readonly instanceNonce = Math.random().toString(36).slice(2, 8);
|
|
352
132
|
private cleared = false;
|
|
353
|
-
private leaderMessages = new Map<string, LeaderMessageReceipt>();
|
|
354
|
-
private outbox = new Map<string, OutboxEntry>();
|
|
355
|
-
private parkedBodies = new Map<string, Set<string>>();
|
|
356
|
-
private agentRunCount = 0;
|
|
357
|
-
private terminalNotices = new Map<string, TerminalNotice>();
|
|
358
|
-
private awaitedTaskNotices = new Map<string, string>();
|
|
359
|
-
private pendingTaskNotices = new Map<string, { run: RunSnapshot; task: TaskSnapshot }>();
|
|
360
|
-
private pendingRunNotices = new Map<string, RunSnapshot>();
|
|
361
|
-
private notificationRetries = new Set<string>();
|
|
362
|
-
private notificationFlushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
363
|
-
private notificationContext: ExtensionContext | undefined;
|
|
364
133
|
|
|
365
134
|
private autoLimit = false;
|
|
366
135
|
|
|
367
136
|
turnActivity = false;
|
|
368
137
|
|
|
369
138
|
constructor(private readonly pi: ExtensionAPI) {
|
|
139
|
+
this.delivery = new LeaderDelivery({
|
|
140
|
+
pi,
|
|
141
|
+
isCleared: () => this.cleared,
|
|
142
|
+
findRun: (runId) => this.runs.get(runId),
|
|
143
|
+
isLive: (key) => this.liveChildren.has(key),
|
|
144
|
+
persist: (ctx) => this.persist(ctx),
|
|
145
|
+
emit: (type, payload) => this.emit(type, payload),
|
|
146
|
+
collectParked: (runId, msg) => this.collectParked(runId, msg),
|
|
147
|
+
});
|
|
370
148
|
try {
|
|
371
|
-
const cfg =
|
|
149
|
+
const cfg = readSubagentConfig();
|
|
372
150
|
if (typeof cfg.autoLimit === "boolean") this.autoLimit = cfg.autoLimit;
|
|
373
151
|
} catch {}
|
|
374
152
|
}
|
|
375
153
|
|
|
376
154
|
setAutoLimit(on: boolean): boolean {
|
|
377
155
|
this.autoLimit = on;
|
|
378
|
-
void
|
|
379
|
-
() => {},
|
|
380
|
-
);
|
|
156
|
+
void writeSubagentConfig({ autoLimit: on }).catch(() => {});
|
|
381
157
|
return on;
|
|
382
158
|
}
|
|
383
159
|
|
|
@@ -393,13 +169,24 @@ export class SubagentManager {
|
|
|
393
169
|
}
|
|
394
170
|
|
|
395
171
|
clearWidget(ctx?: ExtensionContext): void {
|
|
396
|
-
this.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
172
|
+
this.widget.clear(ctx);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** An agent run started; a receipt can only be declared lost after one full later run. */
|
|
176
|
+
noteAgentStart(): void {
|
|
177
|
+
this.delivery.noteAgentStart();
|
|
178
|
+
}
|
|
179
|
+
markAwaitCoverage(run: RunSnapshot, coveredTaskIds?: ReadonlySet<string>): void {
|
|
180
|
+
this.delivery.markAwaitCoverage(run, coveredTaskIds);
|
|
181
|
+
}
|
|
182
|
+
confirmLeaderMessages(ctx: ExtensionContext): void {
|
|
183
|
+
this.delivery.confirmLeaderMessages(ctx);
|
|
184
|
+
}
|
|
185
|
+
flushPendingNotifications(ctx: ExtensionContext): void {
|
|
186
|
+
this.delivery.flushPendingNotifications(ctx);
|
|
187
|
+
}
|
|
188
|
+
scheduleNotificationFlush(ctx: ExtensionContext, delayMs?: number): void {
|
|
189
|
+
this.delivery.scheduleNotificationFlush(ctx, delayMs);
|
|
403
190
|
}
|
|
404
191
|
|
|
405
192
|
listRuns(): RunSnapshot[] {
|
|
@@ -432,17 +219,7 @@ export class SubagentManager {
|
|
|
432
219
|
pending.resolve("(session ended — stop work immediately)");
|
|
433
220
|
}
|
|
434
221
|
this.parked.clear();
|
|
435
|
-
this.
|
|
436
|
-
this.outbox.clear();
|
|
437
|
-
this.parkedBodies.clear();
|
|
438
|
-
this.terminalNotices.clear();
|
|
439
|
-
this.awaitedTaskNotices.clear();
|
|
440
|
-
this.pendingTaskNotices.clear();
|
|
441
|
-
this.pendingRunNotices.clear();
|
|
442
|
-
this.notificationRetries.clear();
|
|
443
|
-
if (this.notificationFlushTimer) clearTimeout(this.notificationFlushTimer);
|
|
444
|
-
this.notificationFlushTimer = undefined;
|
|
445
|
-
this.notificationContext = undefined;
|
|
222
|
+
this.delivery.reset();
|
|
446
223
|
|
|
447
224
|
this.liveWorktrees.clear();
|
|
448
225
|
this.runs.clear();
|
|
@@ -452,14 +229,7 @@ export class SubagentManager {
|
|
|
452
229
|
this.runControllers.clear();
|
|
453
230
|
this.cleared = true;
|
|
454
231
|
this.mailboxes = createMailbox();
|
|
455
|
-
this.
|
|
456
|
-
if (this.pulseTimer) {
|
|
457
|
-
clearTimeout(this.pulseTimer);
|
|
458
|
-
this.pulseTimer = null;
|
|
459
|
-
}
|
|
460
|
-
for (const t of this.widgetTimers.values()) clearTimeout(t);
|
|
461
|
-
this.widgetTimers.clear();
|
|
462
|
-
this.widgetRuns = [];
|
|
232
|
+
this.widget.dispose();
|
|
463
233
|
}
|
|
464
234
|
|
|
465
235
|
async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
|
|
@@ -511,7 +281,7 @@ export class SubagentManager {
|
|
|
511
281
|
}
|
|
512
282
|
if (added > 0) {
|
|
513
283
|
this.emit("subagent:runs-restored", { count: added });
|
|
514
|
-
this.
|
|
284
|
+
this.widget.schedule(this.listRuns()[0], ctx);
|
|
515
285
|
}
|
|
516
286
|
}
|
|
517
287
|
private persist(ctx: ExtensionContext): void {
|
|
@@ -542,698 +312,6 @@ export class SubagentManager {
|
|
|
542
312
|
this.pi.events.emit(type, { type, timestamp: Date.now(), ...payload });
|
|
543
313
|
}
|
|
544
314
|
|
|
545
|
-
private finalReportMatches(task: TaskSnapshot): boolean {
|
|
546
|
-
const report = task.finalParentReport;
|
|
547
|
-
if (!report || report.toolCalls !== task.toolCalls) return false;
|
|
548
|
-
const fingerprint = task.finalTextFingerprint ?? textFingerprint(task.finalText ?? "");
|
|
549
|
-
return report.fingerprint === fingerprint;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
private hasArtifactDelta(task: TaskSnapshot): boolean {
|
|
553
|
-
return artifactFingerprint(task) !== task.finalParentReport?.artifacts;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
private leaderMessageText(content: unknown): string {
|
|
557
|
-
return typeof content === "string"
|
|
558
|
-
? content
|
|
559
|
-
: Array.isArray(content)
|
|
560
|
-
? content
|
|
561
|
-
.filter((part) => part?.type === "text")
|
|
562
|
-
.map((part) => part.text ?? "")
|
|
563
|
-
.join("\n")
|
|
564
|
-
: "";
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
/**
|
|
568
|
-
* One pass over the finalized leader projection. Returns undefined when the pinned pi build
|
|
569
|
-
* cannot project (never treat that as loss, or every receipt would be replayed).
|
|
570
|
-
*/
|
|
571
|
-
private projectionBody(ctx: ExtensionContext): string | undefined {
|
|
572
|
-
try {
|
|
573
|
-
const manager = ctx.sessionManager as unknown as {
|
|
574
|
-
buildSessionProjection?: () => { messages: readonly { role?: string; content?: unknown }[] };
|
|
575
|
-
};
|
|
576
|
-
if (typeof manager.buildSessionProjection !== "function") return undefined;
|
|
577
|
-
const messages = manager.buildSessionProjection().messages;
|
|
578
|
-
const parts: string[] = [];
|
|
579
|
-
for (const message of messages) {
|
|
580
|
-
if (message.role !== "user" && message.role !== "custom") continue;
|
|
581
|
-
parts.push(this.leaderMessageText(message.content));
|
|
582
|
-
}
|
|
583
|
-
return parts.join("\n");
|
|
584
|
-
} catch {
|
|
585
|
-
return undefined;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** An agent run started; a receipt can only be declared lost after one full later run. */
|
|
590
|
-
noteAgentStart(): void {
|
|
591
|
-
this.agentRunCount += 1;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/** True when a terminal await already rendered this task's outcome. */
|
|
595
|
-
private coveredByAwait(runId: string, taskId: string, fingerprint: string): boolean {
|
|
596
|
-
return this.awaitedTaskNotices.get(`${runId}:${taskId}`) === fingerprint;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/** Record what a terminal await actually rendered, so held notices are dropped instead of echoed. */
|
|
600
|
-
markAwaitCoverage(run: RunSnapshot, coveredTaskIds?: ReadonlySet<string>): void {
|
|
601
|
-
for (const task of run.tasks) {
|
|
602
|
-
if (!TERMINAL.includes(task.status)) continue;
|
|
603
|
-
if (coveredTaskIds && !coveredTaskIds.has(task.id)) continue;
|
|
604
|
-
const scope = `${run.id}:${task.id}`;
|
|
605
|
-
this.awaitedTaskNotices.set(scope, outcomeFingerprint(task));
|
|
606
|
-
this.pendingTaskNotices.delete(scope);
|
|
607
|
-
}
|
|
608
|
-
for (const [body, entry] of [...this.outbox]) {
|
|
609
|
-
if (entry.runId !== run.id) continue;
|
|
610
|
-
if (!this.outboxCovered(entry)) continue;
|
|
611
|
-
const task = run.tasks.find((t) => t.id === entry.taskIds[0]);
|
|
612
|
-
// An update is not part of the rendered summary; only terminal/aggregate entries are safe to drop.
|
|
613
|
-
if (entry.kind === "update") continue;
|
|
614
|
-
if (entry.kind === "final" && (!task || !this.finalReportMatches(task))) continue;
|
|
615
|
-
this.dropOutbox(body, entry, "awaited");
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
private outboxCovered(entry: OutboxEntry): boolean {
|
|
620
|
-
const run = this.runs.get(entry.runId);
|
|
621
|
-
if (!run) return false;
|
|
622
|
-
return entry.taskIds.every((taskId) => {
|
|
623
|
-
const task = run.tasks.find((t) => t.id === taskId);
|
|
624
|
-
if (!task || !TERMINAL.includes(task.status)) return false;
|
|
625
|
-
return this.coveredByAwait(entry.runId, taskId, outcomeFingerprint(task));
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
private dropOutbox(body: string, entry: OutboxEntry, delivery: string): void {
|
|
630
|
-
this.outbox.delete(body);
|
|
631
|
-
const task = this.runs.get(entry.runId)?.tasks.find((t) => t.id === entry.taskIds[0]);
|
|
632
|
-
if (entry.kind === "final" && task?.finalParentReport?.body === body) task.finalParentReport.delivery = "awaited";
|
|
633
|
-
if (entry.kind === "terminal") {
|
|
634
|
-
for (const taskId of entry.taskIds) {
|
|
635
|
-
const notice = this.terminalNotices.get(`${entry.runId}:${taskId}`);
|
|
636
|
-
if (notice?.body === body) notice.delivery = "received";
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
this.emit("subagent:notification", {
|
|
640
|
-
runId: entry.runId,
|
|
641
|
-
taskId: entry.taskIds[0],
|
|
642
|
-
kind: "completed",
|
|
643
|
-
body: "",
|
|
644
|
-
suppressed: true,
|
|
645
|
-
delivery,
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
private enqueueNotification(body: string, entry: OutboxEntry, ctx?: ExtensionContext): void {
|
|
650
|
-
this.outbox.set(body, entry);
|
|
651
|
-
const flushContext = ctx ?? this.notificationContext;
|
|
652
|
-
if (flushContext) this.scheduleNotificationFlush(flushContext);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
private noteTerminal(runId: string, taskId: string, body: string, fingerprint: string): void {
|
|
656
|
-
this.terminalNotices.set(`${runId}:${taskId}`, { body, fingerprint, delivery: "pending" });
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
private submitLeaderReport(
|
|
660
|
-
run: RunSnapshot,
|
|
661
|
-
task: TaskSnapshot,
|
|
662
|
-
message: string,
|
|
663
|
-
level: "info" | "warning" | "error",
|
|
664
|
-
final: boolean,
|
|
665
|
-
ctx: ExtensionContext,
|
|
666
|
-
): boolean {
|
|
667
|
-
if (this.cleared) return false;
|
|
668
|
-
this.notificationContext = ctx;
|
|
669
|
-
const body = `[Subagent ${task.agent} (${task.id}, ${run.id})]${final ? " Final result:" : ""}${level === "info" ? "" : ` [${level}]`} ${message}`;
|
|
670
|
-
const previous = this.leaderMessages.get(body);
|
|
671
|
-
if ((previous && !previous.lost) || this.outbox.has(body)) {
|
|
672
|
-
if (final) {
|
|
673
|
-
task.finalParentReport = {
|
|
674
|
-
body,
|
|
675
|
-
message,
|
|
676
|
-
toolCalls: task.toolCalls,
|
|
677
|
-
fingerprint: textFingerprint(message),
|
|
678
|
-
artifacts: artifactFingerprint(task),
|
|
679
|
-
delivery: previous?.delivered ? "message" : "pending",
|
|
680
|
-
};
|
|
681
|
-
}
|
|
682
|
-
return true;
|
|
683
|
-
}
|
|
684
|
-
if (previous?.lost) this.leaderMessages.delete(body);
|
|
685
|
-
const report: TaskSnapshot["finalParentReport"] = final
|
|
686
|
-
? {
|
|
687
|
-
body,
|
|
688
|
-
message,
|
|
689
|
-
toolCalls: task.toolCalls,
|
|
690
|
-
fingerprint: textFingerprint(message),
|
|
691
|
-
artifacts: artifactFingerprint(task),
|
|
692
|
-
delivery: "pending",
|
|
693
|
-
}
|
|
694
|
-
: undefined;
|
|
695
|
-
if (report) task.finalParentReport = report;
|
|
696
|
-
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message, final });
|
|
697
|
-
if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message, final })) {
|
|
698
|
-
let parked = this.parkedBodies.get(run.id);
|
|
699
|
-
if (!parked) {
|
|
700
|
-
parked = new Set();
|
|
701
|
-
this.parkedBodies.set(run.id, parked);
|
|
702
|
-
}
|
|
703
|
-
if (parked.has(body)) return true;
|
|
704
|
-
parked.add(body);
|
|
705
|
-
task.notifiedParent = true;
|
|
706
|
-
if (report) report.delivery = "parked";
|
|
707
|
-
return true;
|
|
708
|
-
}
|
|
709
|
-
this.enqueueNotification(body, {
|
|
710
|
-
runId: run.id,
|
|
711
|
-
taskIds: [task.id],
|
|
712
|
-
kind: final ? "final" : "update",
|
|
713
|
-
deliverAs: "followUp",
|
|
714
|
-
});
|
|
715
|
-
return true;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
private hasPendingSuccessMessages(run: RunSnapshot): boolean {
|
|
719
|
-
return run.tasks.some((task) => {
|
|
720
|
-
const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
|
|
721
|
-
if (notice?.delivery !== "pending") return false;
|
|
722
|
-
const receipt = this.leaderMessages.get(notice.body);
|
|
723
|
-
return receipt ? !receipt.delivered && !receipt.lost : this.outbox.has(notice.body);
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
confirmLeaderMessages(ctx: ExtensionContext): void {
|
|
728
|
-
if (this.cleared) return;
|
|
729
|
-
const projection = this.projectionBody(ctx);
|
|
730
|
-
let changed = false;
|
|
731
|
-
for (const [body, receipt] of [...this.leaderMessages]) {
|
|
732
|
-
if (receipt.delivered || receipt.lost || projection === undefined || !projection.includes(body)) continue;
|
|
733
|
-
receipt.delivered = true;
|
|
734
|
-
changed = true;
|
|
735
|
-
const run = this.runs.get(receipt.runId);
|
|
736
|
-
for (const taskId of receipt.taskIds) {
|
|
737
|
-
const task = run?.tasks.find((t) => t.id === taskId);
|
|
738
|
-
if (!task) continue;
|
|
739
|
-
task.notifiedParent = true;
|
|
740
|
-
if (task.finalParentReport?.body === body) task.finalParentReport.delivery = "message";
|
|
741
|
-
const key = `${receipt.runId}:${taskId}`;
|
|
742
|
-
const notice = this.terminalNotices.get(key);
|
|
743
|
-
if (notice?.body === body) notice.delivery = "received";
|
|
744
|
-
const pending = this.pendingTaskNotices.get(key);
|
|
745
|
-
if (pending && this.finalReportMatches(task)) {
|
|
746
|
-
this.pendingTaskNotices.delete(key);
|
|
747
|
-
if (this.hasArtifactDelta(task)) this.notifyTask(pending.run, task, "completed");
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
if (run && !this.hasPendingSuccessMessages(run)) this.pendingRunNotices.delete(run.id);
|
|
751
|
-
}
|
|
752
|
-
if (changed) this.persist(ctx);
|
|
753
|
-
for (const [body, receipt] of this.leaderMessages) {
|
|
754
|
-
if (!receipt.delivered && !receipt.lost) continue;
|
|
755
|
-
const run = this.runs.get(receipt.runId);
|
|
756
|
-
const tasks = run?.tasks.filter((task) => receipt.taskIds.includes(task.id)) ?? [];
|
|
757
|
-
if (
|
|
758
|
-
tasks.length > 0 &&
|
|
759
|
-
tasks.every((task) => TERMINAL.includes(task.status) && !this.liveChildren.has(`${receipt.runId}:${task.id}`))
|
|
760
|
-
)
|
|
761
|
-
this.leaderMessages.delete(body);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/**
|
|
766
|
-
* A submitted receipt that never appears in the canonical context after a grace period is lost.
|
|
767
|
-
* The grace period keeps the check from racing Pi's input/auth/start hooks, which run before the
|
|
768
|
-
* message is appended to the session and can be slow while the session still reports idle.
|
|
769
|
-
*/
|
|
770
|
-
private markLostReceipts(ctx: ExtensionContext, existing: ReadonlySet<string>): void {
|
|
771
|
-
const now = Date.now();
|
|
772
|
-
const projection = this.projectionBody(ctx);
|
|
773
|
-
if (projection === undefined) return;
|
|
774
|
-
for (const [body, receipt] of [...this.leaderMessages]) {
|
|
775
|
-
if (receipt.delivered || receipt.lost || !existing.has(body)) continue;
|
|
776
|
-
if (this.outbox.has(body)) continue;
|
|
777
|
-
if (now - receipt.submittedAt < RECEIPT_GRACE_MS) continue;
|
|
778
|
-
// A later agent run proves the submitted prompt had its turn; a message that never runs
|
|
779
|
-
// at all is caught by the longer bound. While a prompt is still queued or in preflight
|
|
780
|
-
// (deferred settle, slow input/auth hooks) the session can report idle without the
|
|
781
|
-
// message being appended yet, so the short window alone must not declare loss.
|
|
782
|
-
const laterRun = this.agentRunCount > receipt.submittedRunCount;
|
|
783
|
-
const longWait = now - receipt.submittedAt >= RECEIPT_GRACE_MS * 2;
|
|
784
|
-
if (!laterRun && !longWait) continue;
|
|
785
|
-
if (projection.includes(body)) continue;
|
|
786
|
-
if (receipt.attempts < MAX_DELIVERY_ATTEMPTS) {
|
|
787
|
-
this.leaderMessages.delete(body);
|
|
788
|
-
this.outbox.set(body, {
|
|
789
|
-
runId: receipt.runId,
|
|
790
|
-
taskIds: receipt.taskIds,
|
|
791
|
-
kind: receipt.kind,
|
|
792
|
-
deliverAs: receipt.deliverAs,
|
|
793
|
-
attempts: receipt.attempts,
|
|
794
|
-
attemptedAt: now,
|
|
795
|
-
});
|
|
796
|
-
continue;
|
|
797
|
-
}
|
|
798
|
-
receipt.lost = true;
|
|
799
|
-
for (const taskId of receipt.taskIds) {
|
|
800
|
-
const key = `${receipt.runId}:${taskId}`;
|
|
801
|
-
const notice = this.terminalNotices.get(key);
|
|
802
|
-
if (notice?.body === body) notice.delivery = "lost";
|
|
803
|
-
const task = this.runs.get(receipt.runId)?.tasks.find((t) => t.id === taskId);
|
|
804
|
-
if (task?.finalParentReport?.body === body) task.finalParentReport.delivery = "lost";
|
|
805
|
-
}
|
|
806
|
-
this.emit("subagent:notification", {
|
|
807
|
-
runId: receipt.runId,
|
|
808
|
-
taskId: receipt.taskIds[0],
|
|
809
|
-
kind: "completed",
|
|
810
|
-
body,
|
|
811
|
-
delivery: "lost",
|
|
812
|
-
});
|
|
813
|
-
// A lost final report must still reach the leader: per-task hold or a direct full fallback.
|
|
814
|
-
if (receipt.kind === "final") {
|
|
815
|
-
const run = this.runs.get(receipt.runId);
|
|
816
|
-
for (const taskId of receipt.taskIds) {
|
|
817
|
-
if (this.pendingTaskNotices.has(`${receipt.runId}:${taskId}`)) continue;
|
|
818
|
-
const task = run?.tasks.find((t) => t.id === taskId);
|
|
819
|
-
if (run && task) this.notifyTask(run, task, "completed");
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
private flushOutbox(): void {
|
|
826
|
-
for (const [body, entry] of [...this.outbox]) {
|
|
827
|
-
if (this.outboxCovered(entry)) {
|
|
828
|
-
this.dropOutbox(body, entry, "awaited");
|
|
829
|
-
continue;
|
|
830
|
-
}
|
|
831
|
-
if (entry.attemptedAt && Date.now() - entry.attemptedAt < RETRY_BACKOFF_MS) continue;
|
|
832
|
-
this.outbox.delete(body);
|
|
833
|
-
try {
|
|
834
|
-
this.pi.sendUserMessage(body, { deliverAs: entry.deliverAs });
|
|
835
|
-
this.leaderMessages.set(body, {
|
|
836
|
-
runId: entry.runId,
|
|
837
|
-
taskIds: entry.taskIds,
|
|
838
|
-
kind: entry.kind,
|
|
839
|
-
deliverAs: entry.deliverAs,
|
|
840
|
-
delivered: false,
|
|
841
|
-
submitted: true,
|
|
842
|
-
lost: false,
|
|
843
|
-
submittedAt: Date.now(),
|
|
844
|
-
attempts: (entry.attempts ?? 0) + 1,
|
|
845
|
-
submittedRunCount: this.agentRunCount,
|
|
846
|
-
});
|
|
847
|
-
if (entry.kind === "terminal") {
|
|
848
|
-
for (const taskId of entry.taskIds) {
|
|
849
|
-
const notice = this.terminalNotices.get(`${entry.runId}:${taskId}`);
|
|
850
|
-
if (notice?.body === body) notice.delivery = "pending";
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
this.emit("subagent:notification", {
|
|
854
|
-
runId: entry.runId,
|
|
855
|
-
taskId: entry.taskIds[0],
|
|
856
|
-
kind: "completed",
|
|
857
|
-
body,
|
|
858
|
-
delivery: "submitted",
|
|
859
|
-
});
|
|
860
|
-
} catch {
|
|
861
|
-
const attempts = (entry.attempts ?? 0) + 1;
|
|
862
|
-
if (attempts < MAX_DELIVERY_ATTEMPTS) {
|
|
863
|
-
this.outbox.set(body, { ...entry, attempts, attemptedAt: Date.now() });
|
|
864
|
-
continue;
|
|
865
|
-
}
|
|
866
|
-
this.emit("subagent:notification", {
|
|
867
|
-
runId: entry.runId,
|
|
868
|
-
taskId: entry.taskIds[0],
|
|
869
|
-
kind: "completed",
|
|
870
|
-
body,
|
|
871
|
-
delivery: "failed",
|
|
872
|
-
});
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
flushPendingNotifications(ctx: ExtensionContext): void {
|
|
878
|
-
const existing = new Set([...this.leaderMessages.keys(), ...this.outbox.keys()]);
|
|
879
|
-
this.confirmLeaderMessages(ctx);
|
|
880
|
-
if (this.cleared) return;
|
|
881
|
-
let idle = false;
|
|
882
|
-
try {
|
|
883
|
-
idle = ctx.isIdle() && !ctx.hasPendingMessages();
|
|
884
|
-
} catch {
|
|
885
|
-
idle = false;
|
|
886
|
-
}
|
|
887
|
-
if (idle) this.markLostReceipts(ctx, existing);
|
|
888
|
-
const outstandingReceipts = () =>
|
|
889
|
-
[...this.leaderMessages.values()].some((receipt) => !receipt.delivered && !receipt.lost);
|
|
890
|
-
if (!this.pendingTaskNotices.size && !this.pendingRunNotices.size && !this.outbox.size && !outstandingReceipts())
|
|
891
|
-
return;
|
|
892
|
-
if (idle) {
|
|
893
|
-
this.flushOutbox();
|
|
894
|
-
for (const [key, pending] of [...this.pendingTaskNotices]) {
|
|
895
|
-
const report = pending.task.finalParentReport;
|
|
896
|
-
if (report?.delivery === "lost") {
|
|
897
|
-
this.pendingTaskNotices.delete(key);
|
|
898
|
-
pending.task.finalParentReport = undefined;
|
|
899
|
-
this.notifyTask(pending.run, pending.task, "completed");
|
|
900
|
-
continue;
|
|
901
|
-
}
|
|
902
|
-
if (report?.delivery === "pending") {
|
|
903
|
-
const outstanding = this.leaderMessages.has(report.body) || this.outbox.has(report.body);
|
|
904
|
-
if (outstanding) continue;
|
|
905
|
-
this.pendingTaskNotices.delete(key);
|
|
906
|
-
pending.task.finalParentReport = undefined;
|
|
907
|
-
this.notifyTask(pending.run, pending.task, "completed");
|
|
908
|
-
continue;
|
|
909
|
-
}
|
|
910
|
-
this.pendingTaskNotices.delete(key);
|
|
911
|
-
if (report && this.finalReportMatches(pending.task) && this.hasArtifactDelta(pending.task))
|
|
912
|
-
this.notifyTask(pending.run, pending.task, "completed");
|
|
913
|
-
}
|
|
914
|
-
for (const [key, run] of [...this.pendingRunNotices]) {
|
|
915
|
-
const notices = run.tasks
|
|
916
|
-
.map((task) => this.terminalNotices.get(`${run.id}:${task.id}`))
|
|
917
|
-
.filter((notice): notice is TerminalNotice => Boolean(notice));
|
|
918
|
-
const waiting = notices.some((notice) => {
|
|
919
|
-
const receipt = this.leaderMessages.get(notice.body);
|
|
920
|
-
return notice.delivery === "pending" && (!receipt || (!receipt.delivered && !receipt.lost));
|
|
921
|
-
});
|
|
922
|
-
if (waiting) continue;
|
|
923
|
-
this.pendingRunNotices.delete(key);
|
|
924
|
-
const lost = notices.some((notice) => notice.delivery === "lost");
|
|
925
|
-
if (!lost || this.notificationRetries.has(run.id)) continue;
|
|
926
|
-
this.notificationRetries.add(run.id);
|
|
927
|
-
this.notifyParent(run, run.status === "aborted" ? "aborted" : run.status === "failed" ? "failed" : "completed");
|
|
928
|
-
}
|
|
929
|
-
this.flushOutbox();
|
|
930
|
-
}
|
|
931
|
-
// Anything still unresolved (including a retry backoff) must be revisited without unrelated activity.
|
|
932
|
-
this.scheduleNotificationFlush(ctx, RETRY_BACKOFF_MS);
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
scheduleNotificationFlush(ctx: ExtensionContext, delayMs = 100): void {
|
|
936
|
-
if (
|
|
937
|
-
this.notificationFlushTimer ||
|
|
938
|
-
this.cleared ||
|
|
939
|
-
(!this.pendingTaskNotices.size &&
|
|
940
|
-
!this.pendingRunNotices.size &&
|
|
941
|
-
!this.outbox.size &&
|
|
942
|
-
![...this.leaderMessages.values()].some((receipt) => !receipt.delivered && !receipt.lost))
|
|
943
|
-
)
|
|
944
|
-
return;
|
|
945
|
-
this.notificationFlushTimer = setTimeout(() => {
|
|
946
|
-
this.notificationFlushTimer = undefined;
|
|
947
|
-
this.flushPendingNotifications(ctx);
|
|
948
|
-
}, delayMs);
|
|
949
|
-
this.notificationFlushTimer.unref?.();
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
|
|
953
|
-
const key = `${run.id}:${task.id}`;
|
|
954
|
-
const outcome = outcomeFingerprint(task);
|
|
955
|
-
const final = kind === "completed" && this.finalReportMatches(task);
|
|
956
|
-
const report = task.finalParentReport;
|
|
957
|
-
if (final && report?.delivery === "pending") {
|
|
958
|
-
this.pendingTaskNotices.set(key, { run, task });
|
|
959
|
-
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
960
|
-
this.emit("subagent:notification", {
|
|
961
|
-
runId: run.id,
|
|
962
|
-
taskId: task.id,
|
|
963
|
-
kind,
|
|
964
|
-
body: "",
|
|
965
|
-
suppressed: true,
|
|
966
|
-
delivery: "pending",
|
|
967
|
-
});
|
|
968
|
-
return;
|
|
969
|
-
}
|
|
970
|
-
if (
|
|
971
|
-
final &&
|
|
972
|
-
report &&
|
|
973
|
-
report.delivery !== "lost" &&
|
|
974
|
-
report.delivery !== "pending" &&
|
|
975
|
-
!this.hasArtifactDelta(task)
|
|
976
|
-
) {
|
|
977
|
-
this.emit("subagent:notification", {
|
|
978
|
-
runId: run.id,
|
|
979
|
-
taskId: task.id,
|
|
980
|
-
kind,
|
|
981
|
-
body: "",
|
|
982
|
-
suppressed: true,
|
|
983
|
-
delivery: report.delivery,
|
|
984
|
-
});
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
const covered = this.terminalNotices.get(key);
|
|
988
|
-
if (covered?.fingerprint === outcome && covered.delivery !== "lost") {
|
|
989
|
-
this.emit("subagent:notification", {
|
|
990
|
-
runId: run.id,
|
|
991
|
-
taskId: task.id,
|
|
992
|
-
kind,
|
|
993
|
-
body: "",
|
|
994
|
-
suppressed: true,
|
|
995
|
-
delivery: covered.delivery,
|
|
996
|
-
});
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
if (this.coveredByAwait(run.id, task.id, outcome)) {
|
|
1000
|
-
this.emit("subagent:notification", {
|
|
1001
|
-
runId: run.id,
|
|
1002
|
-
taskId: task.id,
|
|
1003
|
-
kind,
|
|
1004
|
-
body: "",
|
|
1005
|
-
suppressed: true,
|
|
1006
|
-
delivery: "awaited",
|
|
1007
|
-
});
|
|
1008
|
-
return;
|
|
1009
|
-
}
|
|
1010
|
-
const artifactOnly = final && report?.delivery !== "lost";
|
|
1011
|
-
const body = artifactOnly ? makeTaskArtifactNotice(run, task) : makeTaskNotice(run, task, kind);
|
|
1012
|
-
if (this.collectParked(run.id, { kind: "done", taskId: task.id, agent: task.agent, text: body })) {
|
|
1013
|
-
this.noteTerminal(run.id, task.id, body, outcome);
|
|
1014
|
-
this.terminalNotices.get(key)!.delivery = "received";
|
|
1015
|
-
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "parked" });
|
|
1016
|
-
return;
|
|
1017
|
-
}
|
|
1018
|
-
if (kind === "failed") {
|
|
1019
|
-
this.noteTerminal(run.id, task.id, body, outcome);
|
|
1020
|
-
try {
|
|
1021
|
-
this.pi.sendUserMessage(body, { deliverAs: "steer" });
|
|
1022
|
-
this.leaderMessages.set(body, {
|
|
1023
|
-
runId: run.id,
|
|
1024
|
-
taskIds: [task.id],
|
|
1025
|
-
kind: "terminal",
|
|
1026
|
-
deliverAs: "steer",
|
|
1027
|
-
delivered: false,
|
|
1028
|
-
submitted: true,
|
|
1029
|
-
lost: false,
|
|
1030
|
-
submittedAt: Date.now(),
|
|
1031
|
-
attempts: 0,
|
|
1032
|
-
submittedRunCount: this.agentRunCount,
|
|
1033
|
-
});
|
|
1034
|
-
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "submitted" });
|
|
1035
|
-
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext, RECEIPT_GRACE_MS);
|
|
1036
|
-
} catch {
|
|
1037
|
-
this.terminalNotices.get(key)!.delivery = "lost";
|
|
1038
|
-
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "failed" });
|
|
1039
|
-
}
|
|
1040
|
-
return;
|
|
1041
|
-
}
|
|
1042
|
-
this.noteTerminal(run.id, task.id, body, outcome);
|
|
1043
|
-
this.enqueueNotification(body, {
|
|
1044
|
-
runId: run.id,
|
|
1045
|
-
taskIds: [task.id],
|
|
1046
|
-
kind: "terminal",
|
|
1047
|
-
deliverAs: "followUp",
|
|
1048
|
-
});
|
|
1049
|
-
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "held" });
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
private notifyParent(
|
|
1053
|
-
run: RunSnapshot,
|
|
1054
|
-
kind: "completed" | "failed" | "aborted" | "asked",
|
|
1055
|
-
extra?: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
|
|
1056
|
-
): void {
|
|
1057
|
-
if (kind !== "asked" && run.completionAwaited) return;
|
|
1058
|
-
let tasks = run.tasks;
|
|
1059
|
-
if (kind !== "asked") {
|
|
1060
|
-
tasks = tasks.filter((task) => {
|
|
1061
|
-
const key = `${run.id}:${task.id}`;
|
|
1062
|
-
const outcome = outcomeFingerprint(task);
|
|
1063
|
-
const covered = this.terminalNotices.get(key);
|
|
1064
|
-
if (covered?.fingerprint === outcome && covered.delivery !== "lost") return false;
|
|
1065
|
-
if (this.pendingTaskNotices.has(key)) return false;
|
|
1066
|
-
if (this.coveredByAwait(run.id, task.id, outcome)) return false;
|
|
1067
|
-
if (
|
|
1068
|
-
kind === "completed" &&
|
|
1069
|
-
task.finalParentReport?.delivery !== "lost" &&
|
|
1070
|
-
this.finalReportMatches(task) &&
|
|
1071
|
-
!this.hasArtifactDelta(task)
|
|
1072
|
-
)
|
|
1073
|
-
return false;
|
|
1074
|
-
return true;
|
|
1075
|
-
});
|
|
1076
|
-
if (tasks.length === 0) {
|
|
1077
|
-
if (!this.notificationRetries.has(run.id) && this.hasPendingSuccessMessages(run)) {
|
|
1078
|
-
this.pendingRunNotices.set(run.id, run);
|
|
1079
|
-
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
1080
|
-
}
|
|
1081
|
-
this.emit("subagent:notification", { runId: run.id, kind, body: "", suppressed: true });
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
// Aggregate-only mode has no per-task hold: keep a run-level watch so a pending report
|
|
1085
|
-
// that later goes lost still produces a fallback.
|
|
1086
|
-
if (
|
|
1087
|
-
!this.notificationRetries.has(run.id) &&
|
|
1088
|
-
!this.pendingRunNotices.has(run.id) &&
|
|
1089
|
-
run.tasks.some((task) => task.finalParentReport?.delivery === "pending")
|
|
1090
|
-
) {
|
|
1091
|
-
this.pendingRunNotices.set(run.id, run);
|
|
1092
|
-
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
if (kind === "asked") {
|
|
1096
|
-
const body = makeAskNotice(run, extra ?? {});
|
|
1097
|
-
try {
|
|
1098
|
-
this.pi.sendUserMessage(body, { deliverAs: "steer" });
|
|
1099
|
-
this.emit("subagent:notification", { runId: run.id, taskId: extra?.taskId, kind, body, delivery: "submitted" });
|
|
1100
|
-
} catch {
|
|
1101
|
-
this.emit("subagent:notification", { runId: run.id, taskId: extra?.taskId, kind, body, delivery: "failed" });
|
|
1102
|
-
}
|
|
1103
|
-
return;
|
|
1104
|
-
}
|
|
1105
|
-
const body = makeNotice(run, kind, tasks);
|
|
1106
|
-
const deliverAs = kind === "failed" ? "steer" : "followUp";
|
|
1107
|
-
for (const task of tasks) this.noteTerminal(run.id, task.id, body, outcomeFingerprint(task));
|
|
1108
|
-
if (deliverAs === "steer") {
|
|
1109
|
-
try {
|
|
1110
|
-
this.pi.sendUserMessage(body, { deliverAs });
|
|
1111
|
-
this.leaderMessages.set(body, {
|
|
1112
|
-
runId: run.id,
|
|
1113
|
-
taskIds: tasks.map((t) => t.id),
|
|
1114
|
-
kind: "terminal",
|
|
1115
|
-
deliverAs,
|
|
1116
|
-
delivered: false,
|
|
1117
|
-
submitted: true,
|
|
1118
|
-
lost: false,
|
|
1119
|
-
submittedAt: Date.now(),
|
|
1120
|
-
attempts: 0,
|
|
1121
|
-
submittedRunCount: this.agentRunCount,
|
|
1122
|
-
});
|
|
1123
|
-
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext, RECEIPT_GRACE_MS);
|
|
1124
|
-
} catch {
|
|
1125
|
-
for (const task of tasks) {
|
|
1126
|
-
const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
|
|
1127
|
-
if (notice?.body === body) notice.delivery = "lost";
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
this.emit("subagent:notification", { runId: run.id, kind, body, delivery: "submitted" });
|
|
1131
|
-
this.holdRunFallback(run, tasks);
|
|
1132
|
-
return;
|
|
1133
|
-
}
|
|
1134
|
-
this.enqueueNotification(body, {
|
|
1135
|
-
runId: run.id,
|
|
1136
|
-
taskIds: tasks.map((t) => t.id),
|
|
1137
|
-
kind: "terminal",
|
|
1138
|
-
deliverAs,
|
|
1139
|
-
});
|
|
1140
|
-
this.emit("subagent:notification", { runId: run.id, kind, body, delivery: "held" });
|
|
1141
|
-
this.holdRunFallback(run, tasks);
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
/** Keep one bounded retry for aggregates whose per-task receipts never arrived. */
|
|
1145
|
-
private holdRunFallback(run: RunSnapshot, tasks: TaskSnapshot[]): void {
|
|
1146
|
-
if (this.notificationRetries.has(run.id)) return;
|
|
1147
|
-
const covered = tasks.filter((task) => {
|
|
1148
|
-
const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
|
|
1149
|
-
return Boolean(notice && notice.body !== "");
|
|
1150
|
-
});
|
|
1151
|
-
if (covered.length === 0) return;
|
|
1152
|
-
this.pendingRunNotices.set(run.id, run);
|
|
1153
|
-
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
private widgetTui: TUI | null = null;
|
|
1157
|
-
|
|
1158
|
-
/** Settled runs are history (subagent_status/result still reach them) — the widget shows live work only. */
|
|
1159
|
-
private pruneSettledWidget(): void {
|
|
1160
|
-
this.widgetRuns = this.widgetRuns.filter((r) => !TERMINAL.includes(r.status));
|
|
1161
|
-
}
|
|
1162
|
-
private upsertWidgetRun(run: RunSnapshot | undefined): void {
|
|
1163
|
-
this.pruneSettledWidget();
|
|
1164
|
-
if (!run || TERMINAL.includes(run.status)) return;
|
|
1165
|
-
const idx = this.widgetRuns.findIndex((r) => r.id === run.id);
|
|
1166
|
-
if (idx >= 0) this.widgetRuns[idx] = run;
|
|
1167
|
-
else this.widgetRuns.push(run);
|
|
1168
|
-
}
|
|
1169
|
-
private scheduleWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext): void {
|
|
1170
|
-
this.upsertWidgetRun(run);
|
|
1171
|
-
if (!run || this.widgetTimers.has(run.id)) return;
|
|
1172
|
-
this.widgetTimers.set(
|
|
1173
|
-
run.id,
|
|
1174
|
-
setTimeout(() => {
|
|
1175
|
-
this.widgetTimers.delete(run.id);
|
|
1176
|
-
if (ctx?.hasUI) {
|
|
1177
|
-
this.ensureWidget(ctx);
|
|
1178
|
-
this.widgetTui?.requestRender();
|
|
1179
|
-
}
|
|
1180
|
-
this.maybePulse(ctx);
|
|
1181
|
-
}, WIDGET_THROTTLE_MS),
|
|
1182
|
-
);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
private pulseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1186
|
-
private maybePulse(ctx?: ExtensionContext): void {
|
|
1187
|
-
if (this.pulseTimer || !this.widgetTui) return;
|
|
1188
|
-
const talking = this.widgetRuns.some((r) => r.tasks.some(isTalking));
|
|
1189
|
-
if (!talking) return;
|
|
1190
|
-
this.pulseTimer = setTimeout(() => {
|
|
1191
|
-
this.pulseTimer = null;
|
|
1192
|
-
this.widgetTui?.requestRender();
|
|
1193
|
-
this.maybePulse(ctx);
|
|
1194
|
-
}, 700);
|
|
1195
|
-
}
|
|
1196
|
-
private flushWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
1197
|
-
if (run) {
|
|
1198
|
-
const t = this.widgetTimers.get(run.id);
|
|
1199
|
-
if (t) {
|
|
1200
|
-
clearTimeout(t);
|
|
1201
|
-
this.widgetTimers.delete(run.id);
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
this.pruneSettledWidget();
|
|
1205
|
-
if (this.widgetRuns.length === 0) {
|
|
1206
|
-
if (this.widgetTui) this.clearWidget(ctx);
|
|
1207
|
-
} else {
|
|
1208
|
-
if (ctx?.hasUI) {
|
|
1209
|
-
this.ensureWidget(ctx);
|
|
1210
|
-
this.widgetTui?.requestRender();
|
|
1211
|
-
}
|
|
1212
|
-
this.maybePulse(ctx);
|
|
1213
|
-
}
|
|
1214
|
-
if (!run) return;
|
|
1215
|
-
|
|
1216
|
-
onUpdate?.({
|
|
1217
|
-
content: [
|
|
1218
|
-
{
|
|
1219
|
-
type: "text",
|
|
1220
|
-
text: `${run.tasks.filter((t) => TERMINAL.includes(t.status)).length}/${run.tasks.length} done · ${run.status}`,
|
|
1221
|
-
},
|
|
1222
|
-
],
|
|
1223
|
-
});
|
|
1224
|
-
}
|
|
1225
|
-
private ensureWidget(ctx: ExtensionContext): void {
|
|
1226
|
-
if (this.widgetTui !== null || !ctx.hasUI) return;
|
|
1227
|
-
ctx.ui.setWidget(
|
|
1228
|
-
"subagents",
|
|
1229
|
-
(tui, theme) => {
|
|
1230
|
-
this.widgetTui = tui;
|
|
1231
|
-
return new SubagentsWidget(() => [...this.widgetRuns], theme);
|
|
1232
|
-
},
|
|
1233
|
-
{ placement: "aboveEditor" },
|
|
1234
|
-
);
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
315
|
private updateRun(run: RunSnapshot, ctx?: ExtensionContext, _onUpdate?: (partial: any) => void): void {
|
|
1238
316
|
run.aggregateUsage = aggregateUsage(run.tasks);
|
|
1239
317
|
this.runs.set(run.id, run);
|
|
@@ -1242,7 +320,7 @@ export class SubagentManager {
|
|
|
1242
320
|
status: run.status,
|
|
1243
321
|
live: run.tasks.filter((t) => !TERMINAL.includes(t.status)).length,
|
|
1244
322
|
});
|
|
1245
|
-
this.
|
|
323
|
+
this.widget.schedule(run, ctx);
|
|
1246
324
|
}
|
|
1247
325
|
private updateTask(
|
|
1248
326
|
run: RunSnapshot,
|
|
@@ -1257,7 +335,7 @@ export class SubagentManager {
|
|
|
1257
335
|
}
|
|
1258
336
|
|
|
1259
337
|
private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
|
|
1260
|
-
this.notificationContext = ctx;
|
|
338
|
+
this.delivery.notificationContext = ctx;
|
|
1261
339
|
return {
|
|
1262
340
|
onAskParent: async (_taskId, question, urgent) => {
|
|
1263
341
|
if (TERMINAL.includes(task.status)) {
|
|
@@ -1266,7 +344,7 @@ export class SubagentManager {
|
|
|
1266
344
|
this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
|
|
1267
345
|
|
|
1268
346
|
if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
|
|
1269
|
-
this.notifyParent(run, "asked", { taskId: task.id, agent: task.agent, question, urgent });
|
|
347
|
+
this.delivery.notifyParent(run, "asked", { taskId: task.id, agent: task.agent, question, urgent });
|
|
1270
348
|
}
|
|
1271
349
|
|
|
1272
350
|
const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
|
|
@@ -1278,9 +356,9 @@ export class SubagentManager {
|
|
|
1278
356
|
return reply;
|
|
1279
357
|
},
|
|
1280
358
|
onNotifyParent: (_taskId, message, level, final = false) =>
|
|
1281
|
-
this.submitLeaderReport(run, task, message, level, final, ctx),
|
|
359
|
+
this.delivery.submitLeaderReport(run, task, message, level, final, ctx),
|
|
1282
360
|
onSendMessage: (_taskId, to, text, final = false) => {
|
|
1283
|
-
if (to === "leader") return this.submitLeaderReport(run, task, text, "info", final, ctx);
|
|
361
|
+
if (to === "leader") return this.delivery.submitLeaderReport(run, task, text, "info", final, ctx);
|
|
1284
362
|
|
|
1285
363
|
return this.mailboxes.send(`${run.id}:${task.id}`, `${run.id}:${to}`, text);
|
|
1286
364
|
},
|
|
@@ -1350,7 +428,7 @@ export class SubagentManager {
|
|
|
1350
428
|
onUpdate,
|
|
1351
429
|
);
|
|
1352
430
|
} else if (event.type === "tool_execution_end") {
|
|
1353
|
-
this.
|
|
431
|
+
this.widget.schedule(run, ctx);
|
|
1354
432
|
} else if (event.type === "message_end") {
|
|
1355
433
|
const message = event.message as AssistantMessage;
|
|
1356
434
|
if (message?.role === "assistant") {
|
|
@@ -1908,7 +986,7 @@ export class SubagentManager {
|
|
|
1908
986
|
);
|
|
1909
987
|
if (task.status === "completed") outputs.set(task.id, task.finalText ?? "");
|
|
1910
988
|
if (run.notifyPerTask && TERMINAL.includes(task.status)) {
|
|
1911
|
-
this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
|
|
989
|
+
this.delivery.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
|
|
1912
990
|
}
|
|
1913
991
|
},
|
|
1914
992
|
);
|
|
@@ -1946,10 +1024,10 @@ export class SubagentManager {
|
|
|
1946
1024
|
const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
|
|
1947
1025
|
run.status = aborted ? "aborted" : failed ? "failed" : "completed";
|
|
1948
1026
|
run.endedAt = Date.now();
|
|
1949
|
-
this.
|
|
1027
|
+
this.widget.flush(run, ctx, onUpdate);
|
|
1950
1028
|
|
|
1951
1029
|
const live = this.listRuns().find((r) => !TERMINAL.includes(r.status));
|
|
1952
|
-
if (live) this.
|
|
1030
|
+
if (live) this.widget.schedule(live, ctx);
|
|
1953
1031
|
|
|
1954
1032
|
if (this.settlers.has(run.id)) {
|
|
1955
1033
|
this.emit("subagent:run-completed", {
|
|
@@ -1987,7 +1065,7 @@ export class SubagentManager {
|
|
|
1987
1065
|
const { run, inputs } = this.createRun(params, ctx);
|
|
1988
1066
|
void this.executeTasks(run, inputs, ctx, undefined, undefined)
|
|
1989
1067
|
.then(() => {
|
|
1990
|
-
this.notifyParent(
|
|
1068
|
+
this.delivery.notifyParent(
|
|
1991
1069
|
run,
|
|
1992
1070
|
run.status === "completed" ? "completed" : run.status === "aborted" ? "aborted" : "failed",
|
|
1993
1071
|
);
|
|
@@ -2006,7 +1084,7 @@ export class SubagentManager {
|
|
|
2006
1084
|
this.runControllers.delete(run.id);
|
|
2007
1085
|
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
2008
1086
|
this.emit("subagent:run-completed", { runId: run.id, status: "failed", run: cloneRun(run) });
|
|
2009
|
-
this.notifyParent(run, "failed");
|
|
1087
|
+
this.delivery.notifyParent(run, "failed");
|
|
2010
1088
|
this.persist(ctx);
|
|
2011
1089
|
});
|
|
2012
1090
|
return { run: cloneRun(run) };
|
|
@@ -2084,35 +1162,7 @@ export class SubagentManager {
|
|
|
2084
1162
|
changedFiles: undefined,
|
|
2085
1163
|
worktreeError: undefined,
|
|
2086
1164
|
});
|
|
2087
|
-
|
|
2088
|
-
this.terminalNotices.delete(scope);
|
|
2089
|
-
this.pendingTaskNotices.delete(scope);
|
|
2090
|
-
this.pendingRunNotices.delete(run.id);
|
|
2091
|
-
this.notificationRetries.delete(run.id);
|
|
2092
|
-
for (const [body, receipt] of this.leaderMessages) {
|
|
2093
|
-
if (receipt.runId !== run.id || !receipt.taskIds.includes(task.id)) continue;
|
|
2094
|
-
if (receipt.taskIds.length === 1) {
|
|
2095
|
-
this.leaderMessages.delete(body);
|
|
2096
|
-
continue;
|
|
2097
|
-
}
|
|
2098
|
-
// A shared aggregate stays for its remaining tasks; the resumed task's slice is published
|
|
2099
|
-
// again by the run's next settle.
|
|
2100
|
-
receipt.taskIds = receipt.taskIds.filter((id) => id !== task.id);
|
|
2101
|
-
}
|
|
2102
|
-
for (const [body, entry] of this.outbox) {
|
|
2103
|
-
if (entry.runId !== run.id || !entry.taskIds.includes(task.id)) continue;
|
|
2104
|
-
if (entry.taskIds.length === 1) {
|
|
2105
|
-
this.outbox.delete(body);
|
|
2106
|
-
continue;
|
|
2107
|
-
}
|
|
2108
|
-
this.outbox.delete(body);
|
|
2109
|
-
for (const otherId of entry.taskIds) {
|
|
2110
|
-
if (otherId === task.id) continue;
|
|
2111
|
-
const notice = this.terminalNotices.get(`${run.id}:${otherId}`);
|
|
2112
|
-
if (notice) notice.delivery = "lost";
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
this.awaitedTaskNotices.delete(scope);
|
|
1165
|
+
this.delivery.forgetTask(run, task);
|
|
2116
1166
|
run.status = "running";
|
|
2117
1167
|
run.endedAt = undefined;
|
|
2118
1168
|
run.awaited = false;
|
|
@@ -2135,7 +1185,7 @@ export class SubagentManager {
|
|
|
2135
1185
|
}
|
|
2136
1186
|
})
|
|
2137
1187
|
.then(() => {
|
|
2138
|
-
if (run.notifyPerTask) this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
|
|
1188
|
+
if (run.notifyPerTask) this.delivery.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
|
|
2139
1189
|
this.finishRunIfSettled(run, ctx);
|
|
2140
1190
|
});
|
|
2141
1191
|
return { ok: true, task, note: thinkingNote };
|
|
@@ -2147,7 +1197,7 @@ export class SubagentManager {
|
|
|
2147
1197
|
const aborted = run.tasks.some((t) => t.status === "aborted");
|
|
2148
1198
|
run.status = aborted ? "aborted" : failed ? "failed" : "completed";
|
|
2149
1199
|
run.endedAt = Date.now();
|
|
2150
|
-
this.
|
|
1200
|
+
this.widget.flush(run, ctx);
|
|
2151
1201
|
this.emit("subagent:run-completed", {
|
|
2152
1202
|
runId: run.id,
|
|
2153
1203
|
status: run.status,
|
|
@@ -2158,7 +1208,10 @@ export class SubagentManager {
|
|
|
2158
1208
|
this.runControllers.delete(run.id);
|
|
2159
1209
|
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
2160
1210
|
this.persist(ctx);
|
|
2161
|
-
this.notifyParent(
|
|
1211
|
+
this.delivery.notifyParent(
|
|
1212
|
+
run,
|
|
1213
|
+
run.status === "completed" ? "completed" : run.status === "aborted" ? "aborted" : "failed",
|
|
1214
|
+
);
|
|
2162
1215
|
}
|
|
2163
1216
|
|
|
2164
1217
|
steerTask(runId: string, taskId: string | undefined, message: string): boolean {
|
|
@@ -2182,7 +1235,7 @@ export class SubagentManager {
|
|
|
2182
1235
|
this.pendingReplies.delete(`${runId}:${taskId}`);
|
|
2183
1236
|
this.liveChildren.get(`${runId}:${taskId}`)?.abort();
|
|
2184
1237
|
this.mailboxes.close(`${runId}:${taskId}`);
|
|
2185
|
-
if (ctx) this.
|
|
1238
|
+
if (ctx) this.widget.flush(run, ctx);
|
|
2186
1239
|
this.emit("subagent:task-aborted", { runId, taskId });
|
|
2187
1240
|
return true;
|
|
2188
1241
|
}
|