pi-agent-squad 0.8.0 → 0.8.3

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/pool.ts CHANGED
@@ -59,18 +59,45 @@ export function finalOutput(messages: TaskResult["messages"]): string {
59
59
 
60
60
  const IDLE_RECYCLE_MS = 10 * 60 * 1000; // recycle idle processes after 10 min
61
61
  const FORCE_KILL_DELAY_MS = 5000;
62
+ /** Max wait for one RPC command/response round trip on the control channel. */
63
+ const RPC_TIMEOUT_MS = 30 * 1000;
64
+ /** Max wait for a freshly spawned RPC child to answer its first command. */
65
+ const STARTUP_TIMEOUT_MS = 30 * 1000;
62
66
 
63
67
  interface TurnWaiter {
68
+ /**
69
+ * agent_settled generation this waiter is waiting to exceed; `null` while
70
+ * the prompt that started the turn has not been accepted yet. A waiter is
71
+ * only resolved by an agent_settled observed after its own prompt was
72
+ * accepted, so a stale settled from an earlier turn can never complete it.
73
+ */
74
+ waitGen: number | null;
64
75
  resolve: () => void;
65
76
  reject: (error: Error) => void;
66
77
  }
67
78
 
79
+ /** One in-flight RPC request awaiting its id-correlated response. */
80
+ interface PendingCall {
81
+ resolve: (data: unknown) => void;
82
+ reject: (error: Error) => void;
83
+ }
84
+
68
85
  interface PooledProcess {
69
86
  agent: AgentConfig;
87
+ runId: string;
88
+ childIndex: number;
70
89
  proc: ChildProcess;
71
90
  nextId: number;
72
- pending: Map<string, (data: unknown) => void>;
73
- events: Array<Record<string, unknown>>;
91
+ pending: Map<string, PendingCall>;
92
+ /** Count of observed agent_settled events; used to correlate task waiters. */
93
+ settledGeneration: number;
94
+ /** Events for the currently running task only; never a process-wide log. */
95
+ activeTaskEvents?: Array<Record<string, unknown>>;
96
+ /**
97
+ * Temp file holding --append-system-prompt. Deleted once the child
98
+ * confirmed startup (first RPC answer) or when the process is gone.
99
+ */
100
+ promptPath?: string;
74
101
  lastUsed: number;
75
102
  spawnError?: string;
76
103
  closed: boolean;
@@ -81,8 +108,13 @@ interface PooledProcess {
81
108
 
82
109
  export class SubagentPool {
83
110
  private procs = new Map<string, PooledProcess>();
111
+ private starting = new Map<string, Promise<PooledProcess>>();
112
+ private startingProcesses = new Map<string, PooledProcess>();
113
+ /** Per-agent queue serializing check-then-act session ops (send/abort). */
114
+ private sessionOps = new Map<string, Promise<unknown>>();
84
115
  private eventListeners = new Map<string, Set<(event: any) => void>>();
85
116
  private taskTails = new Map<string, Promise<void>>();
117
+ private processExitHandler?: (agentName: string, runId: string) => void;
86
118
  private recycleTimer: ReturnType<typeof setInterval>;
87
119
  private messageRoot: string;
88
120
  private workingDirectory = process.cwd();
@@ -108,6 +140,66 @@ export class SubagentPool {
108
140
  }
109
141
  }
110
142
 
143
+ setProcessExitHandler(handler: ((agentName: string, runId: string) => void) | undefined): void {
144
+ this.processExitHandler = handler;
145
+ }
146
+
147
+ private isUsable(pooled: PooledProcess | undefined): pooled is PooledProcess {
148
+ return (
149
+ !!pooled &&
150
+ !pooled.closed &&
151
+ pooled.proc.exitCode === null &&
152
+ pooled.proc.signalCode === null
153
+ );
154
+ }
155
+
156
+ private stderrTail(pooled: PooledProcess): string {
157
+ const text = (pooled.spawnError ?? "").trim();
158
+ if (!text) return "";
159
+ return text.length > 2000 ? `…${text.slice(-2000)}` : text;
160
+ }
161
+
162
+ /** Reject every in-flight RPC call; the process is going away. */
163
+ private failPending(pooled: PooledProcess, error: Error): void {
164
+ const calls = [...pooled.pending.values()];
165
+ pooled.pending.clear();
166
+ for (const call of calls) call.reject(error);
167
+ }
168
+
169
+ /** Delete the --append-system-prompt temp file (idempotent). */
170
+ private cleanupPromptFile(pooled: PooledProcess): void {
171
+ const filePath = pooled.promptPath;
172
+ if (!filePath) return;
173
+ pooled.promptPath = undefined;
174
+ try {
175
+ fs.unlinkSync(filePath);
176
+ } catch {
177
+ /* already gone */
178
+ }
179
+ try {
180
+ fs.rmdirSync(path.dirname(filePath));
181
+ } catch {
182
+ /* not empty or already gone */
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Serialize check-then-act session operations (send/abort) per agent. This
188
+ * is separate from withAgentLock on purpose: tasks hold the agent lock for
189
+ * their whole duration, while session ops must interleave with a running
190
+ * task (e.g. steering or aborting it from the UI).
191
+ */
192
+ private withSessionOp<T>(agentName: string, run: () => Promise<T>): Promise<T> {
193
+ const previous = this.sessionOps.get(agentName) ?? Promise.resolve();
194
+ const next: Promise<T> = previous.catch(() => {}).then(run);
195
+ const tail = next.catch(() => {});
196
+ this.sessionOps.set(agentName, tail);
197
+ void tail.finally(() => {
198
+ if (this.sessionOps.get(agentName) === tail) this.sessionOps.delete(agentName);
199
+ });
200
+ return next;
201
+ }
202
+
111
203
  /** Serialize tasks per agent so one agent_end cannot complete multiple callers. */
112
204
  private async withAgentLock<T>(agentName: string, run: () => Promise<T>): Promise<T> {
113
205
  const previous = this.taskTails.get(agentName) ?? Promise.resolve();
@@ -129,10 +221,15 @@ export class SubagentPool {
129
221
 
130
222
  private terminateProcess(agentName: string, pooled: PooledProcess, reason: Error): void {
131
223
  if (this.procs.get(agentName) === pooled) this.procs.delete(agentName);
224
+ const wasClosed = pooled.closed;
132
225
  pooled.closed = true;
133
- this.emitAgentEvent(agentName, { type: "session_closed", error: reason.message });
134
- const waiters = pooled.turnWaiters.splice(0);
135
- for (const waiter of waiters) waiter.reject(reason);
226
+ this.failPending(pooled, reason);
227
+ if (!wasClosed) this.processExitHandler?.(agentName, pooled.runId);
228
+ if (!wasClosed) {
229
+ this.emitAgentEvent(agentName, { type: "session_closed", error: reason.message });
230
+ const waiters = pooled.turnWaiters.splice(0);
231
+ for (const waiter of waiters) waiter.reject(reason);
232
+ }
136
233
  try {
137
234
  pooled.proc.kill("SIGTERM");
138
235
  } catch {
@@ -166,17 +263,43 @@ export class SubagentPool {
166
263
  const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
167
264
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
168
265
  const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
169
- await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
170
- return filePath;
266
+ try {
267
+ await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
268
+ return filePath;
269
+ } catch (error) {
270
+ await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
271
+ throw error;
272
+ }
171
273
  }
172
274
 
173
- /** Ensure a resident process exists for the agent */
275
+ /** Ensure a resident process exists for the agent. Concurrent callers share one startup. */
174
276
  async ensureProcess(agent: AgentConfig): Promise<PooledProcess> {
175
277
  if (this.disposed) throw new Error("Subagent pool is disposed.");
176
278
  const existing = this.procs.get(agent.name);
177
- if (existing && !existing.closed && existing.proc.exitCode === null && existing.proc.signalCode === null) {
178
- return existing;
279
+ if (this.isUsable(existing)) return existing;
280
+ const inFlight = this.starting.get(agent.name);
281
+ if (inFlight) {
282
+ // Another caller is already spawning this agent; share its startup
283
+ // instead of spawning an orphan. A rejected startup propagates to
284
+ // every waiter; if it resolved with a process that died right away,
285
+ // fall through to the normal path again.
286
+ const pooled = await inFlight;
287
+ if (this.isUsable(pooled) && this.procs.get(agent.name) === pooled) return pooled;
288
+ return this.ensureProcess(agent);
289
+ }
290
+ const startup = this.startProcess(agent);
291
+ this.starting.set(agent.name, startup);
292
+ try {
293
+ return await startup;
294
+ } finally {
295
+ if (this.starting.get(agent.name) === startup) this.starting.delete(agent.name);
179
296
  }
297
+ }
298
+
299
+ private async startProcess(agent: AgentConfig): Promise<PooledProcess> {
300
+ if (this.disposed) throw new Error("Subagent pool is disposed.");
301
+ const existing = this.procs.get(agent.name);
302
+ if (this.isUsable(existing)) return existing;
180
303
  // kill the old process, if any
181
304
  if (existing) {
182
305
  this.terminateProcess(agent.name, existing, new Error(`Replacing stale subagent process for ${agent.name}.`));
@@ -194,7 +317,7 @@ export class SubagentPool {
194
317
  let tmpPromptPath: string | null = null;
195
318
  if (agent.systemPrompt.trim()) {
196
319
  tmpPromptPath = await this.writePromptToTempFile(agent.name, agent.systemPrompt);
197
- args.push("--append-system-prompt", tmpPromptPath);
320
+ if (tmpPromptPath) args.push("--append-system-prompt", tmpPromptPath);
198
321
  }
199
322
 
200
323
  const runId = randomUUID();
@@ -210,10 +333,13 @@ export class SubagentPool {
210
333
 
211
334
  const pooled: PooledProcess = {
212
335
  agent,
336
+ runId,
337
+ childIndex,
213
338
  proc: null as unknown as ChildProcess,
214
339
  nextId: 1,
215
340
  pending: new Map(),
216
- events: [],
341
+ settledGeneration: 0,
342
+ promptPath: tmpPromptPath ?? undefined,
217
343
  lastUsed: Date.now(),
218
344
  closed: false,
219
345
  turnWaiters: [],
@@ -227,86 +353,83 @@ export class SubagentPool {
227
353
  env,
228
354
  });
229
355
  pooled.proc = proc;
356
+ this.startingProcesses.set(agent.name, pooled);
230
357
 
231
358
  let buffer = "";
232
- let readyResolve: (() => void) | undefined;
233
- const ready = new Promise<void>((r) => (readyResolve = r));
234
- let readyTimer: ReturnType<typeof setTimeout> | undefined;
235
359
  proc.stdout.on("data", (data) => {
236
360
  buffer += data.toString();
237
361
  const lines = buffer.split("\n");
238
362
  buffer = lines.pop() || "";
239
363
  for (const line of lines) this.processLine(pooled, line);
240
- // any event means the RPC loop is up
241
- if (readyResolve && pooled.events.length > 0) {
242
- readyResolve();
243
- readyResolve = undefined;
244
- if (readyTimer) clearTimeout(readyTimer);
245
- }
246
364
  });
247
365
  proc.stderr.on("data", (data) => {
248
366
  pooled.spawnError = (pooled.spawnError ?? "") + data.toString();
249
367
  });
250
368
  proc.on("close", (code) => {
369
+ const wasClosed = pooled.closed;
251
370
  pooled.closed = true;
252
- this.emitAgentEvent(agent.name, {
253
- type: "session_closed",
254
- error: `Subagent process exited (code ${code ?? "unknown"}).`,
255
- });
256
- if (readyResolve) {
257
- readyResolve();
258
- readyResolve = undefined;
259
- if (readyTimer) clearTimeout(readyTimer);
260
- }
261
- for (const [, resolve] of pooled.pending) {
262
- resolve({ type: "process_exit", code });
263
- }
264
- pooled.pending.clear();
265
- pooled.events.push({ type: "process_exit", code });
266
- const waiters = pooled.turnWaiters.splice(0);
267
- const error = new Error(
268
- `Subagent process exited before completing the task (code ${code ?? "unknown"}).${pooled.spawnError ? ` ${pooled.spawnError.trim()}` : ""}`,
371
+ if (this.procs.get(agent.name) === pooled) this.procs.delete(agent.name);
372
+ if (this.startingProcesses.get(agent.name) === pooled) this.startingProcesses.delete(agent.name);
373
+ this.failPending(
374
+ pooled,
375
+ new Error(`Subagent process exited before answering (code ${code ?? "unknown"}).`),
269
376
  );
270
- for (const waiter of waiters) waiter.reject(error);
377
+ const waiters = pooled.turnWaiters.splice(0);
378
+ if (!wasClosed) {
379
+ this.processExitHandler?.(agent.name, pooled.runId);
380
+ if (waiters.length > 0) {
381
+ const error = new Error(
382
+ `Subagent process exited before completing the task (code ${code ?? "unknown"}).${pooled.spawnError ? ` ${this.stderrTail(pooled)}` : ""}`,
383
+ );
384
+ for (const waiter of waiters) waiter.reject(error);
385
+ }
386
+ this.emitAgentEvent(agent.name, {
387
+ type: "session_closed",
388
+ error: `Subagent process exited (code ${code ?? "unknown"}).`,
389
+ });
390
+ }
391
+ // Safety net: a prompt file is still on disk if the child never
392
+ // confirmed startup; the process is gone now, so it can go too.
393
+ this.cleanupPromptFile(pooled);
271
394
  });
272
395
  proc.on("error", (err) => {
273
- pooled.spawnError = err.message;
274
- if (readyResolve) {
275
- readyResolve();
276
- readyResolve = undefined;
277
- if (readyTimer) clearTimeout(readyTimer);
278
- }
396
+ pooled.spawnError = (pooled.spawnError ?? "") + err.message;
397
+ this.failPending(pooled, new Error(`Failed to start subagent ${agent.name}: ${err.message}`));
279
398
  });
280
399
 
281
- // wait for RPC readiness: first event, or a 2s fallback timeout
282
- readyTimer = setTimeout(() => {
283
- if (readyResolve) {
284
- readyResolve();
285
- readyResolve = undefined;
286
- }
287
- }, 2000);
288
400
  try {
289
- await ready;
290
- if (this.disposed) {
291
- this.terminateProcess(agent.name, pooled, new Error("Subagent pool disposed during process startup."));
292
- throw new Error("Subagent pool is disposed.");
293
- }
294
- if (pooled.closed || proc.exitCode !== null || proc.signalCode !== null) {
401
+ // Startup is confirmed only when the RPC loop answers a real command.
402
+ // The previous "first event or 2s" heuristic could resolve before the
403
+ // child had booted, deleting the system-prompt file while it was still
404
+ // needed.
405
+ const response = await this.sendCommandAndWait(pooled, { type: "get_state" }, STARTUP_TIMEOUT_MS);
406
+ this.responseData(response);
407
+ if (this.disposed) throw new Error("Subagent pool is disposed.");
408
+ if (!this.isUsable(pooled)) {
409
+ const stderr = this.stderrTail(pooled);
295
410
  throw new Error(
296
- `Failed to start subagent ${agent.name}.${pooled.spawnError ? ` ${pooled.spawnError.trim()}` : ""}`,
411
+ `Failed to start subagent ${agent.name}.${stderr ? ` ${stderr}` : ""}`,
297
412
  );
298
413
  }
414
+ // The child confirmed startup (RPC answered): the system prompt has
415
+ // been read from the temp file and it can be removed now. The close
416
+ // handler keeps a best-effort cleanup as a safety net.
417
+ this.cleanupPromptFile(pooled);
299
418
  this.procs.set(agent.name, pooled);
419
+ this.startingProcesses.delete(agent.name);
420
+ this.emitAgentEvent(agent.name, {
421
+ type: "session_ready",
422
+ agent: agent.name,
423
+ runId,
424
+ childIndex,
425
+ });
300
426
  return pooled;
301
- } finally {
302
- if (tmpPromptPath) {
303
- try {
304
- fs.unlinkSync(tmpPromptPath);
305
- fs.rmdirSync(path.dirname(tmpPromptPath));
306
- } catch {
307
- /* ignore */
308
- }
309
- }
427
+ } catch (error) {
428
+ const failure = error instanceof Error ? error : new Error(String(error));
429
+ this.terminateProcess(agent.name, pooled, failure);
430
+ this.startingProcesses.delete(agent.name);
431
+ this.cleanupPromptFile(pooled);
432
+ throw failure;
310
433
  }
311
434
  }
312
435
 
@@ -318,22 +441,34 @@ export class SubagentPool {
318
441
  } catch {
319
442
  return;
320
443
  }
321
- if (event?.type === "response" && event?.id !== undefined) {
322
- const resolve = pooled.pending.get(String(event.id));
323
- if (resolve) {
444
+ if (event?.type === "response") {
445
+ // A response without an id cannot be correlated; drop it instead of
446
+ // treating it as an agent event.
447
+ if (event?.id === undefined) return;
448
+ const call = pooled.pending.get(String(event.id));
449
+ if (call) {
324
450
  pooled.pending.delete(String(event.id));
325
- resolve(event);
451
+ call.resolve(event);
326
452
  }
327
453
  return;
328
454
  }
329
- pooled.events.push(event);
455
+ pooled.activeTaskEvents?.push(event);
330
456
  this.emitAgentEvent(pooled.agent.name, event);
331
457
  // The whole session-level run is finished only at agent_settled. Waiting
332
458
  // for agent_end would close the activity before queued interactive steer
333
- // or follow-up messages have been processed.
334
- if (event?.type === "agent_settled" && pooled.turnWaiters.length > 0) {
335
- const waiters = pooled.turnWaiters.splice(0);
336
- for (const waiter of waiters) waiter.resolve();
459
+ // or follow-up messages have been processed. A waiter only completes for
460
+ // a settled observed after its own prompt was accepted (waitGen), so a
461
+ // stale settled already in flight cannot complete a newer task.
462
+ if (event?.type === "agent_settled") {
463
+ pooled.settledGeneration++;
464
+ if (pooled.turnWaiters.length > 0) {
465
+ const remaining: TurnWaiter[] = [];
466
+ for (const waiter of pooled.turnWaiters) {
467
+ if (waiter.waitGen !== null && waiter.waitGen < pooled.settledGeneration) waiter.resolve();
468
+ else remaining.push(waiter);
469
+ }
470
+ pooled.turnWaiters = remaining;
471
+ }
337
472
  }
338
473
  }
339
474
 
@@ -356,17 +491,33 @@ export class SubagentPool {
356
491
  return id;
357
492
  }
358
493
 
359
- private sendCommandAndWait(pooled: PooledProcess, cmd: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
494
+ private sendCommandAndWait(
495
+ pooled: PooledProcess,
496
+ cmd: Record<string, unknown>,
497
+ timeoutMs: number,
498
+ onResponded?: () => void,
499
+ ): Promise<unknown> {
360
500
  const id = this.sendCommand(pooled, cmd);
361
501
  return new Promise((resolve, reject) => {
362
- const timer = setTimeout(() => {
502
+ let timer: ReturnType<typeof setTimeout> | undefined;
503
+ const call: PendingCall = {
504
+ resolve: (data) => {
505
+ if (timer) clearTimeout(timer);
506
+ // Runs synchronously inside processLine before any later stdout
507
+ // line is parsed; runTask relies on that for settled ordering.
508
+ onResponded?.();
509
+ resolve(data);
510
+ },
511
+ reject: (error) => {
512
+ if (timer) clearTimeout(timer);
513
+ reject(error);
514
+ },
515
+ };
516
+ timer = setTimeout(() => {
363
517
  pooled.pending.delete(id);
364
- reject(new Error("RPC command timed out"));
518
+ call.reject(new Error(`RPC command ${String(cmd.type ?? "unknown")} timed out.`));
365
519
  }, timeoutMs);
366
- pooled.pending.set(id, (data) => {
367
- clearTimeout(timer);
368
- resolve(data);
369
- });
520
+ pooled.pending.set(id, call);
370
521
  });
371
522
  }
372
523
 
@@ -382,26 +533,39 @@ export class SubagentPool {
382
533
  agent: agent.name,
383
534
  getMessages: async () => {
384
535
  const pooled = await this.ensureProcess(agent);
385
- const response = await this.sendCommandAndWait(pooled, { type: "get_messages" }, 10000);
536
+ pooled.lastUsed = Date.now();
537
+ const response = await this.sendCommandAndWait(pooled, { type: "get_messages" }, RPC_TIMEOUT_MS);
386
538
  return this.responseData(response)?.messages ?? [];
387
539
  },
388
540
  isStreaming: async () => {
389
541
  const pooled = await this.ensureProcess(agent);
390
- const response = await this.sendCommandAndWait(pooled, { type: "get_state" }, 10000);
542
+ pooled.lastUsed = Date.now();
543
+ const response = await this.sendCommandAndWait(pooled, { type: "get_state" }, RPC_TIMEOUT_MS);
391
544
  return this.responseData(response)?.isStreaming === true;
392
545
  },
393
546
  send: async (message: string) => {
394
- const pooled = await this.ensureProcess(agent);
395
- const stateResponse = await this.sendCommandAndWait(pooled, { type: "get_state" }, 10000);
396
- const streaming = this.responseData(stateResponse)?.isStreaming === true;
397
- const command = streaming ? { type: "steer", message } : { type: "prompt", message };
398
- const response = await this.sendCommandAndWait(pooled, command, 10000);
399
- this.responseData(response);
547
+ // Session ops must interleave with a running task (steering or
548
+ // aborting it from the UI), so this uses withSessionOp and never
549
+ // the agent task lock, which a task holds for its whole duration.
550
+ await this.withSessionOp(agent.name, async () => {
551
+ const pooled = await this.ensureProcess(agent);
552
+ pooled.lastUsed = Date.now();
553
+ const stateResponse = await this.sendCommandAndWait(pooled, { type: "get_state" }, RPC_TIMEOUT_MS);
554
+ const streaming = this.responseData(stateResponse)?.isStreaming === true;
555
+ const command = streaming ? { type: "steer", message } : { type: "prompt", message };
556
+ // Check the RPC response: a rejected steer/prompt must surface
557
+ // to the caller instead of silently dropping the message.
558
+ const response = await this.sendCommandAndWait(pooled, command, RPC_TIMEOUT_MS);
559
+ this.responseData(response);
560
+ });
400
561
  },
401
562
  abort: async () => {
402
- const pooled = await this.ensureProcess(agent);
403
- const response = await this.sendCommandAndWait(pooled, { type: "abort" }, 10000);
404
- this.responseData(response);
563
+ await this.withSessionOp(agent.name, async () => {
564
+ const pooled = await this.ensureProcess(agent);
565
+ pooled.lastUsed = Date.now();
566
+ const response = await this.sendCommandAndWait(pooled, { type: "abort" }, RPC_TIMEOUT_MS);
567
+ this.responseData(response);
568
+ });
405
569
  },
406
570
  subscribe: (listener) => {
407
571
  let listeners = this.eventListeners.get(agent.name);
@@ -418,7 +582,26 @@ export class SubagentPool {
418
582
  };
419
583
  }
420
584
 
421
- /** Send a prompt command (fire-and-forget; events land in events) */
585
+ /** Runtime identity of the resident process, for the main-side run registry. */
586
+ getProcessIdentity(agentName: string): { runId: string; childIndex: number } | undefined {
587
+ const pooled = this.procs.get(agentName);
588
+ if (!pooled || pooled.closed || pooled.proc.exitCode !== null || pooled.proc.signalCode !== null) {
589
+ return undefined;
590
+ }
591
+ return { runId: pooled.runId, childIndex: pooled.childIndex };
592
+ }
593
+
594
+ /** Drop a resident so a subsequent request loads updated agent configuration. */
595
+ restartAgent(agentName: string): void {
596
+ const pooled = this.procs.get(agentName);
597
+ if (pooled) this.terminateProcess(agentName, pooled, new Error(`Reloading configuration for ${agentName}.`));
598
+ }
599
+
600
+ /**
601
+ * Send a prompt command fire-and-forget; events are delivered to listeners.
602
+ * The RPC response is not awaited — prefer the session handle's `send`
603
+ * (validates the response, avoids prompt/settled mismatches) or `runTask`.
604
+ */
422
605
  sendPrompt(agent: AgentConfig, message: string): string {
423
606
  const pooled = this.procs.get(agent.name);
424
607
  if (!pooled || pooled.closed) throw new Error(`No process for agent ${agent.name}`);
@@ -434,7 +617,9 @@ export class SubagentPool {
434
617
  onUpdate?: (events: unknown[]) => void,
435
618
  signal?: AbortSignal,
436
619
  ): Promise<TaskResult> {
620
+ if (signal?.aborted) throw new Error("Subagent task cancelled.");
437
621
  return this.withAgentLock(agent.name, async () => {
622
+ if (signal?.aborted) throw new Error("Subagent task cancelled.");
438
623
  const pooled = await this.ensureProcess(agent);
439
624
  pooled.lastUsed = Date.now();
440
625
 
@@ -447,99 +632,126 @@ export class SubagentPool {
447
632
  model: agent.model,
448
633
  };
449
634
 
450
- // event index at the start of this task
451
- const startIndex = pooled.events.length;
452
- // Register the waiter before sending the prompt so an extremely fast
453
- // agent_end cannot arrive in the gap and leave us waiting until timeout.
454
- const completion = new Promise<void>((resolve, reject) => {
455
- let waiter: TurnWaiter;
635
+ // Collect only this task's events. A process-wide event history would
636
+ // grow without bound for long-lived resident processes.
637
+ const taskEvents: Array<Record<string, unknown>> = [];
638
+ pooled.activeTaskEvents = taskEvents;
639
+ try {
640
+ // One waiter per task, registered before the prompt is sent so no
641
+ // completion event can slip through a registration gap. It fires
642
+ // only for an agent_settled observed after this prompt was accepted
643
+ // (waitGen, see processLine): a stale settled that is still in
644
+ // flight cannot complete this task early, and a rejected prompt
645
+ // rejects the waiter instead of hanging until timeout.
646
+ let finish!: () => void;
647
+ let fail!: (error: Error) => void;
648
+ const completion = new Promise<void>((resolve, reject) => {
649
+ finish = resolve;
650
+ fail = reject;
651
+ });
652
+ // An early failure below throws without awaiting completion.
653
+ void completion.catch(() => {});
654
+ let timer: ReturnType<typeof setTimeout> | undefined;
456
655
  let abortHandler: (() => void) | undefined;
457
- const cleanup = () => {
458
- clearTimeout(timer);
459
- if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
460
- };
461
- const timer = setTimeout(() => {
656
+ const detach = () => {
462
657
  const i = pooled.turnWaiters.indexOf(waiter);
463
658
  if (i >= 0) pooled.turnWaiters.splice(i, 1);
464
- const error = new Error(`Subagent task timed out after ${Math.round(timeoutMs / 1000)}s`);
465
- this.terminateProcess(agent.name, pooled, error);
466
- cleanup();
467
- reject(error);
468
- }, timeoutMs);
469
- waiter = {
659
+ if (timer) clearTimeout(timer);
660
+ if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
661
+ };
662
+ const waiter: TurnWaiter = {
663
+ waitGen: null,
470
664
  resolve: () => {
471
- cleanup();
472
- resolve();
665
+ detach();
666
+ finish();
473
667
  },
474
668
  reject: (error) => {
475
- cleanup();
476
- reject(error);
669
+ detach();
670
+ fail(error);
477
671
  },
478
672
  };
479
- pooled.turnWaiters.push(waiter);
673
+ timer = setTimeout(() => {
674
+ const error = new Error(`Subagent task timed out after ${Math.round(timeoutMs / 1000)}s`);
675
+ detach();
676
+ this.terminateProcess(agent.name, pooled, error);
677
+ fail(error);
678
+ }, timeoutMs);
480
679
  if (signal) {
481
680
  abortHandler = () => {
482
- const i = pooled.turnWaiters.indexOf(waiter);
483
- if (i >= 0) pooled.turnWaiters.splice(i, 1);
484
681
  const error = new Error("Subagent task cancelled.");
682
+ detach();
485
683
  this.terminateProcess(agent.name, pooled, error);
486
- cleanup();
487
- reject(error);
684
+ fail(error);
488
685
  };
489
686
  if (signal.aborted) abortHandler();
490
687
  else signal.addEventListener("abort", abortHandler, { once: true });
491
688
  }
492
- });
493
- try {
494
- this.sendCommand(pooled, { type: "prompt", message: task });
495
- } catch (e) {
496
- const waiter = pooled.turnWaiters.pop();
497
- waiter?.reject(e instanceof Error ? e : new Error(String(e)));
498
- }
689
+ pooled.turnWaiters.push(waiter);
690
+ try {
691
+ await this.withSessionOp(agent.name, async () => {
692
+ // Serialize the busy check with concurrent UI sends so the
693
+ // settled waiter can never bind to a foreign turn: a busy
694
+ // process is a hard error, not a silently queued prompt.
695
+ const stateResponse = await this.sendCommandAndWait(pooled, { type: "get_state" }, RPC_TIMEOUT_MS);
696
+ if (this.responseData(stateResponse)?.isStreaming === true) {
697
+ throw new Error(`Subagent ${agent.name} is busy; the current run must settle first.`);
698
+ }
699
+ const promptResponse = await this.sendCommandAndWait(
700
+ pooled,
701
+ { type: "prompt", message: task },
702
+ RPC_TIMEOUT_MS,
703
+ // Captured synchronously when the prompt response line is
704
+ // parsed: every agent_settled parsed after this moment
705
+ // belongs to (or follows) this task's turn.
706
+ () => {
707
+ waiter.waitGen = pooled.settledGeneration;
708
+ },
709
+ );
710
+ // Check the RPC response; success:false (prompt rejected)
711
+ // must fail the task instead of waiting on an unrelated turn.
712
+ this.responseData(promptResponse);
713
+ });
714
+ } catch (e) {
715
+ const error = e instanceof Error ? e : new Error(String(e));
716
+ waiter.reject(error);
717
+ throw error;
718
+ }
499
719
 
500
- // RPC returns the prompt response at preflight; wait for agent_settled
501
- // so retries, compaction, and interactive queued messages are included.
502
- await completion;
503
-
504
- // collect the events for this task
505
- const taskEvents = pooled.events.slice(startIndex);
506
- for (const ev of taskEvents as any[]) {
507
- if (ev.type === "message_end" && ev.message) {
508
- result.messages.push(ev.message);
509
- if (ev.message.role === "assistant") {
510
- result.usage.turns++;
511
- const u = ev.message.usage;
512
- if (u) {
513
- result.usage.input += u.input || 0;
514
- result.usage.output += u.output || 0;
515
- result.usage.cacheRead += u.cacheRead || 0;
516
- result.usage.cacheWrite += u.cacheWrite || 0;
517
- result.usage.cost += u.cost?.total || 0;
518
- result.usage.contextTokens = u.totalTokens || 0;
720
+ // RPC returns the prompt response at preflight; wait for agent_settled
721
+ // so retries, compaction, and interactive queued messages are included.
722
+ await completion;
723
+
724
+ for (const ev of taskEvents as any[]) {
725
+ if (ev.type === "message_end" && ev.message) {
726
+ result.messages.push(ev.message);
727
+ if (ev.message.role === "assistant") {
728
+ result.usage.turns++;
729
+ const u = ev.message.usage;
730
+ if (u) {
731
+ result.usage.input += u.input || 0;
732
+ result.usage.output += u.output || 0;
733
+ result.usage.cacheRead += u.cacheRead || 0;
734
+ result.usage.cacheWrite += u.cacheWrite || 0;
735
+ result.usage.cost += u.cost?.total || 0;
736
+ result.usage.contextTokens = u.totalTokens || 0;
737
+ }
738
+ if (!result.model && ev.message.model) result.model = ev.message.model;
739
+ if (ev.message.stopReason) result.stopReason = ev.message.stopReason;
740
+ if (ev.message.errorMessage) result.errorMessage = ev.message.errorMessage;
519
741
  }
520
- if (!result.model && ev.message.model) result.model = ev.message.model;
521
- if (ev.message.stopReason) result.stopReason = ev.message.stopReason;
522
- if (ev.message.errorMessage) result.errorMessage = ev.message.errorMessage;
742
+ }
743
+ if (ev.type === "tool_result_end" && ev.message) {
744
+ result.messages.push(ev.message);
523
745
  }
524
746
  }
525
- if (ev.type === "tool_result_end" && ev.message) {
526
- result.messages.push(ev.message);
527
- }
747
+ onUpdate?.(taskEvents);
748
+ return result;
749
+ } finally {
750
+ if (pooled.activeTaskEvents === taskEvents) pooled.activeTaskEvents = undefined;
528
751
  }
529
- onUpdate?.(taskEvents);
530
- return result;
531
752
  });
532
753
  }
533
754
 
534
- /** Get unconsumed events for an agent's process (for message-route scanning) */
535
- drainEvents(agentName: string): Array<Record<string, unknown>> {
536
- const pooled = this.procs.get(agentName);
537
- if (!pooled) return [];
538
- const out = pooled.events;
539
- pooled.events = [];
540
- return out;
541
- }
542
-
543
755
  /** Whether the process is alive */
544
756
  isAlive(agentName: string): boolean {
545
757
  const p = this.procs.get(agentName);
@@ -550,7 +762,7 @@ export class SubagentPool {
550
762
  private recycleIdle() {
551
763
  const now = Date.now();
552
764
  for (const [name, pooled] of this.procs) {
553
- if (pooled.closed || pooled.proc.exitCode !== null || pooled.proc.signalCode !== null) {
765
+ if (!this.isUsable(pooled)) {
554
766
  this.procs.delete(name);
555
767
  continue;
556
768
  }
@@ -568,10 +780,18 @@ export class SubagentPool {
568
780
  if (this.disposed) return;
569
781
  this.disposed = true;
570
782
  clearInterval(this.recycleTimer);
571
- for (const [name, pooled] of this.procs) {
783
+ for (const [name, pooled] of [...this.procs]) {
572
784
  this.terminateProcess(name, pooled, new Error("Subagent pool disposed."));
785
+ this.cleanupPromptFile(pooled);
573
786
  }
574
787
  this.procs.clear();
788
+ for (const [name, pooled] of [...this.startingProcesses]) {
789
+ this.terminateProcess(name, pooled, new Error("Subagent pool disposed during startup."));
790
+ this.cleanupPromptFile(pooled);
791
+ }
792
+ this.startingProcesses.clear();
793
+ this.starting.clear();
794
+ this.sessionOps.clear();
575
795
  this.taskTails.clear();
576
796
  this.eventListeners.clear();
577
797
  }