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/README.md +39 -12
- package/active-runs.ts +87 -0
- package/agents/actor.md +14 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents.ts +27 -6
- package/index.ts +425 -125
- package/message.ts +592 -111
- package/orchestrator.md +118 -134
- package/package.json +1 -1
- package/pool.ts +387 -167
- package/session-ui.ts +44 -3
- package/session.ts +7 -0
- package/spawn.ts +232 -69
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,
|
|
73
|
-
events
|
|
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.
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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 (
|
|
178
|
-
|
|
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
|
-
|
|
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.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
|
|
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}.${
|
|
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
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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"
|
|
322
|
-
|
|
323
|
-
|
|
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.
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
518
|
+
call.reject(new Error(`RPC command ${String(cmd.type ?? "unknown")} timed out.`));
|
|
365
519
|
}, timeoutMs);
|
|
366
|
-
pooled.pending.set(id,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
472
|
-
|
|
665
|
+
detach();
|
|
666
|
+
finish();
|
|
473
667
|
},
|
|
474
668
|
reject: (error) => {
|
|
475
|
-
|
|
476
|
-
|
|
669
|
+
detach();
|
|
670
|
+
fail(error);
|
|
477
671
|
},
|
|
478
672
|
};
|
|
479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
result.
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
742
|
+
}
|
|
743
|
+
if (ev.type === "tool_result_end" && ev.message) {
|
|
744
|
+
result.messages.push(ev.message);
|
|
523
745
|
}
|
|
524
746
|
}
|
|
525
|
-
|
|
526
|
-
|
|
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 (
|
|
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
|
}
|