@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.0
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/CHANGELOG.md +20 -1
- package/CONTRIBUTING.md +8 -0
- package/README.md +98 -58
- package/docs/fork-differences.md +51 -0
- package/docs/verification-checklist.md +132 -0
- package/examples/agent-tool-description.md +3 -9
- package/package.json +13 -11
- package/pnpm-workspace.yaml +9 -0
- package/src/abortable.ts +43 -0
- package/src/agent-manager.ts +404 -140
- package/src/agent-runner.ts +462 -89
- package/src/agent-types.ts +269 -23
- package/src/checkpoint.ts +49 -0
- package/src/child-context.ts +30 -0
- package/src/cross-extension-rpc.ts +65 -5
- package/src/custom-agents.ts +161 -14
- package/src/default-agents.ts +1 -1
- package/src/index.ts +1014 -980
- package/src/invocation-config.ts +81 -12
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +62 -0
- package/src/nested-tools.ts +406 -0
- package/src/output-file.ts +18 -1
- package/src/prompts.ts +71 -0
- package/src/result-report.ts +170 -0
- package/src/schedule-store.ts +300 -13
- package/src/schedule.ts +222 -77
- package/src/settings.ts +206 -32
- package/src/status-note.ts +18 -1
- package/src/text-safety.ts +69 -0
- package/src/types.ts +101 -58
- package/src/ui/agent-widget.ts +104 -36
- package/src/ui/conversation-viewer.ts +36 -18
- package/src/ui/fleet-list.ts +19 -8
- package/src/ui/schedule-menu.ts +63 -19
- package/src/usage.ts +128 -8
- package/src/worktree.ts +19 -0
- package/src/group-join.ts +0 -141
package/src/agent-manager.ts
CHANGED
|
@@ -3,23 +3,46 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
5
5
|
* Excess agents are queued and auto-started as running agents complete.
|
|
6
|
-
*
|
|
6
|
+
* Nested children are exempt from that pool — see `occupiesPoolSlot`.
|
|
7
|
+
* Resumes are exempt from the admission gate: they start immediately and are
|
|
8
|
+
* counted while they run, so a parent-visible continuation is never silently
|
|
9
|
+
* deferred behind fresh spawns — a burst of resumes can therefore exceed the
|
|
10
|
+
* limit by design.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import { randomUUID } from "node:crypto";
|
|
10
|
-
import { statSync } from "node:fs";
|
|
14
|
+
import { rmSync, statSync } from "node:fs";
|
|
11
15
|
import { isAbsolute } from "node:path";
|
|
12
16
|
import type { Model } from "@earendil-works/pi-ai";
|
|
13
17
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
18
|
import { getDefaultMaxTurns, normalizeMaxTurns, resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
15
19
|
import { getAgentConfig } from "./agent-types.js";
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
20
|
+
import { describeModel } from "./model-resolver.js";
|
|
21
|
+
import type { AgentConfig, AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
22
|
+
import { addUsage, type LifetimeUsage } from "./usage.js";
|
|
23
|
+
import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, } from "./worktree.js";
|
|
19
24
|
|
|
20
25
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
21
26
|
export type OnAgentStart = (record: AgentRecord) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Fired by `steer()` once a message is delivered or queued. Every steer path
|
|
29
|
+
* that goes through the manager reports through it — the widget, the fleet
|
|
30
|
+
* list, the conversation viewer, and the cross-extension RPC. The steer tools
|
|
31
|
+
* (`steer_subagent` in index.ts and its nested twin) bypass this method because
|
|
32
|
+
* they await the session directly, so they emit on their own.
|
|
33
|
+
*/
|
|
34
|
+
export type OnAgentSteered = (record: AgentRecord, message: string) => void;
|
|
22
35
|
export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Fired once per assistant `message_end`, for EVERY agent this manager owns —
|
|
38
|
+
* top-level and nested alike, spawns and resumes. The one place where each
|
|
39
|
+
* message is seen exactly once: `AgentRecord.lifetimeUsage` is deliberately
|
|
40
|
+
* double-booked into ancestors (see `nested-tools.ts`) so a hidden child's spend
|
|
41
|
+
* shows up on the record a human can see, which makes those records useless as
|
|
42
|
+
* a basis for anything that must not count a message twice — parent-session
|
|
43
|
+
* accounting above all.
|
|
44
|
+
*/
|
|
45
|
+
export type OnAgentUsage = (record: AgentRecord, usage: LifetimeUsage) => void;
|
|
23
46
|
export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
|
|
24
47
|
|
|
25
48
|
/** Default max concurrent background agents. */
|
|
@@ -47,6 +70,19 @@ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined |
|
|
|
47
70
|
}
|
|
48
71
|
}
|
|
49
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Whether a record occupies one of the `maxConcurrent` background slots.
|
|
75
|
+
* Nested children don't: their parent already holds a slot, so counting (and
|
|
76
|
+
* therefore queueing) them would deadlock a parent that waits on its own child.
|
|
77
|
+
*
|
|
78
|
+
* Note this bounds nothing horizontally — the depth cap limits how DEEP nesting
|
|
79
|
+
* goes, not how WIDE. A parent's only limit on concurrent children is that each
|
|
80
|
+
* spawn costs it a turn, which is unbounded when max turns is unlimited.
|
|
81
|
+
*/
|
|
82
|
+
function occupiesPoolSlot(record: Pick<AgentRecord, "parentAgentId">): boolean {
|
|
83
|
+
return record.parentAgentId === undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
50
86
|
interface SpawnArgs {
|
|
51
87
|
pi: ExtensionAPI;
|
|
52
88
|
ctx: ExtensionContext;
|
|
@@ -55,14 +91,13 @@ interface SpawnArgs {
|
|
|
55
91
|
options: SpawnOptions;
|
|
56
92
|
}
|
|
57
93
|
|
|
58
|
-
interface SpawnOptions {
|
|
94
|
+
export interface SpawnOptions {
|
|
59
95
|
description: string;
|
|
60
96
|
model?: Model<any>;
|
|
61
97
|
maxTurns?: number;
|
|
62
98
|
isolated?: boolean;
|
|
63
99
|
inheritContext?: boolean;
|
|
64
100
|
thinkingLevel?: ThinkingLevel;
|
|
65
|
-
isBackground?: boolean;
|
|
66
101
|
/**
|
|
67
102
|
* Skip the maxConcurrent queue check for this spawn — start immediately even
|
|
68
103
|
* if the configured concurrency limit would otherwise queue it. Used by the
|
|
@@ -82,7 +117,14 @@ interface SpawnOptions {
|
|
|
82
117
|
cwd?: string;
|
|
83
118
|
/** Resolved invocation snapshot captured for UI display. */
|
|
84
119
|
invocation?: AgentInvocation;
|
|
85
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Parent abort signal — when aborted, the subagent is also stopped.
|
|
122
|
+
* Intended for in-process callers holding the manager directly (e.g. through
|
|
123
|
+
* the manager registry). Cross-extension RPC callers should pass serializable
|
|
124
|
+
* option values: `signal` is not part of the documented RPC surface and may
|
|
125
|
+
* not survive a serialized boundary. The `Agent` tool no longer forwards its
|
|
126
|
+
* tool-call signal to child runs.
|
|
127
|
+
*/
|
|
86
128
|
signal?: AbortSignal;
|
|
87
129
|
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
88
130
|
onToolActivity?: (activity: ToolActivity) => void;
|
|
@@ -96,14 +138,39 @@ interface SpawnOptions {
|
|
|
96
138
|
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
97
139
|
/** Called when the session successfully compacts. */
|
|
98
140
|
onCompaction?: (info: CompactionInfo) => void;
|
|
141
|
+
/** Nesting depth: a top-level subagent is 1. */
|
|
142
|
+
depth?: number;
|
|
143
|
+
/** Parent agent ID for ownership-scoped nested controls. */
|
|
144
|
+
parentAgentId?: string;
|
|
145
|
+
/** Effective inherited nesting cap for this branch. */
|
|
146
|
+
maxSubagentDepth?: number;
|
|
147
|
+
/** Config-discovery root inherited by nested launches when it differs from the working directory. */
|
|
148
|
+
configCwd?: string;
|
|
149
|
+
/** Root session id, inherited by nested launches so transcripts stay grouped. */
|
|
150
|
+
rootSessionId?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Config already resolved by the caller's own registry. A nested spawn reads
|
|
153
|
+
* its branch's config root, which may differ from the process-global registry
|
|
154
|
+
* `getAgentConfig` reads; without this the run would resolve the type again
|
|
155
|
+
* and could fall back to the unknown-type default.
|
|
156
|
+
*/
|
|
157
|
+
agentConfig?: AgentConfig;
|
|
99
158
|
}
|
|
100
159
|
|
|
160
|
+
/** The five activity callbacks a resumed run forwards to its caller. */
|
|
161
|
+
type ResumeCallbacks = Pick<
|
|
162
|
+
SpawnOptions,
|
|
163
|
+
"onToolActivity" | "onTextDelta" | "onTurnEnd" | "onAssistantUsage" | "onCompaction"
|
|
164
|
+
>;
|
|
165
|
+
|
|
101
166
|
export class AgentManager {
|
|
102
167
|
private agents = new Map<string, AgentRecord>();
|
|
103
168
|
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
104
169
|
private onComplete?: OnAgentComplete;
|
|
105
170
|
private onStart?: OnAgentStart;
|
|
106
171
|
private onCompact?: OnAgentCompact;
|
|
172
|
+
private onUsage?: OnAgentUsage;
|
|
173
|
+
private onSteered?: OnAgentSteered;
|
|
107
174
|
private maxConcurrent: number;
|
|
108
175
|
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
109
176
|
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
@@ -119,10 +186,14 @@ export class AgentManager {
|
|
|
119
186
|
maxConcurrent = DEFAULT_MAX_CONCURRENT,
|
|
120
187
|
onStart?: OnAgentStart,
|
|
121
188
|
onCompact?: OnAgentCompact,
|
|
189
|
+
onUsage?: OnAgentUsage,
|
|
190
|
+
onSteered?: OnAgentSteered,
|
|
122
191
|
) {
|
|
123
192
|
this.onComplete = onComplete;
|
|
124
193
|
this.onStart = onStart;
|
|
125
194
|
this.onCompact = onCompact;
|
|
195
|
+
this.onUsage = onUsage;
|
|
196
|
+
this.onSteered = onSteered;
|
|
126
197
|
this.maxConcurrent = maxConcurrent;
|
|
127
198
|
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
128
199
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
@@ -162,7 +233,7 @@ export class AgentManager {
|
|
|
162
233
|
id,
|
|
163
234
|
type,
|
|
164
235
|
description: options.description,
|
|
165
|
-
status:
|
|
236
|
+
status: "queued",
|
|
166
237
|
toolUses: 0,
|
|
167
238
|
startedAt: Date.now(),
|
|
168
239
|
abortController,
|
|
@@ -172,30 +243,37 @@ export class AgentManager {
|
|
|
172
243
|
turnCount: 1,
|
|
173
244
|
// max_turns: 0 (unlimited) maps to undefined; the full fallback chain
|
|
174
245
|
// is resolved here so every spawn path agrees. See AgentRecord.effectiveMaxTurns.
|
|
175
|
-
effectiveMaxTurns: normalizeMaxTurns(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
246
|
+
effectiveMaxTurns: normalizeMaxTurns(
|
|
247
|
+
options.maxTurns ?? (options.agentConfig ?? getAgentConfig(type))?.maxTurns ?? getDefaultMaxTurns(),
|
|
248
|
+
),
|
|
249
|
+
// Retention is read from the resolved agent config here, at spawn, so the
|
|
250
|
+
// removal path never re-resolves the registry. A resume reuses the record
|
|
251
|
+
// and therefore the decision its first run made.
|
|
252
|
+
persistSession: (options.agentConfig ?? getAgentConfig(type))?.persistSession === true,
|
|
182
253
|
invocation: options.invocation,
|
|
254
|
+
depth: options.depth ?? 1,
|
|
255
|
+
parentAgentId: options.parentAgentId,
|
|
256
|
+
maxSubagentDepth: options.maxSubagentDepth,
|
|
257
|
+
rootSessionId: options.rootSessionId,
|
|
183
258
|
};
|
|
184
259
|
this.agents.set(id, record);
|
|
185
260
|
|
|
186
261
|
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
187
262
|
|
|
188
|
-
if (
|
|
263
|
+
if (occupiesPoolSlot(record) && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
|
|
189
264
|
// Queue it — will be started when a running agent completes
|
|
190
265
|
this.queue.push({ id, args });
|
|
191
266
|
return id;
|
|
192
267
|
}
|
|
193
268
|
|
|
194
|
-
// startAgent can throw (
|
|
195
|
-
//
|
|
269
|
+
// startAgent can throw (strict worktree-isolation failure, or a throwing
|
|
270
|
+
// `subagents:started` listener after the worktree was created) — reclaim
|
|
271
|
+
// any prologue worktree and drop the record so callers don't see an
|
|
272
|
+
// orphan in `listAgents()`.
|
|
196
273
|
try {
|
|
197
274
|
this.startAgent(id, record, args);
|
|
198
275
|
} catch (err) {
|
|
276
|
+
this.reclaimWorktree(record, options.cwd ?? ctx.cwd);
|
|
199
277
|
this.agents.delete(id);
|
|
200
278
|
throw err;
|
|
201
279
|
}
|
|
@@ -204,6 +282,14 @@ export class AgentManager {
|
|
|
204
282
|
|
|
205
283
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
206
284
|
private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
|
|
285
|
+
// An already-aborted parent signal never fires "abort" again — wiring it
|
|
286
|
+
// alone would start a run under a signal its owner considers dead. Settle
|
|
287
|
+
// through the stopped tail before anything is created: no cwd/worktree
|
|
288
|
+
// validation, no started event, no runner call, no slot, one report.
|
|
289
|
+
if (options.signal?.aborted) {
|
|
290
|
+
this.settleStoppedWithoutRun(record);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
207
293
|
// Re-validate a caller-supplied cwd: queued spawns can start minutes after
|
|
208
294
|
// spawn()'s check, and the directory may be gone by then (TOCTOU). Same
|
|
209
295
|
// curated errors; drainQueue parks a throw on the record as an error.
|
|
@@ -216,9 +302,12 @@ export class AgentManager {
|
|
|
216
302
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
217
303
|
// fail loud if not possible (no silent fallback to main tree). Done
|
|
218
304
|
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
305
|
+
// The project switch is enforced here as well as at the tool boundary
|
|
306
|
+
// because cross-extension RPC forwards its options unvalidated — a schema
|
|
307
|
+
// that omits the field can't stop a caller that never saw the schema.
|
|
219
308
|
let worktreeCwd: string | undefined;
|
|
220
309
|
let worktreePromptInfo: { path: string; parentCwd: string } | undefined;
|
|
221
|
-
if (options.isolation === "worktree") {
|
|
310
|
+
if (options.isolation === "worktree" && isWorktreeIsolationEnabled()) {
|
|
222
311
|
const wt = createWorktree(baseCwd, id);
|
|
223
312
|
if (!wt) {
|
|
224
313
|
throw new Error(
|
|
@@ -240,9 +329,16 @@ export class AgentManager {
|
|
|
240
329
|
|
|
241
330
|
record.status = "running";
|
|
242
331
|
record.startedAt = Date.now();
|
|
243
|
-
if (options.isBackground) this.runningBackground++;
|
|
244
332
|
this.onStart?.(record);
|
|
245
333
|
|
|
334
|
+
// A started listener can stop the record synchronously (an extension
|
|
335
|
+
// reacting to `subagents:started`). Settle it through the stopped tail
|
|
336
|
+
// instead of starting — and counting — a run the user already stopped.
|
|
337
|
+
if (this.stoppedInStartPrologue(record)) {
|
|
338
|
+
this.settleStoppedWithoutRun(record, baseCwd);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
246
342
|
// Wire parent abort signal to stop the subagent when the parent is interrupted
|
|
247
343
|
let detachParentSignal: (() => void) | undefined;
|
|
248
344
|
if (options.signal) {
|
|
@@ -252,6 +348,11 @@ export class AgentManager {
|
|
|
252
348
|
}
|
|
253
349
|
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
254
350
|
|
|
351
|
+
// Increment only after every synchronous throw site (cwd/worktree
|
|
352
|
+
// validation, onStart) — the runner calls are async, so any later throw
|
|
353
|
+
// arrives as a rejection through .catch/.finally and cannot leak the slot.
|
|
354
|
+
if (occupiesPoolSlot(record)) this.runningBackground++;
|
|
355
|
+
record.settled = false;
|
|
255
356
|
const promise = runAgent(ctx, type, prompt, {
|
|
256
357
|
pi,
|
|
257
358
|
agentId: id,
|
|
@@ -260,6 +361,9 @@ export class AgentManager {
|
|
|
260
361
|
isolated: options.isolated,
|
|
261
362
|
inheritContext: options.inheritContext,
|
|
262
363
|
thinkingLevel: options.thinkingLevel,
|
|
364
|
+
// Resolved by the caller's own registry (a nested spawn reads its branch's
|
|
365
|
+
// config root), so the run does not re-resolve it against the global one.
|
|
366
|
+
agentConfig: options.agentConfig,
|
|
263
367
|
// Worktree wins for the working dir (the agent must run in the copy —
|
|
264
368
|
// which, with a custom cwd, was created from that target). Config stays
|
|
265
369
|
// with the parent project when a caller-supplied cwd is in play; it must
|
|
@@ -267,7 +371,7 @@ export class AgentManager {
|
|
|
267
371
|
// (incl. relative extension paths and memory) inside the worktree copy.
|
|
268
372
|
cwd: worktreeCwd ?? customCwd,
|
|
269
373
|
worktree: worktreePromptInfo,
|
|
270
|
-
configCwd: customCwd !== undefined ? ctx.cwd : undefined,
|
|
374
|
+
configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
|
|
271
375
|
signal: record.abortController!.signal,
|
|
272
376
|
onToolActivity: (activity) => {
|
|
273
377
|
if (activity.type === "end") record.toolUses++;
|
|
@@ -280,6 +384,7 @@ export class AgentManager {
|
|
|
280
384
|
onTextDelta: options.onTextDelta,
|
|
281
385
|
onAssistantUsage: (usage) => {
|
|
282
386
|
addUsage(record.lifetimeUsage, usage);
|
|
387
|
+
this.onUsage?.(record, usage);
|
|
283
388
|
options.onAssistantUsage?.(usage);
|
|
284
389
|
},
|
|
285
390
|
onCompaction: (info) => {
|
|
@@ -287,9 +392,38 @@ export class AgentManager {
|
|
|
287
392
|
this.onCompact?.(record, info);
|
|
288
393
|
options.onCompaction?.(info);
|
|
289
394
|
},
|
|
395
|
+
nestedRuntime: {
|
|
396
|
+
manager: this,
|
|
397
|
+
parentAgentId: id,
|
|
398
|
+
depth: record.depth ?? 1,
|
|
399
|
+
maxSubagentDepth: record.maxSubagentDepth,
|
|
400
|
+
},
|
|
290
401
|
onSessionCreated: (session) => {
|
|
291
402
|
record.session = session;
|
|
292
403
|
record.sessionId = session.sessionId;
|
|
404
|
+
// The model and thinking level are only knowable once pi has resolved
|
|
405
|
+
// its defaults and clamped the level to what the model supports. Writing
|
|
406
|
+
// them back here makes the record authoritative, so every surface reads
|
|
407
|
+
// one place instead of each re-deriving "session, else the request" for
|
|
408
|
+
// itself. An agent started outside the Agent tool (RPC, scheduler) has
|
|
409
|
+
// no invocation yet and gains one here.
|
|
410
|
+
if (session.model) {
|
|
411
|
+
record.invocation ??= {};
|
|
412
|
+
// Read the kept request first: a caller's level survives being clamped
|
|
413
|
+
// AND, one line later, being replaced by the effective one.
|
|
414
|
+
const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
|
|
415
|
+
Object.assign(record.invocation, describeModel(session.model));
|
|
416
|
+
// Guarded: a session that reports no level keeps the request rather
|
|
417
|
+
// than losing it. Overwriting unconditionally would turn a stubbed or
|
|
418
|
+
// older session into a blank `thinking:` tag, which is worse than the
|
|
419
|
+
// stale-but-true value it replaced.
|
|
420
|
+
if (session.thinkingLevel) {
|
|
421
|
+
record.invocation.thinking = session.thinkingLevel;
|
|
422
|
+
if (requested && requested !== session.thinkingLevel) {
|
|
423
|
+
record.invocation.requestedThinking = requested;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
293
427
|
// Flush any steers that arrived before the session was ready
|
|
294
428
|
if (record.pendingSteers?.length) {
|
|
295
429
|
for (const msg of record.pendingSteers) {
|
|
@@ -321,12 +455,6 @@ export class AgentManager {
|
|
|
321
455
|
|
|
322
456
|
detach();
|
|
323
457
|
|
|
324
|
-
// Final flush of streaming output file
|
|
325
|
-
if (record.outputCleanup) {
|
|
326
|
-
try { record.outputCleanup(); } catch { /* ignore */ }
|
|
327
|
-
record.outputCleanup = undefined;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
458
|
// Clean up worktree if used
|
|
331
459
|
if (record.worktree) {
|
|
332
460
|
const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
|
|
@@ -340,16 +468,6 @@ export class AgentManager {
|
|
|
340
468
|
}
|
|
341
469
|
}
|
|
342
470
|
|
|
343
|
-
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
344
|
-
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
345
|
-
if (!options.isBackground) {
|
|
346
|
-
record.resultConsumed = true;
|
|
347
|
-
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
348
|
-
} else {
|
|
349
|
-
this.runningBackground--;
|
|
350
|
-
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
351
|
-
this.drainQueue();
|
|
352
|
-
}
|
|
353
471
|
return responseText;
|
|
354
472
|
})
|
|
355
473
|
.catch((err) => {
|
|
@@ -362,12 +480,6 @@ export class AgentManager {
|
|
|
362
480
|
|
|
363
481
|
detach();
|
|
364
482
|
|
|
365
|
-
// Final flush of streaming output file on error
|
|
366
|
-
if (record.outputCleanup) {
|
|
367
|
-
try { record.outputCleanup(); } catch { /* ignore */ }
|
|
368
|
-
record.outputCleanup = undefined;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
483
|
// Best-effort worktree cleanup on error
|
|
372
484
|
if (record.worktree) {
|
|
373
485
|
try {
|
|
@@ -376,25 +488,96 @@ export class AgentManager {
|
|
|
376
488
|
} catch { /* ignore cleanup errors */ }
|
|
377
489
|
}
|
|
378
490
|
|
|
379
|
-
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
380
|
-
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
381
|
-
if (!options.isBackground) {
|
|
382
|
-
record.resultConsumed = true;
|
|
383
|
-
this.onComplete?.(record);
|
|
384
|
-
} else {
|
|
385
|
-
this.runningBackground--;
|
|
386
|
-
this.onComplete?.(record);
|
|
387
|
-
this.drainQueue();
|
|
388
|
-
}
|
|
389
491
|
return "";
|
|
390
|
-
})
|
|
492
|
+
})
|
|
493
|
+
.finally(() => this.afterRun(record));
|
|
391
494
|
|
|
392
495
|
record.promise = promise;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Shared completion tail: release the slot, stop orphaned children, flush the transcript, notify, drain. */
|
|
499
|
+
private afterRun(record: AgentRecord): void {
|
|
500
|
+
if (occupiesPoolSlot(record)) this.runningBackground--;
|
|
501
|
+
// Children spawned by this run must not outlive it — they are hidden from
|
|
502
|
+
// every surface and reachable only through this agent, so an orphan would
|
|
503
|
+
// burn tokens unseen. Runs on every settle path, which is what also covers
|
|
504
|
+
// a resumed turn: grandchildren follow transitively, each through its own
|
|
505
|
+
// settle tail.
|
|
506
|
+
this.abortOwnedChildren(record.id);
|
|
507
|
+
try { record.outputCleanup?.(); } catch { /* ignore */ }
|
|
508
|
+
record.outputCleanup = undefined;
|
|
509
|
+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
510
|
+
// A drain escape must not reject an already-fulfilled run promise or strand
|
|
511
|
+
// `settled` — a stuck `settled = false` would refuse every later resume and
|
|
512
|
+
// leave the queued records behind the freed slot unstarted.
|
|
513
|
+
try { this.drainQueue(); } catch { /* keep the completion tail failure-safe */ }
|
|
514
|
+
record.settled = true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Settle a record whose run never started — stopped while queued, or stopped
|
|
519
|
+
* by a `subagents:started` listener before the runner was wired. Reports the
|
|
520
|
+
* stop through the completion surface exactly once and marks the record fully
|
|
521
|
+
* unwound. No counter change: a never-started record never held a slot.
|
|
522
|
+
*
|
|
523
|
+
* `baseCwd` is the repo a prologue-created worktree came from; omit it on
|
|
524
|
+
* paths where this run's prologue created none (a record stopped while
|
|
525
|
+
* queued, an already-aborted parent signal, or a resume stopped during its
|
|
526
|
+
* start prologue). A resumed record can still carry the prior run's worktree
|
|
527
|
+
* reference — a resume never creates one, and reclaiming that stale path
|
|
528
|
+
* would clobber the prior run's `worktreeResult`.
|
|
529
|
+
*/
|
|
530
|
+
private settleStoppedWithoutRun(record: AgentRecord, baseCwd?: string): void {
|
|
531
|
+
record.status = "stopped";
|
|
532
|
+
record.completedAt ??= Date.now();
|
|
533
|
+
// No turn ever executed — zero the run-local counter so the completion
|
|
534
|
+
// report cannot claim the presumed first turn.
|
|
535
|
+
record.turnCount = 0;
|
|
536
|
+
// Reclaim a worktree created by the start prologue (a stop issued during
|
|
537
|
+
// the `subagents:started` emit): the run never made changes, so this
|
|
538
|
+
// removes the copy and its git registration outright. `baseCwd` is absent
|
|
539
|
+
// on the queued-abort, already-aborted-signal, and resume start-prologue
|
|
540
|
+
// paths, where this run's prologue created no worktree.
|
|
541
|
+
if (baseCwd) {
|
|
542
|
+
this.reclaimWorktree(record, baseCwd);
|
|
543
|
+
}
|
|
544
|
+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
545
|
+
record.settled = true;
|
|
546
|
+
}
|
|
393
547
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
548
|
+
/**
|
|
549
|
+
* Best-effort reclamation of a worktree the start prologue created when the
|
|
550
|
+
* run never executed: a throwing started listener on the spawn or queue
|
|
551
|
+
* path, or a stop issued during the `subagents:started` emit. Removes the
|
|
552
|
+
* copy and its git registration from `baseCwd`'s repo, records the cleanup
|
|
553
|
+
* outcome, and clears the record's reference so no later path retries it.
|
|
554
|
+
* Cleanup errors are ignored — the failure or stop that triggered the
|
|
555
|
+
* reclaim is the actionable signal.
|
|
556
|
+
*/
|
|
557
|
+
private reclaimWorktree(record: AgentRecord, baseCwd: string): void {
|
|
558
|
+
if (!record.worktree) return;
|
|
559
|
+
try {
|
|
560
|
+
record.worktreeResult = cleanupWorktree(baseCwd, record.worktree, record.description);
|
|
561
|
+
} catch { /* ignore cleanup errors */ }
|
|
562
|
+
record.worktree = undefined;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** True when a started listener stopped the record or aborted its controller
|
|
566
|
+
* during the synchronous `onStart` emit. */
|
|
567
|
+
private stoppedInStartPrologue(record: AgentRecord): boolean {
|
|
568
|
+
return record.status === "stopped" || record.abortController?.signal.aborted === true;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Stop the nested children a settled parent owns. Nested records are hidden
|
|
573
|
+
* from the UI and only their owner can consume them, so a child outliving its
|
|
574
|
+
* parent would burn tokens unseen with no way to reach it. Grandchildren are
|
|
575
|
+
* covered transitively — each abort lands in that child's own settle path.
|
|
576
|
+
*/
|
|
577
|
+
private abortOwnedChildren(parentId: string): void {
|
|
578
|
+
for (const [id, record] of this.agents) {
|
|
579
|
+
if (record.parentAgentId === parentId) this.abort(id);
|
|
580
|
+
}
|
|
398
581
|
}
|
|
399
582
|
|
|
400
583
|
/** Start queued agents up to the concurrency limit. */
|
|
@@ -402,99 +585,137 @@ export class AgentManager {
|
|
|
402
585
|
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
|
|
403
586
|
const next = this.queue.shift()!;
|
|
404
587
|
const record = this.agents.get(next.id);
|
|
405
|
-
if (
|
|
588
|
+
if (record?.status !== "queued") continue;
|
|
406
589
|
try {
|
|
407
590
|
this.startAgent(next.id, record, next.args);
|
|
408
591
|
} catch (err) {
|
|
409
|
-
//
|
|
410
|
-
//
|
|
592
|
+
// Only pre-increment throws reach here (cwd re-validation, worktree
|
|
593
|
+
// creation, onStart) — the counter needs no rollback. Surface the failure
|
|
594
|
+
// on the record so the user/agent can see it via /agents, then keep draining.
|
|
411
595
|
record.status = "error";
|
|
412
596
|
record.error = err instanceof Error ? err.message : String(err);
|
|
413
597
|
record.completedAt = Date.now();
|
|
414
|
-
|
|
598
|
+
// No turn ever executed — zero the run-local counter so the completion
|
|
599
|
+
// report cannot claim the presumed first turn.
|
|
600
|
+
record.turnCount = 0;
|
|
601
|
+
// A throwing onStart can follow worktree creation: reclaim the copy
|
|
602
|
+
// and its registration (best-effort, like the run handlers' cleanup).
|
|
603
|
+
this.reclaimWorktree(record, next.args.options.cwd ?? next.args.ctx.cwd);
|
|
604
|
+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
415
605
|
}
|
|
416
606
|
}
|
|
417
607
|
}
|
|
418
608
|
|
|
419
609
|
/**
|
|
420
|
-
*
|
|
421
|
-
* Lets the caller set up the output file path on the record.
|
|
422
|
-
* The record is guaranteed to be in this.agents at this point.
|
|
423
|
-
*/
|
|
424
|
-
private onSpawned?: (id: string) => void;
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Spawn an agent and wait for completion (foreground use).
|
|
428
|
-
* Foreground agents bypass the concurrency queue.
|
|
429
|
-
* Returns { id, record } so callers can access the agent ID.
|
|
610
|
+
* Kick off a resume of an existing agent session with a new prompt.
|
|
430
611
|
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
): Promise<{ id: string; record: AgentRecord }> {
|
|
442
|
-
// Temporarily register the onSpawned hook so startAgent can call it.
|
|
443
|
-
const prevOnSpawned = this.onSpawned;
|
|
444
|
-
this.onSpawned = onSpawned;
|
|
445
|
-
try {
|
|
446
|
-
const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
|
|
447
|
-
const record = this.agents.get(id)!;
|
|
448
|
-
await record.promise;
|
|
449
|
-
return { id, record };
|
|
450
|
-
} finally {
|
|
451
|
-
this.onSpawned = prevOnSpawned;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Resume an existing agent session with a new prompt.
|
|
612
|
+
* Returns the record synchronously; the run settles through `record.promise`
|
|
613
|
+
* and the shared completion tail. Returns `undefined` when the record is
|
|
614
|
+
* unknown, has no session, is already active or winding down, when the
|
|
615
|
+
* started listener throws, or when it stops the record during the
|
|
616
|
+
* `subagents:started` emit.
|
|
617
|
+
*
|
|
618
|
+
* Resumes bypass the maxConcurrent admission gate by design: a continuation
|
|
619
|
+
* of a parent-visible run must start immediately rather than queue behind
|
|
620
|
+
* fresh spawns. It still occupies a pool slot while running, so a burst of
|
|
621
|
+
* resumes can push the actual concurrency above the limit.
|
|
457
622
|
*/
|
|
458
|
-
|
|
459
|
-
id: string,
|
|
460
|
-
prompt: string,
|
|
461
|
-
signal?: AbortSignal,
|
|
462
|
-
): Promise<AgentRecord | undefined> {
|
|
623
|
+
resume(id: string, prompt: string, callbacks?: ResumeCallbacks): AgentRecord | undefined {
|
|
463
624
|
const record = this.agents.get(id);
|
|
464
625
|
if (!record?.session) return undefined;
|
|
626
|
+
// A record in the start prologue is already `running` while `settled` may
|
|
627
|
+
// still hold the prior run's `true`, so the running arm is load-bearing.
|
|
628
|
+
// A queued record has no session yet and is refused by the queued arm.
|
|
629
|
+
if (record.status === "running" || record.status === "queued" || record.settled === false) {
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
465
632
|
|
|
633
|
+
const prior = {
|
|
634
|
+
status: record.status,
|
|
635
|
+
startedAt: record.startedAt,
|
|
636
|
+
completedAt: record.completedAt,
|
|
637
|
+
result: record.result,
|
|
638
|
+
error: record.error,
|
|
639
|
+
resultConsumed: record.resultConsumed,
|
|
640
|
+
abortController: record.abortController,
|
|
641
|
+
turnCount: record.turnCount,
|
|
642
|
+
lastCheckpoint: record.lastCheckpoint,
|
|
643
|
+
};
|
|
466
644
|
record.status = "running";
|
|
467
645
|
record.startedAt = Date.now();
|
|
468
646
|
record.completedAt = undefined;
|
|
469
647
|
record.result = undefined;
|
|
470
648
|
record.error = undefined;
|
|
471
|
-
|
|
649
|
+
record.resultConsumed = undefined; // a prior pull must not swallow the resume report
|
|
650
|
+
// Run-local, mirroring a fresh spawn's initial turn count.
|
|
651
|
+
record.turnCount = 1;
|
|
652
|
+
// Run-local too: the prior run's latest checkpoint must not surface as the
|
|
653
|
+
// resumed run's state. The .checkpoints.md history stays cumulative.
|
|
654
|
+
record.lastCheckpoint = undefined;
|
|
655
|
+
record.abortController = new AbortController();
|
|
472
656
|
try {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
},
|
|
480
|
-
onCompaction: (info) => {
|
|
481
|
-
record.compactionCount++;
|
|
482
|
-
this.onCompact?.(record, info);
|
|
483
|
-
},
|
|
484
|
-
signal,
|
|
485
|
-
});
|
|
486
|
-
// Same contract as the spawn path (#144): a failed final turn is an
|
|
487
|
-
// error, not a completion — but the resumed text stays available.
|
|
488
|
-
record.status = failure ? "error" : "completed";
|
|
489
|
-
if (failure) record.error = failure;
|
|
490
|
-
record.result = text;
|
|
491
|
-
record.completedAt = Date.now();
|
|
492
|
-
} catch (err) {
|
|
493
|
-
record.status = "error";
|
|
494
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
495
|
-
record.completedAt = Date.now();
|
|
657
|
+
this.onStart?.(record);
|
|
658
|
+
} catch {
|
|
659
|
+
// A listener threw (stale extension context). Restore the pre-resume
|
|
660
|
+
// state and refuse: the tool reports failure and the counter is untouched.
|
|
661
|
+
Object.assign(record, prior);
|
|
662
|
+
return undefined;
|
|
496
663
|
}
|
|
497
|
-
|
|
664
|
+
// A started listener can stop the record synchronously; settle it through
|
|
665
|
+
// the stopped tail instead of starting — and counting — a run that was
|
|
666
|
+
// already stopped.
|
|
667
|
+
if (this.stoppedInStartPrologue(record)) {
|
|
668
|
+
this.settleStoppedWithoutRun(record);
|
|
669
|
+
return undefined;
|
|
670
|
+
}
|
|
671
|
+
record.settled = false;
|
|
672
|
+
// Occupies a slot but is never gated by maxConcurrent — a burst of resumes
|
|
673
|
+
// can exceed the cap by design. They share the pool counter, so queued
|
|
674
|
+
// fresh spawns wait for these resumes to settle before drainQueue admits them.
|
|
675
|
+
// A nested child is not in the pool (`occupiesPoolSlot`), and afterRun only
|
|
676
|
+
// decrements for records that are: counting it here leaked a slot per nested
|
|
677
|
+
// resume until every top-level spawn queued forever.
|
|
678
|
+
if (occupiesPoolSlot(record)) this.runningBackground++;
|
|
679
|
+
const promise = resumeAgent(record.session, prompt, {
|
|
680
|
+
onToolActivity: (activity) => {
|
|
681
|
+
if (activity.type === "end") record.toolUses++;
|
|
682
|
+
callbacks?.onToolActivity?.(activity);
|
|
683
|
+
},
|
|
684
|
+
onTextDelta: callbacks?.onTextDelta,
|
|
685
|
+
onTurnEnd: (turnCount) => {
|
|
686
|
+
record.turnCount = turnCount;
|
|
687
|
+
callbacks?.onTurnEnd?.(turnCount);
|
|
688
|
+
},
|
|
689
|
+
onAssistantUsage: (usage) => {
|
|
690
|
+
addUsage(record.lifetimeUsage, usage);
|
|
691
|
+
this.onUsage?.(record, usage);
|
|
692
|
+
callbacks?.onAssistantUsage?.(usage);
|
|
693
|
+
},
|
|
694
|
+
onCompaction: (info) => {
|
|
695
|
+
record.compactionCount++;
|
|
696
|
+
this.onCompact?.(record, info);
|
|
697
|
+
callbacks?.onCompaction?.(info);
|
|
698
|
+
},
|
|
699
|
+
signal: record.abortController.signal,
|
|
700
|
+
})
|
|
701
|
+
.then(({ text, failure }) => {
|
|
702
|
+
// Stop wins; a stopped resume is never overwritten with completed.
|
|
703
|
+
if (record.status !== "stopped") {
|
|
704
|
+
record.status = failure ? "error" : "completed";
|
|
705
|
+
if (failure) record.error = failure;
|
|
706
|
+
}
|
|
707
|
+
record.result = text;
|
|
708
|
+
record.completedAt ??= Date.now();
|
|
709
|
+
return text;
|
|
710
|
+
})
|
|
711
|
+
.catch((err) => {
|
|
712
|
+
if (record.status !== "stopped") record.status = "error";
|
|
713
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
714
|
+
record.completedAt ??= Date.now();
|
|
715
|
+
return "";
|
|
716
|
+
})
|
|
717
|
+
.finally(() => this.afterRun(record));
|
|
718
|
+
record.promise = promise;
|
|
498
719
|
return record;
|
|
499
720
|
}
|
|
500
721
|
|
|
@@ -504,7 +725,10 @@ export class AgentManager {
|
|
|
504
725
|
* current tool execution and appears as a user message. If the session isn't
|
|
505
726
|
* ready yet, the message is queued on `pendingSteers` and flushed when the
|
|
506
727
|
* session is created. Returns false if the agent can't accept steering
|
|
507
|
-
* (unknown id, or no longer running/queued).
|
|
728
|
+
* (unknown id, or no longer running/queued). Fires `onSteered` on either
|
|
729
|
+
* accepted path, so the viewers and the cross-extension RPC report the steer
|
|
730
|
+
* the same way without each emitting by hand. The steer tools bypass this
|
|
731
|
+
* method because they await delivery.
|
|
508
732
|
*/
|
|
509
733
|
steer(id: string, message: string): boolean {
|
|
510
734
|
const record = this.agents.get(id);
|
|
@@ -516,6 +740,7 @@ export class AgentManager {
|
|
|
516
740
|
if (!record.pendingSteers) record.pendingSteers = [];
|
|
517
741
|
record.pendingSteers.push(message);
|
|
518
742
|
}
|
|
743
|
+
this.onSteered?.(record, message);
|
|
519
744
|
return true;
|
|
520
745
|
}
|
|
521
746
|
|
|
@@ -536,8 +761,7 @@ export class AgentManager {
|
|
|
536
761
|
// Remove from queue if queued
|
|
537
762
|
if (record.status === "queued") {
|
|
538
763
|
this.queue = this.queue.filter(q => q.id !== id);
|
|
539
|
-
record
|
|
540
|
-
record.completedAt = Date.now();
|
|
764
|
+
this.settleStoppedWithoutRun(record);
|
|
541
765
|
return true;
|
|
542
766
|
}
|
|
543
767
|
|
|
@@ -548,17 +772,47 @@ export class AgentManager {
|
|
|
548
772
|
return true;
|
|
549
773
|
}
|
|
550
774
|
|
|
551
|
-
/**
|
|
775
|
+
/**
|
|
776
|
+
* Dispose a record's session and remove it from the map.
|
|
777
|
+
*
|
|
778
|
+
* The record's removal is also the child session file's disposal point: the
|
|
779
|
+
* 10-minute eviction and `clearCompleted()` on session switch are the grace
|
|
780
|
+
* window in which a live record can still resume the agent, so nothing may
|
|
781
|
+
* delete the file earlier. A `persist_session` agent keeps its file
|
|
782
|
+
* indefinitely; every other settled agent's file goes here.
|
|
783
|
+
*
|
|
784
|
+
* A run still in flight keeps its file even at removal: deleting the file a
|
|
785
|
+
* live child is still appending to loses the transcript the crash-recovery
|
|
786
|
+
* case exists for. The callers that could reach one — eviction and the session
|
|
787
|
+
* switch — skip it for the same reason, so this is the second line of defence,
|
|
788
|
+
* not the only one.
|
|
789
|
+
*
|
|
790
|
+
* Best-effort: a missing file, a permission error or a Windows lock must not
|
|
791
|
+
* throw into the completion/eviction path.
|
|
792
|
+
*/
|
|
552
793
|
private removeRecord(id: string, record: AgentRecord): void {
|
|
794
|
+
// Read from the session, not a copy taken at creation: pi moves the file on
|
|
795
|
+
// `ctx.newSession()`/`fork()`, and deleting the path it started with would
|
|
796
|
+
// leak the one it actually wrote.
|
|
797
|
+
const sessionFile = record.session?.sessionManager.getSessionFile();
|
|
553
798
|
record.session?.dispose?.();
|
|
554
799
|
record.session = undefined;
|
|
555
800
|
this.agents.delete(id);
|
|
801
|
+
// After dispose, so the file handle is closed — Windows will not unlink an
|
|
802
|
+
// open file.
|
|
803
|
+
if (record.settled === false || record.persistSession || !sessionFile) return;
|
|
804
|
+
try {
|
|
805
|
+
rmSync(sessionFile, { force: true });
|
|
806
|
+
} catch { /* retention is best-effort — never fail a turn over it */ }
|
|
556
807
|
}
|
|
557
808
|
|
|
558
809
|
private cleanup() {
|
|
559
810
|
const cutoff = Date.now() - 10 * 60_000;
|
|
560
811
|
for (const [id, record] of this.agents) {
|
|
561
812
|
if (record.status === "running" || record.status === "queued") continue;
|
|
813
|
+
// Stopped but still unwinding: removing it would dispose a session a live
|
|
814
|
+
// run is still writing to, and the file has to outlive the run.
|
|
815
|
+
if (record.settled === false) continue;
|
|
562
816
|
if ((record.completedAt ?? 0) >= cutoff) continue;
|
|
563
817
|
this.removeRecord(id, record);
|
|
564
818
|
}
|
|
@@ -573,6 +827,8 @@ export class AgentManager {
|
|
|
573
827
|
clearCompleted(skipUnconsumed = false): void {
|
|
574
828
|
for (const [id, record] of this.agents) {
|
|
575
829
|
if (record.status === "running" || record.status === "queued") continue;
|
|
830
|
+
// See cleanup(): an unwinding run keeps its record until it settles.
|
|
831
|
+
if (record.settled === false) continue;
|
|
576
832
|
if (skipUnconsumed && !record.resultConsumed) continue;
|
|
577
833
|
this.removeRecord(id, record);
|
|
578
834
|
}
|
|
@@ -592,8 +848,7 @@ export class AgentManager {
|
|
|
592
848
|
for (const queued of this.queue) {
|
|
593
849
|
const record = this.agents.get(queued.id);
|
|
594
850
|
if (record) {
|
|
595
|
-
record
|
|
596
|
-
record.completedAt = Date.now();
|
|
851
|
+
this.settleStoppedWithoutRun(record);
|
|
597
852
|
count++;
|
|
598
853
|
}
|
|
599
854
|
}
|
|
@@ -610,14 +865,21 @@ export class AgentManager {
|
|
|
610
865
|
return count;
|
|
611
866
|
}
|
|
612
867
|
|
|
613
|
-
/**
|
|
868
|
+
/**
|
|
869
|
+
* Wait for all running and queued agents to complete (including queued ones).
|
|
870
|
+
*
|
|
871
|
+
* A stopped run is still unwinding when its `settled` flag is `false`: it no
|
|
872
|
+
* longer counts as `running` but still holds its slot, so its promise is part
|
|
873
|
+
* of the pending set — the queued records behind it start only once it
|
|
874
|
+
* settles.
|
|
875
|
+
*/
|
|
614
876
|
async waitForAll(): Promise<void> {
|
|
615
877
|
// Loop because drainQueue respects the concurrency limit — as running
|
|
616
878
|
// agents finish they start queued ones, which need awaiting too.
|
|
617
879
|
while (true) {
|
|
618
880
|
this.drainQueue();
|
|
619
881
|
const pending = [...this.agents.values()]
|
|
620
|
-
.filter(r => r.status === "running" || r.status === "queued")
|
|
882
|
+
.filter(r => r.status === "running" || r.status === "queued" || r.settled === false)
|
|
621
883
|
.map(r => r.promise)
|
|
622
884
|
.filter(Boolean);
|
|
623
885
|
if (pending.length === 0) break;
|
|
@@ -629,10 +891,12 @@ export class AgentManager {
|
|
|
629
891
|
clearInterval(this.cleanupInterval);
|
|
630
892
|
// Clear queue
|
|
631
893
|
this.queue = [];
|
|
632
|
-
|
|
633
|
-
|
|
894
|
+
// An in-flight run keeps its session file for the crash-recovery follow-up;
|
|
895
|
+
// a settled one is deleted here, because the manager's map is discarded and
|
|
896
|
+
// nothing would ever revisit it.
|
|
897
|
+
for (const [id, record] of this.agents) {
|
|
898
|
+
this.removeRecord(id, record);
|
|
634
899
|
}
|
|
635
|
-
this.agents.clear();
|
|
636
900
|
// Prune any orphaned git worktrees (crash recovery)
|
|
637
901
|
try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
|
|
638
902
|
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|