@herbertgao/pi-subagents 0.17.1 → 0.18.1
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 +12 -0
- package/README.md +427 -120
- package/docs/rpc.md +184 -0
- package/docs/workflows.md +466 -0
- package/examples/agent-tool-description.md +6 -6
- package/examples/workflows/compose.js +52 -0
- package/examples/workflows/fan-out-audit.js +56 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +30 -0
- package/examples/workflows/review-panel.js +68 -0
- package/examples/workflows/structured-findings.js +81 -0
- package/package.json +12 -10
- package/src/agent-file-toggle.ts +52 -12
- package/src/agent-manager.ts +837 -146
- package/src/agent-runner.ts +213 -39
- package/src/cross-extension-rpc.ts +73 -14
- package/src/custom-agents.ts +101 -47
- package/src/index.ts +2249 -914
- package/src/invocation-config.ts +13 -0
- package/src/mention-clone.ts +215 -0
- package/src/mention.ts +147 -0
- package/src/model-resolver.ts +9 -1
- package/src/nested-tools.ts +40 -26
- package/src/output-file.ts +18 -8
- package/src/prompts.ts +46 -9
- package/src/schedule.ts +21 -16
- package/src/settings.ts +137 -7
- package/src/structured-output.ts +136 -0
- package/src/types.ts +126 -8
- package/src/ui/agent-mention.ts +274 -0
- package/src/ui/agent-widget.ts +20 -5
- package/src/ui/conversation-viewer.ts +10 -4
- package/src/ui/fleet-list.ts +167 -22
- package/src/ui/workflow-card.ts +555 -0
- package/src/ui/workflow-dialog.ts +1304 -0
- package/src/ui/workflow-menu.ts +226 -0
- package/src/workflow/collisions.ts +122 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +463 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +142 -0
- package/src/workflow/meta.ts +401 -0
- package/src/workflow/progress.ts +622 -0
- package/src/workflow/runtime.ts +1399 -0
- package/src/workflow/saved.ts +230 -0
- package/src/workflow/task.ts +333 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +97 -95
- package/src/xml.ts +13 -0
package/src/agent-manager.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* agent-manager.ts — Tracks agents, background execution, resume support.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* There are two independent concurrency pools, never one:
|
|
5
|
+
*
|
|
6
|
+
* - Background (`maxConcurrent`, default 10) bounds detached agents.
|
|
7
|
+
* - Foreground (`maxConcurrentForeground`, default 0 = unlimited) bounds
|
|
8
|
+
* agents a caller is blocking on inline — `spawnAndWait`.
|
|
9
|
+
*
|
|
10
|
+
* Independent by design: a foreground agent blocks the parent anyway, so
|
|
11
|
+
* charging it to the background pool would let a saturated pool starve the main
|
|
12
|
+
* session of work it could have done itself. Excess agents in either pool are
|
|
13
|
+
* queued and auto-started as slots free up. Nested children take no slot in
|
|
14
|
+
* either — see `occupiesPoolSlot` / `occupiesForegroundSlot`.
|
|
6
15
|
*/
|
|
7
16
|
|
|
8
17
|
import { randomUUID } from "node:crypto"
|
|
@@ -15,36 +24,87 @@ import type {
|
|
|
15
24
|
ExtensionContext,
|
|
16
25
|
} from "@earendil-works/pi-coding-agent"
|
|
17
26
|
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js"
|
|
27
|
+
import { assignHandle, handleBase } from "./mention.js"
|
|
18
28
|
import { describeModel } from "./model-resolver.js"
|
|
19
29
|
import type {
|
|
20
30
|
AgentInvocation,
|
|
21
31
|
AgentRecord,
|
|
32
|
+
AgentTombstone,
|
|
22
33
|
IsolationMode,
|
|
34
|
+
MentionResolution,
|
|
23
35
|
SubagentType,
|
|
24
36
|
ThinkingLevel,
|
|
25
37
|
} from "./types.js"
|
|
26
38
|
import { addUsage, type LifetimeUsage } from "./usage.js"
|
|
39
|
+
import type { CompiledSchema } from "./workflow/json-schema.js"
|
|
27
40
|
import {
|
|
28
41
|
cleanupWorktree,
|
|
29
42
|
createWorktree,
|
|
30
43
|
isWorktreeIsolationEnabled,
|
|
31
44
|
pruneWorktrees,
|
|
45
|
+
type WorktreeCleanupResult,
|
|
32
46
|
} from "./worktree.js"
|
|
33
47
|
|
|
34
48
|
export type OnAgentComplete = (record: AgentRecord) => void
|
|
35
49
|
export type OnAgentStart = (record: AgentRecord) => void
|
|
36
50
|
export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void
|
|
51
|
+
/**
|
|
52
|
+
* Fired once per assistant `message_end`, for EVERY agent this manager owns —
|
|
53
|
+
* top-level and nested alike, spawns and resumes. The one place where each
|
|
54
|
+
* message is seen exactly once: `AgentRecord.lifetimeUsage` is deliberately
|
|
55
|
+
* double-booked into ancestors (see `nested-tools.ts`) so a hidden child's spend
|
|
56
|
+
* shows up on the record a human can see, which makes those records useless as
|
|
57
|
+
* a basis for anything that must not count a message twice — parent-session
|
|
58
|
+
* accounting above all.
|
|
59
|
+
*/
|
|
37
60
|
export type OnAgentUsage = (record: AgentRecord, usage: LifetimeUsage) => void
|
|
38
61
|
export type CompactionInfo = {
|
|
39
62
|
reason: "manual" | "threshold" | "overflow"
|
|
40
63
|
tokensBefore: number
|
|
41
64
|
}
|
|
42
65
|
|
|
43
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Default max concurrent background agents.
|
|
68
|
+
*
|
|
69
|
+
* Raised from 4 when top-level spawns started defaulting to background
|
|
70
|
+
* (`backgroundByDefault`): foreground agents bypass this pool entirely, so
|
|
71
|
+
* while foreground was the default a fan-out of six ran six. With background
|
|
72
|
+
* as the default every top-level agent takes a slot, and a limit of 4 would
|
|
73
|
+
* have silently queued the tail of exactly the parallel fan-outs the `Agent`
|
|
74
|
+
* tool description tells the model to send.
|
|
75
|
+
*/
|
|
44
76
|
const DEFAULT_MAX_CONCURRENT = 10
|
|
45
|
-
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Default max concurrent foreground (blocking) agents — `0` = unlimited, the
|
|
80
|
+
* extension's existing convention for "no ceiling" (`defaultMaxTurns`).
|
|
81
|
+
*
|
|
82
|
+
* Off by default because nothing here ever bounded foreground work, and pi
|
|
83
|
+
* dispatches a message's tool calls through `Promise.all`, so an unqualified
|
|
84
|
+
* fan-out of blocking `Agent` calls has always run all at once. Users who want
|
|
85
|
+
* it bounded — chiefly local models, where parallel agents thrash the prompt
|
|
86
|
+
* cache (#253) — opt in; everyone else keeps today's behaviour exactly.
|
|
87
|
+
*/
|
|
46
88
|
const DEFAULT_MAX_CONCURRENT_FOREGROUND = 0
|
|
47
89
|
|
|
90
|
+
/**
|
|
91
|
+
* How many evicted agents stay addressable by name. Only a bound on memory —
|
|
92
|
+
* a session that spawns hundreds of agents shouldn't retain every one — and
|
|
93
|
+
* far above the handful anyone keeps in their head.
|
|
94
|
+
*/
|
|
95
|
+
const MAX_TOMBSTONES = 100
|
|
96
|
+
|
|
97
|
+
function applyWorktreeCleanupFailure(
|
|
98
|
+
record: AgentRecord,
|
|
99
|
+
result: WorktreeCleanupResult,
|
|
100
|
+
): boolean {
|
|
101
|
+
if (!result.error || !result.path) return false
|
|
102
|
+
const message = `Worktree cleanup failed: ${result.error}\nAgent worktree remains at \`${result.path}\` for recovery.`
|
|
103
|
+
record.status = "error"
|
|
104
|
+
record.error = record.error ? `${record.error}\n${message}` : message
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
48
108
|
/**
|
|
49
109
|
* Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
|
|
50
110
|
* (parent cwd). Anything else must be an absolute path to an existing
|
|
@@ -81,17 +141,57 @@ function assertValidSpawnCwd(
|
|
|
81
141
|
* spawn costs it a turn, which is unbounded when max turns is unlimited.
|
|
82
142
|
*/
|
|
83
143
|
function occupiesPoolSlot(
|
|
84
|
-
record: Pick<AgentRecord, "isBackground" | "parentAgentId">,
|
|
144
|
+
record: Pick<AgentRecord, "isBackground" | "parentAgentId" | "workflowId">,
|
|
145
|
+
): boolean {
|
|
146
|
+
return !!record.isBackground && isTopLevelAgent(record)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether a record is one of the session's own agents, rather than something
|
|
151
|
+
* another agent or a workflow owns.
|
|
152
|
+
*
|
|
153
|
+
* The single definition behind every user-facing surface — the fleet list, the
|
|
154
|
+
* widget, the `/agents` menus, `@handle` resolution, and the completion events
|
|
155
|
+
* and session entries. An owned child reports through its owner, so surfacing
|
|
156
|
+
* it separately would double-count the same work in the places a person reads.
|
|
157
|
+
*/
|
|
158
|
+
export function isTopLevelAgent(
|
|
159
|
+
record: Pick<AgentRecord, "parentAgentId" | "workflowId">,
|
|
85
160
|
): boolean {
|
|
86
|
-
return
|
|
161
|
+
return record.parentAgentId === undefined && record.workflowId === undefined
|
|
87
162
|
}
|
|
88
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Whether a record occupies one of the `maxConcurrentForeground` slots.
|
|
166
|
+
*
|
|
167
|
+
* Keyed on `blocking` — a caller awaiting this record inline — rather than on
|
|
168
|
+
* `isBackground === false`, because `spawn()` is also the funnel for DETACHED
|
|
169
|
+
* starts (cross-extension RPC, `@handle` mentions, the registry) that may pass
|
|
170
|
+
* `isBackground: false` and are documented to run immediately regardless. Those
|
|
171
|
+
* block nobody, so bounding them buys nothing and would park a record with no
|
|
172
|
+
* one waiting to release it.
|
|
173
|
+
*
|
|
174
|
+
* Nested children are excluded for the same reason as `occupiesPoolSlot`, and
|
|
175
|
+
* more sharply: their parent is blocked *awaiting them*, so queueing a child
|
|
176
|
+
* behind its own parent is a guaranteed deadlock rather than a possible one.
|
|
177
|
+
* Enforced here rather than at the call site so no caller can reintroduce it.
|
|
178
|
+
*
|
|
179
|
+
* A workflow's children go out through `spawnAndWait` and so are `blocking`
|
|
180
|
+
* too, and are excluded on the same `isTopLevelAgent` test as the background
|
|
181
|
+
* pool: the run already caps how many of its agents run at once, and charging
|
|
182
|
+
* them here as well would let one fan-out queue behind a limit meant for the
|
|
183
|
+
* session's own work.
|
|
184
|
+
*
|
|
185
|
+
* Like the background pool this bounds width at the top level only — a parent's
|
|
186
|
+
* own fan-out is limited by nothing but its turn budget.
|
|
187
|
+
*/
|
|
89
188
|
function occupiesForegroundSlot(
|
|
90
|
-
record: Pick<AgentRecord, "blocking" | "parentAgentId">,
|
|
189
|
+
record: Pick<AgentRecord, "blocking" | "parentAgentId" | "workflowId">,
|
|
91
190
|
): boolean {
|
|
92
|
-
return !!record.blocking && record
|
|
191
|
+
return !!record.blocking && isTopLevelAgent(record)
|
|
93
192
|
}
|
|
94
193
|
|
|
194
|
+
/** Which concurrency pool a spawn is charged to, if any. */
|
|
95
195
|
type Pool = "background" | "foreground"
|
|
96
196
|
|
|
97
197
|
interface SpawnArgs {
|
|
@@ -104,6 +204,31 @@ interface SpawnArgs {
|
|
|
104
204
|
|
|
105
205
|
interface SpawnOptions {
|
|
106
206
|
description: string
|
|
207
|
+
/**
|
|
208
|
+
* Optional memorable name for this instance, becoming a second handle
|
|
209
|
+
* (`@auth-audit`) alongside the type-derived one. Slugged, not validated —
|
|
210
|
+
* anything unusable degrades via `handleBase` rather than failing the spawn.
|
|
211
|
+
*/
|
|
212
|
+
name?: string
|
|
213
|
+
/**
|
|
214
|
+
* Reopen this pi session file instead of starting a fresh conversation, so a
|
|
215
|
+
* mention of an evicted agent continues where it left off. The agent's
|
|
216
|
+
* definition is still resolved from its type, so the continuation runs under
|
|
217
|
+
* the type's CURRENT config.
|
|
218
|
+
*/
|
|
219
|
+
resumeSessionFile?: string
|
|
220
|
+
/**
|
|
221
|
+
* Take an evicted agent's names back verbatim instead of allocating fresh
|
|
222
|
+
* ones, so a resumed conversation keeps the handle the user just typed —
|
|
223
|
+
* `handleBase(type)` cannot reproduce a numbered `explore-2`. Safe without an
|
|
224
|
+
* `assignHandle` pass because tombstoned names are excluded from allocation
|
|
225
|
+
* (`takenHandles`), so nothing live can be holding them.
|
|
226
|
+
*
|
|
227
|
+
* Internal capability, like `resumeSessionFile`: a forged handle would
|
|
228
|
+
* duplicate a live agent's name and make `resolveMention` ambiguous, so
|
|
229
|
+
* `spawnTopLevel` strips it from anything a caller sends.
|
|
230
|
+
*/
|
|
231
|
+
reclaim?: { handle: string; alias?: string }
|
|
107
232
|
model?: Model<any>
|
|
108
233
|
maxTurns?: number
|
|
109
234
|
isolated?: boolean
|
|
@@ -111,13 +236,38 @@ interface SpawnOptions {
|
|
|
111
236
|
thinkingLevel?: ThinkingLevel
|
|
112
237
|
isBackground?: boolean
|
|
113
238
|
/**
|
|
114
|
-
* Skip
|
|
115
|
-
* if the configured concurrency limit would otherwise queue it.
|
|
116
|
-
*
|
|
239
|
+
* Skip whichever pool's queue check applies to this spawn — start immediately
|
|
240
|
+
* even if the configured concurrency limit would otherwise queue it. The slot
|
|
241
|
+
* is still COUNTED once the run starts, so a bypassing spawn transiently
|
|
242
|
+
* exceeds the limit rather than being invisible to it.
|
|
243
|
+
*
|
|
244
|
+
* Used by the scheduler, so a fired job can't be deferred past its trigger
|
|
245
|
+
* window, and by the `/agents` agent-file generator, which has no way to
|
|
246
|
+
* cancel a wait (see its call site).
|
|
117
247
|
*/
|
|
118
248
|
bypassQueue?: boolean
|
|
119
|
-
/**
|
|
249
|
+
/**
|
|
250
|
+
* A caller is awaiting this record inline (`spawnAndWait`) — what
|
|
251
|
+
* `maxConcurrentForeground` bounds. Set only by `spawnAndWait`; stripped from
|
|
252
|
+
* caller-supplied options by `spawnTopLevel`, since a forged `blocking` would
|
|
253
|
+
* defer a detached start behind a queue its caller cannot see or release.
|
|
254
|
+
*/
|
|
120
255
|
blocking?: boolean
|
|
256
|
+
/**
|
|
257
|
+
* The workflow run this child belongs to, when a workflow spawned it.
|
|
258
|
+
*
|
|
259
|
+
* Ownership, not decoration. A workflow's children are the workflow's — they
|
|
260
|
+
* report through its card, its notification and its dialog, so they are
|
|
261
|
+
* filtered out of every top-level surface exactly as nested children are, and
|
|
262
|
+
* they take no `maxConcurrent` slot: the run has its own concurrency cap, and
|
|
263
|
+
* counting them twice would let one workflow starve the whole session.
|
|
264
|
+
*/
|
|
265
|
+
workflowId?: string
|
|
266
|
+
/**
|
|
267
|
+
* Make the child report through a `StructuredOutput` tool built from this
|
|
268
|
+
* compiled schema. Set only by the workflow host, for `agent({ schema })`.
|
|
269
|
+
*/
|
|
270
|
+
structuredOutput?: CompiledSchema
|
|
121
271
|
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
|
|
122
272
|
isolation?: IsolationMode
|
|
123
273
|
/**
|
|
@@ -129,13 +279,41 @@ interface SpawnOptions {
|
|
|
129
279
|
* branch lands in that repo.
|
|
130
280
|
*/
|
|
131
281
|
cwd?: string
|
|
282
|
+
/**
|
|
283
|
+
* Last chance to look at an isolated agent's worktree, awaited immediately
|
|
284
|
+
* before it is committed to a branch and removed.
|
|
285
|
+
*
|
|
286
|
+
* Exists because that removal happens inside the settle path, before
|
|
287
|
+
* `spawnAndWait` resolves: by the time a caller has the finished record, the
|
|
288
|
+
* directory the child actually wrote in is gone. Anything that must inspect
|
|
289
|
+
* or verify that tree — a workflow `gate` is the motivating case — has to run
|
|
290
|
+
* here or it silently inspects the main tree instead.
|
|
291
|
+
*
|
|
292
|
+
* Fires only on the normal settle path, and only when a worktree was created.
|
|
293
|
+
* Not on the error path and not on the stop-during-copy guard: those are
|
|
294
|
+
* already failing, and delaying cleanup there would leak a copy for no gain.
|
|
295
|
+
* A rejection is swallowed — the hook can never keep the worktree alive.
|
|
296
|
+
*/
|
|
297
|
+
onBeforeWorktreeCleanup?: (worktreePath: string) => Promise<void>
|
|
132
298
|
/** Resolved invocation snapshot captured for UI display. */
|
|
133
299
|
invocation?: AgentInvocation
|
|
134
300
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
135
301
|
signal?: AbortSignal
|
|
136
|
-
/**
|
|
302
|
+
/**
|
|
303
|
+
* Called synchronously once the record is in the map and its promise is set,
|
|
304
|
+
* before `onSessionCreated` fires — where callers attach the output file.
|
|
305
|
+
*
|
|
306
|
+
* Carried on the options rather than parked on the manager for the duration
|
|
307
|
+
* of a spawn: with a foreground queue, `startAgent` can run at drain time,
|
|
308
|
+
* long after any such field would have been restored, and the callback would
|
|
309
|
+
* silently never fire (or fire into an unrelated caller's closure).
|
|
310
|
+
*/
|
|
137
311
|
onSpawned?: (id: string) => void
|
|
138
|
-
/**
|
|
312
|
+
/**
|
|
313
|
+
* Called synchronously when the spawn is queued instead of started, with how
|
|
314
|
+
* many entries in its own pool are ahead of it. The foreground UI uses it to
|
|
315
|
+
* say so while it waits; nothing else needs it.
|
|
316
|
+
*/
|
|
139
317
|
onQueued?: (id: string, ahead: number) => void
|
|
140
318
|
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
141
319
|
onToolActivity?: (activity: ToolActivity) => void
|
|
@@ -146,7 +324,11 @@ interface SpawnOptions {
|
|
|
146
324
|
/** Called at the end of each agentic turn with the cumulative count. */
|
|
147
325
|
onTurnEnd?: (turnCount: number) => void
|
|
148
326
|
/** Called once per assistant message_end with that message's usage delta. */
|
|
149
|
-
onAssistantUsage?: (usage:
|
|
327
|
+
onAssistantUsage?: (usage: {
|
|
328
|
+
input: number
|
|
329
|
+
output: number
|
|
330
|
+
cacheWrite: number
|
|
331
|
+
}) => void
|
|
150
332
|
/** Called when the session successfully compacts. */
|
|
151
333
|
onCompaction?: (info: CompactionInfo) => void
|
|
152
334
|
/** Nesting depth: top-level subagent = 1. */
|
|
@@ -172,10 +354,12 @@ interface ResumeOptions {
|
|
|
172
354
|
isBackground?: boolean
|
|
173
355
|
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
174
356
|
onToolActivity?: (activity: ToolActivity) => void
|
|
175
|
-
/** Called at the end of each resumed agentic turn with the cumulative count. */
|
|
176
|
-
onTurnEnd?: (turnCount: number) => void
|
|
177
357
|
/** Called once per assistant message_end with that message's usage delta. */
|
|
178
|
-
onAssistantUsage?: (usage:
|
|
358
|
+
onAssistantUsage?: (usage: {
|
|
359
|
+
input: number
|
|
360
|
+
output: number
|
|
361
|
+
cacheWrite: number
|
|
362
|
+
}) => void
|
|
179
363
|
/** Called when the session successfully compacts. */
|
|
180
364
|
onCompaction?: (info: CompactionInfo) => void
|
|
181
365
|
/**
|
|
@@ -189,14 +373,29 @@ interface ResumeOptions {
|
|
|
189
373
|
onStarted?: () => void
|
|
190
374
|
}
|
|
191
375
|
|
|
376
|
+
/** Best-effort ceiling on one child's shutdown handlers, so teardown can't strand a quit. */
|
|
192
377
|
const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000
|
|
193
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Close the extension lifecycle `runAgent` opened with `bindExtensions`, then dispose.
|
|
381
|
+
*
|
|
382
|
+
* `AgentSession.dispose()` only calls `ExtensionRunner.invalidate()` — pi emits the event
|
|
383
|
+
* itself in `AgentSessionRuntime.dispose()` beforehand, and this is the one place that binds
|
|
384
|
+
* extensions onto a session without going through that path. Without the emit, everything an
|
|
385
|
+
* extension armed in `session_start` leaks once per spawn, and its next tick throws
|
|
386
|
+
* `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242).
|
|
387
|
+
*/
|
|
194
388
|
async function shutdownChildSession(
|
|
195
389
|
session: AgentSession | undefined,
|
|
196
390
|
): Promise<void> {
|
|
197
391
|
try {
|
|
198
392
|
const runner = session?.extensionRunner
|
|
393
|
+
// Optional all the way down: on a pi without the getter, or a stubbed session from a
|
|
394
|
+
// partial `onSessionCreated`, skip the emit — the same degrade as before this fix.
|
|
199
395
|
if (runner?.hasHandlers?.("session_shutdown")) {
|
|
396
|
+
// Raced, not awaited outright. `emit` runs every handler serially with no timeout of
|
|
397
|
+
// its own, and dispose() is reached from pi's own `session_shutdown` with the TUI
|
|
398
|
+
// already torn down — one hung handler would leave a dead terminal.
|
|
200
399
|
await Promise.race([
|
|
201
400
|
runner.emit({ type: "session_shutdown", reason: "quit" }),
|
|
202
401
|
new Promise<void>((resolve) =>
|
|
@@ -205,8 +404,9 @@ async function shutdownChildSession(
|
|
|
205
404
|
])
|
|
206
405
|
}
|
|
207
406
|
} catch {
|
|
208
|
-
/*
|
|
407
|
+
/* a partial session must degrade, not take the teardown down with it */
|
|
209
408
|
}
|
|
409
|
+
// Always, even on timeout: disposal is what this function ultimately exists to do.
|
|
210
410
|
try {
|
|
211
411
|
session?.dispose?.()
|
|
212
412
|
} catch {
|
|
@@ -227,14 +427,48 @@ export class AgentManager {
|
|
|
227
427
|
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
228
428
|
private worktreeRepos = new Set<string>()
|
|
229
429
|
|
|
230
|
-
/**
|
|
430
|
+
/**
|
|
431
|
+
* Startup phases, keyed by agent id. `spawn()` still returns synchronously,
|
|
432
|
+
* but an agent using worktree isolation is not running yet when it does —
|
|
433
|
+
* copying the repo is an awaited git call. This is what `awaitStartup` hands
|
|
434
|
+
* callers that must fail their tool call on a startup failure, and what
|
|
435
|
+
* `waitForAll` waits on while a record is "running" with no `promise` yet.
|
|
436
|
+
* Entries are dropped once the run is underway, and kept (rejected) after a
|
|
437
|
+
* startup failure so a late `awaitStartup` still sees it.
|
|
438
|
+
*/
|
|
439
|
+
private startups = new Map<string, Promise<void>>()
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Evicted agents that can still be reached by name, keyed by handle. Outlives
|
|
443
|
+
* the 10-minute record cleanup — that timer exists to bound memory, not to
|
|
444
|
+
* expire a conversation the user might still want — and is cleared alongside
|
|
445
|
+
* completed records on session start/switch.
|
|
446
|
+
*/
|
|
447
|
+
private tombstones = new Map<string, AgentTombstone>()
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Agents waiting to start, tagged with the pool they wait on. One queue for
|
|
451
|
+
* both pools: `drainQueue` picks the earliest entry whose own pool has room,
|
|
452
|
+
* so neither can head-of-line-block the other, and every removal path
|
|
453
|
+
* (`abort`, `abortAll`, `dispose`) stays a single filter.
|
|
454
|
+
*
|
|
455
|
+
* `release` wakes a caller blocked in `spawnAndWait`, and is fired once the
|
|
456
|
+
* entry's `start` has SETTLED rather than at drain time: startup is async
|
|
457
|
+
* now, so releasing earlier would wake the caller before `record.promise`
|
|
458
|
+
* exists and it would read a still-starting agent as one that never ran.
|
|
459
|
+
* Removing an entry from this array MUST release it — a queued record has no
|
|
460
|
+
* promise to await, and pi has no tool-execution timeout to bail the caller
|
|
461
|
+
* out.
|
|
462
|
+
*/
|
|
231
463
|
private queue: {
|
|
232
464
|
id: string
|
|
233
465
|
pool: Pool
|
|
234
|
-
start: () => void
|
|
466
|
+
start: () => Promise<void>
|
|
235
467
|
release: () => void
|
|
236
468
|
}[] = []
|
|
469
|
+
/** Number of currently running background agents. */
|
|
237
470
|
private runningBackground = 0
|
|
471
|
+
/** Number of currently running foreground (blocking) agents. */
|
|
238
472
|
private runningForeground = 0
|
|
239
473
|
|
|
240
474
|
constructor(
|
|
@@ -265,9 +499,12 @@ export class AgentManager {
|
|
|
265
499
|
return this.maxConcurrent
|
|
266
500
|
}
|
|
267
501
|
|
|
268
|
-
/** Update the max concurrent blocking agents limit. 0
|
|
502
|
+
/** Update the max concurrent foreground (blocking) agents limit. 0 = unlimited. */
|
|
269
503
|
setMaxConcurrentForeground(n: number) {
|
|
504
|
+
// Floor 0, not 1: unlimited is a meaningful value here and the default.
|
|
270
505
|
this.maxConcurrentForeground = Math.max(0, n)
|
|
506
|
+
// Start queued agents if the new limit allows — including everything, when
|
|
507
|
+
// the limit is cleared back to unlimited mid-run.
|
|
271
508
|
this.drainQueue()
|
|
272
509
|
}
|
|
273
510
|
|
|
@@ -275,11 +512,22 @@ export class AgentManager {
|
|
|
275
512
|
return this.maxConcurrentForeground
|
|
276
513
|
}
|
|
277
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Which pool a spawn is charged to, or undefined for one that is charged to
|
|
517
|
+
* neither (nested children, detached non-background spawns).
|
|
518
|
+
*
|
|
519
|
+
* Nothing here queues when the limit is unset — `poolHasRoom` reports an
|
|
520
|
+
* unlimited pool as always having room, so that alone is what keeps the
|
|
521
|
+
* default path identical. The `> 0` guard is belt and braces on top: it also
|
|
522
|
+
* keeps the counter from churning and the settle path from calling a drain
|
|
523
|
+
* that would find nothing to do. Both are unobservable, which is why no test
|
|
524
|
+
* pins them; the observable half — that the default start stays synchronous —
|
|
525
|
+
* is pinned in `test/foreground-concurrency.test.ts`.
|
|
526
|
+
*/
|
|
278
527
|
private poolFor(record: AgentRecord): Pool | undefined {
|
|
279
528
|
if (occupiesPoolSlot(record)) return "background"
|
|
280
|
-
if (this.maxConcurrentForeground > 0 && occupiesForegroundSlot(record))
|
|
529
|
+
if (this.maxConcurrentForeground > 0 && occupiesForegroundSlot(record))
|
|
281
530
|
return "foreground"
|
|
282
|
-
}
|
|
283
531
|
return undefined
|
|
284
532
|
}
|
|
285
533
|
|
|
@@ -293,6 +541,11 @@ export class AgentManager {
|
|
|
293
541
|
/**
|
|
294
542
|
* Spawn an agent and return its ID immediately (for background use).
|
|
295
543
|
* If the concurrency limit is reached, the agent is queued.
|
|
544
|
+
*
|
|
545
|
+
* The id comes back synchronously, but with `isolation: "worktree"` the agent
|
|
546
|
+
* is not running yet when it does — the repo copy is an awaited git call.
|
|
547
|
+
* Callers that must fail a tool call on a startup failure await
|
|
548
|
+
* `awaitStartup(id)`; everyone else sees it on the record (status "error").
|
|
296
549
|
*/
|
|
297
550
|
spawn(
|
|
298
551
|
pi: ExtensionAPI,
|
|
@@ -311,12 +564,28 @@ export class AgentManager {
|
|
|
311
564
|
const record: AgentRecord = {
|
|
312
565
|
id,
|
|
313
566
|
type,
|
|
567
|
+
// Owned children — nested, or a workflow's — are filtered out of every
|
|
568
|
+
// top-level surface, so no handle: nothing can address them and they must
|
|
569
|
+
// not consume a name a top-level sibling could otherwise take.
|
|
570
|
+
handle: !isTopLevelAgent(options)
|
|
571
|
+
? undefined
|
|
572
|
+
: // A reclaimed handle is used as-is: it belongs to the conversation this
|
|
573
|
+
// spawn is reopening, and re-deriving it would lose the numbering.
|
|
574
|
+
(options.reclaim?.handle ??
|
|
575
|
+
assignHandle(handleBase(type), this.takenHandles())),
|
|
314
576
|
description: options.description,
|
|
577
|
+
// Reclaimed here, or filled in below from `name` — in which case it must
|
|
578
|
+
// see the handle this record just took, since both come out of the same
|
|
579
|
+
// namespace.
|
|
580
|
+
alias: isTopLevelAgent(options) ? options.reclaim?.alias : undefined,
|
|
581
|
+
// Overwritten below when the spawn is actually queued; a foreground spawn
|
|
582
|
+
// that queues flips to "queued" there rather than being guessed at here,
|
|
583
|
+
// since the pool decision needs the finished record.
|
|
315
584
|
status: options.isBackground ? "queued" : "running",
|
|
316
585
|
toolUses: 0,
|
|
317
586
|
startedAt: Date.now(),
|
|
318
587
|
abortController,
|
|
319
|
-
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
588
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
|
|
320
589
|
compactionCount: 0,
|
|
321
590
|
// Raw tri-state (not coerced to a boolean): true = background, false =
|
|
322
591
|
// foreground (has an inline tool-result surface), undefined = caller never
|
|
@@ -324,20 +593,39 @@ export class AgentManager {
|
|
|
324
593
|
// only filter excludes only explicit `false`, so undefined agents — which
|
|
325
594
|
// have no inline surface — stay visible instead of vanishing.
|
|
326
595
|
isBackground: options.isBackground,
|
|
596
|
+
// Whether anyone is awaiting this agent is a property of the agent, not
|
|
597
|
+
// of the call that made it — and both settle paths need it long after
|
|
598
|
+
// `options` has stopped being the interesting object.
|
|
327
599
|
blocking: options.blocking,
|
|
328
600
|
invocation: options.invocation,
|
|
329
601
|
depth: options.depth ?? 1,
|
|
330
602
|
parentAgentId: options.parentAgentId,
|
|
603
|
+
workflowId: options.workflowId,
|
|
331
604
|
maxSubagentDepth: options.maxSubagentDepth,
|
|
332
605
|
rootSessionId: options.rootSessionId,
|
|
333
606
|
}
|
|
334
607
|
this.agents.set(id, record)
|
|
608
|
+
// After the insert, so `takenHandles()` already counts this record's own
|
|
609
|
+
// handle — a spawn named after its own type gets `explore-2`, not a
|
|
610
|
+
// duplicate `explore` that would make resolution ambiguous.
|
|
611
|
+
if (
|
|
612
|
+
record.handle !== undefined &&
|
|
613
|
+
record.alias === undefined &&
|
|
614
|
+
options.name !== undefined
|
|
615
|
+
) {
|
|
616
|
+
record.alias = assignHandle(handleBase(options.name), this.takenHandles())
|
|
617
|
+
}
|
|
335
618
|
|
|
336
619
|
const args: SpawnArgs = { pi, ctx, type, prompt, options }
|
|
337
620
|
|
|
338
621
|
const pool = this.poolFor(record)
|
|
339
|
-
if (pool && !options.bypassQueue && !this.poolHasRoom(pool)) {
|
|
622
|
+
if (pool !== undefined && !options.bypassQueue && !this.poolHasRoom(pool)) {
|
|
623
|
+
// Queue it — started when a running agent in the same pool completes.
|
|
624
|
+
// Idempotent for background (already "queued"); the flip that matters is
|
|
625
|
+
// a blocking foreground spawn, optimistically marked "running" above.
|
|
340
626
|
record.status = "queued"
|
|
627
|
+
// A queued record never reaches startAgent's signal wiring, so arm the
|
|
628
|
+
// parent abort here or Esc could not release the position.
|
|
341
629
|
if (!this.armQueuedAbort(id, options.signal)) return id
|
|
342
630
|
let release!: () => void
|
|
343
631
|
record.startGate = new Promise<void>((resolve) => {
|
|
@@ -346,29 +634,37 @@ export class AgentManager {
|
|
|
346
634
|
this.queue.push({
|
|
347
635
|
id,
|
|
348
636
|
pool,
|
|
349
|
-
start: () => this.
|
|
350
|
-
release,
|
|
637
|
+
start: () => this.launch(id, record, args, pool),
|
|
638
|
+
release: () => release(),
|
|
351
639
|
})
|
|
352
640
|
options.onQueued?.(
|
|
353
641
|
id,
|
|
354
|
-
this.queue.filter((
|
|
642
|
+
this.queue.filter((e) => e.pool === pool).length - 1,
|
|
355
643
|
)
|
|
356
644
|
return id
|
|
357
645
|
}
|
|
358
646
|
|
|
359
|
-
|
|
360
|
-
// up the record so callers don't see an orphan in `listAgents()`.
|
|
361
|
-
try {
|
|
362
|
-
this.startAgent(id, record, args)
|
|
363
|
-
} catch (err) {
|
|
364
|
-
this.agents.delete(id)
|
|
365
|
-
throw err
|
|
366
|
-
}
|
|
647
|
+
this.launch(id, record, args, undefined)
|
|
367
648
|
return id
|
|
368
649
|
}
|
|
369
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Wire a parent abort signal for a record that is about to be QUEUED.
|
|
653
|
+
* `startAgent` does this for running agents, and a queued record never gets
|
|
654
|
+
* there, so without this Esc could not release a queue position.
|
|
655
|
+
*
|
|
656
|
+
* Returns false when the signal is ALREADY aborted, in which case the record
|
|
657
|
+
* is stopped here and must not be enqueued: `addEventListener` never fires on
|
|
658
|
+
* an aborted signal, so a `spawnAndWait` on it would wait forever — pi has no
|
|
659
|
+
* tool-execution timeout to bail it out.
|
|
660
|
+
*
|
|
661
|
+
* The listener is left in place when the agent starts. `startAgent` adds its
|
|
662
|
+
* own, so both fire on a later abort, but `abort()` on an already-stopped
|
|
663
|
+
* record is a no-op — so detaching would only be tidiness, and tidiness the
|
|
664
|
+
* `abortAll`/`dispose` paths could not offer anyway.
|
|
665
|
+
*/
|
|
370
666
|
private armQueuedAbort(id: string, signal?: AbortSignal): boolean {
|
|
371
|
-
if (
|
|
667
|
+
if (signal === undefined) return true
|
|
372
668
|
if (signal.aborted) {
|
|
373
669
|
const record = this.agents.get(id)
|
|
374
670
|
if (record) {
|
|
@@ -381,8 +677,71 @@ export class AgentManager {
|
|
|
381
677
|
return true
|
|
382
678
|
}
|
|
383
679
|
|
|
680
|
+
/**
|
|
681
|
+
* Kick off an agent's startup and register it under `startups`. The returned
|
|
682
|
+
* promise never rejects — the failure is delivered through `awaitStartup`,
|
|
683
|
+
* and to the record.
|
|
684
|
+
*
|
|
685
|
+
* @param queuedPool - The pool this start was QUEUED on, or undefined for an
|
|
686
|
+
* immediate start. A queue drain can be minutes after `spawn()` returned,
|
|
687
|
+
* and nobody is awaiting `awaitStartup` by then, so a failure has to live
|
|
688
|
+
* on the record as status "error" — what drainQueue did when the throw was
|
|
689
|
+
* still synchronous. An immediate start instead drops the record, exactly
|
|
690
|
+
* as the throw out of `spawn()` did: no orphan in `listAgents()`, and the
|
|
691
|
+
* handle goes back.
|
|
692
|
+
*/
|
|
693
|
+
private launch(
|
|
694
|
+
id: string,
|
|
695
|
+
record: AgentRecord,
|
|
696
|
+
args: SpawnArgs,
|
|
697
|
+
queuedPool: Pool | undefined,
|
|
698
|
+
): Promise<void> {
|
|
699
|
+
const startup = this.startAgent(id, record, args).then(
|
|
700
|
+
() => {
|
|
701
|
+
this.startups.delete(id)
|
|
702
|
+
},
|
|
703
|
+
(err) => {
|
|
704
|
+
this.startups.delete(id)
|
|
705
|
+
if (queuedPool !== undefined) {
|
|
706
|
+
// Mirrors settleRun: an inline caller gets this failure as a throw
|
|
707
|
+
// out of spawnAndWait, so an unconsumed record would ALSO nudge the
|
|
708
|
+
// session about it — the same failure reported twice.
|
|
709
|
+
if (queuedPool === "foreground") record.resultConsumed = true
|
|
710
|
+
record.status = "error"
|
|
711
|
+
record.error = err instanceof Error ? err.message : String(err)
|
|
712
|
+
record.completedAt = Date.now()
|
|
713
|
+
this.onComplete?.(record)
|
|
714
|
+
} else {
|
|
715
|
+
this.agents.delete(id)
|
|
716
|
+
}
|
|
717
|
+
// The agent never kept its slot (startAgent gives it back on failure),
|
|
718
|
+
// so anything queued behind it can go now.
|
|
719
|
+
this.drainQueue()
|
|
720
|
+
throw err
|
|
721
|
+
},
|
|
722
|
+
)
|
|
723
|
+
this.startups.set(id, startup)
|
|
724
|
+
// Nothing is obliged to await `startups` — swallow the rejection once here
|
|
725
|
+
// so an unawaited startup can't take the process down, and hand callers
|
|
726
|
+
// (drainQueue) that swallowed promise.
|
|
727
|
+
return startup.catch(() => {})
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Resolves once the agent is actually running, and rejects with the startup
|
|
732
|
+
* failure (strict worktree isolation) that `spawn()` used to throw before the
|
|
733
|
+
* repo copy became async. Resolves immediately for an agent that is already
|
|
734
|
+
* running, still queued, or unknown — so callers can await it unconditionally.
|
|
735
|
+
*
|
|
736
|
+
* Call it in the same tick as the `spawn()` it belongs to: a failed startup
|
|
737
|
+
* takes its record (and this entry) with it, exactly as the throw did.
|
|
738
|
+
*/
|
|
739
|
+
awaitStartup(id: string): Promise<void> {
|
|
740
|
+
return this.startups.get(id) ?? Promise.resolve()
|
|
741
|
+
}
|
|
742
|
+
|
|
384
743
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
385
|
-
private startAgent(
|
|
744
|
+
private async startAgent(
|
|
386
745
|
id: string,
|
|
387
746
|
record: AgentRecord,
|
|
388
747
|
{ pi, ctx, type, prompt, options }: SpawnArgs,
|
|
@@ -396,13 +755,42 @@ export class AgentManager {
|
|
|
396
755
|
const customCwd = options.cwd ?? undefined // null (RPC "unset") → undefined
|
|
397
756
|
const baseCwd = customCwd ?? ctx.cwd
|
|
398
757
|
|
|
758
|
+
// Take the running state — and with it the concurrency slot — BEFORE the
|
|
759
|
+
// first await. Creating a worktree is an awaited git call, and drainQueue
|
|
760
|
+
// reads the pool counters synchronously in a loop: incrementing after the
|
|
761
|
+
// await would let it start every queued agent at once while the first is
|
|
762
|
+
// still copying its repo. Claiming "running" here also keeps abort() and
|
|
763
|
+
// abortAll() able to reach an agent whose worktree is still being created.
|
|
764
|
+
//
|
|
765
|
+
// The pool is resolved ONCE, here, and carried to `settleRun` below:
|
|
766
|
+
// `poolFor` reads `maxConcurrentForeground`, which the user can change from
|
|
767
|
+
// `/agents → Settings` mid-run, so recomputing it at settle time would
|
|
768
|
+
// decrement a pool this run never charged (counter underflow, limit
|
|
769
|
+
// silently lifted) or skip the decrement for one it did (leaked slot —
|
|
770
|
+
// every later blocking spawn queues forever). The two startup exits below
|
|
771
|
+
// never reach `settleRun`, so they hand the slot back themselves.
|
|
772
|
+
const pool = this.poolFor(record)
|
|
773
|
+
const releaseSlot = () => {
|
|
774
|
+
if (pool === "background") this.runningBackground--
|
|
775
|
+
else if (pool === "foreground") this.runningForeground--
|
|
776
|
+
}
|
|
777
|
+
record.status = "running"
|
|
778
|
+
record.startedAt = Date.now()
|
|
779
|
+
record.startGate = undefined
|
|
780
|
+
if (pool === "background") this.runningBackground++
|
|
781
|
+
else if (pool === "foreground") this.runningForeground++
|
|
782
|
+
|
|
399
783
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
400
|
-
// fail loud if not possible (no silent fallback to main tree). Done
|
|
401
|
-
//
|
|
784
|
+
// fail loud if not possible (no silent fallback to main tree). Done BEFORE
|
|
785
|
+
// the run is kicked off so a failure doesn't leave a half-running agent.
|
|
786
|
+
// The project switch is enforced here as well as at the tool boundary
|
|
787
|
+
// because cross-extension RPC forwards its options unvalidated — a schema
|
|
788
|
+
// that omits the field can't stop a caller that never saw the schema.
|
|
402
789
|
let worktreeCwd: string | undefined
|
|
403
790
|
if (options.isolation === "worktree" && isWorktreeIsolationEnabled()) {
|
|
404
|
-
const wt = createWorktree(baseCwd, id)
|
|
791
|
+
const wt = await createWorktree(pi, baseCwd, id)
|
|
405
792
|
if (!wt) {
|
|
793
|
+
releaseSlot()
|
|
406
794
|
throw new Error(
|
|
407
795
|
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
408
796
|
"Initialize git and commit at least once, or omit `isolation`.",
|
|
@@ -417,22 +805,38 @@ export class AgentManager {
|
|
|
417
805
|
// subdirectory, silently dropping extensions/skills.
|
|
418
806
|
worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path
|
|
419
807
|
this.worktreeRepos.add(baseCwd)
|
|
808
|
+
|
|
809
|
+
// No longer "running" means a stop landed while the copy was being made
|
|
810
|
+
// (abort(), abortAll()) — a window that did not exist when creation was
|
|
811
|
+
// synchronous. The record is already terminal, so launching the run would
|
|
812
|
+
// burn tokens on work nobody is waiting for: discard the fresh (and by
|
|
813
|
+
// definition unchanged) worktree instead.
|
|
814
|
+
if (record.status !== "running") {
|
|
815
|
+
releaseSlot()
|
|
816
|
+
record.worktreeResult = await cleanupWorktree(
|
|
817
|
+
pi,
|
|
818
|
+
baseCwd,
|
|
819
|
+
wt,
|
|
820
|
+
options.description,
|
|
821
|
+
)
|
|
822
|
+
if (applyWorktreeCleanupFailure(record, record.worktreeResult)) {
|
|
823
|
+
throw new Error(record.error)
|
|
824
|
+
}
|
|
825
|
+
this.drainQueue()
|
|
826
|
+
return
|
|
827
|
+
}
|
|
420
828
|
}
|
|
421
829
|
|
|
422
|
-
record.status = "running"
|
|
423
|
-
record.startedAt = Date.now()
|
|
424
|
-
record.startGate = undefined
|
|
425
|
-
const pool = this.poolFor(record)
|
|
426
|
-
if (pool === "background") this.runningBackground++
|
|
427
|
-
else if (pool === "foreground") this.runningForeground++
|
|
428
830
|
this.onStart?.(record)
|
|
429
831
|
|
|
430
832
|
// Wire parent abort signal to stop the subagent when the parent is interrupted
|
|
431
833
|
let detachParentSignal: (() => void) | undefined
|
|
432
834
|
if (options.signal) {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
835
|
+
// A queued spawn can start minutes after the caller handed us its signal,
|
|
836
|
+
// by which time it may already be aborted — and `addEventListener` would
|
|
837
|
+
// never fire, leaving a child the parent can no longer reach.
|
|
838
|
+
if (options.signal.aborted) this.abort(id)
|
|
839
|
+
else {
|
|
436
840
|
const onParentAbort = () => this.abort(id)
|
|
437
841
|
options.signal.addEventListener("abort", onParentAbort, { once: true })
|
|
438
842
|
detachParentSignal = () =>
|
|
@@ -452,12 +856,18 @@ export class AgentManager {
|
|
|
452
856
|
isolated: options.isolated,
|
|
453
857
|
inheritContext: options.inheritContext,
|
|
454
858
|
thinkingLevel: options.thinkingLevel,
|
|
859
|
+
structuredOutput: options.structuredOutput,
|
|
860
|
+
resumeSessionFile: options.resumeSessionFile,
|
|
861
|
+
nested: options.parentAgentId !== undefined,
|
|
862
|
+
workflow: options.workflowId !== undefined,
|
|
455
863
|
// Worktree wins for the working dir (the agent must run in the copy —
|
|
456
864
|
// which, with a custom cwd, was created from that target). Config stays
|
|
457
865
|
// with the parent project when a caller-supplied cwd is in play; it must
|
|
458
866
|
// stay undefined otherwise so plain worktree runs keep resolving config
|
|
459
867
|
// (incl. relative extension paths and memory) inside the worktree copy.
|
|
460
868
|
cwd: worktreeCwd ?? customCwd,
|
|
869
|
+
// Set iff a worktree was created (see above) — names the directory the
|
|
870
|
+
// copy came from, so the prompt can tell the agent not to work there.
|
|
461
871
|
worktreeBase: worktreeCwd ? baseCwd : undefined,
|
|
462
872
|
configCwd:
|
|
463
873
|
options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
|
|
@@ -486,11 +896,31 @@ export class AgentManager {
|
|
|
486
896
|
},
|
|
487
897
|
onSessionCreated: (session) => {
|
|
488
898
|
record.session = session
|
|
899
|
+
// Capture now, while the session object exists: after eviction this
|
|
900
|
+
// path is the only thing that can reopen the conversation, and an
|
|
901
|
+
// in-memory session reports undefined, which correctly means
|
|
902
|
+
// "nothing to come back to".
|
|
903
|
+
// Optional chaining, not defensiveness for its own sake: this is the
|
|
904
|
+
// only field read off the session at creation, so an older pi or a
|
|
905
|
+
// stubbed session must degrade to "not resumable" rather than throw
|
|
906
|
+
// and take the whole spawn down with it.
|
|
907
|
+
record.sessionFile = session.sessionManager?.getSessionFile?.()
|
|
908
|
+
// Same reason, different field: the model and thinking level are only
|
|
909
|
+
// knowable once pi has resolved its defaults and clamped the level to
|
|
910
|
+
// what the model supports. Writing them back here makes the record
|
|
911
|
+
// authoritative, so every surface reads one place instead of each
|
|
912
|
+
// re-deriving "session, else the request" for itself.
|
|
489
913
|
if (session.model) {
|
|
490
914
|
record.invocation ??= {}
|
|
915
|
+
// Read the kept request first: a caller's level survives being clamped
|
|
916
|
+
// AND, one line later, being replaced by the effective one.
|
|
491
917
|
const requested =
|
|
492
918
|
record.invocation.requestedThinking ?? record.invocation.thinking
|
|
493
919
|
Object.assign(record.invocation, describeModel(session.model))
|
|
920
|
+
// Guarded for the reason above: a session that reports no level keeps
|
|
921
|
+
// the request rather than losing it. Overwriting unconditionally would
|
|
922
|
+
// turn an older or stubbed session into a blank `thinking:` tag, which
|
|
923
|
+
// is worse than the stale-but-true value it replaced.
|
|
494
924
|
if (session.thinkingLevel) {
|
|
495
925
|
record.invocation.thinking = session.thinkingLevel
|
|
496
926
|
if (requested && requested !== session.thinkingLevel) {
|
|
@@ -508,61 +938,92 @@ export class AgentManager {
|
|
|
508
938
|
options.onSessionCreated?.(session)
|
|
509
939
|
},
|
|
510
940
|
})
|
|
511
|
-
.then(
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
941
|
+
.then(
|
|
942
|
+
async ({
|
|
943
|
+
responseText,
|
|
944
|
+
session,
|
|
945
|
+
aborted,
|
|
946
|
+
steered,
|
|
947
|
+
failure,
|
|
948
|
+
structuredJson,
|
|
949
|
+
structuredRetried,
|
|
950
|
+
}) => {
|
|
951
|
+
// Don't overwrite status if externally stopped via abort()
|
|
952
|
+
if (record.status !== "stopped") {
|
|
953
|
+
// Precedence: a hard abort keeps "aborted"; then a failed final turn
|
|
954
|
+
// (provider error that pi resolved instead of rejecting, #144) is an
|
|
955
|
+
// honest "error" — not a completion with an empty or stale result.
|
|
956
|
+
if (aborted) {
|
|
957
|
+
record.status = "aborted"
|
|
958
|
+
} else if (failure) {
|
|
959
|
+
record.status = "error"
|
|
960
|
+
record.error = failure
|
|
961
|
+
} else {
|
|
962
|
+
record.status = steered ? "steered" : "completed"
|
|
963
|
+
}
|
|
524
964
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
965
|
+
record.result = responseText
|
|
966
|
+
// Kept beside `result`, never inside it: `result` is prose meant for a
|
|
967
|
+
// reader — it is previewed, transcribed, and appended to below — while
|
|
968
|
+
// this is a machine-readable payload one caller asked for by schema.
|
|
969
|
+
record.structuredJson = structuredJson
|
|
970
|
+
record.structuredRetried = structuredRetried
|
|
971
|
+
record.session = session
|
|
972
|
+
record.completedAt ??= Date.now()
|
|
973
|
+
|
|
974
|
+
detach()
|
|
975
|
+
|
|
976
|
+
// Final flush of streaming output file
|
|
977
|
+
if (record.outputCleanup) {
|
|
978
|
+
try {
|
|
979
|
+
record.outputCleanup()
|
|
980
|
+
} catch {
|
|
981
|
+
/* ignore */
|
|
982
|
+
}
|
|
983
|
+
record.outputCleanup = undefined
|
|
538
984
|
}
|
|
539
|
-
record.outputCleanup = undefined
|
|
540
|
-
}
|
|
541
985
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
options.
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
986
|
+
// Clean up worktree if used
|
|
987
|
+
if (record.worktree) {
|
|
988
|
+
// The one moment the child's tree still exists and the child is done
|
|
989
|
+
// writing to it. try/catch, not decoration: a hook that throws must
|
|
990
|
+
// not leave the worktree behind.
|
|
991
|
+
if (options.onBeforeWorktreeCleanup) {
|
|
992
|
+
try {
|
|
993
|
+
await options.onBeforeWorktreeCleanup(record.worktree.path)
|
|
994
|
+
} catch {
|
|
995
|
+
/* ignore — never block cleanup */
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
const wtResult = await cleanupWorktree(
|
|
999
|
+
pi,
|
|
1000
|
+
baseCwd,
|
|
1001
|
+
record.worktree,
|
|
1002
|
+
options.description,
|
|
1003
|
+
)
|
|
1004
|
+
record.worktreeResult = wtResult
|
|
1005
|
+
const cleanupFailed = applyWorktreeCleanupFailure(record, wtResult)
|
|
1006
|
+
if (!cleanupFailed && wtResult.hasChanges && wtResult.branch) {
|
|
1007
|
+
// With a caller-supplied cwd the branch lives in THAT repo, not the
|
|
1008
|
+
// parent session's — say so, or the orchestrator merges in the wrong repo.
|
|
1009
|
+
const repoNote =
|
|
1010
|
+
customCwd !== undefined ? ` in \`${baseCwd}\`` : ""
|
|
1011
|
+
// Appended to the prose only. A structured child's caller parses
|
|
1012
|
+
// `structuredJson`, which stays untouched — but `result` is also
|
|
1013
|
+
// what a human reads, so the note still belongs on it.
|
|
1014
|
+
record.result =
|
|
1015
|
+
(record.result ?? "") +
|
|
1016
|
+
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`
|
|
1017
|
+
}
|
|
557
1018
|
}
|
|
558
|
-
}
|
|
559
1019
|
|
|
560
|
-
|
|
1020
|
+
this.abortOwnedChildren(id)
|
|
561
1021
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1022
|
+
this.settleRun(record, true, pool)
|
|
1023
|
+
return responseText
|
|
1024
|
+
},
|
|
1025
|
+
)
|
|
1026
|
+
.catch(async (err) => {
|
|
566
1027
|
// Don't overwrite status if externally stopped via abort()
|
|
567
1028
|
if (record.status !== "stopped") {
|
|
568
1029
|
record.status = "error"
|
|
@@ -585,12 +1046,14 @@ export class AgentManager {
|
|
|
585
1046
|
// Best-effort worktree cleanup on error
|
|
586
1047
|
if (record.worktree) {
|
|
587
1048
|
try {
|
|
588
|
-
const wtResult = cleanupWorktree(
|
|
1049
|
+
const wtResult = await cleanupWorktree(
|
|
1050
|
+
pi,
|
|
589
1051
|
baseCwd,
|
|
590
1052
|
record.worktree,
|
|
591
1053
|
options.description,
|
|
592
1054
|
)
|
|
593
1055
|
record.worktreeResult = wtResult
|
|
1056
|
+
applyWorktreeCleanupFailure(record, wtResult)
|
|
594
1057
|
} catch {
|
|
595
1058
|
/* ignore cleanup errors */
|
|
596
1059
|
}
|
|
@@ -604,10 +1067,32 @@ export class AgentManager {
|
|
|
604
1067
|
|
|
605
1068
|
record.promise = promise
|
|
606
1069
|
|
|
607
|
-
//
|
|
1070
|
+
// Notify caller that spawn is complete (record is in the map, promise is set).
|
|
1071
|
+
// Called synchronously — onSessionCreated fires asynchronously inside runAgent.
|
|
1072
|
+
// Used by spawnAndWait to let the caller set up output files before streaming
|
|
1073
|
+
// starts. Read off the options, so a spawn that started from a queue drain
|
|
1074
|
+
// still reaches the caller that queued it.
|
|
608
1075
|
options.onSpawned?.(id)
|
|
609
1076
|
}
|
|
610
1077
|
|
|
1078
|
+
/**
|
|
1079
|
+
* The shared tail of both settle paths: release whatever pool slot the run
|
|
1080
|
+
* held, notify, and let the queue drain into the freed slot.
|
|
1081
|
+
*
|
|
1082
|
+
* The decrement lives HERE and nowhere else. `abort()` on a running record
|
|
1083
|
+
* only fires its controller and leaves the run to settle normally, so
|
|
1084
|
+
* decrementing there too would double-free — permanently lifting the limit.
|
|
1085
|
+
*
|
|
1086
|
+
* Foreground agents fire `onComplete` for lifecycle symmetry, with
|
|
1087
|
+
* `resultConsumed` set so the callback skips notifications the inline result
|
|
1088
|
+
* already delivered.
|
|
1089
|
+
*
|
|
1090
|
+
* @param guardCallback swallow a throwing `onComplete` (the success path does;
|
|
1091
|
+
* the error path historically did not, and keeps not doing so).
|
|
1092
|
+
* @param pool the pool this run was CHARGED TO at start time — passed in, not
|
|
1093
|
+
* recomputed, so a mid-run change to `maxConcurrentForeground` can't make
|
|
1094
|
+
* the release disagree with the acquire.
|
|
1095
|
+
*/
|
|
611
1096
|
private settleRun(
|
|
612
1097
|
record: AgentRecord,
|
|
613
1098
|
guardCallback: boolean,
|
|
@@ -626,7 +1111,14 @@ export class AgentManager {
|
|
|
626
1111
|
} else {
|
|
627
1112
|
this.onComplete?.(record)
|
|
628
1113
|
}
|
|
629
|
-
|
|
1114
|
+
|
|
1115
|
+
// The isBackground half reproduces the pre-pool condition exactly — a
|
|
1116
|
+
// background settle has always drained, even for a nested child that held
|
|
1117
|
+
// no slot — so that path is unchanged whether or not the foreground pool is
|
|
1118
|
+
// on. The `pool` half only adds the drain a freed FOREGROUND slot needs.
|
|
1119
|
+
// A drain with nothing freed is a no-op anyway, but "no-op" is a claim
|
|
1120
|
+
// about reachability, and matching the old condition needs no such claim.
|
|
1121
|
+
if (record.isBackground || pool !== undefined) this.drainQueue()
|
|
630
1122
|
}
|
|
631
1123
|
|
|
632
1124
|
/**
|
|
@@ -641,31 +1133,46 @@ export class AgentManager {
|
|
|
641
1133
|
}
|
|
642
1134
|
}
|
|
643
1135
|
|
|
644
|
-
/**
|
|
1136
|
+
/**
|
|
1137
|
+
* Start queued agents up to each pool's concurrency limit.
|
|
1138
|
+
*
|
|
1139
|
+
* `findIndex` on the entry's OWN pool rather than `shift`: with one queue
|
|
1140
|
+
* serving two independent limits, a saturated foreground pool at the head
|
|
1141
|
+
* would otherwise stall every background agent behind it. Taking the earliest
|
|
1142
|
+
* eligible entry keeps FIFO within each pool, which is what callers see.
|
|
1143
|
+
*/
|
|
645
1144
|
private drainQueue() {
|
|
646
1145
|
for (;;) {
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
)
|
|
650
|
-
if (index === -1) return
|
|
651
|
-
const [next] = this.queue.splice(index, 1)
|
|
1146
|
+
const i = this.queue.findIndex((e) => this.poolHasRoom(e.pool))
|
|
1147
|
+
if (i === -1) return
|
|
1148
|
+
const [next] = this.queue.splice(i, 1)
|
|
652
1149
|
const record = this.agents.get(next.id)
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
if (record) {
|
|
657
|
-
if (next.pool === "foreground") record.resultConsumed = true
|
|
658
|
-
record.status = "error"
|
|
659
|
-
record.error = err instanceof Error ? err.message : String(err)
|
|
660
|
-
record.completedAt = Date.now()
|
|
661
|
-
this.onComplete?.(record)
|
|
662
|
-
}
|
|
663
|
-
} finally {
|
|
1150
|
+
// Stale entries (aborted while queued) are not started — but are still
|
|
1151
|
+
// released, since nothing else will.
|
|
1152
|
+
if (record?.status !== "queued") {
|
|
664
1153
|
next.release()
|
|
1154
|
+
continue
|
|
665
1155
|
}
|
|
1156
|
+
// Detached, and never rejects: a late failure (e.g. strict worktree
|
|
1157
|
+
// isolation) lands on the record inside `launch`, exactly as the
|
|
1158
|
+
// synchronous throw did here before, and draining continues either way.
|
|
1159
|
+
//
|
|
1160
|
+
// The release waits for that startup to SETTLE rather than firing here.
|
|
1161
|
+
// Startup is async now, so a release at drain time would wake a blocked
|
|
1162
|
+
// `spawnAndWait` while `record.promise` was still undefined, and it would
|
|
1163
|
+
// read a perfectly healthy agent as one that never ran.
|
|
1164
|
+
void next.start().then(
|
|
1165
|
+
() => next.release(),
|
|
1166
|
+
() => next.release(),
|
|
1167
|
+
)
|
|
666
1168
|
}
|
|
667
1169
|
}
|
|
668
1170
|
|
|
1171
|
+
/**
|
|
1172
|
+
* Remove queued entries and wake anyone blocked on them. The single point
|
|
1173
|
+
* that enforces "leaving the queue releases the waiter" — a missed release is
|
|
1174
|
+
* an unbounded hang, not a failed call.
|
|
1175
|
+
*/
|
|
669
1176
|
private dequeue(pred: (entry: { id: string; pool: Pool }) => boolean): void {
|
|
670
1177
|
const kept: typeof this.queue = []
|
|
671
1178
|
for (const entry of this.queue) {
|
|
@@ -675,7 +1182,16 @@ export class AgentManager {
|
|
|
675
1182
|
this.queue = kept
|
|
676
1183
|
}
|
|
677
1184
|
|
|
678
|
-
/**
|
|
1185
|
+
/**
|
|
1186
|
+
* Spawn an agent and wait for completion (foreground use).
|
|
1187
|
+
* Charged to the foreground pool (`maxConcurrentForeground`), which is
|
|
1188
|
+
* unlimited by default; never to the background one.
|
|
1189
|
+
* Returns { id, record } so callers can access the agent ID.
|
|
1190
|
+
*
|
|
1191
|
+
* @param onSpawned - Called synchronously once the run is kicked off, before
|
|
1192
|
+
* onSessionCreated fires. Use this to set record.outputFile so
|
|
1193
|
+
* streamToOutputFile can pick it up.
|
|
1194
|
+
*/
|
|
679
1195
|
async spawnAndWait(
|
|
680
1196
|
pi: ExtensionAPI,
|
|
681
1197
|
ctx: ExtensionContext,
|
|
@@ -684,6 +1200,11 @@ export class AgentManager {
|
|
|
684
1200
|
options: Omit<SpawnOptions, "isBackground">,
|
|
685
1201
|
onSpawned?: (id: string) => void,
|
|
686
1202
|
): Promise<{ id: string; record: AgentRecord }> {
|
|
1203
|
+
// `blocking` is what maxConcurrentForeground bounds, and this is its only
|
|
1204
|
+
// source. onSpawned rides on the options rather than on a field of this
|
|
1205
|
+
// manager: a queued spawn starts at drain time, long after any install/
|
|
1206
|
+
// restore pair around this call would have put the field back — and it now
|
|
1207
|
+
// fires after an await (worktree creation) even on the immediate path.
|
|
687
1208
|
const id = this.spawn(pi, ctx, type, prompt, {
|
|
688
1209
|
...options,
|
|
689
1210
|
isBackground: false,
|
|
@@ -691,9 +1212,32 @@ export class AgentManager {
|
|
|
691
1212
|
onSpawned,
|
|
692
1213
|
})
|
|
693
1214
|
const record = this.agents.get(id)!
|
|
1215
|
+
|
|
1216
|
+
// Queued: nothing to await yet — the promise appears when the drain starts
|
|
1217
|
+
// it. The gate resolves (never rejects) on every path out of the queue,
|
|
1218
|
+
// start and abort alike, so a rejection can never escape into the caller's
|
|
1219
|
+
// tool `execute` and take down pi's whole Promise.all tool batch.
|
|
694
1220
|
if (record.status === "queued") await record.startGate
|
|
1221
|
+
|
|
1222
|
+
// The run promise only exists once startup is past its awaited repo copy —
|
|
1223
|
+
// without this the call would return before the agent had started at all.
|
|
1224
|
+
// A startup failure (strict worktree isolation) rejects here, which is what
|
|
1225
|
+
// the immediate path owes its caller: pi only marks a tool result failed
|
|
1226
|
+
// when `execute` throws. A queued spawn's failure landed on the record
|
|
1227
|
+
// instead (nobody was awaiting `startups` at drain time) and is rethrown
|
|
1228
|
+
// below, so the contract is the same either way.
|
|
1229
|
+
await this.awaitStartup(id)
|
|
1230
|
+
|
|
1231
|
+
// undefined when it was aborted while queued, or stopped mid-copy, and so
|
|
1232
|
+
// never ran — the record is already terminal with a completedAt, which is
|
|
1233
|
+
// what the caller renders.
|
|
695
1234
|
if (record.promise) await record.promise
|
|
696
|
-
|
|
1235
|
+
|
|
1236
|
+
// A record that ended "error" without ever getting a promise never ran: the
|
|
1237
|
+
// same startup failure spawn() rethrows on the immediate path (#179). Keep
|
|
1238
|
+
// one contract rather than letting queue pressure decide whether a strict
|
|
1239
|
+
// worktree failure throws or returns as a result.
|
|
1240
|
+
if (record.promise === undefined && record.status === "error") {
|
|
697
1241
|
throw new Error(record.error ?? "Agent failed to start")
|
|
698
1242
|
}
|
|
699
1243
|
return { id, record }
|
|
@@ -739,11 +1283,24 @@ export class AgentManager {
|
|
|
739
1283
|
|
|
740
1284
|
const start = () => this.startResume(id, record, prompt, signal, options)
|
|
741
1285
|
if (occupiesPoolSlot(record) && !this.poolHasRoom("background")) {
|
|
742
|
-
//
|
|
1286
|
+
// At the concurrency limit — queue it, drains when a slot frees. A
|
|
1287
|
+
// detached resume has no inline caller, hence nothing to release. The
|
|
1288
|
+
// queue is shared with spawns, whose startup is async, so entries are
|
|
1289
|
+
// promise-shaped even though a resume starts synchronously; failures
|
|
1290
|
+
// land on the record here, since drainQueue no longer catches.
|
|
743
1291
|
this.queue.push({
|
|
744
1292
|
id,
|
|
745
1293
|
pool: "background",
|
|
746
|
-
start
|
|
1294
|
+
start: async () => {
|
|
1295
|
+
try {
|
|
1296
|
+
start()
|
|
1297
|
+
} catch (err) {
|
|
1298
|
+
record.status = "error"
|
|
1299
|
+
record.error = err instanceof Error ? err.message : String(err)
|
|
1300
|
+
record.completedAt = Date.now()
|
|
1301
|
+
this.onComplete?.(record)
|
|
1302
|
+
}
|
|
1303
|
+
},
|
|
747
1304
|
release: () => {},
|
|
748
1305
|
})
|
|
749
1306
|
} else {
|
|
@@ -765,7 +1322,6 @@ export class AgentManager {
|
|
|
765
1322
|
if (activity.type === "end") record.toolUses++
|
|
766
1323
|
options?.onToolActivity?.(activity)
|
|
767
1324
|
},
|
|
768
|
-
onTurnEnd: options?.onTurnEnd,
|
|
769
1325
|
onAssistantUsage: (usage) => {
|
|
770
1326
|
addUsage(record.lifetimeUsage, usage)
|
|
771
1327
|
this.onUsage?.(record, usage)
|
|
@@ -870,7 +1426,6 @@ export class AgentManager {
|
|
|
870
1426
|
if (activity.type === "end") record.toolUses++
|
|
871
1427
|
options.onToolActivity?.(activity)
|
|
872
1428
|
},
|
|
873
|
-
onTurnEnd: options.onTurnEnd,
|
|
874
1429
|
onAssistantUsage: (usage) => {
|
|
875
1430
|
addUsage(record.lifetimeUsage, usage)
|
|
876
1431
|
this.onUsage?.(record, usage)
|
|
@@ -934,6 +1489,83 @@ export class AgentManager {
|
|
|
934
1489
|
return this.agents.get(id)
|
|
935
1490
|
}
|
|
936
1491
|
|
|
1492
|
+
/** Handles already in use, so a fresh spawn can pick an unclaimed one. */
|
|
1493
|
+
private takenHandles(): Set<string> {
|
|
1494
|
+
const taken = new Set<string>()
|
|
1495
|
+
for (const record of this.agents.values()) {
|
|
1496
|
+
if (record.handle) taken.add(record.handle)
|
|
1497
|
+
if (record.alias) taken.add(record.alias)
|
|
1498
|
+
}
|
|
1499
|
+
// Tombstones hold their names too: an evicted `@explore` is still
|
|
1500
|
+
// resurrectable, so a later Explore must become `explore-2` rather than
|
|
1501
|
+
// shadowing a conversation the user can still reach.
|
|
1502
|
+
for (const entry of this.tombstones.values()) {
|
|
1503
|
+
taken.add(entry.handle)
|
|
1504
|
+
if (entry.alias) taken.add(entry.alias)
|
|
1505
|
+
}
|
|
1506
|
+
return taken
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* Resolve an `@name` from the prompt. Matches a top-level agent's handle
|
|
1511
|
+
* case-insensitively, preferring one that can still be steered and otherwise
|
|
1512
|
+
* the most recently started (which is the one a resume should continue), then
|
|
1513
|
+
* falls back to an exact agent id so `@<agentId>` works too.
|
|
1514
|
+
*/
|
|
1515
|
+
resolveMention(name: string): MentionResolution | undefined {
|
|
1516
|
+
const wanted = name.toLowerCase()
|
|
1517
|
+
let fallback: AgentRecord | undefined
|
|
1518
|
+
for (const record of this.agents.values()) {
|
|
1519
|
+
if (record.parentAgentId !== undefined) continue
|
|
1520
|
+
// Handle and alias share one namespace, so at most one agent answers a
|
|
1521
|
+
// name and it makes no difference which of the two matched.
|
|
1522
|
+
if (
|
|
1523
|
+
record.handle?.toLowerCase() !== wanted &&
|
|
1524
|
+
record.alias?.toLowerCase() !== wanted
|
|
1525
|
+
)
|
|
1526
|
+
continue
|
|
1527
|
+
if (record.status === "running" || record.status === "queued")
|
|
1528
|
+
return { kind: "live", record }
|
|
1529
|
+
if (!fallback || record.startedAt > fallback.startedAt) fallback = record
|
|
1530
|
+
}
|
|
1531
|
+
if (fallback) return { kind: "live", record: fallback }
|
|
1532
|
+
const byId = this.agents.get(name)
|
|
1533
|
+
if (byId?.parentAgentId === undefined && byId !== undefined)
|
|
1534
|
+
return { kind: "live", record: byId }
|
|
1535
|
+
// Only once nothing live answers: a tombstone is a conversation to reopen,
|
|
1536
|
+
// and reopening one while its record still exists would fork the session.
|
|
1537
|
+
for (const entry of this.tombstones.values()) {
|
|
1538
|
+
if (
|
|
1539
|
+
entry.handle.toLowerCase() === wanted ||
|
|
1540
|
+
entry.alias?.toLowerCase() === wanted ||
|
|
1541
|
+
entry.id === name
|
|
1542
|
+
) {
|
|
1543
|
+
return { kind: "tombstone", entry }
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return undefined
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Forget an evicted agent, by handle. For the case where its session file has
|
|
1551
|
+
* gone: the entry can then only ever fail, while still holding the name
|
|
1552
|
+
* against the type that would otherwise start a fresh agent under it.
|
|
1553
|
+
*
|
|
1554
|
+
* A *successful* resume does not drop its tombstone — the live record it
|
|
1555
|
+
* creates already wins in `resolveMention`, and overwrites the entry in place
|
|
1556
|
+
* when it is itself evicted.
|
|
1557
|
+
*/
|
|
1558
|
+
dropTombstone(handle: string): void {
|
|
1559
|
+
this.tombstones.delete(handle)
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/** Evicted agents whose conversation can still be reopened, newest first. */
|
|
1563
|
+
listTombstones(): AgentTombstone[] {
|
|
1564
|
+
return [...this.tombstones.values()].sort(
|
|
1565
|
+
(a, b) => b.completedAt - a.completedAt,
|
|
1566
|
+
)
|
|
1567
|
+
}
|
|
1568
|
+
|
|
937
1569
|
listAgents(): AgentRecord[] {
|
|
938
1570
|
return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt)
|
|
939
1571
|
}
|
|
@@ -942,7 +1574,9 @@ export class AgentManager {
|
|
|
942
1574
|
const record = this.agents.get(id)
|
|
943
1575
|
if (!record) return false
|
|
944
1576
|
|
|
945
|
-
// Remove from queue if queued
|
|
1577
|
+
// Remove from queue if queued. No decrement — the slot was never taken —
|
|
1578
|
+
// and no onComplete, matching what a queued background abort has always
|
|
1579
|
+
// done; a blocking caller learns of the stop from its own tool result.
|
|
946
1580
|
if (record.status === "queued") {
|
|
947
1581
|
this.dequeue((q) => q.id === id)
|
|
948
1582
|
record.status = "stopped"
|
|
@@ -959,12 +1593,48 @@ export class AgentManager {
|
|
|
959
1593
|
|
|
960
1594
|
/** Dispose a record's session and remove it from the map. */
|
|
961
1595
|
private removeRecord(id: string, record: AgentRecord): void {
|
|
1596
|
+
this.tombstone(record)
|
|
962
1597
|
const session = record.session
|
|
1598
|
+
// Detached before the shutdown starts, so the record leaves the map at once and
|
|
1599
|
+
// nothing can observe a session that is half torn down.
|
|
963
1600
|
record.session = undefined
|
|
964
1601
|
this.agents.delete(id)
|
|
1602
|
+
// A failed startup keeps its (rejected) entry so a late awaitStartup still
|
|
1603
|
+
// sees it; drop it with the record so the map can't grow unbounded.
|
|
1604
|
+
this.startups.delete(id)
|
|
1605
|
+
// Fire-and-forget is right here and only here: this runs from the 60s cleanup timer
|
|
1606
|
+
// and from `clearCompleted()` on session boundaries, with the process staying alive,
|
|
1607
|
+
// so handlers get their full window. The quit path awaits instead — see dispose().
|
|
965
1608
|
void shutdownChildSession(session)
|
|
966
1609
|
}
|
|
967
1610
|
|
|
1611
|
+
/**
|
|
1612
|
+
* Preserve enough of a departing record for `@handle` to reopen its
|
|
1613
|
+
* conversation later. Nothing to keep unless it has both a handle to be
|
|
1614
|
+
* addressed by and a session file to reopen — an in-memory session leaves no
|
|
1615
|
+
* transcript, so the mention would have nothing to continue from.
|
|
1616
|
+
*/
|
|
1617
|
+
private tombstone(record: AgentRecord): void {
|
|
1618
|
+
if (!record.handle || !record.sessionFile) return
|
|
1619
|
+
this.tombstones.set(record.handle, {
|
|
1620
|
+
handle: record.handle,
|
|
1621
|
+
alias: record.alias,
|
|
1622
|
+
id: record.id,
|
|
1623
|
+
type: record.type,
|
|
1624
|
+
description: record.description,
|
|
1625
|
+
sessionFile: record.sessionFile,
|
|
1626
|
+
completedAt: record.completedAt ?? Date.now(),
|
|
1627
|
+
})
|
|
1628
|
+
// Bound the memory a long session can accumulate. Oldest first, since the
|
|
1629
|
+
// agent someone still wants to reach is the one they used most recently.
|
|
1630
|
+
while (this.tombstones.size > MAX_TOMBSTONES) {
|
|
1631
|
+
const oldest = [...this.tombstones.values()].reduce((a, b) =>
|
|
1632
|
+
a.completedAt <= b.completedAt ? a : b,
|
|
1633
|
+
)
|
|
1634
|
+
this.tombstones.delete(oldest.handle)
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
968
1638
|
private cleanup() {
|
|
969
1639
|
const cutoff = Date.now() - 10 * 60_000
|
|
970
1640
|
for (const [id, record] of this.agents) {
|
|
@@ -986,6 +1656,13 @@ export class AgentManager {
|
|
|
986
1656
|
if (skipUnconsumed && !record.resultConsumed) continue
|
|
987
1657
|
this.removeRecord(id, record)
|
|
988
1658
|
}
|
|
1659
|
+
// Unconditional: both callers are session boundaries (`session_start` and
|
|
1660
|
+
// `session_before_switch`), and `skipUnconsumed` only spares records whose
|
|
1661
|
+
// results the LLM has yet to read — it does not make the sweep partial in
|
|
1662
|
+
// the sense that matters here. A new session means new handles, or
|
|
1663
|
+
// `@explore` would silently reach an agent the user never started. Claude
|
|
1664
|
+
// Code resets its registry on `/clear` for the same reason.
|
|
1665
|
+
this.tombstones.clear()
|
|
989
1666
|
}
|
|
990
1667
|
|
|
991
1668
|
/** Whether any agents are still running or queued. */
|
|
@@ -1026,35 +1703,49 @@ export class AgentManager {
|
|
|
1026
1703
|
// agents finish they start queued ones, which need awaiting too.
|
|
1027
1704
|
while (true) {
|
|
1028
1705
|
this.drainQueue()
|
|
1029
|
-
const pending = [
|
|
1030
|
-
|
|
1031
|
-
.
|
|
1032
|
-
|
|
1706
|
+
const pending: Promise<unknown>[] = []
|
|
1707
|
+
for (const record of this.agents.values()) {
|
|
1708
|
+
if (record.status !== "running" && record.status !== "queued") continue
|
|
1709
|
+
// An agent whose worktree is still being created is "running" with no
|
|
1710
|
+
// `promise` yet — without its startup the wait would return too early.
|
|
1711
|
+
const startup = this.startups.get(record.id)
|
|
1712
|
+
if (startup) pending.push(startup)
|
|
1713
|
+
if (record.promise) pending.push(record.promise)
|
|
1714
|
+
}
|
|
1033
1715
|
if (pending.length === 0) break
|
|
1034
1716
|
await Promise.allSettled(pending)
|
|
1035
1717
|
}
|
|
1036
1718
|
}
|
|
1037
1719
|
|
|
1038
|
-
|
|
1720
|
+
/**
|
|
1721
|
+
* @param pi - Needed to run `git worktree prune`, which is async now and so
|
|
1722
|
+
* cannot be reached through a stored spawn argument at shutdown. Omitting
|
|
1723
|
+
* it (tests, teardown of a manager that never spawned) skips the prune.
|
|
1724
|
+
*/
|
|
1725
|
+
async dispose(pi?: ExtensionAPI): Promise<void> {
|
|
1039
1726
|
clearInterval(this.cleanupInterval)
|
|
1727
|
+
// Clear queue — via dequeue, so anyone blocked in spawnAndWait is woken
|
|
1728
|
+
// rather than left awaiting a gate nothing will ever resolve.
|
|
1040
1729
|
this.dequeue(() => true)
|
|
1041
1730
|
const sessions = [...this.agents.values()].map((record) => record.session)
|
|
1042
1731
|
this.agents.clear()
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
// exit with in-flight agents would otherwise leave stale registrations there.
|
|
1052
|
-
for (const repo of this.worktreeRepos) {
|
|
1053
|
-
try {
|
|
1054
|
-
pruneWorktrees(repo)
|
|
1055
|
-
} catch {
|
|
1056
|
-
/* ignore */
|
|
1732
|
+
this.startups.clear()
|
|
1733
|
+
if (pi) {
|
|
1734
|
+
// Prune any orphaned git worktrees (crash recovery). Detached: dispose runs
|
|
1735
|
+
// on the shutdown path, which cannot wait for git. Started before the awaited
|
|
1736
|
+
// shutdown below rather than after it, so the git calls have that window to
|
|
1737
|
+
// finish in instead of racing the process exit that follows.
|
|
1738
|
+
const prune = (repo: string) => {
|
|
1739
|
+
pruneWorktrees(pi, repo).catch(() => {})
|
|
1057
1740
|
}
|
|
1741
|
+
prune(process.cwd())
|
|
1742
|
+
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
|
1743
|
+
// exit with in-flight agents would otherwise leave stale registrations there.
|
|
1744
|
+
for (const repo of this.worktreeRepos) prune(repo)
|
|
1058
1745
|
}
|
|
1746
|
+
// Awaited, unlike the eviction path: pi awaits this extension's `session_shutdown`
|
|
1747
|
+
// handler and the process exits right after it returns, so anything left unawaited
|
|
1748
|
+
// here never runs at all. Bounded — each call carries its own ceiling, concurrently.
|
|
1749
|
+
await Promise.all(sessions.map((session) => shutdownChildSession(session)))
|
|
1059
1750
|
}
|
|
1060
1751
|
}
|