@arhen/pi-core-subagent 1.3.48 → 1.3.50
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 +6 -7
- package/package.json +1 -1
- package/src/agentfile.ts +4 -41
- package/src/child.ts +2 -17
- package/src/format.ts +15 -60
- package/src/graph.ts +7 -36
- package/src/index.ts +18 -58
- package/src/mailbox.ts +0 -7
- package/src/manager.ts +97 -346
- package/src/peek.ts +13 -69
- package/src/schemas.ts +1 -4
- package/src/types.ts +1 -18
- package/src/worktree.ts +37 -164
package/src/manager.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
|
|
2
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
3
2
|
import { rename, rm, writeFile } from "node:fs/promises";
|
|
4
3
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
@@ -57,34 +56,16 @@ import {
|
|
|
57
56
|
|
|
58
57
|
export const DEFAULT_CONCURRENCY = 3;
|
|
59
58
|
export const MAX_CONCURRENCY = 8;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* There used to be a second one, an event-heartbeat "stall" watchdog. It was
|
|
64
|
-
* deleted: it was armed before the child session even existed, so the only
|
|
65
|
-
* window it could fire in was a slow startup (where firing is always wrong),
|
|
66
|
-
* and once events flowed it could never fire at all. Every observed firing
|
|
67
|
-
* across three releases was a false kill. A wedged child that emits events was
|
|
68
|
-
* always bounded by this cap alone; nothing else changed by removing it.
|
|
69
|
-
*/
|
|
70
|
-
const DEFAULT_RUNTIME_MS = 3_600_000; // 1 h
|
|
71
|
-
/** "Unlimited" still has a ceiling — an unbounded child pins hasActiveRun() and
|
|
72
|
-
* its concurrency slot for the life of the session. */
|
|
73
|
-
const UNLIMITED_RUNTIME_MS = 21_600_000; // 6 h
|
|
74
|
-
/** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
|
|
75
|
-
const PARENT_REPLY_TIMEOUT_MS = 600_000; // 10 min
|
|
76
|
-
/** Intercom messages buffered per park before the followUp path takes over. */
|
|
59
|
+
const DEFAULT_RUNTIME_MS = 3_600_000;
|
|
60
|
+
const UNLIMITED_RUNTIME_MS = 21_600_000;
|
|
61
|
+
const PARENT_REPLY_TIMEOUT_MS = 600_000;
|
|
77
62
|
const PARKED_MSG_CAP = 24;
|
|
78
63
|
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
79
64
|
const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
80
|
-
/** Tools that can mutate the tree — their presence is what earns a worktree. */
|
|
81
65
|
const WRITE_CAPABLE = ["bash", "edit", "write"];
|
|
82
|
-
/** Task ids become git refs + filesystem paths. */
|
|
83
66
|
const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
84
67
|
const WIDGET_THROTTLE_MS = 150;
|
|
85
68
|
|
|
86
|
-
// ── helpers ──────────────────────────────────────────────────────────────
|
|
87
|
-
|
|
88
69
|
function newId(prefix: string): string {
|
|
89
70
|
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
90
71
|
}
|
|
@@ -103,7 +84,6 @@ function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
|
|
|
103
84
|
}
|
|
104
85
|
return total;
|
|
105
86
|
}
|
|
106
|
-
/** realpath when possible; the raw path otherwise (cwd may not exist yet). */
|
|
107
87
|
function safeRealPath(path: string): string {
|
|
108
88
|
try {
|
|
109
89
|
return realpathSync(path);
|
|
@@ -118,10 +98,6 @@ function getParentSessionFile(ctx: ExtensionContext): string | undefined {
|
|
|
118
98
|
return undefined;
|
|
119
99
|
}
|
|
120
100
|
}
|
|
121
|
-
/**
|
|
122
|
-
* pi 0.84 StopReason enum: "stop" is NORMAL completion (was "end" in older pi).
|
|
123
|
-
* Only length/error/aborted/deferred/pending/toolUse-as-final are failures.
|
|
124
|
-
*/
|
|
125
101
|
export function classifyFailure(
|
|
126
102
|
stopReason: string | undefined,
|
|
127
103
|
errorMessage?: string,
|
|
@@ -159,20 +135,10 @@ function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage):
|
|
|
159
135
|
export function cloneRun(run: RunSnapshot): RunSnapshot {
|
|
160
136
|
return JSON.parse(JSON.stringify(run)) as RunSnapshot;
|
|
161
137
|
}
|
|
162
|
-
/** Resolve a child model from the pi model registry.
|
|
163
|
-
* Order: explicit "provider/model-id" or bare id → agent file model → parent's
|
|
164
|
-
* current model (ctx.model) → undefined (createAgentSession falls back to settings).
|
|
165
|
-
*
|
|
166
|
-
* A BARE id is ambiguous: `claude-sonnet-5` exists on anthropic, commandcode,
|
|
167
|
-
* github-copilot and openrouter at once, and an agent file's `model:` is written
|
|
168
|
-
* without a provider. Taking the registry's first match routed children to a
|
|
169
|
-
* provider the session never chose (403 MODEL_NOT_IN_PLAN on every spawn), so the
|
|
170
|
-
* ACTIVE SESSION's provider is searched first — exact id, then its own prefixed
|
|
171
|
-
* form (9router carries `cc/claude-opus-5`, not `claude-opus-5`). */
|
|
172
138
|
export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
|
|
173
|
-
if (!explicit?.trim()) return ctx.model;
|
|
139
|
+
if (!explicit?.trim()) return ctx.model;
|
|
174
140
|
const ref = explicit.trim();
|
|
175
|
-
if (!ctx.modelRegistry) return ctx.model;
|
|
141
|
+
if (!ctx.modelRegistry) return ctx.model;
|
|
176
142
|
const available = ctx.modelRegistry.getAvailable();
|
|
177
143
|
const sessionProvider = ctx.model?.provider;
|
|
178
144
|
if (sessionProvider && !ref.includes("/")) {
|
|
@@ -180,8 +146,7 @@ export function resolveChildModel(ctx: ExtensionContext, explicit: string | unde
|
|
|
180
146
|
const hit = own.find((m) => m.id === ref) ?? own.find((m) => m.id.endsWith(`/${ref}`));
|
|
181
147
|
if (hit) return hit;
|
|
182
148
|
}
|
|
183
|
-
|
|
184
|
-
// match and every provider/id split point must be tried, not just the first.
|
|
149
|
+
|
|
185
150
|
const byId = available.find((m) => m.id === ref);
|
|
186
151
|
if (byId) return byId;
|
|
187
152
|
for (let slash = ref.indexOf("/"); slash > 0; slash = ref.indexOf("/", slash + 1)) {
|
|
@@ -191,11 +156,6 @@ export function resolveChildModel(ctx: ExtensionContext, explicit: string | unde
|
|
|
191
156
|
throw new Error(`Model not found: ${ref}`);
|
|
192
157
|
}
|
|
193
158
|
|
|
194
|
-
/** One throwaway request against the resolved model. A child that cannot reach its
|
|
195
|
-
* model dies on its FIRST turn with no output, after a worktree and a session have
|
|
196
|
-
* already been built — and an agent file's `model:` is chosen by a file the leader
|
|
197
|
-
* never wrote, so "it resolved" is not evidence it is usable (plan gates, ZDR,
|
|
198
|
-
* dead keys all pass resolution). Returns the error text, or undefined when OK. */
|
|
199
159
|
async function probeModel(
|
|
200
160
|
ctx: ExtensionContext,
|
|
201
161
|
model: Model<Api>,
|
|
@@ -213,9 +173,6 @@ async function probeModel(
|
|
|
213
173
|
}
|
|
214
174
|
}
|
|
215
175
|
|
|
216
|
-
/** Preflight for the model a child is about to run on. Probes only what is not
|
|
217
|
-
* already proven: the session's own model answered this very turn. On failure the
|
|
218
|
-
* session model is the fallback — the one model known to work right now. */
|
|
219
176
|
export async function ensureUsableModel(
|
|
220
177
|
ctx: ExtensionContext,
|
|
221
178
|
model: Model<Api> | undefined,
|
|
@@ -233,12 +190,9 @@ export async function ensureUsableModel(
|
|
|
233
190
|
};
|
|
234
191
|
}
|
|
235
192
|
|
|
236
|
-
/** Extension-registered providers (e.g. 9router) live only in the parent's
|
|
237
|
-
* in-memory runtime. A child builds its runtime from disk and would lose them,
|
|
238
|
-
* so replay the parent's registrations before the child resolves auth. */
|
|
239
193
|
async function createChildModelRuntime(ctx: ExtensionContext) {
|
|
240
194
|
const ids = ctx.modelRegistry.getRegisteredProviderIds?.() ?? [];
|
|
241
|
-
if (ids.length === 0) return undefined;
|
|
195
|
+
if (ids.length === 0) return undefined;
|
|
242
196
|
const agentDir = getAgentDir();
|
|
243
197
|
const runtime = await ModelRuntime.create({
|
|
244
198
|
authPath: join(agentDir, "auth.json"),
|
|
@@ -257,9 +211,6 @@ async function createChildModelRuntime(ctx: ExtensionContext) {
|
|
|
257
211
|
return runtime;
|
|
258
212
|
}
|
|
259
213
|
|
|
260
|
-
/** Validate a thinking level against the RESOLVED model's registry entry.
|
|
261
|
-
* thinkingLevelMap: null = unsupported, missing key = provider default,
|
|
262
|
-
* absent map = provider defaults. Non-reasoning models only accept "off". */
|
|
263
214
|
export function validateThinking(model: Model<Api> | undefined, level: string | undefined): void {
|
|
264
215
|
if (!level || level === "off") return;
|
|
265
216
|
if (!model) return;
|
|
@@ -275,9 +226,6 @@ export function validateThinking(model: Model<Api> | undefined, level: string |
|
|
|
275
226
|
}
|
|
276
227
|
}
|
|
277
228
|
|
|
278
|
-
// Cached catalog removed: agents are defined inline by the leader per call,
|
|
279
|
-
// so there is nothing to inject into the parent context. Zero per-request cost.
|
|
280
|
-
|
|
281
229
|
interface ChildEventState {
|
|
282
230
|
pendingFailure?: ReturnType<typeof classifyFailure>;
|
|
283
231
|
failChildEnd?: (error: Error) => void;
|
|
@@ -293,9 +241,7 @@ export interface ParkedMsg {
|
|
|
293
241
|
|
|
294
242
|
export class SubagentManager {
|
|
295
243
|
private runs = new Map<string, RunSnapshot>();
|
|
296
|
-
/** Runs that are still settleable (presence = not yet settled). */
|
|
297
244
|
private settlers = new Map<string, true>();
|
|
298
|
-
/** Everyone parked on a run — a set, so re-parking can't build a closure chain. */
|
|
299
245
|
private settleWaiters = new Map<string, Set<(run: RunSnapshot) => void>>();
|
|
300
246
|
private pendingReplies = new Map<string, PendingReply>();
|
|
301
247
|
private liveChildren = new Map<
|
|
@@ -303,25 +249,17 @@ export class SubagentManager {
|
|
|
303
249
|
{ abort: () => void; dispose: () => void; steer: (message: string) => void }
|
|
304
250
|
>();
|
|
305
251
|
private mailboxes: Mailbox = createMailbox();
|
|
306
|
-
/** Live worktrees by `${runId}:${taskId}` — lets cancel drop dirs and keeps
|
|
307
|
-
* cleanup from touching a branch that a running child owns. */
|
|
308
252
|
private liveWorktrees = new Map<string, Worktree>();
|
|
309
253
|
private runControllers = new Map<string, AbortController>();
|
|
310
|
-
private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
254
|
+
private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
311
255
|
private widgetRuns: RunSnapshot[] = [];
|
|
312
256
|
private eventSeq = 0;
|
|
313
|
-
/** Sidecar writes are serialized on this chain — ordering the DECISION isn't
|
|
314
|
-
* enough, two renames in flight can still land out of order. */
|
|
315
257
|
private persistSeq = 0;
|
|
316
258
|
private persistedSeq = 0;
|
|
317
259
|
private persistChain: Promise<unknown> = Promise.resolve();
|
|
318
|
-
/** Distinguishes managers sharing a pid (tests, SDK hosts with two sessions)
|
|
319
|
-
* so their tmp paths can't collide. */
|
|
320
260
|
private readonly instanceNonce = Math.random().toString(36).slice(2, 8);
|
|
321
|
-
/** Set by clearRuns — blocks late persists from erasing the sidecar. */
|
|
322
261
|
private cleared = false;
|
|
323
262
|
|
|
324
|
-
/** When true, tasks without an explicit maxRuntimeMs get the 1 h default ceiling; when false (default) the raised 6 h ceiling applies — toggle via `/subagents auto-limit on|off`. */
|
|
325
263
|
private autoLimit = false;
|
|
326
264
|
|
|
327
265
|
turnActivity = false;
|
|
@@ -330,12 +268,9 @@ export class SubagentManager {
|
|
|
330
268
|
try {
|
|
331
269
|
const cfg = JSON.parse(readFileSync(join(getAgentDir(), "subagents-config.json"), "utf8"));
|
|
332
270
|
if (typeof cfg.autoLimit === "boolean") this.autoLimit = cfg.autoLimit;
|
|
333
|
-
} catch {
|
|
334
|
-
/* no config yet — defaults */
|
|
335
|
-
}
|
|
271
|
+
} catch {}
|
|
336
272
|
}
|
|
337
273
|
|
|
338
|
-
/** Flip the auto-limit flag; persists to the agent dir. Returns the new value. */
|
|
339
274
|
setAutoLimit(on: boolean): boolean {
|
|
340
275
|
this.autoLimit = on;
|
|
341
276
|
void writeFile(join(getAgentDir(), "subagents-config.json"), JSON.stringify({ autoLimit: on }, null, 2)).catch(
|
|
@@ -348,7 +283,6 @@ export class SubagentManager {
|
|
|
348
283
|
return this.autoLimit;
|
|
349
284
|
}
|
|
350
285
|
|
|
351
|
-
/** Any run still has queued/running tasks? */
|
|
352
286
|
hasActiveRun(): boolean {
|
|
353
287
|
for (const run of this.runs.values()) {
|
|
354
288
|
if (run.tasks.some((t) => !TERMINAL.includes(t.status))) return true;
|
|
@@ -356,16 +290,13 @@ export class SubagentManager {
|
|
|
356
290
|
return false;
|
|
357
291
|
}
|
|
358
292
|
|
|
359
|
-
/** Hide the widget + clear the footer status entry. */
|
|
360
293
|
clearWidget(ctx: ExtensionContext): void {
|
|
361
294
|
this.widgetRuns = [];
|
|
362
295
|
this.widgetTui = null;
|
|
363
296
|
if (ctx.hasUI) {
|
|
364
297
|
try {
|
|
365
298
|
ctx.ui.setWidget("subagents", undefined);
|
|
366
|
-
} catch {
|
|
367
|
-
/* ignore */
|
|
368
|
-
}
|
|
299
|
+
} catch {}
|
|
369
300
|
}
|
|
370
301
|
}
|
|
371
302
|
|
|
@@ -381,10 +312,7 @@ export class SubagentManager {
|
|
|
381
312
|
child.dispose();
|
|
382
313
|
}
|
|
383
314
|
this.liveChildren.clear();
|
|
384
|
-
|
|
385
|
-
// resumed onAskParent closure re-checks status, and a still-awaiting task
|
|
386
|
-
// would flip back to "running" and re-insert the run after the maps clear —
|
|
387
|
-
// a ghost run that widgets re-arm on and persist forever.
|
|
315
|
+
|
|
388
316
|
for (const run of this.runs.values()) {
|
|
389
317
|
for (const task of run.tasks) {
|
|
390
318
|
if (!TERMINAL.includes(task.status)) {
|
|
@@ -393,8 +321,7 @@ export class SubagentManager {
|
|
|
393
321
|
}
|
|
394
322
|
}
|
|
395
323
|
}
|
|
396
|
-
|
|
397
|
-
// leave their promises pending forever (autoAwait / await_subagent hang).
|
|
324
|
+
|
|
398
325
|
for (const [runId, waiters] of this.settleWaiters) {
|
|
399
326
|
const run = this.runs.get(runId);
|
|
400
327
|
for (const waiter of waiters) waiter(run ? cloneRun(run) : ({ id: runId, status: "aborted" } as RunSnapshot));
|
|
@@ -403,17 +330,16 @@ export class SubagentManager {
|
|
|
403
330
|
pending.resolve("(session ended — stop work immediately)");
|
|
404
331
|
}
|
|
405
332
|
this.parked.clear();
|
|
406
|
-
|
|
407
|
-
// keep branch, drop dir) once this pid is gone.
|
|
333
|
+
|
|
408
334
|
this.liveWorktrees.clear();
|
|
409
335
|
this.runs.clear();
|
|
410
336
|
this.settlers.clear();
|
|
411
337
|
this.settleWaiters.clear();
|
|
412
338
|
this.pendingReplies.clear();
|
|
413
339
|
this.runControllers.clear();
|
|
414
|
-
this.cleared = true;
|
|
340
|
+
this.cleared = true;
|
|
415
341
|
this.mailboxes = createMailbox();
|
|
416
|
-
this.widgetTui = null;
|
|
342
|
+
this.widgetTui = null;
|
|
417
343
|
if (this.pulseTimer) {
|
|
418
344
|
clearTimeout(this.pulseTimer);
|
|
419
345
|
this.pulseTimer = null;
|
|
@@ -423,31 +349,27 @@ export class SubagentManager {
|
|
|
423
349
|
this.widgetRuns = [];
|
|
424
350
|
}
|
|
425
351
|
|
|
426
|
-
// ── persistence (sidecar per parent session) ────────────────────────
|
|
427
352
|
async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
|
|
428
|
-
this.cleared = false;
|
|
353
|
+
this.cleared = false;
|
|
429
354
|
const parentFile = getParentSessionFile(ctx);
|
|
430
355
|
if (!parentFile) return;
|
|
431
356
|
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
432
357
|
let runs: RunSnapshot[];
|
|
433
|
-
|
|
358
|
+
|
|
434
359
|
try {
|
|
435
360
|
const dir = dirname(sidecar);
|
|
436
361
|
const prefix = `${basename(sidecar)}.`;
|
|
437
362
|
for (const entry of readdirSync(dir)) {
|
|
438
363
|
if (entry.startsWith(prefix) && entry.endsWith(".tmp")) rmSync(join(dir, entry), { force: true });
|
|
439
364
|
}
|
|
440
|
-
} catch {
|
|
441
|
-
/* best-effort */
|
|
442
|
-
}
|
|
365
|
+
} catch {}
|
|
443
366
|
try {
|
|
444
367
|
if (!existsSync(sidecar)) return;
|
|
445
368
|
const raw = JSON.parse(readFileSync(sidecar, "utf-8"));
|
|
446
369
|
if (!Array.isArray(raw)) return;
|
|
447
370
|
runs = (raw as RunSnapshot[]).map((run) => {
|
|
448
371
|
const interrupted = run.tasks.some((t) => !TERMINAL.includes(t.status));
|
|
449
|
-
|
|
450
|
-
// task end and run end) must not stay "running" forever.
|
|
372
|
+
|
|
451
373
|
let status = interrupted ? ("aborted" as RunStatus) : run.status;
|
|
452
374
|
if (!TERMINAL.includes(status)) {
|
|
453
375
|
const anyFailed = run.tasks.some((t) => t.status === "failed");
|
|
@@ -480,93 +402,67 @@ export class SubagentManager {
|
|
|
480
402
|
}
|
|
481
403
|
}
|
|
482
404
|
private persist(ctx: ExtensionContext): void {
|
|
483
|
-
// After clearRuns the map is empty by design; a late persist (e.g. the
|
|
484
|
-
// background rejection handler firing after session_shutdown) would write
|
|
485
|
-
// `[]` over a good sidecar and erase the session's history.
|
|
486
405
|
if (this.cleared) return;
|
|
487
406
|
try {
|
|
488
407
|
const parentFile = getParentSessionFile(ctx);
|
|
489
408
|
if (!parentFile) return;
|
|
490
409
|
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
491
|
-
|
|
492
|
-
// ALL run history for the session on the next read. The tmp name must be
|
|
493
|
-
// UNIQUE per write — a shared one lets two concurrent persists interleave
|
|
494
|
-
// their bytes (unparseable sidecar) or rename an older snapshot last.
|
|
410
|
+
|
|
495
411
|
const seq = ++this.persistSeq;
|
|
496
412
|
const tmp = `${sidecar}.${process.pid}.${this.instanceNonce}.${seq}.tmp`;
|
|
497
413
|
const payload = JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2);
|
|
498
|
-
|
|
499
|
-
// renames can never be in flight and land out of order.
|
|
414
|
+
|
|
500
415
|
this.persistChain = this.persistChain.then(async () => {
|
|
501
|
-
// A newer snapshot already landed — this one is stale, don't write it.
|
|
502
416
|
if (seq < this.persistedSeq) return;
|
|
503
417
|
try {
|
|
504
418
|
await writeFile(tmp, payload);
|
|
505
419
|
await rename(tmp, sidecar);
|
|
506
420
|
this.persistedSeq = seq;
|
|
507
421
|
} catch {
|
|
508
|
-
await rm(tmp, { force: true }).catch(() => {});
|
|
422
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
509
423
|
}
|
|
510
424
|
});
|
|
511
|
-
} catch {
|
|
512
|
-
/* ignore */
|
|
513
|
-
}
|
|
425
|
+
} catch {}
|
|
514
426
|
}
|
|
515
427
|
|
|
516
428
|
private emit(type: string, payload: Record<string, unknown>): void {
|
|
517
429
|
this.pi.events.emit(type, { type, timestamp: Date.now(), ...payload });
|
|
518
430
|
}
|
|
519
431
|
|
|
520
|
-
/** Only startup failures are forced: they're dead-on-arrival and the leader must
|
|
521
|
-
* notice before it respawns the same broken config. Completed/aborted and even
|
|
522
|
-
* mid-run failures wait for the turn's end (followUp) — they're not urgent and
|
|
523
|
-
* steering every one of them would interrupt the leader mid-tool-call.
|
|
524
|
-
* force = deliver immediately even while streaming (steer). */
|
|
525
432
|
private deliverMode(kind: string, task: TaskSnapshot): "steer" | "followUp" {
|
|
526
433
|
return isStartupFailure(task, kind) ? "steer" : "followUp";
|
|
527
434
|
}
|
|
528
435
|
|
|
529
|
-
/** Per-task wake-up: failures steer in immediately, the rest queue as follow-up. */
|
|
530
436
|
private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
|
|
531
437
|
const body = makeTaskNotice(run, task, kind);
|
|
532
|
-
|
|
438
|
+
|
|
533
439
|
if (this.collectParked(run.id, { kind: "done", taskId: task.id, agent: task.agent, text: body })) {
|
|
534
440
|
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body });
|
|
535
441
|
return;
|
|
536
442
|
}
|
|
537
443
|
try {
|
|
538
444
|
this.pi.sendUserMessage(body, { deliverAs: this.deliverMode(kind, task) });
|
|
539
|
-
} catch {
|
|
540
|
-
/* parent mid-stream; consumers can poll subagent_status */
|
|
541
|
-
}
|
|
445
|
+
} catch {}
|
|
542
446
|
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body });
|
|
543
447
|
}
|
|
544
448
|
|
|
545
|
-
/** Wake the parent with a 3-line notice. Full text stays out of context.
|
|
546
|
-
* deliverAs queues the message if the parent is mid-stream (e.g. inside
|
|
547
|
-
* await_subagent) instead of throwing/aborting. */
|
|
548
449
|
private notifyParent(
|
|
549
450
|
run: RunSnapshot,
|
|
550
451
|
kind: "completed" | "failed" | "aborted" | "asked",
|
|
551
452
|
extra?: { taskId?: string; question?: string },
|
|
552
453
|
): void {
|
|
553
|
-
if (kind !== "asked" && run.awaited) return;
|
|
454
|
+
if (kind !== "asked" && run.awaited) return;
|
|
554
455
|
const body =
|
|
555
456
|
kind === "asked"
|
|
556
457
|
? `A subagent is asking you a question (task ${extra?.taskId}): ${extra?.question ?? ""}\nReply with reply_subagent(runId: "${run.id}", taskId: "${extra?.taskId}", message: ...).`
|
|
557
458
|
: makeNotice(run, kind);
|
|
558
459
|
try {
|
|
559
460
|
this.pi.sendUserMessage(body, { deliverAs: "followUp" });
|
|
560
|
-
} catch {
|
|
561
|
-
/* parent mid-stream; consumers can poll subagent_status */
|
|
562
|
-
}
|
|
461
|
+
} catch {}
|
|
563
462
|
this.emit("subagent:notification", { runId: run.id, kind, body });
|
|
564
463
|
}
|
|
565
464
|
|
|
566
|
-
// Widget: register-once + requestRender (todo-overlay pattern).
|
|
567
|
-
// scheduleWidget throttles status changes into requestRender calls.
|
|
568
465
|
private widgetTui: TUI | null = null;
|
|
569
|
-
/** Upsert a run into the widget's visible set (all runs, not just the latest). */
|
|
570
466
|
private upsertWidgetRun(run: RunSnapshot | undefined): void {
|
|
571
467
|
if (!run) return;
|
|
572
468
|
const idx = this.widgetRuns.findIndex((r) => r.id === run.id);
|
|
@@ -589,12 +485,11 @@ export class SubagentManager {
|
|
|
589
485
|
);
|
|
590
486
|
}
|
|
591
487
|
|
|
592
|
-
/** While any live task's last activity is a talk tool, keep re-rendering so its name pulses. */
|
|
593
488
|
private pulseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
594
489
|
private maybePulse(ctx?: ExtensionContext): void {
|
|
595
490
|
if (this.pulseTimer || !this.widgetTui) return;
|
|
596
491
|
const talking = this.widgetRuns.some((r) => r.tasks.some(isTalking));
|
|
597
|
-
if (!talking) return;
|
|
492
|
+
if (!talking) return;
|
|
598
493
|
this.pulseTimer = setTimeout(() => {
|
|
599
494
|
this.pulseTimer = null;
|
|
600
495
|
this.widgetTui?.requestRender();
|
|
@@ -615,7 +510,7 @@ export class SubagentManager {
|
|
|
615
510
|
this.widgetTui?.requestRender();
|
|
616
511
|
}
|
|
617
512
|
this.maybePulse(ctx);
|
|
618
|
-
|
|
513
|
+
|
|
619
514
|
onUpdate?.({
|
|
620
515
|
content: [
|
|
621
516
|
{
|
|
@@ -659,30 +554,20 @@ export class SubagentManager {
|
|
|
659
554
|
this.updateRun(run, ctx, onUpdate);
|
|
660
555
|
}
|
|
661
556
|
|
|
662
|
-
// ── intercom + mailbox ──────────────────────────────────────────────
|
|
663
557
|
private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
|
|
664
558
|
return {
|
|
665
559
|
onAskParent: async (_taskId, question) => {
|
|
666
|
-
// A tool call already in flight can reach here AFTER the task ended
|
|
667
|
-
// (abort/timeout/cancel). Reviving it would leave a "running" task in a
|
|
668
|
-
// finished run — hasActiveRun() then never clears.
|
|
669
560
|
if (TERMINAL.includes(task.status)) {
|
|
670
561
|
return "(your task has already ended — stop work and return immediately)";
|
|
671
562
|
}
|
|
672
563
|
this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
|
|
673
|
-
|
|
674
|
-
// (no steering queue, no turn boundary); otherwise it goes out as a notice.
|
|
675
|
-
// Either way the pending reply entry must exist, or reply_subagent has
|
|
676
|
-
// nowhere to land and the child waits on an answer that never comes.
|
|
564
|
+
|
|
677
565
|
if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
|
|
678
566
|
this.notifyParent(run, "asked", { taskId: task.id, question });
|
|
679
567
|
}
|
|
680
|
-
|
|
681
|
-
// non-terminal forever (widget never clears, run never settles).
|
|
568
|
+
|
|
682
569
|
const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
|
|
683
|
-
|
|
684
|
-
// terminal task back to "running" (that would let a canceled task be
|
|
685
|
-
// reported as completed).
|
|
570
|
+
|
|
686
571
|
if (TERMINAL.includes(task.status)) {
|
|
687
572
|
return "(your task was canceled while you waited — stop work and return immediately)";
|
|
688
573
|
}
|
|
@@ -691,14 +576,11 @@ export class SubagentManager {
|
|
|
691
576
|
},
|
|
692
577
|
onNotifyParent: (_taskId, message, level) => {
|
|
693
578
|
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
|
|
694
|
-
|
|
695
|
-
// NOT gate this — between two parks the leader is awaited but listening.
|
|
579
|
+
|
|
696
580
|
if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message })) return;
|
|
697
581
|
try {
|
|
698
582
|
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
|
|
699
|
-
} catch {
|
|
700
|
-
/* parent mid-stream */
|
|
701
|
-
}
|
|
583
|
+
} catch {}
|
|
702
584
|
},
|
|
703
585
|
onSendMessage: (_taskId, to, text) => {
|
|
704
586
|
if (to === "leader") {
|
|
@@ -712,12 +594,10 @@ export class SubagentManager {
|
|
|
712
594
|
if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text })) return true;
|
|
713
595
|
try {
|
|
714
596
|
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
|
|
715
|
-
} catch {
|
|
716
|
-
/* parent mid-stream */
|
|
717
|
-
}
|
|
597
|
+
} catch {}
|
|
718
598
|
return true;
|
|
719
599
|
}
|
|
720
|
-
|
|
600
|
+
|
|
721
601
|
return this.mailboxes.send(`${run.id}:${task.id}`, `${run.id}:${to}`, text);
|
|
722
602
|
},
|
|
723
603
|
onPollMailbox: (taskId) => this.mailboxes.poll(`${run.id}:${taskId}`),
|
|
@@ -726,8 +606,6 @@ export class SubagentManager {
|
|
|
726
606
|
private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
|
|
727
607
|
const key = `${runId}:${taskId}`;
|
|
728
608
|
return new Promise<string>((resolve) => {
|
|
729
|
-
// Identity-tagged: two asks from one child must not delete each other's
|
|
730
|
-
// entry (the loser would hang until its own timer).
|
|
731
609
|
const entry: PendingReply = {
|
|
732
610
|
resolve: (message) => {
|
|
733
611
|
if (timer) clearTimeout(timer);
|
|
@@ -751,14 +629,10 @@ export class SubagentManager {
|
|
|
751
629
|
deliverReply(runId: string, taskId: string, message: string): boolean {
|
|
752
630
|
const pending = this.pendingReplies.get(`${runId}:${taskId}`);
|
|
753
631
|
if (!pending) return false;
|
|
754
|
-
pending.resolve(message);
|
|
632
|
+
pending.resolve(message);
|
|
755
633
|
return true;
|
|
756
634
|
}
|
|
757
635
|
|
|
758
|
-
/**
|
|
759
|
-
* Child session events → task state. Extracted from runChild so the
|
|
760
|
-
* per-event classification is readable and unit-testable.
|
|
761
|
-
*/
|
|
762
636
|
private onChildEvent(
|
|
763
637
|
event: AgentSessionEvent,
|
|
764
638
|
run: RunSnapshot,
|
|
@@ -807,16 +681,13 @@ export class SubagentManager {
|
|
|
807
681
|
this.updateRun(run, ctx, onUpdate);
|
|
808
682
|
} else if (event.type === "agent_end") {
|
|
809
683
|
if (event.willRetry) {
|
|
810
|
-
state.pendingFailure = undefined;
|
|
684
|
+
state.pendingFailure = undefined;
|
|
811
685
|
} else {
|
|
812
686
|
const failure = lastAssistantFailure(event.messages as AssistantMessage[]);
|
|
813
687
|
if (failure) {
|
|
814
688
|
state.pendingFailure = failure;
|
|
815
689
|
state.failChildEnd?.(failureError(failure));
|
|
816
690
|
}
|
|
817
|
-
// NOTE: success does NOT resolve childEndPromise here — pi may run a
|
|
818
|
-
// continuation leg (compaction/overflow recovery) that emits another
|
|
819
|
-
// agent_end. Resolve only on agent_settled, after all legs finish.
|
|
820
691
|
}
|
|
821
692
|
} else if (event.type === "agent_settled") {
|
|
822
693
|
state.childEndResolve?.();
|
|
@@ -827,54 +698,40 @@ export class SubagentManager {
|
|
|
827
698
|
run: RunSnapshot,
|
|
828
699
|
task: TaskSnapshot,
|
|
829
700
|
input: TaskInput,
|
|
830
|
-
/** The task text as WRITTEN, before upstream outputs were spliced in. */
|
|
831
701
|
routingTask: string,
|
|
832
702
|
ctx: ExtensionContext,
|
|
833
703
|
signal: AbortSignal | undefined,
|
|
834
704
|
onUpdate?: (partial: any) => void,
|
|
835
705
|
): Promise<void> {
|
|
836
|
-
if (TERMINAL.includes(task.status)) return;
|
|
837
|
-
|
|
838
|
-
// Matched user agent file (`.agents/agents` etc., by description): the file
|
|
839
|
-
// is authoritative — body = system prompt, frontmatter model/tools win over
|
|
840
|
-
// inline. No match → inline on-demand definition as usual.
|
|
841
|
-
// Route on the task as written, never on the upstream output spliced into
|
|
842
|
-
// it: a chain step would otherwise match a different file (and a different
|
|
843
|
-
// model) at runtime than the one createRun pre-flighted.
|
|
706
|
+
if (TERMINAL.includes(task.status)) return;
|
|
707
|
+
|
|
844
708
|
const file = resolveAgentFile(input.agent, routingTask, task.cwd, getAgentDir());
|
|
845
|
-
if (file?.path) task.agentFile = file.path;
|
|
709
|
+
if (file?.path) task.agentFile = file.path;
|
|
846
710
|
const prompt = file?.body ?? input.prompt?.trim();
|
|
847
711
|
const thinking = input.thinking;
|
|
848
|
-
|
|
849
|
-
// intent) but never widen it — a repo-planted agent file can't grant write.
|
|
712
|
+
|
|
850
713
|
const allowedTools = input.write ? WRITE_TOOLS : READONLY_TOOLS;
|
|
851
714
|
const fileTools = file?.tools?.filter((t) => allowedTools.includes(t));
|
|
852
|
-
const
|
|
715
|
+
const explicitTools = input.tools ?? (input.write ? WRITE_TOOLS : undefined);
|
|
716
|
+
const baseTools = explicitTools ?? (fileTools?.length ? fileTools : allowedTools);
|
|
717
|
+
if (explicitTools && fileTools?.length)
|
|
718
|
+
task.toolsNote = `explicit tools overrode agent-file tools (${fileTools.join(", ")})`;
|
|
853
719
|
const tools = [...baseTools, ...CHILD_TALK_TOOLS];
|
|
854
|
-
|
|
855
|
-
// tools: [bash] without write:true still gets a worktree, and a file that
|
|
856
|
-
// narrowed the child to read-only never gets the commit/merge ceremony.
|
|
720
|
+
|
|
857
721
|
const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
|
|
858
722
|
|
|
859
|
-
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
860
|
-
// Model + thinking resolve against the pi model registry BEFORE any worktree
|
|
861
|
-
// exists — a bad request fails the TASK with a helpful message and can't leak a
|
|
862
|
-
// checkout past this early return.
|
|
863
723
|
let model: Model<Api> | undefined;
|
|
864
724
|
try {
|
|
865
725
|
model = resolveChildModel(ctx, file?.model ?? input.model);
|
|
866
726
|
validateThinking(model, thinking);
|
|
867
|
-
|
|
868
|
-
// and fall back to the session's own model when the probe fails.
|
|
727
|
+
|
|
869
728
|
const checked = await ensureUsableModel(ctx, model, signal);
|
|
870
729
|
model = checked.model;
|
|
871
730
|
if (checked.note) {
|
|
872
731
|
task.modelNote = checked.note;
|
|
873
732
|
validateThinking(model, thinking);
|
|
874
733
|
}
|
|
875
|
-
|
|
876
|
-
// requested one, and showing the request in the widget hides that entirely
|
|
877
|
-
// (a 403 then names a model the leader never asked for).
|
|
734
|
+
|
|
878
735
|
if (model) this.updateTask(run, task, { model: model.id, modelNote: checked.note }, ctx, onUpdate);
|
|
879
736
|
} catch (err) {
|
|
880
737
|
this.updateTask(
|
|
@@ -891,16 +748,10 @@ export class SubagentManager {
|
|
|
891
748
|
return;
|
|
892
749
|
}
|
|
893
750
|
|
|
894
|
-
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
895
|
-
// non-git repos fall back to in-place. Created BEFORE session start so the
|
|
896
|
-
// child's cwd + AGENTS.md context chain are the worktree's.
|
|
897
751
|
let wt: Worktree | undefined;
|
|
898
752
|
let isolationReason: string | undefined;
|
|
899
753
|
if (canWrite) {
|
|
900
754
|
try {
|
|
901
|
-
// Stack on the upstream write task's branch, so a chained writer actually
|
|
902
|
-
// SEES the work it was told to build on. Read-only upstreams have no
|
|
903
|
-
// branch, so those stay based on HEAD.
|
|
904
755
|
const upstream = (task.needs ?? [])
|
|
905
756
|
.map((id) => run.tasks.find((t) => t.id === id))
|
|
906
757
|
.filter((t) => t?.branch && t.status === "completed")
|
|
@@ -909,14 +760,11 @@ export class SubagentManager {
|
|
|
909
760
|
if (wt && upstream?.branch) task.stackedOn = upstream.branch;
|
|
910
761
|
if (!wt) isolationReason = "not a git repository";
|
|
911
762
|
} catch (err) {
|
|
912
|
-
wt = undefined;
|
|
763
|
+
wt = undefined;
|
|
913
764
|
isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
914
765
|
}
|
|
915
766
|
}
|
|
916
|
-
|
|
917
|
-
// Both sides go through realpath — a symlinked root would otherwise look
|
|
918
|
-
// "outside" the repo. If the mapping can't be trusted, drop the worktree AND
|
|
919
|
-
// reset the cwd (never point the child at a dir that was just removed).
|
|
767
|
+
|
|
920
768
|
let childCwd = wt?.path ?? task.cwd;
|
|
921
769
|
if (wt) {
|
|
922
770
|
const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
|
|
@@ -927,8 +775,7 @@ export class SubagentManager {
|
|
|
927
775
|
isolationReason = "task cwd is outside the repository";
|
|
928
776
|
} else if (rel && rel !== ".") {
|
|
929
777
|
childCwd = join(wt.path, rel);
|
|
930
|
-
|
|
931
|
-
// checkout — create it rather than fail session start.
|
|
778
|
+
|
|
932
779
|
try {
|
|
933
780
|
mkdirSync(childCwd, { recursive: true });
|
|
934
781
|
} catch {
|
|
@@ -937,19 +784,14 @@ export class SubagentManager {
|
|
|
937
784
|
}
|
|
938
785
|
}
|
|
939
786
|
if (canWrite) {
|
|
940
|
-
// Never let isolation lapse quietly: the leader must know its edits landed
|
|
941
|
-
// straight in the working tree with no branch to review.
|
|
942
787
|
task.isolation = wt ? "worktree" : "in-place";
|
|
943
788
|
task.isolationReason = wt ? undefined : (isolationReason ?? "worktree unavailable");
|
|
944
789
|
}
|
|
945
790
|
if (wt) {
|
|
946
|
-
claimWorktree(wt);
|
|
791
|
+
claimWorktree(wt);
|
|
947
792
|
this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
|
|
948
793
|
}
|
|
949
794
|
|
|
950
|
-
// Guarded: a throwing event listener or widget failure here would escape
|
|
951
|
-
// runChild BEFORE the try/finally that releases the worktree — leaking a
|
|
952
|
-
// checkout that every reaper then skips (registered + owned by a live pid).
|
|
953
795
|
try {
|
|
954
796
|
this.updateTask(
|
|
955
797
|
run,
|
|
@@ -957,11 +799,9 @@ export class SubagentManager {
|
|
|
957
799
|
{
|
|
958
800
|
status: "starting",
|
|
959
801
|
startedAt: Date.now(),
|
|
960
|
-
|
|
961
|
-
// the prompt the child actually receives.
|
|
802
|
+
|
|
962
803
|
task: input.task,
|
|
963
|
-
|
|
964
|
-
// and this patch used to clobber the resolved id recorded above.
|
|
804
|
+
|
|
965
805
|
model: model?.id ?? input.model,
|
|
966
806
|
thinking,
|
|
967
807
|
tools,
|
|
@@ -969,12 +809,8 @@ export class SubagentManager {
|
|
|
969
809
|
ctx,
|
|
970
810
|
onUpdate,
|
|
971
811
|
);
|
|
972
|
-
} catch {
|
|
973
|
-
/* a listener/widget failure must not strand the checkout */
|
|
974
|
-
}
|
|
812
|
+
} catch {}
|
|
975
813
|
|
|
976
|
-
// Set once the dir must outlive this call: committed work awaiting the
|
|
977
|
-
// leader's merge, or a commit failure whose work exists ONLY in the dir.
|
|
978
814
|
let keepWorktreeDir = false;
|
|
979
815
|
let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
980
816
|
let unsubscribe: (() => void) | undefined;
|
|
@@ -984,13 +820,10 @@ export class SubagentManager {
|
|
|
984
820
|
|
|
985
821
|
const key = `${run.id}:${task.id}`;
|
|
986
822
|
try {
|
|
987
|
-
// node_modules is a SHARED symlink to the leader's real tree, so dep writes
|
|
988
|
-
// escape the worktree entirely and `rm -rf node_modules/` destroys the
|
|
989
|
-
// project's deps. The child is the only thing that can avoid that.
|
|
990
823
|
const worktreeNote = wt
|
|
991
824
|
? ` You work in an isolated git worktree (branch ${wt.branch})${task.stackedOn ? `, stacked on ${task.stackedOn} (its changes are already in your tree)` : ""}. Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes. node_modules is a SHARED symlink to the main checkout: never install, upgrade, or delete dependencies (no npm/bun/yarn/pnpm install, no \`rm -rf node_modules\`) — those writes escape your worktree and damage the user's project. If the task truly needs a dependency change, edit the manifest only and say so in your answer.`
|
|
992
825
|
: "";
|
|
993
|
-
const subagentInstruction = `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. 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. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it.
|
|
826
|
+
const subagentInstruction = `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. 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. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. An unanswered ask_parent times out after 10 minutes — proceed with your best judgment then. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${worktreeNote}`;
|
|
994
827
|
|
|
995
828
|
const loader = new DefaultResourceLoader({
|
|
996
829
|
cwd: childCwd,
|
|
@@ -1039,7 +872,7 @@ export class SubagentManager {
|
|
|
1039
872
|
|
|
1040
873
|
const abortChild = () => {
|
|
1041
874
|
void child?.abort();
|
|
1042
|
-
this.runControllers.get(run.id)?.abort();
|
|
875
|
+
this.runControllers.get(run.id)?.abort();
|
|
1043
876
|
};
|
|
1044
877
|
const runController = this.runControllers.get(run.id);
|
|
1045
878
|
if (signal) signal.addEventListener("abort", abortChild, { once: true });
|
|
@@ -1048,7 +881,7 @@ export class SubagentManager {
|
|
|
1048
881
|
signal?.removeEventListener("abort", abortChild);
|
|
1049
882
|
runController?.signal.removeEventListener("abort", abortChild);
|
|
1050
883
|
};
|
|
1051
|
-
|
|
884
|
+
|
|
1052
885
|
if (run.status === "aborted" || TERMINAL.includes(task.status) || signal?.aborted) {
|
|
1053
886
|
await child.abort();
|
|
1054
887
|
throw new Error("Canceled by subagent_cancel");
|
|
@@ -1056,7 +889,7 @@ export class SubagentManager {
|
|
|
1056
889
|
this.liveChildren.set(key, {
|
|
1057
890
|
abort: () => void child?.abort(),
|
|
1058
891
|
dispose: () => child?.dispose(),
|
|
1059
|
-
|
|
892
|
+
|
|
1060
893
|
steer: (message) =>
|
|
1061
894
|
void child?.prompt(message, { streamingBehavior: "steer" }).catch((err) =>
|
|
1062
895
|
this.pi.sendUserMessage(`[steer_subagent] ${err instanceof Error ? err.message : String(err)}`, {
|
|
@@ -1065,9 +898,6 @@ export class SubagentManager {
|
|
|
1065
898
|
),
|
|
1066
899
|
});
|
|
1067
900
|
|
|
1068
|
-
// The ceiling is the ONLY bound on a child that emits events forever (retry
|
|
1069
|
-
// or tool-call livelock). So auto-limit off RAISES it, never removes it —
|
|
1070
|
-
// removing it reproduced the immortal-child hang.
|
|
1071
901
|
const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : UNLIMITED_RUNTIME_MS);
|
|
1072
902
|
const promptPromise = child.prompt(task.task, { source: "extension" });
|
|
1073
903
|
const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise];
|
|
@@ -1087,31 +917,19 @@ export class SubagentManager {
|
|
|
1087
917
|
const finalText =
|
|
1088
918
|
task.finalText ||
|
|
1089
919
|
truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
|
|
1090
|
-
|
|
1091
|
-
// final text was being assembled must not be overwritten with "completed"
|
|
1092
|
-
// (which would also skip the partial commit and drop the child's work).
|
|
1093
|
-
// `awaiting_parent` also means the child hadn't finished talking — marking
|
|
1094
|
-
// it completed would publish a truncated finalText to every dependent.
|
|
920
|
+
|
|
1095
921
|
if (task.status === "awaiting_parent") {
|
|
1096
922
|
this.pendingReplies.get(key)?.resolve("(your task is being finalized — stop work and return now)");
|
|
1097
923
|
}
|
|
1098
924
|
if (!TERMINAL.includes(task.status)) {
|
|
1099
925
|
this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
|
|
1100
926
|
if (wt) {
|
|
1101
|
-
// Commit the child's changes, then report the branch + diff so the
|
|
1102
|
-
// leader can review and merge (PR-style). The worktree dir stays
|
|
1103
|
-
// until the branch is merged — cleanupMerged removes both then.
|
|
1104
|
-
// Commit/diff failures must NOT downgrade a completed task or destroy
|
|
1105
|
-
// its work: the error is reported, the status stays completed.
|
|
1106
927
|
let committed: "committed" | "empty" | undefined;
|
|
1107
928
|
try {
|
|
1108
929
|
committed = commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
1109
|
-
|
|
1110
|
-
// leader off to review and merge nothing.
|
|
930
|
+
|
|
1111
931
|
keepWorktreeDir = committed === "committed";
|
|
1112
932
|
} catch (commitErr) {
|
|
1113
|
-
// Never drop a checkout whose work isn't on the branch — it would be
|
|
1114
|
-
// unreachable once the base-tip branch is reaped as "merged".
|
|
1115
933
|
keepWorktreeDir = true;
|
|
1116
934
|
this.updateTask(
|
|
1117
935
|
run,
|
|
@@ -1124,7 +942,7 @@ export class SubagentManager {
|
|
|
1124
942
|
onUpdate,
|
|
1125
943
|
);
|
|
1126
944
|
}
|
|
1127
|
-
|
|
945
|
+
|
|
1128
946
|
if (committed === "committed") {
|
|
1129
947
|
try {
|
|
1130
948
|
const { stat, files } = branchDiff(wt);
|
|
@@ -1152,20 +970,14 @@ export class SubagentManager {
|
|
|
1152
970
|
}
|
|
1153
971
|
} catch (err) {
|
|
1154
972
|
if (timeout) clearTimeout(timeout);
|
|
1155
|
-
|
|
973
|
+
|
|
1156
974
|
const aborted = signal?.aborted || run.status === "aborted" || task.status === "aborted";
|
|
1157
975
|
const subagentStatus = (err as Error & { subagentStatus?: string })?.subagentStatus;
|
|
1158
976
|
try {
|
|
1159
|
-
// Unblock a child stuck in ask_parent, then time-box the abort so a
|
|
1160
|
-
// wedged session can never hang this catch/finally.
|
|
1161
977
|
this.pendingReplies.get(key)?.resolve("(parent unreachable)");
|
|
1162
978
|
await Promise.race([child?.abort(), new Promise((r) => setTimeout(r, 5000))]);
|
|
1163
|
-
} catch {
|
|
1164
|
-
|
|
1165
|
-
}
|
|
1166
|
-
// Publish whatever the child DID say before it was killed. A timeout or
|
|
1167
|
-
// abort used to discard it, so a chain dependent received nothing at all
|
|
1168
|
-
// while the child's partial work was still committed to its branch.
|
|
979
|
+
} catch {}
|
|
980
|
+
|
|
1169
981
|
const salvaged =
|
|
1170
982
|
task.finalText ||
|
|
1171
983
|
truncateText(
|
|
@@ -1185,46 +997,37 @@ export class SubagentManager {
|
|
|
1185
997
|
);
|
|
1186
998
|
} finally {
|
|
1187
999
|
this.liveChildren.delete(key);
|
|
1188
|
-
|
|
1189
|
-
// timer and leaves the child's await unsettled.
|
|
1000
|
+
|
|
1190
1001
|
this.pendingReplies.get(key)?.resolve("(task ended — stop work now)");
|
|
1191
1002
|
this.pendingReplies.delete(key);
|
|
1192
1003
|
abortListener?.();
|
|
1193
1004
|
unsubscribe?.();
|
|
1194
1005
|
if (timeout) clearTimeout(timeout);
|
|
1195
1006
|
child?.dispose();
|
|
1196
|
-
|
|
1197
|
-
// still be unwinding), commit whatever partial work exists so the branch
|
|
1198
|
-
// really keeps it, then drop the checkout dir. A commit FAILURE keeps the
|
|
1199
|
-
// dir — dropping it would make the work unreachable.
|
|
1007
|
+
|
|
1200
1008
|
if (wt && task.status !== "completed") {
|
|
1201
1009
|
await new Promise((r) => setTimeout(r, 250));
|
|
1202
1010
|
let partial: "committed" | "empty" | undefined;
|
|
1203
1011
|
try {
|
|
1204
1012
|
partial = commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
1205
1013
|
} catch {
|
|
1206
|
-
keepWorktreeDir = true;
|
|
1014
|
+
keepWorktreeDir = true;
|
|
1207
1015
|
}
|
|
1208
|
-
|
|
1016
|
+
|
|
1209
1017
|
if (partial === "committed" || keepWorktreeDir) {
|
|
1210
1018
|
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
1211
1019
|
}
|
|
1212
1020
|
}
|
|
1213
1021
|
if (wt && !keepWorktreeDir) removeWorktree(wt);
|
|
1214
|
-
|
|
1215
|
-
// in liveBranches() so cleanup can't reap it.
|
|
1022
|
+
|
|
1216
1023
|
this.liveWorktrees.delete(key);
|
|
1217
1024
|
}
|
|
1218
1025
|
}
|
|
1219
1026
|
|
|
1220
|
-
// ── run lifecycle ───────────────────────────────────────────────────
|
|
1221
1027
|
createRun(params: SubagentParamsShape, ctx: ExtensionContext): { run: RunSnapshot; inputs: TaskInput[] } {
|
|
1222
1028
|
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
1223
1029
|
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
1224
|
-
|
|
1225
|
-
// those in place when switching to tasks:[...], and the intent is not
|
|
1226
|
-
// ambiguous — rejecting a well-formed 3-task call over leftovers is worse
|
|
1227
|
-
// than ignoring them. Genuine ambiguity (tasks AND chain) is still refused.
|
|
1030
|
+
|
|
1228
1031
|
const hasSingle = !hasChain && !hasTasks && Boolean(params.agent && params.task);
|
|
1229
1032
|
if (hasChain && hasTasks) {
|
|
1230
1033
|
throw new Error(`Provide either tasks (parallel) or chain (sequential), not both.`);
|
|
@@ -1234,12 +1037,7 @@ export class SubagentManager {
|
|
|
1234
1037
|
`Provide one subagent mode: agent+task (single), tasks: [...] (parallel), or chain: [...] (sequential).`,
|
|
1235
1038
|
);
|
|
1236
1039
|
}
|
|
1237
|
-
|
|
1238
|
-
// otherwise: `write: true` next to tasks:[...] produced read-only children
|
|
1239
|
-
// that reported they "cannot edit files", with nothing explaining why.
|
|
1240
|
-
// `cwd` and `maxRuntimeMs` are meaningful run-wide (and the schema advertises
|
|
1241
|
-
// maxRuntimeMs without a single-mode marker), so they FAN OUT as per-task
|
|
1242
|
-
// defaults instead of being refused. The rest genuinely describe one agent.
|
|
1040
|
+
|
|
1243
1041
|
if (hasChain || hasTasks) {
|
|
1244
1042
|
const stray = (["write", "prompt", "tools", "model", "thinking"] as const).filter((k) => params[k] !== undefined);
|
|
1245
1043
|
if (stray.length > 0) {
|
|
@@ -1265,15 +1063,12 @@ export class SubagentManager {
|
|
|
1265
1063
|
},
|
|
1266
1064
|
]
|
|
1267
1065
|
: (hasTasks ? params.tasks! : params.chain!).map((item) => ({
|
|
1268
|
-
// Run-wide defaults; a per-task value always wins.
|
|
1269
1066
|
...item,
|
|
1270
1067
|
cwd: item.cwd ?? params.cwd,
|
|
1271
1068
|
maxRuntimeMs: item.maxRuntimeMs ?? params.maxRuntimeMs,
|
|
1272
1069
|
}));
|
|
1273
1070
|
if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
|
|
1274
|
-
|
|
1275
|
-
// Explicit ids are checked against each other; generated ones are checked
|
|
1276
|
-
// against explicit ones so a collision can't silently fall back to in-place.
|
|
1071
|
+
|
|
1277
1072
|
const ids = new Set<string>();
|
|
1278
1073
|
for (const input of inputs) {
|
|
1279
1074
|
if (input.id !== undefined) {
|
|
@@ -1291,14 +1086,9 @@ export class SubagentManager {
|
|
|
1291
1086
|
}
|
|
1292
1087
|
}
|
|
1293
1088
|
const edges = resolveNeeds(inputs, mode);
|
|
1294
|
-
|
|
1295
|
-
// that emits session_shutdown with no following session_start (SIGTERM, SDK
|
|
1296
|
-
// reuse, tests) leaves every later persist a silent no-op.
|
|
1089
|
+
|
|
1297
1090
|
this.cleared = false;
|
|
1298
|
-
|
|
1299
|
-
// overrides the requested model, so an unresolvable one is the leader's
|
|
1300
|
-
// mistake to see NOW — not N children dying one by one on their first turn
|
|
1301
|
-
// with an error naming a model the leader never asked for.
|
|
1091
|
+
|
|
1302
1092
|
for (let i = 0; i < inputs.length; i++) {
|
|
1303
1093
|
const input = inputs[i] as TaskInput;
|
|
1304
1094
|
const cwd = input.cwd ?? ctx.cwd;
|
|
@@ -1339,8 +1129,7 @@ export class SubagentManager {
|
|
|
1339
1129
|
})),
|
|
1340
1130
|
aggregateUsage: emptyUsage(),
|
|
1341
1131
|
};
|
|
1342
|
-
|
|
1343
|
-
// send_agent_message/poll_agent_messages can be used reliably.
|
|
1132
|
+
|
|
1344
1133
|
const roster = run.tasks.map((t) => `${t.id} (${t.agent})`).join(", ");
|
|
1345
1134
|
for (const task of run.tasks) {
|
|
1346
1135
|
task.roster = roster;
|
|
@@ -1368,15 +1157,12 @@ export class SubagentManager {
|
|
|
1368
1157
|
run.startedAt = Date.now();
|
|
1369
1158
|
this.updateRun(run, ctx, onUpdate);
|
|
1370
1159
|
|
|
1371
|
-
// One wave scheduler for every mode. A wave is the set of tasks whose needs
|
|
1372
|
-
// are all satisfied; the loop boundary between waves IS the gate. Chain mode
|
|
1373
|
-
// reaches here as needs: [previous], so it needs no special case.
|
|
1374
1160
|
const outputs = new Map<string, string>();
|
|
1375
1161
|
const settled = new Set<string>();
|
|
1376
1162
|
for (const task of run.tasks) {
|
|
1377
|
-
if (TERMINAL.includes(task.status)) settled.add(task.id);
|
|
1163
|
+
if (TERMINAL.includes(task.status)) settled.add(task.id);
|
|
1378
1164
|
}
|
|
1379
|
-
|
|
1165
|
+
|
|
1380
1166
|
const inputById = new Map(run.tasks.map((t, i) => [t.id, inputs[i]]));
|
|
1381
1167
|
|
|
1382
1168
|
const { skipped } = await runWaveScheduler(
|
|
@@ -1385,12 +1171,8 @@ export class SubagentManager {
|
|
|
1385
1171
|
outputs,
|
|
1386
1172
|
settled,
|
|
1387
1173
|
async (task) => {
|
|
1388
|
-
// The scheduler passes the index into the FILTERED list — never use it
|
|
1389
|
-
// against the unfiltered inputs. Look the input up by task id instead.
|
|
1390
1174
|
const input = inputById.get(task.id);
|
|
1391
1175
|
if (!input) {
|
|
1392
|
-
// Impossible unless ids drift from inputs — fail loudly instead of
|
|
1393
|
-
// leaving the task queued forever (hasActiveRun would never clear).
|
|
1394
1176
|
this.updateTask(
|
|
1395
1177
|
run,
|
|
1396
1178
|
task,
|
|
@@ -1415,7 +1197,7 @@ export class SubagentManager {
|
|
|
1415
1197
|
}
|
|
1416
1198
|
},
|
|
1417
1199
|
);
|
|
1418
|
-
|
|
1200
|
+
|
|
1419
1201
|
for (const s of skipped) {
|
|
1420
1202
|
const task = run.tasks.find((t) => t.id === s.id);
|
|
1421
1203
|
if (task && !TERMINAL.includes(task.status)) {
|
|
@@ -1424,7 +1206,7 @@ export class SubagentManager {
|
|
|
1424
1206
|
task,
|
|
1425
1207
|
{
|
|
1426
1208
|
status: "aborted",
|
|
1427
|
-
|
|
1209
|
+
|
|
1428
1210
|
error: task.error || `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
|
|
1429
1211
|
endedAt: Date.now(),
|
|
1430
1212
|
},
|
|
@@ -1433,9 +1215,7 @@ export class SubagentManager {
|
|
|
1433
1215
|
);
|
|
1434
1216
|
}
|
|
1435
1217
|
}
|
|
1436
|
-
|
|
1437
|
-
// would otherwise leave tasks queued inside a terminal run — hasActiveRun()
|
|
1438
|
-
// then never clears and the widget stays pinned.
|
|
1218
|
+
|
|
1439
1219
|
for (const task of run.tasks) {
|
|
1440
1220
|
if (TERMINAL.includes(task.status)) continue;
|
|
1441
1221
|
this.updateTask(
|
|
@@ -1452,11 +1232,10 @@ export class SubagentManager {
|
|
|
1452
1232
|
run.status = aborted ? "aborted" : failed ? "failed" : "completed";
|
|
1453
1233
|
run.endedAt = Date.now();
|
|
1454
1234
|
this.flushWidget(run, ctx, onUpdate);
|
|
1455
|
-
|
|
1456
|
-
// The agent_start handler clears them on the next turn that spawns nothing.
|
|
1235
|
+
|
|
1457
1236
|
const live = this.listRuns().find((r) => !TERMINAL.includes(r.status));
|
|
1458
1237
|
if (live) this.scheduleWidget(live, ctx);
|
|
1459
|
-
|
|
1238
|
+
|
|
1460
1239
|
if (this.settlers.has(run.id)) {
|
|
1461
1240
|
this.emit("subagent:run-completed", {
|
|
1462
1241
|
runId: run.id,
|
|
@@ -1469,9 +1248,7 @@ export class SubagentManager {
|
|
|
1469
1248
|
this.runControllers.delete(run.id);
|
|
1470
1249
|
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
1471
1250
|
this.persist(ctx);
|
|
1472
|
-
|
|
1473
|
-
// Once per repo, never for a branch another live run owns, never fatal — a
|
|
1474
|
-
// throw here would re-settle an already-finished run as failed.
|
|
1251
|
+
|
|
1475
1252
|
try {
|
|
1476
1253
|
const roots = new Set<string>();
|
|
1477
1254
|
for (const task of run.tasks) {
|
|
@@ -1480,14 +1257,9 @@ export class SubagentManager {
|
|
|
1480
1257
|
if (root) roots.add(root);
|
|
1481
1258
|
}
|
|
1482
1259
|
for (const root of roots) cleanupMerged(root, { skipBranches: this.liveBranches() });
|
|
1483
|
-
} catch {
|
|
1484
|
-
/* cleanup is best-effort; the run outcome must stand */
|
|
1485
|
-
}
|
|
1260
|
+
} catch {}
|
|
1486
1261
|
}
|
|
1487
1262
|
|
|
1488
|
-
/** Branches owned by worktrees of still-running children. */
|
|
1489
|
-
/** True only for checkouts THIS manager still runs — a marker carrying our own
|
|
1490
|
-
* pid from a previous session in the same process is not proof of life. */
|
|
1491
1263
|
ownsWorktree = (path: string): boolean => {
|
|
1492
1264
|
for (const wt of this.liveWorktrees.values()) if (wt.path === path) return true;
|
|
1493
1265
|
return false;
|
|
@@ -1496,7 +1268,6 @@ export class SubagentManager {
|
|
|
1496
1268
|
return new Set(Array.from(this.liveWorktrees.values(), (wt) => wt.branch));
|
|
1497
1269
|
}
|
|
1498
1270
|
|
|
1499
|
-
/** Spawn a run that keeps executing after this call returns. Every run is background. */
|
|
1500
1271
|
startInBackground(params: SubagentParamsShape, ctx: ExtensionContext): RunDetails {
|
|
1501
1272
|
const { run, inputs } = this.createRun(params, ctx);
|
|
1502
1273
|
void this.executeTasks(run, inputs, ctx, undefined, undefined)
|
|
@@ -1507,7 +1278,6 @@ export class SubagentManager {
|
|
|
1507
1278
|
);
|
|
1508
1279
|
})
|
|
1509
1280
|
.catch((err) => {
|
|
1510
|
-
// Never leave a background run unsettled: mark failed, settle, notify.
|
|
1511
1281
|
run.status = "failed";
|
|
1512
1282
|
run.endedAt = Date.now();
|
|
1513
1283
|
for (const task of run.tasks) {
|
|
@@ -1527,7 +1297,6 @@ export class SubagentManager {
|
|
|
1527
1297
|
return { run: cloneRun(run) };
|
|
1528
1298
|
}
|
|
1529
1299
|
|
|
1530
|
-
/** Push a steering message into a live child's session. Returns false when unknown or not running. */
|
|
1531
1300
|
steerTask(runId: string, taskId: string | undefined, message: string): boolean {
|
|
1532
1301
|
const run = this.runs.get(runId);
|
|
1533
1302
|
if (!run) return false;
|
|
@@ -1537,12 +1306,11 @@ export class SubagentManager {
|
|
|
1537
1306
|
return true;
|
|
1538
1307
|
}
|
|
1539
1308
|
|
|
1540
|
-
/** Abort ONE task; siblings keep running. Returns false when unknown or already finished. */
|
|
1541
1309
|
cancelTask(runId: string, taskId: string, ctx?: ExtensionContext): boolean {
|
|
1542
1310
|
const run = this.runs.get(runId);
|
|
1543
1311
|
const task = run?.tasks.find((t) => t.id === taskId);
|
|
1544
1312
|
if (!run || !task || TERMINAL.includes(task.status)) return false;
|
|
1545
|
-
|
|
1313
|
+
|
|
1546
1314
|
task.status = "aborted";
|
|
1547
1315
|
task.error = task.error || "Canceled from peek";
|
|
1548
1316
|
task.endedAt = Date.now();
|
|
@@ -1558,11 +1326,10 @@ export class SubagentManager {
|
|
|
1558
1326
|
cancelRun(runId: string): { aborted: number } {
|
|
1559
1327
|
const run = this.runs.get(runId);
|
|
1560
1328
|
if (!run) return { aborted: 0 };
|
|
1561
|
-
if (TERMINAL.includes(run.status)) return { aborted: 0 };
|
|
1329
|
+
if (TERMINAL.includes(run.status)) return { aborted: 0 };
|
|
1562
1330
|
let aborted = 0;
|
|
1563
1331
|
this.runControllers.get(runId)?.abort();
|
|
1564
|
-
|
|
1565
|
-
// the child alive past the abort.
|
|
1332
|
+
|
|
1566
1333
|
for (const [key, pending] of this.pendingReplies) {
|
|
1567
1334
|
if (key.startsWith(`${runId}:`)) {
|
|
1568
1335
|
this.pendingReplies.delete(key);
|
|
@@ -1577,11 +1344,10 @@ export class SubagentManager {
|
|
|
1577
1344
|
for (const task of run.tasks) {
|
|
1578
1345
|
if (TERMINAL.includes(task.status)) continue;
|
|
1579
1346
|
task.status = "aborted";
|
|
1580
|
-
task.error = task.error || "Canceled by subagent_cancel";
|
|
1347
|
+
task.error = task.error || "Canceled by subagent_cancel";
|
|
1581
1348
|
task.endedAt = Date.now();
|
|
1582
1349
|
aborted += 1;
|
|
1583
|
-
|
|
1584
|
-
// runChild's finally commits + drops the dir (it owns the live worktree).
|
|
1350
|
+
|
|
1585
1351
|
const wt = this.liveWorktrees.get(`${runId}:${task.id}`);
|
|
1586
1352
|
if (wt) task.branch = wt.branch;
|
|
1587
1353
|
}
|
|
@@ -1594,7 +1360,6 @@ export class SubagentManager {
|
|
|
1594
1360
|
return { aborted };
|
|
1595
1361
|
}
|
|
1596
1362
|
|
|
1597
|
-
/** Settle-and-delete: every awaiter resolves once, then the set is dropped. */
|
|
1598
1363
|
private settleRun(runId: string, run: RunSnapshot): void {
|
|
1599
1364
|
if (!this.settlers.has(runId)) return;
|
|
1600
1365
|
this.settlers.delete(runId);
|
|
@@ -1605,12 +1370,8 @@ export class SubagentManager {
|
|
|
1605
1370
|
for (const waiter of waiters) waiter(snapshot);
|
|
1606
1371
|
}
|
|
1607
1372
|
|
|
1608
|
-
/** Child→leader messages collected while the parent is parked in await_subagent. */
|
|
1609
|
-
/** Every awaiter parked on a run — a SET, so two concurrent awaits can't
|
|
1610
|
-
* overwrite each other's buffer and silently swallow one side's intercom. */
|
|
1611
1373
|
private parked = new Map<string, Set<{ msgs: ParkedMsg[]; wake: () => void }>>();
|
|
1612
1374
|
|
|
1613
|
-
/** While the parent is parked on this run, deliver the message through the wait instead of the steering queue. */
|
|
1614
1375
|
private collectParked(runId: string, msg: ParkedMsg): boolean {
|
|
1615
1376
|
const parked = this.parked.get(runId);
|
|
1616
1377
|
if (!parked || parked.size === 0) return false;
|
|
@@ -1620,22 +1381,18 @@ export class SubagentManager {
|
|
|
1620
1381
|
p.msgs.push(msg);
|
|
1621
1382
|
delivered = true;
|
|
1622
1383
|
} else if (msg.kind === "ask") {
|
|
1623
|
-
// An unanswered ask blocks a child for 10 minutes — it must never be the
|
|
1624
|
-
// message that gets dropped by the cap. Evict the oldest NON-ask first
|
|
1625
|
-
// (asks already block children; displacing one hangs the earlier asker).
|
|
1626
1384
|
const drop = p.msgs.findIndex((m) => m.kind !== "ask");
|
|
1627
1385
|
if (drop !== -1) {
|
|
1628
1386
|
p.msgs.splice(drop, 1);
|
|
1629
1387
|
p.msgs.push(msg);
|
|
1630
1388
|
} else {
|
|
1631
|
-
p.msgs[p.msgs.length - 1] = msg;
|
|
1389
|
+
p.msgs[p.msgs.length - 1] = msg;
|
|
1632
1390
|
}
|
|
1633
1391
|
delivered = true;
|
|
1634
1392
|
}
|
|
1635
|
-
p.wake();
|
|
1393
|
+
p.wake();
|
|
1636
1394
|
}
|
|
1637
|
-
|
|
1638
|
-
// followUp notice instead of assuming the leader saw it.
|
|
1395
|
+
|
|
1639
1396
|
return delivered;
|
|
1640
1397
|
}
|
|
1641
1398
|
|
|
@@ -1649,7 +1406,7 @@ export class SubagentManager {
|
|
|
1649
1406
|
const finish = (): void => {
|
|
1650
1407
|
const parked = this.parked.get(runId);
|
|
1651
1408
|
if (!parked || !entry) return;
|
|
1652
|
-
parked.delete(entry);
|
|
1409
|
+
parked.delete(entry);
|
|
1653
1410
|
if (parked.size === 0) this.parked.delete(runId);
|
|
1654
1411
|
};
|
|
1655
1412
|
if (TERMINAL.includes(run.status)) {
|
|
@@ -1658,9 +1415,6 @@ export class SubagentManager {
|
|
|
1658
1415
|
}
|
|
1659
1416
|
const msgs: ParkedMsg[] = [];
|
|
1660
1417
|
const settled = new Promise<RunSnapshot | undefined>((resolve) => {
|
|
1661
|
-
// Waiters are a SET, not a chain: the autoAwait loop re-parks on every
|
|
1662
|
-
// child message, and wrapping the previous settler each time grew an
|
|
1663
|
-
// unbounded closure chain (each holding a snapshot clone).
|
|
1664
1418
|
const waiter = (r: RunSnapshot) => {
|
|
1665
1419
|
this.settleWaiters.get(runId)?.delete(waiter);
|
|
1666
1420
|
resolve(r);
|
|
@@ -1671,8 +1425,7 @@ export class SubagentManager {
|
|
|
1671
1425
|
this.settleWaiters.set(runId, waiters);
|
|
1672
1426
|
}
|
|
1673
1427
|
waiters.add(waiter);
|
|
1674
|
-
|
|
1675
|
-
// IN the await result, no steering queue, no turn boundary needed.
|
|
1428
|
+
|
|
1676
1429
|
entry = { msgs, wake: () => waiter(cloneRun(run)) };
|
|
1677
1430
|
let parked = this.parked.get(runId);
|
|
1678
1431
|
if (!parked) {
|
|
@@ -1686,9 +1439,7 @@ export class SubagentManager {
|
|
|
1686
1439
|
return Promise.race([
|
|
1687
1440
|
settled.then((r) => {
|
|
1688
1441
|
finish();
|
|
1689
|
-
|
|
1690
|
-
// leader. A slice that already timed out is abandoned — setting it here
|
|
1691
|
-
// would suppress the run's completion notice the leader still needs.
|
|
1442
|
+
|
|
1692
1443
|
if (!timedOut) run.awaited = true;
|
|
1693
1444
|
return { run: r, intercom: msgs };
|
|
1694
1445
|
}),
|