@arhen/pi-core-subagent 1.3.2 → 1.3.4
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 +2 -1
- package/package.json +54 -46
- package/src/child.ts +17 -15
- package/src/format.ts +237 -0
- package/src/graph.ts +145 -0
- package/src/index.ts +94 -1315
- package/src/manager.ts +1051 -0
- package/src/peek.ts +4 -2
- package/src/schemas.ts +88 -0
- package/src/types.ts +70 -0
package/src/manager.ts
ADDED
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
/** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
6
|
+
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
7
|
+
import {
|
|
8
|
+
type AgentSessionEvent,
|
|
9
|
+
createAgentSession,
|
|
10
|
+
DefaultResourceLoader,
|
|
11
|
+
type ExtensionAPI,
|
|
12
|
+
type ExtensionContext,
|
|
13
|
+
getAgentDir,
|
|
14
|
+
ModelRuntime,
|
|
15
|
+
SessionManager,
|
|
16
|
+
type ToolDefinition,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
19
|
+
import { CHILD_TALK_TOOLS, type ChildHandlers, createChildTools, createWatchdog, type Watchdog } from "./child.ts";
|
|
20
|
+
import {
|
|
21
|
+
activitySnippet,
|
|
22
|
+
describeCall,
|
|
23
|
+
getFirstText,
|
|
24
|
+
makeNotice,
|
|
25
|
+
makeTaskNotice,
|
|
26
|
+
SubagentsWidget,
|
|
27
|
+
truncateText,
|
|
28
|
+
} from "./format.ts";
|
|
29
|
+
import { applyUpstream, resolveNeeds, runWaveScheduler } from "./graph.ts";
|
|
30
|
+
import { createMailbox, type Mailbox } from "./mailbox.ts";
|
|
31
|
+
import type { SubagentParamsShape, TaskInput } from "./schemas.ts";
|
|
32
|
+
import {
|
|
33
|
+
MAX_TASKS,
|
|
34
|
+
type PendingReply,
|
|
35
|
+
type RunDetails,
|
|
36
|
+
type RunMode,
|
|
37
|
+
type RunSnapshot,
|
|
38
|
+
type RunStatus,
|
|
39
|
+
type TaskSnapshot,
|
|
40
|
+
type TaskStatus,
|
|
41
|
+
TERMINAL,
|
|
42
|
+
type UsageStats,
|
|
43
|
+
} from "./types.ts";
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_CONCURRENCY = 3;
|
|
46
|
+
export const MAX_CONCURRENCY = 8;
|
|
47
|
+
/** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
|
|
48
|
+
export const DEFAULT_RUNTIME_MS = 0;
|
|
49
|
+
export const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
|
|
50
|
+
export const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
51
|
+
export const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
52
|
+
const WIDGET_THROTTLE_MS = 150;
|
|
53
|
+
|
|
54
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export function newId(prefix: string): string {
|
|
57
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
58
|
+
}
|
|
59
|
+
export function emptyUsage(): UsageStats {
|
|
60
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
61
|
+
}
|
|
62
|
+
export function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
|
|
63
|
+
const total = emptyUsage();
|
|
64
|
+
for (const task of tasks) {
|
|
65
|
+
total.input += task.usage.input;
|
|
66
|
+
total.output += task.usage.output;
|
|
67
|
+
total.cacheRead += task.usage.cacheRead;
|
|
68
|
+
total.cacheWrite += task.usage.cacheWrite;
|
|
69
|
+
total.cost += task.usage.cost;
|
|
70
|
+
total.turns += task.usage.turns;
|
|
71
|
+
}
|
|
72
|
+
return total;
|
|
73
|
+
}
|
|
74
|
+
export function getParentSessionFile(ctx: ExtensionContext): string | undefined {
|
|
75
|
+
try {
|
|
76
|
+
return ctx.sessionManager.getSessionFile?.();
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* pi 0.84 StopReason enum: "stop" is NORMAL completion (was "end" in older pi).
|
|
83
|
+
* Only length/error/aborted/deferred/pending/toolUse-as-final are failures.
|
|
84
|
+
*/
|
|
85
|
+
export function classifyFailure(
|
|
86
|
+
stopReason: string | undefined,
|
|
87
|
+
errorMessage?: string,
|
|
88
|
+
): { status: "failed" | "aborted"; message: string } | undefined {
|
|
89
|
+
if (!stopReason || stopReason === "stop" || stopReason === "end") return undefined;
|
|
90
|
+
if (stopReason === "aborted") return { status: "aborted", message: errorMessage || "Subagent was aborted." };
|
|
91
|
+
return { status: "failed", message: errorMessage || `Subagent ended with stopReason "${stopReason}".` };
|
|
92
|
+
}
|
|
93
|
+
export function lastAssistantFailure(
|
|
94
|
+
messages: AssistantMessage[] | undefined,
|
|
95
|
+
): { status: "failed" | "aborted"; message: string } | undefined {
|
|
96
|
+
for (const message of [...(messages ?? [])].reverse()) {
|
|
97
|
+
if (message?.role !== "assistant") continue;
|
|
98
|
+
return classifyFailure(message.stopReason, message.errorMessage);
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
export function failureError(failure: { status: "failed" | "aborted"; message: string }): Error {
|
|
103
|
+
const error = new Error(failure.message);
|
|
104
|
+
(error as Error & { subagentStatus?: string }).subagentStatus = failure.status;
|
|
105
|
+
return error;
|
|
106
|
+
}
|
|
107
|
+
export function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage): void {
|
|
108
|
+
if (message?.role !== "assistant") return;
|
|
109
|
+
task.usage.turns += 1;
|
|
110
|
+
const usage = message.usage;
|
|
111
|
+
if (!usage) return;
|
|
112
|
+
task.usage.input += usage.input ?? 0;
|
|
113
|
+
task.usage.output += usage.output ?? 0;
|
|
114
|
+
task.usage.cacheRead += usage.cacheRead ?? 0;
|
|
115
|
+
task.usage.cacheWrite += usage.cacheWrite ?? 0;
|
|
116
|
+
task.usage.cost += usage.cost?.total ?? 0;
|
|
117
|
+
if (message.model && !task.model) task.model = message.model;
|
|
118
|
+
}
|
|
119
|
+
export function cloneRun(run: RunSnapshot): RunSnapshot {
|
|
120
|
+
return JSON.parse(JSON.stringify(run)) as RunSnapshot;
|
|
121
|
+
}
|
|
122
|
+
/** Resolve a child model from the pi model registry.
|
|
123
|
+
* Order: explicit "provider/model-id" or bare id (searched across available
|
|
124
|
+
* models) → agent file model → parent's current model (ctx.model) → undefined
|
|
125
|
+
* (createAgentSession falls back to settings). */
|
|
126
|
+
export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
|
|
127
|
+
if (!explicit?.trim()) return ctx.model; // inherit the parent's active model
|
|
128
|
+
const ref = explicit.trim();
|
|
129
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
130
|
+
// Model ids can contain slashes (e.g. 9router/cc/claude-opus-5), so a bare id
|
|
131
|
+
// match and every provider/id split point must be tried, not just the first.
|
|
132
|
+
const byId = available.find((m) => m.id === ref);
|
|
133
|
+
if (byId) return byId;
|
|
134
|
+
for (let slash = ref.indexOf("/"); slash > 0; slash = ref.indexOf("/", slash + 1)) {
|
|
135
|
+
const model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
|
|
136
|
+
if (model) return model;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Model not found: ${ref}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Extension-registered providers (e.g. 9router) live only in the parent's
|
|
142
|
+
* in-memory runtime. A child builds its runtime from disk and would lose them,
|
|
143
|
+
* so replay the parent's registrations before the child resolves auth. */
|
|
144
|
+
async function createChildModelRuntime(ctx: ExtensionContext) {
|
|
145
|
+
const ids = ctx.modelRegistry.getRegisteredProviderIds?.() ?? [];
|
|
146
|
+
if (ids.length === 0) return undefined; // no extension providers: disk runtime is enough
|
|
147
|
+
const agentDir = getAgentDir();
|
|
148
|
+
const runtime = await ModelRuntime.create({
|
|
149
|
+
authPath: join(agentDir, "auth.json"),
|
|
150
|
+
modelsPath: join(agentDir, "models.json"),
|
|
151
|
+
});
|
|
152
|
+
for (const id of ids) {
|
|
153
|
+
const native = ctx.modelRegistry.getRegisteredNativeProvider?.(id);
|
|
154
|
+
if (native) {
|
|
155
|
+
runtime.registerNativeProvider(native);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const config = ctx.modelRegistry.getRegisteredProviderConfig?.(id);
|
|
159
|
+
if (config) runtime.registerProvider(id, config);
|
|
160
|
+
}
|
|
161
|
+
await runtime.refresh({ allowNetwork: false });
|
|
162
|
+
return runtime;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Validate a thinking level against the RESOLVED model's registry entry.
|
|
166
|
+
* thinkingLevelMap: null = unsupported, missing key = provider default,
|
|
167
|
+
* absent map = provider defaults. Non-reasoning models only accept "off". */
|
|
168
|
+
export function validateThinking(model: Model<Api> | undefined, level: string | undefined): void {
|
|
169
|
+
if (!level || level === "off") return;
|
|
170
|
+
if (!model) return;
|
|
171
|
+
const map = model.thinkingLevelMap;
|
|
172
|
+
if (map && level in map && map[level as keyof typeof map] === null) {
|
|
173
|
+
const supported = Object.keys(map).filter((k) => map[k as keyof typeof map] !== null);
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Thinking level "${level}" is not supported by ${model.provider}/${model.id}. Supported: ${supported.length ? supported.join(" | ") : 'none — use thinking: "off"'}.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (!model.reasoning) {
|
|
179
|
+
throw new Error(`Model ${model.provider}/${model.id} does not support thinking. Use thinking: "off".`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Cached catalog removed: agents are defined inline by the leader per call,
|
|
184
|
+
// so there is nothing to inject into the parent context. Zero per-request cost.
|
|
185
|
+
|
|
186
|
+
interface ChildEventState {
|
|
187
|
+
pendingFailure?: ReturnType<typeof classifyFailure>;
|
|
188
|
+
failChildEnd?: (error: Error) => void;
|
|
189
|
+
childEndResolve?: () => void;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export class SubagentManager {
|
|
193
|
+
private runs = new Map<string, RunSnapshot>();
|
|
194
|
+
private settlers = new Map<string, (run: RunSnapshot) => void>();
|
|
195
|
+
private pendingReplies = new Map<string, PendingReply>();
|
|
196
|
+
private liveChildren = new Map<string, { abort: () => void; dispose: () => void; touchWatchdog: () => void }>();
|
|
197
|
+
private mailboxes: Mailbox = createMailbox();
|
|
198
|
+
private runControllers = new Map<string, AbortController>();
|
|
199
|
+
private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
|
|
200
|
+
private widgetRuns: RunSnapshot[] = [];
|
|
201
|
+
private eventSeq = 0;
|
|
202
|
+
|
|
203
|
+
turnActivity = false;
|
|
204
|
+
|
|
205
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
206
|
+
|
|
207
|
+
/** Any run still has queued/running tasks? */
|
|
208
|
+
hasActiveRun(): boolean {
|
|
209
|
+
for (const run of this.runs.values()) {
|
|
210
|
+
if (run.tasks.some((t) => !TERMINAL.includes(t.status))) return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Hide the widget + clear the footer status entry. */
|
|
216
|
+
clearWidget(ctx: ExtensionContext): void {
|
|
217
|
+
this.widgetRuns = [];
|
|
218
|
+
this.widgetTui = null;
|
|
219
|
+
if (ctx.hasUI) {
|
|
220
|
+
try {
|
|
221
|
+
ctx.ui.setWidget("subagents", undefined);
|
|
222
|
+
} catch {
|
|
223
|
+
/* ignore */
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
listRuns(): RunSnapshot[] {
|
|
229
|
+
return Array.from(this.runs.values()).sort((a, b) => b.createdAt - a.createdAt);
|
|
230
|
+
}
|
|
231
|
+
getRun(runId: string | undefined): RunSnapshot | undefined {
|
|
232
|
+
return runId ? this.runs.get(runId) : undefined;
|
|
233
|
+
}
|
|
234
|
+
clearRuns(): void {
|
|
235
|
+
for (const child of this.liveChildren.values()) {
|
|
236
|
+
child.abort();
|
|
237
|
+
child.dispose();
|
|
238
|
+
}
|
|
239
|
+
this.liveChildren.clear();
|
|
240
|
+
this.runs.clear();
|
|
241
|
+
this.settlers.clear();
|
|
242
|
+
this.pendingReplies.clear();
|
|
243
|
+
this.runControllers.clear();
|
|
244
|
+
this.mailboxes = createMailbox();
|
|
245
|
+
this.widgetTui = null; // force re-registration on the next session
|
|
246
|
+
for (const t of this.widgetTimers.values()) clearTimeout(t);
|
|
247
|
+
this.widgetTimers.clear();
|
|
248
|
+
this.widgetRuns = [];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── persistence (sidecar per parent session) ────────────────────────
|
|
252
|
+
async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
|
|
253
|
+
const parentFile = getParentSessionFile(ctx);
|
|
254
|
+
if (!parentFile) return;
|
|
255
|
+
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
256
|
+
let runs: RunSnapshot[];
|
|
257
|
+
try {
|
|
258
|
+
if (!existsSync(sidecar)) return;
|
|
259
|
+
const raw = JSON.parse(readFileSync(sidecar, "utf-8"));
|
|
260
|
+
if (!Array.isArray(raw)) return;
|
|
261
|
+
runs = (raw as RunSnapshot[]).map((run) => {
|
|
262
|
+
const interrupted = run.tasks.some((t) => !TERMINAL.includes(t.status));
|
|
263
|
+
// A persisted "running" run whose tasks are all terminal (crash between
|
|
264
|
+
// task end and run end) must not stay "running" forever.
|
|
265
|
+
let status = interrupted ? ("aborted" as RunStatus) : run.status;
|
|
266
|
+
if (!TERMINAL.includes(status)) {
|
|
267
|
+
const anyFailed = run.tasks.some((t) => t.status === "failed");
|
|
268
|
+
const anyAborted = run.tasks.some((t) => t.status === "aborted");
|
|
269
|
+
status = anyFailed ? "failed" : anyAborted ? "aborted" : "completed";
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
...run,
|
|
273
|
+
status,
|
|
274
|
+
endedAt: interrupted ? Date.now() : run.endedAt,
|
|
275
|
+
tasks: run.tasks.map((t) =>
|
|
276
|
+
TERMINAL.includes(t.status)
|
|
277
|
+
? t
|
|
278
|
+
: { ...t, status: "aborted" as TaskStatus, error: t.error || "Interrupted by session reload" },
|
|
279
|
+
),
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
} catch {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
let added = 0;
|
|
286
|
+
for (const run of runs) {
|
|
287
|
+
if (!run?.id || this.runs.has(run.id)) continue;
|
|
288
|
+
this.runs.set(run.id, run);
|
|
289
|
+
added += 1;
|
|
290
|
+
}
|
|
291
|
+
if (added > 0) {
|
|
292
|
+
this.emit("subagent:runs-restored", { count: added });
|
|
293
|
+
this.scheduleWidget(this.listRuns()[0], ctx);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
private persist(ctx: ExtensionContext): void {
|
|
297
|
+
try {
|
|
298
|
+
const parentFile = getParentSessionFile(ctx);
|
|
299
|
+
if (!parentFile) return;
|
|
300
|
+
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
301
|
+
void writeFile(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)).catch(() => {}); // never surface as an unhandled rejection
|
|
302
|
+
} catch {
|
|
303
|
+
/* ignore */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private emit(type: string, payload: Record<string, unknown>): void {
|
|
308
|
+
this.pi.events.emit(type, { type, timestamp: Date.now(), ...payload });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Per-task wake-up: queued follow-up so the parent can interleave responses. */
|
|
312
|
+
private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
|
|
313
|
+
const body = makeTaskNotice(run, task, kind);
|
|
314
|
+
try {
|
|
315
|
+
this.pi.sendUserMessage(body, { deliverAs: "followUp" });
|
|
316
|
+
} catch {
|
|
317
|
+
/* parent mid-stream; consumers can poll subagent_status */
|
|
318
|
+
}
|
|
319
|
+
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Wake the parent with a 3-line notice. Full text stays out of context.
|
|
323
|
+
* deliverAs followUp queues the message if the parent is mid-stream
|
|
324
|
+
* (e.g. inside await_subagent) instead of throwing/aborting. */
|
|
325
|
+
private notifyParent(
|
|
326
|
+
run: RunSnapshot,
|
|
327
|
+
kind: "completed" | "failed" | "aborted" | "asked",
|
|
328
|
+
extra?: { taskId?: string; question?: string },
|
|
329
|
+
): void {
|
|
330
|
+
if (kind !== "asked" && run.awaited) return; // parent already got the result via await_subagent
|
|
331
|
+
const body =
|
|
332
|
+
kind === "asked"
|
|
333
|
+
? `A background subagent is asking you a question (task ${extra?.taskId}): ${extra?.question ?? ""}\nReply with reply_subagent(runId: "${run.id}", taskId: "${extra?.taskId}", message: ...).`
|
|
334
|
+
: makeNotice(run, kind);
|
|
335
|
+
try {
|
|
336
|
+
this.pi.sendUserMessage(body, { deliverAs: "followUp" });
|
|
337
|
+
} catch {
|
|
338
|
+
/* parent mid-stream; consumers can poll subagent_status */
|
|
339
|
+
}
|
|
340
|
+
this.emit("subagent:notification", { runId: run.id, kind, body });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Widget: register-once + requestRender (todo-overlay pattern).
|
|
344
|
+
// scheduleWidget throttles status changes into requestRender calls.
|
|
345
|
+
private widgetTui: TUI | null = null;
|
|
346
|
+
/** Upsert a run into the widget's visible set (all runs, not just the latest). */
|
|
347
|
+
private upsertWidgetRun(run: RunSnapshot | undefined): void {
|
|
348
|
+
if (!run) return;
|
|
349
|
+
const idx = this.widgetRuns.findIndex((r) => r.id === run.id);
|
|
350
|
+
if (idx >= 0) this.widgetRuns[idx] = run;
|
|
351
|
+
else this.widgetRuns.push(run);
|
|
352
|
+
}
|
|
353
|
+
private scheduleWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext): void {
|
|
354
|
+
this.upsertWidgetRun(run);
|
|
355
|
+
if (!run || this.widgetTimers.has(run.id)) return;
|
|
356
|
+
this.widgetTimers.set(
|
|
357
|
+
run.id,
|
|
358
|
+
setTimeout(() => {
|
|
359
|
+
this.widgetTimers.delete(run.id);
|
|
360
|
+
if (ctx?.hasUI) {
|
|
361
|
+
this.ensureWidget(ctx);
|
|
362
|
+
this.widgetTui?.requestRender();
|
|
363
|
+
}
|
|
364
|
+
}, WIDGET_THROTTLE_MS),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
private flushWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
368
|
+
if (run) {
|
|
369
|
+
const t = this.widgetTimers.get(run.id);
|
|
370
|
+
if (t) {
|
|
371
|
+
clearTimeout(t);
|
|
372
|
+
this.widgetTimers.delete(run.id);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!run || this.widgetRuns.length === 0) return;
|
|
376
|
+
if (ctx?.hasUI) {
|
|
377
|
+
this.ensureWidget(ctx);
|
|
378
|
+
this.widgetTui?.requestRender();
|
|
379
|
+
}
|
|
380
|
+
// Transcript gets one status line only — the live per-task view is the widget's job.
|
|
381
|
+
onUpdate?.({
|
|
382
|
+
content: [
|
|
383
|
+
{
|
|
384
|
+
type: "text",
|
|
385
|
+
text: `${run.tasks.filter((t) => TERMINAL.includes(t.status)).length}/${run.tasks.length} done · ${run.status}`,
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
private ensureWidget(ctx: ExtensionContext): void {
|
|
391
|
+
if (this.widgetTui !== null || !ctx.hasUI) return;
|
|
392
|
+
ctx.ui.setWidget(
|
|
393
|
+
"subagents",
|
|
394
|
+
(tui, theme) => {
|
|
395
|
+
this.widgetTui = tui;
|
|
396
|
+
return new SubagentsWidget(() => [...this.widgetRuns], theme);
|
|
397
|
+
},
|
|
398
|
+
{ placement: "aboveEditor" },
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private updateRun(run: RunSnapshot, ctx?: ExtensionContext, _onUpdate?: (partial: any) => void): void {
|
|
403
|
+
run.aggregateUsage = aggregateUsage(run.tasks);
|
|
404
|
+
this.runs.set(run.id, run);
|
|
405
|
+
this.emit("subagent:run-updated", {
|
|
406
|
+
runId: run.id,
|
|
407
|
+
status: run.status,
|
|
408
|
+
live: run.tasks.filter((t) => !TERMINAL.includes(t.status)).length,
|
|
409
|
+
});
|
|
410
|
+
this.scheduleWidget(run, ctx);
|
|
411
|
+
}
|
|
412
|
+
private updateTask(
|
|
413
|
+
run: RunSnapshot,
|
|
414
|
+
task: TaskSnapshot,
|
|
415
|
+
patch: Partial<TaskSnapshot>,
|
|
416
|
+
ctx: ExtensionContext,
|
|
417
|
+
onUpdate?: (partial: any) => void,
|
|
418
|
+
): void {
|
|
419
|
+
Object.assign(task, patch);
|
|
420
|
+
this.emit("subagent:task-updated", { runId: run.id, taskId: task.id, status: task.status });
|
|
421
|
+
this.updateRun(run, ctx, onUpdate);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── intercom + mailbox ──────────────────────────────────────────────
|
|
425
|
+
private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
|
|
426
|
+
return {
|
|
427
|
+
onAskParent: async (_taskId, question) => {
|
|
428
|
+
this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
|
|
429
|
+
this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
|
|
430
|
+
// A blocking run's parent can't reply mid-tool (followUp only fires after the
|
|
431
|
+
// tool returns) — only background runs can truly wait for the answer.
|
|
432
|
+
if (!run.background) {
|
|
433
|
+
this.updateTask(run, task, { status: "running" }, ctx);
|
|
434
|
+
this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
|
|
435
|
+
return "Parent cannot answer while this run is blocking. Continue autonomously with your best judgment.";
|
|
436
|
+
}
|
|
437
|
+
this.notifyParent(run, "asked", { taskId: task.id, question });
|
|
438
|
+
// M3: a waiting child is not stalled — keep the watchdog fed until the reply.
|
|
439
|
+
const keepAlive = setInterval(() => this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog(), 30_000);
|
|
440
|
+
try {
|
|
441
|
+
const reply = await this.awaitParentReply(run.id, task.id);
|
|
442
|
+
this.updateTask(run, task, { status: "running" }, ctx);
|
|
443
|
+
this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
|
|
444
|
+
return reply;
|
|
445
|
+
} finally {
|
|
446
|
+
clearInterval(keepAlive);
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
onNotifyParent: (_taskId, message, level) => {
|
|
450
|
+
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
|
|
451
|
+
if (!run.awaited) {
|
|
452
|
+
try {
|
|
453
|
+
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
|
|
454
|
+
} catch {
|
|
455
|
+
/* parent mid-stream */
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
onSendMessage: (_taskId, to, text) => {
|
|
460
|
+
if (to === "leader") {
|
|
461
|
+
this.emit("subagent:intercom", {
|
|
462
|
+
runId: run.id,
|
|
463
|
+
taskId: task.id,
|
|
464
|
+
kind: "notify",
|
|
465
|
+
level: "info",
|
|
466
|
+
message: text,
|
|
467
|
+
});
|
|
468
|
+
if (!run.awaited) {
|
|
469
|
+
try {
|
|
470
|
+
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
|
|
471
|
+
} catch {
|
|
472
|
+
/* parent mid-stream */
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
// Run-scoped keys: sibling ids are run-local; cross-run task_1 can never collide.
|
|
478
|
+
return this.mailboxes.send(`${run.id}:${task.id}`, `${run.id}:${to}`, text);
|
|
479
|
+
},
|
|
480
|
+
onPollMailbox: (taskId) => this.mailboxes.poll(`${run.id}:${taskId}`),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
private awaitParentReply(runId: string, taskId: string): Promise<string> {
|
|
484
|
+
return new Promise<string>((resolve) => {
|
|
485
|
+
this.pendingReplies.set(`${runId}:${taskId}`, { resolve });
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
deliverReply(runId: string, taskId: string, message: string): boolean {
|
|
489
|
+
const pending = this.pendingReplies.get(`${runId}:${taskId}`);
|
|
490
|
+
if (!pending) return false;
|
|
491
|
+
this.pendingReplies.delete(`${runId}:${taskId}`);
|
|
492
|
+
pending.resolve(message);
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Child session events → task state. Extracted from runChild so the
|
|
498
|
+
* per-event classification is readable and unit-testable.
|
|
499
|
+
*/
|
|
500
|
+
private onChildEvent(
|
|
501
|
+
event: AgentSessionEvent,
|
|
502
|
+
run: RunSnapshot,
|
|
503
|
+
task: TaskSnapshot,
|
|
504
|
+
ctx: ExtensionContext,
|
|
505
|
+
onUpdate: ((partial: any) => void) | undefined,
|
|
506
|
+
watchdog: Watchdog,
|
|
507
|
+
state: ChildEventState,
|
|
508
|
+
): void {
|
|
509
|
+
const active =
|
|
510
|
+
event.type === "message_update" ||
|
|
511
|
+
event.type === "message_end" ||
|
|
512
|
+
event.type === "tool_execution_start" ||
|
|
513
|
+
event.type === "tool_execution_update" ||
|
|
514
|
+
event.type === "tool_execution_end" ||
|
|
515
|
+
event.type === "bash_execution_update" ||
|
|
516
|
+
event.type === "agent_settled";
|
|
517
|
+
if (active) {
|
|
518
|
+
watchdog.touch();
|
|
519
|
+
this.emit("subagent:session-event", {
|
|
520
|
+
runId: run.id,
|
|
521
|
+
taskId: task.id,
|
|
522
|
+
seq: this.eventSeq++,
|
|
523
|
+
event: { type: event.type },
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (event.type === "tool_execution_start") {
|
|
527
|
+
this.updateTask(
|
|
528
|
+
run,
|
|
529
|
+
task,
|
|
530
|
+
{ toolCalls: task.toolCalls + 1, lastActivity: describeCall(event.toolName, event.args, task.cwd) },
|
|
531
|
+
ctx,
|
|
532
|
+
onUpdate,
|
|
533
|
+
);
|
|
534
|
+
} else if (event.type === "tool_execution_end") {
|
|
535
|
+
this.scheduleWidget(run, ctx);
|
|
536
|
+
} else if (event.type === "message_end") {
|
|
537
|
+
const message = event.message as AssistantMessage;
|
|
538
|
+
if (message?.role === "assistant") {
|
|
539
|
+
updateUsageFromMessage(task, message);
|
|
540
|
+
const text = getFirstText(message);
|
|
541
|
+
if (text) {
|
|
542
|
+
task.finalText = truncateText(text);
|
|
543
|
+
task.lastActivity = activitySnippet(text);
|
|
544
|
+
}
|
|
545
|
+
state.pendingFailure = classifyFailure(message.stopReason, message.errorMessage);
|
|
546
|
+
}
|
|
547
|
+
this.updateRun(run, ctx, onUpdate);
|
|
548
|
+
} else if (event.type === "agent_end") {
|
|
549
|
+
if (event.willRetry) {
|
|
550
|
+
state.pendingFailure = undefined; // retry in flight — don't trust stale failures
|
|
551
|
+
} else {
|
|
552
|
+
const failure = lastAssistantFailure(event.messages as AssistantMessage[]);
|
|
553
|
+
if (failure) {
|
|
554
|
+
state.pendingFailure = failure;
|
|
555
|
+
state.failChildEnd?.(failureError(failure));
|
|
556
|
+
}
|
|
557
|
+
// NOTE: success does NOT resolve childEndPromise here — pi may run a
|
|
558
|
+
// continuation leg (compaction/overflow recovery) that emits another
|
|
559
|
+
// agent_end. Resolve only on agent_settled, after all legs finish.
|
|
560
|
+
}
|
|
561
|
+
} else if (event.type === "agent_settled") {
|
|
562
|
+
state.childEndResolve?.();
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private async runChild(
|
|
567
|
+
run: RunSnapshot,
|
|
568
|
+
task: TaskSnapshot,
|
|
569
|
+
input: TaskInput,
|
|
570
|
+
ctx: ExtensionContext,
|
|
571
|
+
signal: AbortSignal | undefined,
|
|
572
|
+
onUpdate?: (partial: any) => void,
|
|
573
|
+
): Promise<void> {
|
|
574
|
+
if (TERMINAL.includes(task.status)) return; // canceled while queued
|
|
575
|
+
|
|
576
|
+
// Inline params win; otherwise fall back to an existing agent file
|
|
577
|
+
// (~/.agents, .pi/agents, user dir). Never creates files.
|
|
578
|
+
const prompt = input.prompt?.trim();
|
|
579
|
+
const thinking = input.thinking;
|
|
580
|
+
const baseTools = input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS);
|
|
581
|
+
const tools = [...baseTools, ...(run.allowIntercom ? CHILD_TALK_TOOLS : [])];
|
|
582
|
+
|
|
583
|
+
// Model + thinking resolve against the pi model registry; a bad request
|
|
584
|
+
// fails the TASK with a helpful message, not the whole run.
|
|
585
|
+
let model: Model<Api> | undefined;
|
|
586
|
+
try {
|
|
587
|
+
model = resolveChildModel(ctx, input.model);
|
|
588
|
+
validateThinking(model, thinking);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
this.updateTask(
|
|
591
|
+
run,
|
|
592
|
+
task,
|
|
593
|
+
{
|
|
594
|
+
status: "failed",
|
|
595
|
+
error: err instanceof Error ? err.message : String(err),
|
|
596
|
+
endedAt: Date.now(),
|
|
597
|
+
},
|
|
598
|
+
ctx,
|
|
599
|
+
onUpdate,
|
|
600
|
+
);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
this.updateTask(
|
|
605
|
+
run,
|
|
606
|
+
task,
|
|
607
|
+
{
|
|
608
|
+
status: "starting",
|
|
609
|
+
startedAt: Date.now(),
|
|
610
|
+
// Upstream outputs were spliced in by the scheduler; the snapshot must show
|
|
611
|
+
// the prompt the child actually receives.
|
|
612
|
+
task: input.task,
|
|
613
|
+
model: input.model,
|
|
614
|
+
thinking,
|
|
615
|
+
tools,
|
|
616
|
+
},
|
|
617
|
+
ctx,
|
|
618
|
+
onUpdate,
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
622
|
+
let unsubscribe: (() => void) | undefined;
|
|
623
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
624
|
+
let abortListener: (() => void) | undefined;
|
|
625
|
+
const watchdog = createWatchdog(DEFAULT_STALL_MS, `Subagent ${task.agent}`);
|
|
626
|
+
const childState: ChildEventState = {};
|
|
627
|
+
|
|
628
|
+
const key = `${run.id}:${task.id}`;
|
|
629
|
+
try {
|
|
630
|
+
const subagentInstruction = run.allowIntercom
|
|
631
|
+
? `You are running as a subagent. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets.`
|
|
632
|
+
: "You are running as a subagent. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer for the parent agent.";
|
|
633
|
+
|
|
634
|
+
const loader = new DefaultResourceLoader({
|
|
635
|
+
cwd: task.cwd,
|
|
636
|
+
agentDir: getAgentDir(),
|
|
637
|
+
noExtensions: true,
|
|
638
|
+
appendSystemPromptOverride: (base) => [
|
|
639
|
+
...base,
|
|
640
|
+
[prompt?.trim(), subagentInstruction].filter(Boolean).join("\n\n"),
|
|
641
|
+
],
|
|
642
|
+
});
|
|
643
|
+
await loader.reload();
|
|
644
|
+
|
|
645
|
+
const customTools: ToolDefinition[] = run.allowIntercom
|
|
646
|
+
? createChildTools(task.id, this.makeChildHandlers(run, task, ctx))
|
|
647
|
+
: [];
|
|
648
|
+
|
|
649
|
+
const created = await createAgentSession({
|
|
650
|
+
cwd: task.cwd,
|
|
651
|
+
agentDir: getAgentDir(),
|
|
652
|
+
modelRuntime: await createChildModelRuntime(ctx),
|
|
653
|
+
resourceLoader: loader,
|
|
654
|
+
sessionManager: SessionManager.create(task.cwd, undefined, { parentSession: getParentSessionFile(ctx) }),
|
|
655
|
+
model,
|
|
656
|
+
thinkingLevel: thinking as ThinkingLevel | undefined,
|
|
657
|
+
tools,
|
|
658
|
+
customTools,
|
|
659
|
+
});
|
|
660
|
+
child = created.session;
|
|
661
|
+
child.setSessionName?.(`subagent: ${task.agent}`);
|
|
662
|
+
this.updateTask(
|
|
663
|
+
run,
|
|
664
|
+
task,
|
|
665
|
+
{ status: "running", sessionId: child.sessionId, sessionFile: child.sessionFile },
|
|
666
|
+
ctx,
|
|
667
|
+
onUpdate,
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
const childFailurePromise = new Promise<never>((_, reject) => {
|
|
671
|
+
childState.failChildEnd = reject;
|
|
672
|
+
});
|
|
673
|
+
const childEndPromise = new Promise<void>((resolve) => {
|
|
674
|
+
childState.childEndResolve = resolve;
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
unsubscribe = child.subscribe((event: AgentSessionEvent) =>
|
|
678
|
+
this.onChildEvent(event, run, task, ctx, onUpdate, watchdog, childState),
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
const abortChild = () => {
|
|
682
|
+
void child?.abort();
|
|
683
|
+
this.runControllers.get(run.id)?.abort(); // parent abort kills ALL siblings, not just this child
|
|
684
|
+
};
|
|
685
|
+
const runController = this.runControllers.get(run.id);
|
|
686
|
+
if (signal) signal.addEventListener("abort", abortChild, { once: true });
|
|
687
|
+
if (runController) runController.signal.addEventListener("abort", abortChild, { once: true });
|
|
688
|
+
abortListener = () => {
|
|
689
|
+
signal?.removeEventListener("abort", abortChild);
|
|
690
|
+
runController?.signal.removeEventListener("abort", abortChild);
|
|
691
|
+
};
|
|
692
|
+
// Cancel may have landed during session creation — honor it before prompting.
|
|
693
|
+
if (run.status === "aborted" || TERMINAL.includes(task.status) || signal?.aborted) {
|
|
694
|
+
await child.abort();
|
|
695
|
+
throw new Error("Canceled by subagent_cancel");
|
|
696
|
+
}
|
|
697
|
+
this.liveChildren.set(key, {
|
|
698
|
+
abort: () => void child?.abort(),
|
|
699
|
+
dispose: () => watchdog.dispose(),
|
|
700
|
+
touchWatchdog: () => watchdog.touch(),
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const maxRuntimeMs = input.maxRuntimeMs ?? DEFAULT_RUNTIME_MS;
|
|
704
|
+
const promptPromise = child.prompt(task.task, { source: "extension" });
|
|
705
|
+
const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise, watchdog.promise];
|
|
706
|
+
if (maxRuntimeMs > 0) {
|
|
707
|
+
races.push(
|
|
708
|
+
new Promise<never>((_, reject) => {
|
|
709
|
+
timeout = setTimeout(() => reject(new Error(`Subagent timed out after ${maxRuntimeMs}ms`)), maxRuntimeMs);
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
await Promise.race(races);
|
|
714
|
+
if (timeout) clearTimeout(timeout);
|
|
715
|
+
|
|
716
|
+
childState.pendingFailure ??= lastAssistantFailure(child.messages as AssistantMessage[]);
|
|
717
|
+
if (childState.pendingFailure) throw failureError(childState.pendingFailure);
|
|
718
|
+
|
|
719
|
+
const finalText =
|
|
720
|
+
task.finalText ||
|
|
721
|
+
truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
|
|
722
|
+
if (task.status !== "aborted") {
|
|
723
|
+
this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
|
|
724
|
+
}
|
|
725
|
+
} catch (err) {
|
|
726
|
+
if (timeout) clearTimeout(timeout);
|
|
727
|
+
// Cancel is authoritative: parent tool signal OR run/task already marked aborted.
|
|
728
|
+
const aborted = signal?.aborted || run.status === "aborted" || task.status === "aborted";
|
|
729
|
+
const subagentStatus = (err as Error & { subagentStatus?: string })?.subagentStatus;
|
|
730
|
+
try {
|
|
731
|
+
// Unblock a child stuck in ask_parent, then time-box the abort so a
|
|
732
|
+
// wedged session can never hang this catch/finally.
|
|
733
|
+
this.pendingReplies.get(key)?.resolve("(parent unreachable)");
|
|
734
|
+
await Promise.race([child?.abort(), new Promise((r) => setTimeout(r, 5000))]);
|
|
735
|
+
} catch {
|
|
736
|
+
/* ignore */
|
|
737
|
+
}
|
|
738
|
+
this.updateTask(
|
|
739
|
+
run,
|
|
740
|
+
task,
|
|
741
|
+
{
|
|
742
|
+
status: aborted ? "aborted" : ((subagentStatus as TaskStatus) ?? "failed"),
|
|
743
|
+
error: err instanceof Error ? err.message : String(err),
|
|
744
|
+
endedAt: Date.now(),
|
|
745
|
+
},
|
|
746
|
+
ctx,
|
|
747
|
+
onUpdate,
|
|
748
|
+
);
|
|
749
|
+
} finally {
|
|
750
|
+
this.liveChildren.delete(key);
|
|
751
|
+
this.pendingReplies.delete(key);
|
|
752
|
+
abortListener?.();
|
|
753
|
+
unsubscribe?.();
|
|
754
|
+
watchdog.dispose();
|
|
755
|
+
if (timeout) clearTimeout(timeout);
|
|
756
|
+
child?.dispose();
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ── run lifecycle ───────────────────────────────────────────────────
|
|
761
|
+
createRun(params: SubagentParamsShape, ctx: ExtensionContext): { run: RunSnapshot; inputs: TaskInput[] } {
|
|
762
|
+
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
763
|
+
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
764
|
+
const hasSingle = Boolean(params.agent && params.task);
|
|
765
|
+
if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) {
|
|
766
|
+
throw new Error(`Provide exactly one subagent mode (single, tasks, or chain).`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const mode: RunMode = hasChain ? "chain" : hasTasks ? "parallel" : "single";
|
|
770
|
+
const inputs: TaskInput[] = hasSingle
|
|
771
|
+
? [
|
|
772
|
+
{
|
|
773
|
+
agent: params.agent as string,
|
|
774
|
+
task: params.task as string,
|
|
775
|
+
prompt: params.prompt,
|
|
776
|
+
write: params.write,
|
|
777
|
+
model: params.model,
|
|
778
|
+
thinking: params.thinking,
|
|
779
|
+
cwd: params.cwd,
|
|
780
|
+
tools: params.tools,
|
|
781
|
+
maxRuntimeMs: params.maxRuntimeMs,
|
|
782
|
+
},
|
|
783
|
+
]
|
|
784
|
+
: hasTasks
|
|
785
|
+
? params.tasks!
|
|
786
|
+
: params.chain!;
|
|
787
|
+
if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
|
|
788
|
+
const ids = new Set<string>();
|
|
789
|
+
for (const input of inputs) {
|
|
790
|
+
if (input.id !== undefined) {
|
|
791
|
+
if (ids.has(input.id)) throw new Error(`Duplicate task id: ${input.id}`);
|
|
792
|
+
ids.add(input.id);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const edges = resolveNeeds(inputs, mode);
|
|
796
|
+
|
|
797
|
+
const run: RunSnapshot = {
|
|
798
|
+
id: newId("run"),
|
|
799
|
+
mode,
|
|
800
|
+
status: "queued",
|
|
801
|
+
background: Boolean(params.background),
|
|
802
|
+
allowIntercom: Boolean(params.allowIntercom),
|
|
803
|
+
notifyPerTask: params.notifyPerTask ?? false,
|
|
804
|
+
createdAt: Date.now(),
|
|
805
|
+
concurrency: Math.max(1, Math.min(params.concurrency ?? DEFAULT_CONCURRENCY, MAX_CONCURRENCY)),
|
|
806
|
+
tasks: inputs.map((input, index) => ({
|
|
807
|
+
id: input.id ?? `task_${index + 1}`,
|
|
808
|
+
runId: "",
|
|
809
|
+
agent: input.agent,
|
|
810
|
+
task: input.task,
|
|
811
|
+
cwd: input.cwd ?? ctx.cwd,
|
|
812
|
+
status: "queued" as TaskStatus,
|
|
813
|
+
needs: edges[index],
|
|
814
|
+
model: input.model,
|
|
815
|
+
thinking: input.thinking,
|
|
816
|
+
tools: input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS),
|
|
817
|
+
toolCalls: 0,
|
|
818
|
+
usage: emptyUsage(),
|
|
819
|
+
})),
|
|
820
|
+
aggregateUsage: emptyUsage(),
|
|
821
|
+
};
|
|
822
|
+
// Roster: each child learns its own address + sibling addresses so
|
|
823
|
+
// send_agent_message/poll_agent_messages can be used reliably.
|
|
824
|
+
const roster = run.tasks.map((t) => `${t.id} (${t.agent})`).join(", ");
|
|
825
|
+
for (const task of run.tasks) {
|
|
826
|
+
task.roster = roster;
|
|
827
|
+
}
|
|
828
|
+
run.tasks.forEach((t) => {
|
|
829
|
+
t.runId = run.id;
|
|
830
|
+
});
|
|
831
|
+
this.turnActivity = true;
|
|
832
|
+
this.runs.set(run.id, run);
|
|
833
|
+
this.settlers.set(run.id, () => {});
|
|
834
|
+
this.runControllers.set(run.id, new AbortController());
|
|
835
|
+
for (const task of run.tasks) this.mailboxes.open(`${run.id}:${task.id}`);
|
|
836
|
+
this.emit("subagent:run-created", { run: cloneRun(run) });
|
|
837
|
+
return { run, inputs };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private async executeTasks(
|
|
841
|
+
run: RunSnapshot,
|
|
842
|
+
inputs: TaskInput[],
|
|
843
|
+
ctx: ExtensionContext,
|
|
844
|
+
signal: AbortSignal | undefined,
|
|
845
|
+
onUpdate?: (partial: any) => void,
|
|
846
|
+
): Promise<void> {
|
|
847
|
+
run.status = "running";
|
|
848
|
+
run.startedAt = Date.now();
|
|
849
|
+
this.updateRun(run, ctx, onUpdate);
|
|
850
|
+
|
|
851
|
+
// One wave scheduler for every mode. A wave is the set of tasks whose needs
|
|
852
|
+
// are all satisfied; the loop boundary between waves IS the gate. Chain mode
|
|
853
|
+
// reaches here as needs: [previous], so it needs no special case.
|
|
854
|
+
const outputs = new Map<string, string>();
|
|
855
|
+
const settled = new Set<string>();
|
|
856
|
+
for (const task of run.tasks) {
|
|
857
|
+
if (TERMINAL.includes(task.status)) settled.add(task.id); // canceled before start
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const { skipped } = await runWaveScheduler(
|
|
861
|
+
run.tasks.filter((t) => !TERMINAL.includes(t.status)),
|
|
862
|
+
run.mode === "single" ? 1 : run.concurrency,
|
|
863
|
+
outputs,
|
|
864
|
+
settled,
|
|
865
|
+
async (task, index) => {
|
|
866
|
+
const input = inputs[index]!;
|
|
867
|
+
await this.runChild(
|
|
868
|
+
run,
|
|
869
|
+
task,
|
|
870
|
+
{ ...input, task: applyUpstream(input.task, task.needs ?? [], outputs) },
|
|
871
|
+
ctx,
|
|
872
|
+
signal,
|
|
873
|
+
onUpdate,
|
|
874
|
+
);
|
|
875
|
+
if (task.status === "completed") outputs.set(task.id, task.finalText ?? "");
|
|
876
|
+
if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
|
|
877
|
+
this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
|
|
878
|
+
}
|
|
879
|
+
},
|
|
880
|
+
);
|
|
881
|
+
// Broken-upstream tasks are detected by the scheduler; mark them after the wave.
|
|
882
|
+
for (const s of skipped) {
|
|
883
|
+
const task = run.tasks.find((t) => t.id === s.id);
|
|
884
|
+
if (task) {
|
|
885
|
+
this.updateTask(
|
|
886
|
+
run,
|
|
887
|
+
task,
|
|
888
|
+
{
|
|
889
|
+
status: "aborted",
|
|
890
|
+
error: `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
|
|
891
|
+
endedAt: Date.now(),
|
|
892
|
+
},
|
|
893
|
+
ctx,
|
|
894
|
+
onUpdate,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const failed = run.tasks.some((t) => t.status === "failed");
|
|
900
|
+
const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
|
|
901
|
+
run.status = aborted ? "aborted" : failed ? "failed" : "completed";
|
|
902
|
+
run.endedAt = Date.now();
|
|
903
|
+
this.flushWidget(run, ctx, onUpdate);
|
|
904
|
+
// Finished runs (including aborted ones) stay on screen so the outcome is readable.
|
|
905
|
+
// The agent_start handler clears them on the next turn that spawns nothing.
|
|
906
|
+
const live = this.listRuns().find((r) => !TERMINAL.includes(r.status));
|
|
907
|
+
if (live) this.scheduleWidget(live, ctx);
|
|
908
|
+
// L7: cancelRun already emitted + settled — don't double-report.
|
|
909
|
+
if (this.settlers.has(run.id)) {
|
|
910
|
+
this.emit("subagent:run-completed", {
|
|
911
|
+
runId: run.id,
|
|
912
|
+
status: run.status,
|
|
913
|
+
run: cloneRun(run),
|
|
914
|
+
aggregateUsage: run.aggregateUsage,
|
|
915
|
+
});
|
|
916
|
+
this.settleRun(run.id, run);
|
|
917
|
+
}
|
|
918
|
+
this.runControllers.delete(run.id);
|
|
919
|
+
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
920
|
+
this.persist(ctx);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async runBlocking(
|
|
924
|
+
params: SubagentParamsShape,
|
|
925
|
+
signal: AbortSignal | undefined,
|
|
926
|
+
onUpdate: ((partial: any) => void) | undefined,
|
|
927
|
+
ctx: ExtensionContext,
|
|
928
|
+
): Promise<RunDetails> {
|
|
929
|
+
const { run, inputs } = this.createRun(params, ctx);
|
|
930
|
+
await this.executeTasks(run, inputs, ctx, signal, onUpdate);
|
|
931
|
+
return { run: cloneRun(run) };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
startInBackground(params: SubagentParamsShape, ctx: ExtensionContext): RunDetails {
|
|
935
|
+
const { run, inputs } = this.createRun(params, ctx);
|
|
936
|
+
void this.executeTasks(run, inputs, ctx, undefined, undefined)
|
|
937
|
+
.then(() => {
|
|
938
|
+
this.notifyParent(
|
|
939
|
+
run,
|
|
940
|
+
run.status === "completed" ? "completed" : run.status === "aborted" ? "aborted" : "failed",
|
|
941
|
+
);
|
|
942
|
+
})
|
|
943
|
+
.catch((err) => {
|
|
944
|
+
// Never leave a background run unsettled: mark failed, settle, notify.
|
|
945
|
+
run.status = "failed";
|
|
946
|
+
run.endedAt = Date.now();
|
|
947
|
+
for (const task of run.tasks) {
|
|
948
|
+
if (!TERMINAL.includes(task.status)) {
|
|
949
|
+
task.status = "failed";
|
|
950
|
+
task.error = task.error || String(err instanceof Error ? err.message : err);
|
|
951
|
+
task.endedAt = Date.now();
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
this.settleRun(run.id, run);
|
|
955
|
+
this.runControllers.delete(run.id);
|
|
956
|
+
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
957
|
+
this.emit("subagent:run-completed", { runId: run.id, status: "failed", run: cloneRun(run) });
|
|
958
|
+
this.notifyParent(run, "failed");
|
|
959
|
+
this.persist(ctx);
|
|
960
|
+
});
|
|
961
|
+
return { run: cloneRun(run), background: true };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Abort ONE task; siblings keep running. Returns false when unknown or already finished. */
|
|
965
|
+
cancelTask(runId: string, taskId: string, ctx?: ExtensionContext): boolean {
|
|
966
|
+
const run = this.runs.get(runId);
|
|
967
|
+
const task = run?.tasks.find((t) => t.id === taskId);
|
|
968
|
+
if (!run || !task || TERMINAL.includes(task.status)) return false;
|
|
969
|
+
// Mark first: runChild's catch reads task.status to classify the outcome as aborted.
|
|
970
|
+
task.status = "aborted";
|
|
971
|
+
task.error = task.error || "Canceled from peek";
|
|
972
|
+
task.endedAt = Date.now();
|
|
973
|
+
this.liveChildren.get(`${runId}:${taskId}`)?.abort();
|
|
974
|
+
this.mailboxes.close(`${runId}:${taskId}`);
|
|
975
|
+
if (ctx) this.flushWidget(run, ctx);
|
|
976
|
+
this.emit("subagent:task-aborted", { runId, taskId });
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
cancelRun(runId: string): { aborted: number } {
|
|
981
|
+
const run = this.runs.get(runId);
|
|
982
|
+
if (!run) return { aborted: 0 };
|
|
983
|
+
if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
|
|
984
|
+
let aborted = 0;
|
|
985
|
+
this.runControllers.get(runId)?.abort();
|
|
986
|
+
for (const [key, child] of this.liveChildren) {
|
|
987
|
+
if (key.startsWith(`${runId}:`)) {
|
|
988
|
+
child.abort();
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
for (const task of run.tasks) {
|
|
992
|
+
if (TERMINAL.includes(task.status)) continue;
|
|
993
|
+
task.status = "aborted";
|
|
994
|
+
task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
|
|
995
|
+
task.endedAt = Date.now();
|
|
996
|
+
aborted += 1;
|
|
997
|
+
}
|
|
998
|
+
run.status = "aborted";
|
|
999
|
+
run.endedAt = Date.now();
|
|
1000
|
+
this.settleRun(runId, run);
|
|
1001
|
+
this.runControllers.delete(runId);
|
|
1002
|
+
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
1003
|
+
this.emit("subagent:run-completed", { runId: run.id, status: "aborted", run: cloneRun(run) });
|
|
1004
|
+
return { aborted };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** Settle-and-delete: awaiters resolve once; no leak, no closure chain. */
|
|
1008
|
+
private settleRun(runId: string, run: RunSnapshot): void {
|
|
1009
|
+
const s = this.settlers.get(runId);
|
|
1010
|
+
if (!s) return;
|
|
1011
|
+
this.settlers.delete(runId);
|
|
1012
|
+
s(cloneRun(run));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
awaitRun(runId: string, timeoutMs?: number): Promise<RunSnapshot | undefined> {
|
|
1016
|
+
const run = this.runs.get(runId);
|
|
1017
|
+
if (!run) return Promise.resolve(undefined);
|
|
1018
|
+
if (TERMINAL.includes(run.status)) {
|
|
1019
|
+
run.awaited = true;
|
|
1020
|
+
return Promise.resolve(cloneRun(run));
|
|
1021
|
+
}
|
|
1022
|
+
const settled = new Promise<RunSnapshot | undefined>((resolve) => {
|
|
1023
|
+
const prev = this.settlers.get(runId);
|
|
1024
|
+
this.settlers.set(runId, (r) => {
|
|
1025
|
+
prev?.(r);
|
|
1026
|
+
resolve(r);
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
if (timeoutMs) {
|
|
1030
|
+
return Promise.race([
|
|
1031
|
+
settled.then((r) => {
|
|
1032
|
+
// settled won: the parent got the real result — suppress the notice.
|
|
1033
|
+
run.awaited = true;
|
|
1034
|
+
return r;
|
|
1035
|
+
}),
|
|
1036
|
+
new Promise<RunSnapshot | undefined>((resolve) => {
|
|
1037
|
+
const timer = setTimeout(
|
|
1038
|
+
() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined),
|
|
1039
|
+
timeoutMs,
|
|
1040
|
+
);
|
|
1041
|
+
settled.then(() => clearTimeout(timer));
|
|
1042
|
+
}),
|
|
1043
|
+
]);
|
|
1044
|
+
}
|
|
1045
|
+
// Awaiting to completion: parent gets the real result, so suppress the
|
|
1046
|
+
// completion notice. On timeout we resolve a snapshot and leave awaited
|
|
1047
|
+
// unset, so the parent still receives the completion notification.
|
|
1048
|
+
run.awaited = true;
|
|
1049
|
+
return settled;
|
|
1050
|
+
}
|
|
1051
|
+
}
|