@arhen/pi-core-subagent 1.3.49 → 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 +5 -6
- package/package.json +1 -1
- package/src/agentfile.ts +4 -41
- package/src/child.ts +2 -17
- package/src/format.ts +11 -58
- package/src/graph.ts +7 -36
- package/src/index.ts +16 -56
- package/src/mailbox.ts +0 -7
- package/src/manager.ts +93 -348
- package/src/peek.ts +13 -69
- package/src/schemas.ts +1 -4
- package/src/types.ts +0 -19
- 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,29 +698,18 @@ 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
|
-
// explicit tool intent: tools: or write: true win over them — silently
|
|
850
|
-
// displacing explicit intent produced read-only children that "completed"
|
|
851
|
-
// with zero edits (issue #3). The gate still holds: a repo-planted file
|
|
852
|
-
// can never WIDEN past the leader's read/write choice (filtered above).
|
|
712
|
+
|
|
853
713
|
const allowedTools = input.write ? WRITE_TOOLS : READONLY_TOOLS;
|
|
854
714
|
const fileTools = file?.tools?.filter((t) => allowedTools.includes(t));
|
|
855
715
|
const explicitTools = input.tools ?? (input.write ? WRITE_TOOLS : undefined);
|
|
@@ -857,30 +717,21 @@ export class SubagentManager {
|
|
|
857
717
|
if (explicitTools && fileTools?.length)
|
|
858
718
|
task.toolsNote = `explicit tools overrode agent-file tools (${fileTools.join(", ")})`;
|
|
859
719
|
const tools = [...baseTools, ...CHILD_TALK_TOOLS];
|
|
860
|
-
|
|
861
|
-
// tools: [bash] without write:true still gets a worktree, and a file that
|
|
862
|
-
// narrowed the child to read-only never gets the commit/merge ceremony.
|
|
720
|
+
|
|
863
721
|
const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
|
|
864
722
|
|
|
865
|
-
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
866
|
-
// Model + thinking resolve against the pi model registry BEFORE any worktree
|
|
867
|
-
// exists — a bad request fails the TASK with a helpful message and can't leak a
|
|
868
|
-
// checkout past this early return.
|
|
869
723
|
let model: Model<Api> | undefined;
|
|
870
724
|
try {
|
|
871
725
|
model = resolveChildModel(ctx, file?.model ?? input.model);
|
|
872
726
|
validateThinking(model, thinking);
|
|
873
|
-
|
|
874
|
-
// and fall back to the session's own model when the probe fails.
|
|
727
|
+
|
|
875
728
|
const checked = await ensureUsableModel(ctx, model, signal);
|
|
876
729
|
model = checked.model;
|
|
877
730
|
if (checked.note) {
|
|
878
731
|
task.modelNote = checked.note;
|
|
879
732
|
validateThinking(model, thinking);
|
|
880
733
|
}
|
|
881
|
-
|
|
882
|
-
// requested one, and showing the request in the widget hides that entirely
|
|
883
|
-
// (a 403 then names a model the leader never asked for).
|
|
734
|
+
|
|
884
735
|
if (model) this.updateTask(run, task, { model: model.id, modelNote: checked.note }, ctx, onUpdate);
|
|
885
736
|
} catch (err) {
|
|
886
737
|
this.updateTask(
|
|
@@ -897,16 +748,10 @@ export class SubagentManager {
|
|
|
897
748
|
return;
|
|
898
749
|
}
|
|
899
750
|
|
|
900
|
-
// Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
|
|
901
|
-
// non-git repos fall back to in-place. Created BEFORE session start so the
|
|
902
|
-
// child's cwd + AGENTS.md context chain are the worktree's.
|
|
903
751
|
let wt: Worktree | undefined;
|
|
904
752
|
let isolationReason: string | undefined;
|
|
905
753
|
if (canWrite) {
|
|
906
754
|
try {
|
|
907
|
-
// Stack on the upstream write task's branch, so a chained writer actually
|
|
908
|
-
// SEES the work it was told to build on. Read-only upstreams have no
|
|
909
|
-
// branch, so those stay based on HEAD.
|
|
910
755
|
const upstream = (task.needs ?? [])
|
|
911
756
|
.map((id) => run.tasks.find((t) => t.id === id))
|
|
912
757
|
.filter((t) => t?.branch && t.status === "completed")
|
|
@@ -915,14 +760,11 @@ export class SubagentManager {
|
|
|
915
760
|
if (wt && upstream?.branch) task.stackedOn = upstream.branch;
|
|
916
761
|
if (!wt) isolationReason = "not a git repository";
|
|
917
762
|
} catch (err) {
|
|
918
|
-
wt = undefined;
|
|
763
|
+
wt = undefined;
|
|
919
764
|
isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
920
765
|
}
|
|
921
766
|
}
|
|
922
|
-
|
|
923
|
-
// Both sides go through realpath — a symlinked root would otherwise look
|
|
924
|
-
// "outside" the repo. If the mapping can't be trusted, drop the worktree AND
|
|
925
|
-
// reset the cwd (never point the child at a dir that was just removed).
|
|
767
|
+
|
|
926
768
|
let childCwd = wt?.path ?? task.cwd;
|
|
927
769
|
if (wt) {
|
|
928
770
|
const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
|
|
@@ -933,8 +775,7 @@ export class SubagentManager {
|
|
|
933
775
|
isolationReason = "task cwd is outside the repository";
|
|
934
776
|
} else if (rel && rel !== ".") {
|
|
935
777
|
childCwd = join(wt.path, rel);
|
|
936
|
-
|
|
937
|
-
// checkout — create it rather than fail session start.
|
|
778
|
+
|
|
938
779
|
try {
|
|
939
780
|
mkdirSync(childCwd, { recursive: true });
|
|
940
781
|
} catch {
|
|
@@ -943,19 +784,14 @@ export class SubagentManager {
|
|
|
943
784
|
}
|
|
944
785
|
}
|
|
945
786
|
if (canWrite) {
|
|
946
|
-
// Never let isolation lapse quietly: the leader must know its edits landed
|
|
947
|
-
// straight in the working tree with no branch to review.
|
|
948
787
|
task.isolation = wt ? "worktree" : "in-place";
|
|
949
788
|
task.isolationReason = wt ? undefined : (isolationReason ?? "worktree unavailable");
|
|
950
789
|
}
|
|
951
790
|
if (wt) {
|
|
952
|
-
claimWorktree(wt);
|
|
791
|
+
claimWorktree(wt);
|
|
953
792
|
this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
|
|
954
793
|
}
|
|
955
794
|
|
|
956
|
-
// Guarded: a throwing event listener or widget failure here would escape
|
|
957
|
-
// runChild BEFORE the try/finally that releases the worktree — leaking a
|
|
958
|
-
// checkout that every reaper then skips (registered + owned by a live pid).
|
|
959
795
|
try {
|
|
960
796
|
this.updateTask(
|
|
961
797
|
run,
|
|
@@ -963,11 +799,9 @@ export class SubagentManager {
|
|
|
963
799
|
{
|
|
964
800
|
status: "starting",
|
|
965
801
|
startedAt: Date.now(),
|
|
966
|
-
|
|
967
|
-
// the prompt the child actually receives.
|
|
802
|
+
|
|
968
803
|
task: input.task,
|
|
969
|
-
|
|
970
|
-
// and this patch used to clobber the resolved id recorded above.
|
|
804
|
+
|
|
971
805
|
model: model?.id ?? input.model,
|
|
972
806
|
thinking,
|
|
973
807
|
tools,
|
|
@@ -975,12 +809,8 @@ export class SubagentManager {
|
|
|
975
809
|
ctx,
|
|
976
810
|
onUpdate,
|
|
977
811
|
);
|
|
978
|
-
} catch {
|
|
979
|
-
/* a listener/widget failure must not strand the checkout */
|
|
980
|
-
}
|
|
812
|
+
} catch {}
|
|
981
813
|
|
|
982
|
-
// Set once the dir must outlive this call: committed work awaiting the
|
|
983
|
-
// leader's merge, or a commit failure whose work exists ONLY in the dir.
|
|
984
814
|
let keepWorktreeDir = false;
|
|
985
815
|
let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
986
816
|
let unsubscribe: (() => void) | undefined;
|
|
@@ -990,13 +820,10 @@ export class SubagentManager {
|
|
|
990
820
|
|
|
991
821
|
const key = `${run.id}:${task.id}`;
|
|
992
822
|
try {
|
|
993
|
-
// node_modules is a SHARED symlink to the leader's real tree, so dep writes
|
|
994
|
-
// escape the worktree entirely and `rm -rf node_modules/` destroys the
|
|
995
|
-
// project's deps. The child is the only thing that can avoid that.
|
|
996
823
|
const worktreeNote = wt
|
|
997
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.`
|
|
998
825
|
: "";
|
|
999
|
-
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}`;
|
|
1000
827
|
|
|
1001
828
|
const loader = new DefaultResourceLoader({
|
|
1002
829
|
cwd: childCwd,
|
|
@@ -1045,7 +872,7 @@ export class SubagentManager {
|
|
|
1045
872
|
|
|
1046
873
|
const abortChild = () => {
|
|
1047
874
|
void child?.abort();
|
|
1048
|
-
this.runControllers.get(run.id)?.abort();
|
|
875
|
+
this.runControllers.get(run.id)?.abort();
|
|
1049
876
|
};
|
|
1050
877
|
const runController = this.runControllers.get(run.id);
|
|
1051
878
|
if (signal) signal.addEventListener("abort", abortChild, { once: true });
|
|
@@ -1054,7 +881,7 @@ export class SubagentManager {
|
|
|
1054
881
|
signal?.removeEventListener("abort", abortChild);
|
|
1055
882
|
runController?.signal.removeEventListener("abort", abortChild);
|
|
1056
883
|
};
|
|
1057
|
-
|
|
884
|
+
|
|
1058
885
|
if (run.status === "aborted" || TERMINAL.includes(task.status) || signal?.aborted) {
|
|
1059
886
|
await child.abort();
|
|
1060
887
|
throw new Error("Canceled by subagent_cancel");
|
|
@@ -1062,7 +889,7 @@ export class SubagentManager {
|
|
|
1062
889
|
this.liveChildren.set(key, {
|
|
1063
890
|
abort: () => void child?.abort(),
|
|
1064
891
|
dispose: () => child?.dispose(),
|
|
1065
|
-
|
|
892
|
+
|
|
1066
893
|
steer: (message) =>
|
|
1067
894
|
void child?.prompt(message, { streamingBehavior: "steer" }).catch((err) =>
|
|
1068
895
|
this.pi.sendUserMessage(`[steer_subagent] ${err instanceof Error ? err.message : String(err)}`, {
|
|
@@ -1071,9 +898,6 @@ export class SubagentManager {
|
|
|
1071
898
|
),
|
|
1072
899
|
});
|
|
1073
900
|
|
|
1074
|
-
// The ceiling is the ONLY bound on a child that emits events forever (retry
|
|
1075
|
-
// or tool-call livelock). So auto-limit off RAISES it, never removes it —
|
|
1076
|
-
// removing it reproduced the immortal-child hang.
|
|
1077
901
|
const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : UNLIMITED_RUNTIME_MS);
|
|
1078
902
|
const promptPromise = child.prompt(task.task, { source: "extension" });
|
|
1079
903
|
const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise];
|
|
@@ -1093,31 +917,19 @@ export class SubagentManager {
|
|
|
1093
917
|
const finalText =
|
|
1094
918
|
task.finalText ||
|
|
1095
919
|
truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
|
|
1096
|
-
|
|
1097
|
-
// final text was being assembled must not be overwritten with "completed"
|
|
1098
|
-
// (which would also skip the partial commit and drop the child's work).
|
|
1099
|
-
// `awaiting_parent` also means the child hadn't finished talking — marking
|
|
1100
|
-
// it completed would publish a truncated finalText to every dependent.
|
|
920
|
+
|
|
1101
921
|
if (task.status === "awaiting_parent") {
|
|
1102
922
|
this.pendingReplies.get(key)?.resolve("(your task is being finalized — stop work and return now)");
|
|
1103
923
|
}
|
|
1104
924
|
if (!TERMINAL.includes(task.status)) {
|
|
1105
925
|
this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
|
|
1106
926
|
if (wt) {
|
|
1107
|
-
// Commit the child's changes, then report the branch + diff so the
|
|
1108
|
-
// leader can review and merge (PR-style). The worktree dir stays
|
|
1109
|
-
// until the branch is merged — cleanupMerged removes both then.
|
|
1110
|
-
// Commit/diff failures must NOT downgrade a completed task or destroy
|
|
1111
|
-
// its work: the error is reported, the status stays completed.
|
|
1112
927
|
let committed: "committed" | "empty" | undefined;
|
|
1113
928
|
try {
|
|
1114
929
|
committed = commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
1115
|
-
|
|
1116
|
-
// leader off to review and merge nothing.
|
|
930
|
+
|
|
1117
931
|
keepWorktreeDir = committed === "committed";
|
|
1118
932
|
} catch (commitErr) {
|
|
1119
|
-
// Never drop a checkout whose work isn't on the branch — it would be
|
|
1120
|
-
// unreachable once the base-tip branch is reaped as "merged".
|
|
1121
933
|
keepWorktreeDir = true;
|
|
1122
934
|
this.updateTask(
|
|
1123
935
|
run,
|
|
@@ -1130,7 +942,7 @@ export class SubagentManager {
|
|
|
1130
942
|
onUpdate,
|
|
1131
943
|
);
|
|
1132
944
|
}
|
|
1133
|
-
|
|
945
|
+
|
|
1134
946
|
if (committed === "committed") {
|
|
1135
947
|
try {
|
|
1136
948
|
const { stat, files } = branchDiff(wt);
|
|
@@ -1158,20 +970,14 @@ export class SubagentManager {
|
|
|
1158
970
|
}
|
|
1159
971
|
} catch (err) {
|
|
1160
972
|
if (timeout) clearTimeout(timeout);
|
|
1161
|
-
|
|
973
|
+
|
|
1162
974
|
const aborted = signal?.aborted || run.status === "aborted" || task.status === "aborted";
|
|
1163
975
|
const subagentStatus = (err as Error & { subagentStatus?: string })?.subagentStatus;
|
|
1164
976
|
try {
|
|
1165
|
-
// Unblock a child stuck in ask_parent, then time-box the abort so a
|
|
1166
|
-
// wedged session can never hang this catch/finally.
|
|
1167
977
|
this.pendingReplies.get(key)?.resolve("(parent unreachable)");
|
|
1168
978
|
await Promise.race([child?.abort(), new Promise((r) => setTimeout(r, 5000))]);
|
|
1169
|
-
} catch {
|
|
1170
|
-
|
|
1171
|
-
}
|
|
1172
|
-
// Publish whatever the child DID say before it was killed. A timeout or
|
|
1173
|
-
// abort used to discard it, so a chain dependent received nothing at all
|
|
1174
|
-
// while the child's partial work was still committed to its branch.
|
|
979
|
+
} catch {}
|
|
980
|
+
|
|
1175
981
|
const salvaged =
|
|
1176
982
|
task.finalText ||
|
|
1177
983
|
truncateText(
|
|
@@ -1191,46 +997,37 @@ export class SubagentManager {
|
|
|
1191
997
|
);
|
|
1192
998
|
} finally {
|
|
1193
999
|
this.liveChildren.delete(key);
|
|
1194
|
-
|
|
1195
|
-
// timer and leaves the child's await unsettled.
|
|
1000
|
+
|
|
1196
1001
|
this.pendingReplies.get(key)?.resolve("(task ended — stop work now)");
|
|
1197
1002
|
this.pendingReplies.delete(key);
|
|
1198
1003
|
abortListener?.();
|
|
1199
1004
|
unsubscribe?.();
|
|
1200
1005
|
if (timeout) clearTimeout(timeout);
|
|
1201
1006
|
child?.dispose();
|
|
1202
|
-
|
|
1203
|
-
// still be unwinding), commit whatever partial work exists so the branch
|
|
1204
|
-
// really keeps it, then drop the checkout dir. A commit FAILURE keeps the
|
|
1205
|
-
// dir — dropping it would make the work unreachable.
|
|
1007
|
+
|
|
1206
1008
|
if (wt && task.status !== "completed") {
|
|
1207
1009
|
await new Promise((r) => setTimeout(r, 250));
|
|
1208
1010
|
let partial: "committed" | "empty" | undefined;
|
|
1209
1011
|
try {
|
|
1210
1012
|
partial = commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
1211
1013
|
} catch {
|
|
1212
|
-
keepWorktreeDir = true;
|
|
1014
|
+
keepWorktreeDir = true;
|
|
1213
1015
|
}
|
|
1214
|
-
|
|
1016
|
+
|
|
1215
1017
|
if (partial === "committed" || keepWorktreeDir) {
|
|
1216
1018
|
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
1217
1019
|
}
|
|
1218
1020
|
}
|
|
1219
1021
|
if (wt && !keepWorktreeDir) removeWorktree(wt);
|
|
1220
|
-
|
|
1221
|
-
// in liveBranches() so cleanup can't reap it.
|
|
1022
|
+
|
|
1222
1023
|
this.liveWorktrees.delete(key);
|
|
1223
1024
|
}
|
|
1224
1025
|
}
|
|
1225
1026
|
|
|
1226
|
-
// ── run lifecycle ───────────────────────────────────────────────────
|
|
1227
1027
|
createRun(params: SubagentParamsShape, ctx: ExtensionContext): { run: RunSnapshot; inputs: TaskInput[] } {
|
|
1228
1028
|
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
1229
1029
|
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
1230
|
-
|
|
1231
|
-
// those in place when switching to tasks:[...], and the intent is not
|
|
1232
|
-
// ambiguous — rejecting a well-formed 3-task call over leftovers is worse
|
|
1233
|
-
// than ignoring them. Genuine ambiguity (tasks AND chain) is still refused.
|
|
1030
|
+
|
|
1234
1031
|
const hasSingle = !hasChain && !hasTasks && Boolean(params.agent && params.task);
|
|
1235
1032
|
if (hasChain && hasTasks) {
|
|
1236
1033
|
throw new Error(`Provide either tasks (parallel) or chain (sequential), not both.`);
|
|
@@ -1240,12 +1037,7 @@ export class SubagentManager {
|
|
|
1240
1037
|
`Provide one subagent mode: agent+task (single), tasks: [...] (parallel), or chain: [...] (sequential).`,
|
|
1241
1038
|
);
|
|
1242
1039
|
}
|
|
1243
|
-
|
|
1244
|
-
// otherwise: `write: true` next to tasks:[...] produced read-only children
|
|
1245
|
-
// that reported they "cannot edit files", with nothing explaining why.
|
|
1246
|
-
// `cwd` and `maxRuntimeMs` are meaningful run-wide (and the schema advertises
|
|
1247
|
-
// maxRuntimeMs without a single-mode marker), so they FAN OUT as per-task
|
|
1248
|
-
// defaults instead of being refused. The rest genuinely describe one agent.
|
|
1040
|
+
|
|
1249
1041
|
if (hasChain || hasTasks) {
|
|
1250
1042
|
const stray = (["write", "prompt", "tools", "model", "thinking"] as const).filter((k) => params[k] !== undefined);
|
|
1251
1043
|
if (stray.length > 0) {
|
|
@@ -1271,15 +1063,12 @@ export class SubagentManager {
|
|
|
1271
1063
|
},
|
|
1272
1064
|
]
|
|
1273
1065
|
: (hasTasks ? params.tasks! : params.chain!).map((item) => ({
|
|
1274
|
-
// Run-wide defaults; a per-task value always wins.
|
|
1275
1066
|
...item,
|
|
1276
1067
|
cwd: item.cwd ?? params.cwd,
|
|
1277
1068
|
maxRuntimeMs: item.maxRuntimeMs ?? params.maxRuntimeMs,
|
|
1278
1069
|
}));
|
|
1279
1070
|
if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
|
|
1280
|
-
|
|
1281
|
-
// Explicit ids are checked against each other; generated ones are checked
|
|
1282
|
-
// against explicit ones so a collision can't silently fall back to in-place.
|
|
1071
|
+
|
|
1283
1072
|
const ids = new Set<string>();
|
|
1284
1073
|
for (const input of inputs) {
|
|
1285
1074
|
if (input.id !== undefined) {
|
|
@@ -1297,14 +1086,9 @@ export class SubagentManager {
|
|
|
1297
1086
|
}
|
|
1298
1087
|
}
|
|
1299
1088
|
const edges = resolveNeeds(inputs, mode);
|
|
1300
|
-
|
|
1301
|
-
// that emits session_shutdown with no following session_start (SIGTERM, SDK
|
|
1302
|
-
// reuse, tests) leaves every later persist a silent no-op.
|
|
1089
|
+
|
|
1303
1090
|
this.cleared = false;
|
|
1304
|
-
|
|
1305
|
-
// overrides the requested model, so an unresolvable one is the leader's
|
|
1306
|
-
// mistake to see NOW — not N children dying one by one on their first turn
|
|
1307
|
-
// with an error naming a model the leader never asked for.
|
|
1091
|
+
|
|
1308
1092
|
for (let i = 0; i < inputs.length; i++) {
|
|
1309
1093
|
const input = inputs[i] as TaskInput;
|
|
1310
1094
|
const cwd = input.cwd ?? ctx.cwd;
|
|
@@ -1345,8 +1129,7 @@ export class SubagentManager {
|
|
|
1345
1129
|
})),
|
|
1346
1130
|
aggregateUsage: emptyUsage(),
|
|
1347
1131
|
};
|
|
1348
|
-
|
|
1349
|
-
// send_agent_message/poll_agent_messages can be used reliably.
|
|
1132
|
+
|
|
1350
1133
|
const roster = run.tasks.map((t) => `${t.id} (${t.agent})`).join(", ");
|
|
1351
1134
|
for (const task of run.tasks) {
|
|
1352
1135
|
task.roster = roster;
|
|
@@ -1374,15 +1157,12 @@ export class SubagentManager {
|
|
|
1374
1157
|
run.startedAt = Date.now();
|
|
1375
1158
|
this.updateRun(run, ctx, onUpdate);
|
|
1376
1159
|
|
|
1377
|
-
// One wave scheduler for every mode. A wave is the set of tasks whose needs
|
|
1378
|
-
// are all satisfied; the loop boundary between waves IS the gate. Chain mode
|
|
1379
|
-
// reaches here as needs: [previous], so it needs no special case.
|
|
1380
1160
|
const outputs = new Map<string, string>();
|
|
1381
1161
|
const settled = new Set<string>();
|
|
1382
1162
|
for (const task of run.tasks) {
|
|
1383
|
-
if (TERMINAL.includes(task.status)) settled.add(task.id);
|
|
1163
|
+
if (TERMINAL.includes(task.status)) settled.add(task.id);
|
|
1384
1164
|
}
|
|
1385
|
-
|
|
1165
|
+
|
|
1386
1166
|
const inputById = new Map(run.tasks.map((t, i) => [t.id, inputs[i]]));
|
|
1387
1167
|
|
|
1388
1168
|
const { skipped } = await runWaveScheduler(
|
|
@@ -1391,12 +1171,8 @@ export class SubagentManager {
|
|
|
1391
1171
|
outputs,
|
|
1392
1172
|
settled,
|
|
1393
1173
|
async (task) => {
|
|
1394
|
-
// The scheduler passes the index into the FILTERED list — never use it
|
|
1395
|
-
// against the unfiltered inputs. Look the input up by task id instead.
|
|
1396
1174
|
const input = inputById.get(task.id);
|
|
1397
1175
|
if (!input) {
|
|
1398
|
-
// Impossible unless ids drift from inputs — fail loudly instead of
|
|
1399
|
-
// leaving the task queued forever (hasActiveRun would never clear).
|
|
1400
1176
|
this.updateTask(
|
|
1401
1177
|
run,
|
|
1402
1178
|
task,
|
|
@@ -1421,7 +1197,7 @@ export class SubagentManager {
|
|
|
1421
1197
|
}
|
|
1422
1198
|
},
|
|
1423
1199
|
);
|
|
1424
|
-
|
|
1200
|
+
|
|
1425
1201
|
for (const s of skipped) {
|
|
1426
1202
|
const task = run.tasks.find((t) => t.id === s.id);
|
|
1427
1203
|
if (task && !TERMINAL.includes(task.status)) {
|
|
@@ -1430,7 +1206,7 @@ export class SubagentManager {
|
|
|
1430
1206
|
task,
|
|
1431
1207
|
{
|
|
1432
1208
|
status: "aborted",
|
|
1433
|
-
|
|
1209
|
+
|
|
1434
1210
|
error: task.error || `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
|
|
1435
1211
|
endedAt: Date.now(),
|
|
1436
1212
|
},
|
|
@@ -1439,9 +1215,7 @@ export class SubagentManager {
|
|
|
1439
1215
|
);
|
|
1440
1216
|
}
|
|
1441
1217
|
}
|
|
1442
|
-
|
|
1443
|
-
// would otherwise leave tasks queued inside a terminal run — hasActiveRun()
|
|
1444
|
-
// then never clears and the widget stays pinned.
|
|
1218
|
+
|
|
1445
1219
|
for (const task of run.tasks) {
|
|
1446
1220
|
if (TERMINAL.includes(task.status)) continue;
|
|
1447
1221
|
this.updateTask(
|
|
@@ -1458,11 +1232,10 @@ export class SubagentManager {
|
|
|
1458
1232
|
run.status = aborted ? "aborted" : failed ? "failed" : "completed";
|
|
1459
1233
|
run.endedAt = Date.now();
|
|
1460
1234
|
this.flushWidget(run, ctx, onUpdate);
|
|
1461
|
-
|
|
1462
|
-
// The agent_start handler clears them on the next turn that spawns nothing.
|
|
1235
|
+
|
|
1463
1236
|
const live = this.listRuns().find((r) => !TERMINAL.includes(r.status));
|
|
1464
1237
|
if (live) this.scheduleWidget(live, ctx);
|
|
1465
|
-
|
|
1238
|
+
|
|
1466
1239
|
if (this.settlers.has(run.id)) {
|
|
1467
1240
|
this.emit("subagent:run-completed", {
|
|
1468
1241
|
runId: run.id,
|
|
@@ -1475,9 +1248,7 @@ export class SubagentManager {
|
|
|
1475
1248
|
this.runControllers.delete(run.id);
|
|
1476
1249
|
for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
|
|
1477
1250
|
this.persist(ctx);
|
|
1478
|
-
|
|
1479
|
-
// Once per repo, never for a branch another live run owns, never fatal — a
|
|
1480
|
-
// throw here would re-settle an already-finished run as failed.
|
|
1251
|
+
|
|
1481
1252
|
try {
|
|
1482
1253
|
const roots = new Set<string>();
|
|
1483
1254
|
for (const task of run.tasks) {
|
|
@@ -1486,14 +1257,9 @@ export class SubagentManager {
|
|
|
1486
1257
|
if (root) roots.add(root);
|
|
1487
1258
|
}
|
|
1488
1259
|
for (const root of roots) cleanupMerged(root, { skipBranches: this.liveBranches() });
|
|
1489
|
-
} catch {
|
|
1490
|
-
/* cleanup is best-effort; the run outcome must stand */
|
|
1491
|
-
}
|
|
1260
|
+
} catch {}
|
|
1492
1261
|
}
|
|
1493
1262
|
|
|
1494
|
-
/** Branches owned by worktrees of still-running children. */
|
|
1495
|
-
/** True only for checkouts THIS manager still runs — a marker carrying our own
|
|
1496
|
-
* pid from a previous session in the same process is not proof of life. */
|
|
1497
1263
|
ownsWorktree = (path: string): boolean => {
|
|
1498
1264
|
for (const wt of this.liveWorktrees.values()) if (wt.path === path) return true;
|
|
1499
1265
|
return false;
|
|
@@ -1502,7 +1268,6 @@ export class SubagentManager {
|
|
|
1502
1268
|
return new Set(Array.from(this.liveWorktrees.values(), (wt) => wt.branch));
|
|
1503
1269
|
}
|
|
1504
1270
|
|
|
1505
|
-
/** Spawn a run that keeps executing after this call returns. Every run is background. */
|
|
1506
1271
|
startInBackground(params: SubagentParamsShape, ctx: ExtensionContext): RunDetails {
|
|
1507
1272
|
const { run, inputs } = this.createRun(params, ctx);
|
|
1508
1273
|
void this.executeTasks(run, inputs, ctx, undefined, undefined)
|
|
@@ -1513,7 +1278,6 @@ export class SubagentManager {
|
|
|
1513
1278
|
);
|
|
1514
1279
|
})
|
|
1515
1280
|
.catch((err) => {
|
|
1516
|
-
// Never leave a background run unsettled: mark failed, settle, notify.
|
|
1517
1281
|
run.status = "failed";
|
|
1518
1282
|
run.endedAt = Date.now();
|
|
1519
1283
|
for (const task of run.tasks) {
|
|
@@ -1533,7 +1297,6 @@ export class SubagentManager {
|
|
|
1533
1297
|
return { run: cloneRun(run) };
|
|
1534
1298
|
}
|
|
1535
1299
|
|
|
1536
|
-
/** Push a steering message into a live child's session. Returns false when unknown or not running. */
|
|
1537
1300
|
steerTask(runId: string, taskId: string | undefined, message: string): boolean {
|
|
1538
1301
|
const run = this.runs.get(runId);
|
|
1539
1302
|
if (!run) return false;
|
|
@@ -1543,12 +1306,11 @@ export class SubagentManager {
|
|
|
1543
1306
|
return true;
|
|
1544
1307
|
}
|
|
1545
1308
|
|
|
1546
|
-
/** Abort ONE task; siblings keep running. Returns false when unknown or already finished. */
|
|
1547
1309
|
cancelTask(runId: string, taskId: string, ctx?: ExtensionContext): boolean {
|
|
1548
1310
|
const run = this.runs.get(runId);
|
|
1549
1311
|
const task = run?.tasks.find((t) => t.id === taskId);
|
|
1550
1312
|
if (!run || !task || TERMINAL.includes(task.status)) return false;
|
|
1551
|
-
|
|
1313
|
+
|
|
1552
1314
|
task.status = "aborted";
|
|
1553
1315
|
task.error = task.error || "Canceled from peek";
|
|
1554
1316
|
task.endedAt = Date.now();
|
|
@@ -1564,11 +1326,10 @@ export class SubagentManager {
|
|
|
1564
1326
|
cancelRun(runId: string): { aborted: number } {
|
|
1565
1327
|
const run = this.runs.get(runId);
|
|
1566
1328
|
if (!run) return { aborted: 0 };
|
|
1567
|
-
if (TERMINAL.includes(run.status)) return { aborted: 0 };
|
|
1329
|
+
if (TERMINAL.includes(run.status)) return { aborted: 0 };
|
|
1568
1330
|
let aborted = 0;
|
|
1569
1331
|
this.runControllers.get(runId)?.abort();
|
|
1570
|
-
|
|
1571
|
-
// the child alive past the abort.
|
|
1332
|
+
|
|
1572
1333
|
for (const [key, pending] of this.pendingReplies) {
|
|
1573
1334
|
if (key.startsWith(`${runId}:`)) {
|
|
1574
1335
|
this.pendingReplies.delete(key);
|
|
@@ -1583,11 +1344,10 @@ export class SubagentManager {
|
|
|
1583
1344
|
for (const task of run.tasks) {
|
|
1584
1345
|
if (TERMINAL.includes(task.status)) continue;
|
|
1585
1346
|
task.status = "aborted";
|
|
1586
|
-
task.error = task.error || "Canceled by subagent_cancel";
|
|
1347
|
+
task.error = task.error || "Canceled by subagent_cancel";
|
|
1587
1348
|
task.endedAt = Date.now();
|
|
1588
1349
|
aborted += 1;
|
|
1589
|
-
|
|
1590
|
-
// runChild's finally commits + drops the dir (it owns the live worktree).
|
|
1350
|
+
|
|
1591
1351
|
const wt = this.liveWorktrees.get(`${runId}:${task.id}`);
|
|
1592
1352
|
if (wt) task.branch = wt.branch;
|
|
1593
1353
|
}
|
|
@@ -1600,7 +1360,6 @@ export class SubagentManager {
|
|
|
1600
1360
|
return { aborted };
|
|
1601
1361
|
}
|
|
1602
1362
|
|
|
1603
|
-
/** Settle-and-delete: every awaiter resolves once, then the set is dropped. */
|
|
1604
1363
|
private settleRun(runId: string, run: RunSnapshot): void {
|
|
1605
1364
|
if (!this.settlers.has(runId)) return;
|
|
1606
1365
|
this.settlers.delete(runId);
|
|
@@ -1611,12 +1370,8 @@ export class SubagentManager {
|
|
|
1611
1370
|
for (const waiter of waiters) waiter(snapshot);
|
|
1612
1371
|
}
|
|
1613
1372
|
|
|
1614
|
-
/** Child→leader messages collected while the parent is parked in await_subagent. */
|
|
1615
|
-
/** Every awaiter parked on a run — a SET, so two concurrent awaits can't
|
|
1616
|
-
* overwrite each other's buffer and silently swallow one side's intercom. */
|
|
1617
1373
|
private parked = new Map<string, Set<{ msgs: ParkedMsg[]; wake: () => void }>>();
|
|
1618
1374
|
|
|
1619
|
-
/** While the parent is parked on this run, deliver the message through the wait instead of the steering queue. */
|
|
1620
1375
|
private collectParked(runId: string, msg: ParkedMsg): boolean {
|
|
1621
1376
|
const parked = this.parked.get(runId);
|
|
1622
1377
|
if (!parked || parked.size === 0) return false;
|
|
@@ -1626,22 +1381,18 @@ export class SubagentManager {
|
|
|
1626
1381
|
p.msgs.push(msg);
|
|
1627
1382
|
delivered = true;
|
|
1628
1383
|
} else if (msg.kind === "ask") {
|
|
1629
|
-
// An unanswered ask blocks a child for 10 minutes — it must never be the
|
|
1630
|
-
// message that gets dropped by the cap. Evict the oldest NON-ask first
|
|
1631
|
-
// (asks already block children; displacing one hangs the earlier asker).
|
|
1632
1384
|
const drop = p.msgs.findIndex((m) => m.kind !== "ask");
|
|
1633
1385
|
if (drop !== -1) {
|
|
1634
1386
|
p.msgs.splice(drop, 1);
|
|
1635
1387
|
p.msgs.push(msg);
|
|
1636
1388
|
} else {
|
|
1637
|
-
p.msgs[p.msgs.length - 1] = msg;
|
|
1389
|
+
p.msgs[p.msgs.length - 1] = msg;
|
|
1638
1390
|
}
|
|
1639
1391
|
delivered = true;
|
|
1640
1392
|
}
|
|
1641
|
-
p.wake();
|
|
1393
|
+
p.wake();
|
|
1642
1394
|
}
|
|
1643
|
-
|
|
1644
|
-
// followUp notice instead of assuming the leader saw it.
|
|
1395
|
+
|
|
1645
1396
|
return delivered;
|
|
1646
1397
|
}
|
|
1647
1398
|
|
|
@@ -1655,7 +1406,7 @@ export class SubagentManager {
|
|
|
1655
1406
|
const finish = (): void => {
|
|
1656
1407
|
const parked = this.parked.get(runId);
|
|
1657
1408
|
if (!parked || !entry) return;
|
|
1658
|
-
parked.delete(entry);
|
|
1409
|
+
parked.delete(entry);
|
|
1659
1410
|
if (parked.size === 0) this.parked.delete(runId);
|
|
1660
1411
|
};
|
|
1661
1412
|
if (TERMINAL.includes(run.status)) {
|
|
@@ -1664,9 +1415,6 @@ export class SubagentManager {
|
|
|
1664
1415
|
}
|
|
1665
1416
|
const msgs: ParkedMsg[] = [];
|
|
1666
1417
|
const settled = new Promise<RunSnapshot | undefined>((resolve) => {
|
|
1667
|
-
// Waiters are a SET, not a chain: the autoAwait loop re-parks on every
|
|
1668
|
-
// child message, and wrapping the previous settler each time grew an
|
|
1669
|
-
// unbounded closure chain (each holding a snapshot clone).
|
|
1670
1418
|
const waiter = (r: RunSnapshot) => {
|
|
1671
1419
|
this.settleWaiters.get(runId)?.delete(waiter);
|
|
1672
1420
|
resolve(r);
|
|
@@ -1677,8 +1425,7 @@ export class SubagentManager {
|
|
|
1677
1425
|
this.settleWaiters.set(runId, waiters);
|
|
1678
1426
|
}
|
|
1679
1427
|
waiters.add(waiter);
|
|
1680
|
-
|
|
1681
|
-
// IN the await result, no steering queue, no turn boundary needed.
|
|
1428
|
+
|
|
1682
1429
|
entry = { msgs, wake: () => waiter(cloneRun(run)) };
|
|
1683
1430
|
let parked = this.parked.get(runId);
|
|
1684
1431
|
if (!parked) {
|
|
@@ -1692,9 +1439,7 @@ export class SubagentManager {
|
|
|
1692
1439
|
return Promise.race([
|
|
1693
1440
|
settled.then((r) => {
|
|
1694
1441
|
finish();
|
|
1695
|
-
|
|
1696
|
-
// leader. A slice that already timed out is abandoned — setting it here
|
|
1697
|
-
// would suppress the run's completion notice the leader still needs.
|
|
1442
|
+
|
|
1698
1443
|
if (!timedOut) run.awaited = true;
|
|
1699
1444
|
return { run: r, intercom: msgs };
|
|
1700
1445
|
}),
|