@ferris1225/pi-subagents 0.29.0 → 0.32.2

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/src/dispatch.ts CHANGED
@@ -1,26 +1,27 @@
1
1
  /**
2
2
  * The `subagent` tool: dispatches explore/worker/reviewer agents as isolated pi
3
- * child processes, single or parallel. Owns the dispatch pipeline: config load
4
- * + unavailable-model repair, per-run widget tracking, the auto-fix chain
3
+ * child processes, single or parallel. Owns the dispatch pipeline: config load,
4
+ * per-agent model-pool resolution, per-run widget tracking, the auto-fix chain
5
5
  * (REVIEW_FAIL → worker → re-review), and completion delivery.
6
6
  *
7
- * Vision: a task flagged `vision: true` runs on the configured vision-capable
8
- * model (config.visionModel); when none is configured it falls back to the main
9
- * session's current model. If the configured vision model is no longer
10
- * available, the user is asked (TUI picker) to pick a replacement, which is
11
- * persisted; outside the TUI it degrades to the main-session model with a
12
- * warning.
7
+ * Vision: a task flagged `vision: true` uses the configured vision model as an
8
+ * explicit primary, then the agent's configured backup and the current
9
+ * main-window model. Stale refs remain in the pool and fail normally at runtime.
13
10
  */
14
11
 
12
+ import { StringEnum } from "@earendil-works/pi-ai";
15
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
14
  import { Text } from "@earendil-works/pi-tui";
15
+ import { existsSync } from "node:fs";
16
+ import { realpath, rm } from "node:fs/promises";
17
+ import { resolve } from "node:path";
17
18
  import { Type } from "typebox";
18
19
  import { discoverAgents, type AgentConfig } from "./agents.ts";
19
20
  import {
20
21
  completionTriggersTurn,
21
22
  type CompletionMessageItem,
22
23
  } from "./completion.ts";
23
- import { loadConfig, saveConfig, type SubagentsConfig } from "./config.ts";
24
+ import { loadConfig, type SubagentsConfig } from "./config.ts";
24
25
  import {
25
26
  dispatchFailedResult,
26
27
  failedStartResult,
@@ -37,7 +38,7 @@ import {
37
38
  summarizeChainResult,
38
39
  type ChainStep,
39
40
  } from "./fixloop.ts";
40
- import { availableModelRefs, repairUnavailableModelOverrides, resolveVisionModelRef } from "./models.ts";
41
+ import { currentModelRef, resolveAgentModelPool } from "./models.ts";
41
42
  import {
42
43
  formatTaskSummary,
43
44
  formatToolActivity,
@@ -45,8 +46,13 @@ import {
45
46
  statusIcon,
46
47
  type RunChainMeta,
47
48
  } from "./monitor.ts";
48
- import type { SubagentRuntime } from "./runtime.ts";
49
+ import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
50
+ import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
51
+ import { forkRetainedSession } from "./session-fork.ts";
49
52
  import {
53
+ buildFallbackResumeReason,
54
+ buildResumePrompt,
55
+ RpcRunControl,
50
56
  getResultOutput,
51
57
  isFailedResult,
52
58
  isModelLevelFailure,
@@ -55,13 +61,50 @@ import {
55
61
  type SingleResult,
56
62
  type SubagentDetails,
57
63
  type SubagentLiveEvent,
64
+ type SubagentRecordEvent,
58
65
  } from "./spawn.ts";
59
- import { promptSelectOne } from "./ui.ts";
66
+ import { inspectorStore, summarizeToolArgs } from "./trajectory.ts";
67
+ import {
68
+ createWorktreeIsolation,
69
+ resolveWorktreeTarget,
70
+ type IsolationMode,
71
+ type WorktreeFinalization,
72
+ type WorktreeIsolation,
73
+ } from "./worktree.ts";
60
74
 
61
75
  const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
76
+ export const FORK_CONTINUATION_PROMPT =
77
+ "Continue from the retained context above. Review the prior work, then take the most useful next step toward completing the existing objective without repeating completed work.";
78
+ export const WORKTREE_ISOLATION_INSTRUCTIONS =
79
+ "You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
80
+
81
+ export function buildWorktreeTaskPrompt(task: string): string {
82
+ return `${WORKTREE_ISOLATION_INSTRUCTIONS}\n\nTask: ${task}`;
83
+ }
84
+
85
+ function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
86
+ return {
87
+ ...agent,
88
+ systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
89
+ };
90
+ }
91
+
92
+ interface DispatchEnvironment {
93
+ ctx: ExtensionContext;
94
+ config: SubagentsConfig;
95
+ agents: AgentConfig[];
96
+ sessionRef?: string;
97
+ }
62
98
 
63
99
  const VISION_DESCRIPTION =
64
- "Set true when the task may require viewing images (screenshots, mockups, designs) — the sub-agent then runs on the configured vision-capable model, or the main session's current model when none is configured";
100
+ "Set true when the task may require viewing images (screenshots, mockups, designs) — the configured vision model becomes primary, followed by the agent backup and current main-window model";
101
+
102
+ const ISOLATION_DESCRIPTION =
103
+ "Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents only)";
104
+
105
+ const IsolationSchema = Type.Optional(
106
+ StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
107
+ );
65
108
 
66
109
  const TaskItem = Type.Object({
67
110
  agent: Type.String({ description: "Name of the agent to invoke" }),
@@ -71,6 +114,7 @@ const TaskItem = Type.Object({
71
114
  }),
72
115
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
73
116
  vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
117
+ isolation: IsolationSchema,
74
118
  });
75
119
 
76
120
  const SubagentParams = Type.Object({
@@ -81,57 +125,79 @@ const SubagentParams = Type.Object({
81
125
  tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
82
126
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
83
127
  vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
128
+ isolation: IsolationSchema,
84
129
  });
85
130
 
86
- /** True when any dispatched task carries the vision flag. */
87
- function hasVisionTask(params: { vision?: boolean; tasks?: Array<{ vision?: boolean }> }): boolean {
88
- return params.vision === true || (params.tasks ?? []).some((t) => t.vision === true);
131
+ export function defaultIsolationMode(mode: "single" | "parallel", agentName: string, requested?: IsolationMode): IsolationMode {
132
+ if (requested) return requested;
133
+ return mode === "parallel" && agentName === "worker" ? "worktree" : "shared";
89
134
  }
90
135
 
91
- /**
92
- * When the configured vision model is unavailable, ask the user to pick a
93
- * replacement (TUI) and persist it; outside the TUI, warn and fall back to the
94
- * main session's model. Returns the repaired vision model (undefined = use the
95
- * main-session fallback).
96
- */
97
- async function repairVisionModelForDispatch(
98
- ctx: ExtensionContext,
99
- config: SubagentsConfig,
100
- configPath: string,
101
- ): Promise<string | undefined> {
102
- const configured = config.visionModel?.trim();
103
- if (!configured) return undefined;
104
- const refs = availableModelRefs(ctx);
105
- if (refs.includes(configured)) return configured;
106
-
107
- if (ctx.mode === "tui" && refs.length > 0) {
136
+ export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
137
+ if (agent.name === "explore" || agent.name === "reviewer") return false;
138
+ if (agent.name === "worker") return true;
139
+ if (!agent.tools) return true;
140
+ return agent.tools.includes("edit") || agent.tools.includes("write");
141
+ }
142
+
143
+ const autoFixRootTails = new Map<string, Promise<void>>();
144
+
145
+ async function canonicalAutoFixRoot(cwd: string): Promise<string> {
146
+ try {
147
+ return (await resolveWorktreeTarget(cwd)).originalRoot;
148
+ } catch {
108
149
  try {
109
- const picked = await promptSelectOne(
110
- ctx,
111
- `Vision model "${configured}" is unavailable. Pick a replacement?`,
112
- "Type to filter • ↑/↓ • Enter selects • Esc falls back to the main session's model",
113
- refs.map((ref) => ({ value: ref, label: ref })),
114
- );
115
- if (picked !== undefined) {
116
- try {
117
- await saveConfig({ ...config, visionModel: picked }, configPath);
118
- ctx.ui.notify(`Vision model switched to ${picked}.`, "info");
119
- } catch {
120
- /* persistence failure is non-fatal; the pick still applies this dispatch */
121
- }
122
- return picked;
123
- }
150
+ return await realpath(resolve(cwd));
124
151
  } catch {
125
- /* a failed picker must never break the dispatch */
152
+ return resolve(cwd);
126
153
  }
127
- ctx.ui.notify(`Vision model left as "${configured}"; this dispatch runs without the vision override.`, "warning");
128
- return undefined;
129
154
  }
130
- ctx.ui.notify(
131
- `Configured vision model "${configured}" is unavailable; this dispatch uses the main session's model.`,
132
- "warning",
133
- );
134
- return undefined;
155
+ }
156
+
157
+ /** Keep the complete worker→review loop exclusive for one canonical repository.
158
+ * Child processes have independent file-mutation queues, so queue concurrency
159
+ * alone cannot make shared-checkout edits safe. */
160
+ function serializeAutoFixChain(
161
+ cwd: string,
162
+ task: (signal: AbortSignal) => Promise<void>,
163
+ ): (signal: AbortSignal) => Promise<void> {
164
+ return async (signal) => {
165
+ if (signal.aborted) return;
166
+ const root = await canonicalAutoFixRoot(cwd);
167
+ const key = process.platform === "win32" ? root.toLowerCase() : root;
168
+ const previous = autoFixRootTails.get(key) ?? Promise.resolve();
169
+ let release!: () => void;
170
+ const gate = new Promise<void>((resolveGate) => {
171
+ release = resolveGate;
172
+ });
173
+ const tail = previous.catch(() => undefined).then(() => gate);
174
+ autoFixRootTails.set(key, tail);
175
+ await previous.catch(() => undefined);
176
+ try {
177
+ if (!signal.aborted) await task(signal);
178
+ } finally {
179
+ release();
180
+ if (autoFixRootTails.get(key) === tail) autoFixRootTails.delete(key);
181
+ }
182
+ };
183
+ }
184
+
185
+ function resolveDispatchModelPool(
186
+ agent: AgentConfig,
187
+ config: SubagentsConfig,
188
+ mainRef: string | undefined,
189
+ vision: boolean,
190
+ ): { agent: AgentConfig; fallbackModelRefs: string[] } {
191
+ const pool = resolveAgentModelPool({
192
+ primaryRef: vision ? config.visionModel : config.agentModels[agent.name],
193
+ backupRef: config.agentBackupModels[agent.name],
194
+ mainRef,
195
+ declaredDefaultRef: agent.model,
196
+ });
197
+ return {
198
+ agent: { ...agent, model: pool.primaryRef },
199
+ fallbackModelRefs: pool.fallbackModelRefs,
200
+ };
135
201
  }
136
202
 
137
203
  export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
@@ -142,10 +208,12 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
142
208
  "Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
143
209
  "Agents: explore (read-only codebase recon), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
144
210
  "Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
211
+ "Isolation: single tasks default to shared; parallel worker tasks default to detached Git worktrees unless isolation: shared is explicit. explore/reviewer cannot use worktree isolation.",
212
+ "Use subagent_control to steer, retarget, park, resume, or fork a thread by its stable run id.",
145
213
  "It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
146
214
  "Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
147
215
  "Results arrive as wake-up messages automatically — you do NOT need to wait. If you must get a result in-turn, subagent_wait is a non-blocking lookup by default (pass timeoutMs to block).",
148
- "Vision: set vision: true when the task may require viewing images (screenshots, mockups, design files — e.g. frontend work) — the sub-agent then runs on the vision-capable model configured in /subagents-setup, or the main session's current model when none is configured.",
216
+ "Vision: set vision: true when the task may require viewing images (screenshots, mockups, design files — e.g. frontend work) — the configured vision model is primary, followed by that agent's backup and the current main-window model.",
149
217
  ].join(" "),
150
218
  promptSnippet:
151
219
  "Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completion automatically resumes the main agent. Simple tasks: use direct tools, not subagents.",
@@ -155,36 +223,20 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
155
223
  "Use subagent with agent 'worker' for a self-contained implementation task worth a separate context; it plans internally.",
156
224
  "Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
157
225
  "subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
158
- "Run independent tasks in parallel by passing a tasks array to subagent; let the automatically resumed main agent start dependent work after results arrive.",
226
+ "Run independent tasks in parallel by passing a tasks array to subagent; parallel worker items default to isolation: worktree so their edits are integrated independently. Pass isolation: shared only when workers intentionally need the caller's live uncommitted tree.",
227
+ "Use isolation: worktree only for worker/write-capable agents and only inside a Git repository with a committed HEAD; setup or integration failures never silently fall back to shared.",
159
228
  "NEVER sleep or poll, and do NOT call subagent_wait to hold the turn — subagent ends the turn immediately and the result arrives as a message that wakes you automatically (even mid-turn). Ending your turn is the default and the only correct way to wait.",
160
229
  "If you must keep the turn for a result, call subagent_wait with an explicit timeoutMs (non-blocking by default) — never bash sleep/timeout to wait for a sub-agent.",
161
- "When a delegated task may require viewing images (frontend screenshots, mockups, design comparisons), pass vision: true and give the sub-agent the exact image paths — it reads them with its read tool. The sub-agent then runs on the configured vision-capable model, or the main session's current model when none is configured.",
230
+ "When a delegated task may require viewing images (frontend screenshots, mockups, design comparisons), pass vision: true and give the sub-agent the exact image paths — it reads them with its read tool. The configured vision model becomes primary; model-level failures continue through the agent's backup pool and current main-window model.",
231
+ "When a sub-agent result arrives it is already shown to the user — do NOT restate, paraphrase, or summarize it; reply with only your own conclusion or next action (often just one line), since duplicating the result wastes tokens for nothing.",
162
232
  ],
163
233
  parameters: SubagentParams,
164
234
 
165
235
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
166
236
  monitor.beginTurn();
167
- let config = await loadConfig(runtime.configPath);
237
+ const config = await loadConfig(runtime.configPath);
168
238
  // Pick up concurrency changes from /subagents-setup without a restart.
169
239
  runtime.backgroundQueue.setConcurrency(config.maxConcurrency);
170
- const repairedModels = repairUnavailableModelOverrides(ctx, config.agentModels);
171
- if (repairedModels.changed) {
172
- config = { ...config, agentModels: repairedModels.agentModels };
173
- try {
174
- await saveConfig(config, runtime.configPath);
175
- ctx.ui.notify(
176
- repairedModels.fallbackRef
177
- ? `Unavailable sub-agent models switched to ${repairedModels.fallbackRef} and saved to config.`
178
- : "Unavailable sub-agent model overrides removed; no main-window model is available.",
179
- "warning",
180
- );
181
- } catch (error) {
182
- ctx.ui.notify(
183
- `Could not persist repaired sub-agent model config: ${error instanceof Error ? error.message : String(error)}`,
184
- "warning",
185
- );
186
- }
187
- }
188
240
 
189
241
  // Finished runs leave the widget immediately. Their final findings are sent
190
242
  // back as a custom message that automatically starts a follow-up turn.
@@ -203,51 +255,92 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
203
255
  };
204
256
 
205
257
  // Live sub-agent activity → concise one-line status ("thinking",
206
- // "read src/index.ts", ...), never a raw args blob. The live handler only
207
- // updates widget status; finishing (removeRun + notify) is owned by the
208
- // queue task / launchInLoop. That keeps a startup retry — which fires a
209
- // transient "failed" status before relaunching from ripping the row out
210
- // early, and lets the queue task decide between delivering a reviewer's
211
- // result and starting an auto-fix chain (a triggered chain keeps the
212
- // parent row annotated until it completes).
213
- const makeLiveHandler = (runId: number) => (e: SubagentLiveEvent): void => {
214
- switch (e.kind) {
215
- case "status":
216
- // Only update the widget status here. Finishing (removeRun + notify) is
217
- // owned by the queue task / launchInLoop so that a startup retry — which
218
- // fires a transient "failed" status before relaunching the child — never
219
- // rips the row out from under the retry or emits a premature "✗" toast.
220
- monitor.setStatus(runId, e.status);
221
- break;
222
- case "usage":
223
- monitor.setUsage(runId, e.usage, e.model);
224
- break;
225
- case "tool_start":
226
- monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
227
- break;
228
- case "tool_end":
229
- monitor.recordToolEnd(runId, e.toolName, e.isError);
230
- break;
231
- case "thinking":
232
- monitor.setActivity(runId, "thinking");
233
- break;
234
- case "text":
235
- // A text delta is model output, not a filesystem write.
236
- monitor.setActivity(runId, "responding");
237
- break;
238
- }
239
- };
258
+ // "read src/index.ts", ...), never a raw args blob. In parallel, every
259
+ // live event is appended to the thread's append-only trajectory (status,
260
+ // model-candidate changes, usage, tool starts/ends with a redacted
261
+ // args summary) so /subagents-inspect can replay what happened. The live
262
+ // handler only updates widget status; finishing (removeRun + notify) is
263
+ // owned by the queue task / launchInLoop. That keeps a startup retry
264
+ // which fires a transient "failed" status before relaunching — from
265
+ // ripping the row out early, and lets the queue task decide between
266
+ // delivering a reviewer's result and starting an auto-fix chain.
267
+ const makeLiveHandler =
268
+ (runId: number, threadId?: number, generation?: number) =>
269
+ (e: SubagentLiveEvent): void => {
270
+ if (generation !== undefined && runtime.threads.get(runId)?.generation !== generation) return;
271
+ switch (e.kind) {
272
+ case "status":
273
+ // Only update the widget status here. Finishing (removeRun + notify) is
274
+ // owned by the queue task / launchInLoop so that a startup retry — which
275
+ // fires a transient "failed" status before relaunching the child — never
276
+ // rips the row out from under the retry or emits a premature "✗" toast.
277
+ monitor.setStatus(runId, e.status);
278
+ break;
279
+ case "model":
280
+ monitor.setModel(runId, e.model, e.fallbackFrom);
281
+ break;
282
+ case "usage":
283
+ monitor.setUsage(runId, e.usage, e.model);
284
+ break;
285
+ case "tool_start":
286
+ monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
287
+ break;
288
+ case "tool_end":
289
+ monitor.recordToolEnd(runId, e.toolName, e.isError);
290
+ break;
291
+ case "thinking":
292
+ monitor.setActivity(runId, "thinking");
293
+ break;
294
+ case "text":
295
+ // A text delta is model output, not a filesystem write.
296
+ monitor.setActivity(runId, "responding");
297
+ break;
298
+ }
299
+ if (threadId !== undefined) {
300
+ const trajectory = inspectorStore.get(threadId).trajectory;
301
+ switch (e.kind) {
302
+ case "status":
303
+ trajectory.append({ kind: "status", status: e.status });
304
+ break;
305
+ case "model":
306
+ trajectory.append({ kind: "candidate", model: e.model, fallbackFrom: e.fallbackFrom });
307
+ break;
308
+ case "usage":
309
+ trajectory.append({ kind: "usage", usage: { ...e.usage }, model: e.model });
310
+ break;
311
+ case "tool_start":
312
+ trajectory.append({
313
+ kind: "tool_start",
314
+ tool: e.toolName,
315
+ toolCallId: e.toolCallId,
316
+ summary: summarizeToolArgs(e.args),
317
+ });
318
+ break;
319
+ case "tool_end":
320
+ trajectory.append({ kind: "tool_end", tool: e.toolName, toolCallId: e.toolCallId, isError: e.isError });
321
+ break;
322
+ // Text/thinking deltas arrive via onRecord below.
323
+ }
324
+ }
325
+ };
326
+
327
+ /** Raw streamed output (text/thinking deltas) → the thread's bounded
328
+ * transcript buffer; dropped on restart, never carried across generations. */
329
+ const makeRecordHandler =
330
+ (threadId: number, generation?: number) =>
331
+ (e: SubagentRecordEvent): void => {
332
+ if (generation !== undefined && runtime.threads.get(threadId)?.generation !== generation) return;
333
+ const transcript = inspectorStore.get(threadId).transcript;
334
+ if (e.kind === "thinking") transcript.appendThinking(e.delta);
335
+ else transcript.appendText(e.delta);
336
+ };
240
337
  const discovery = discoverAgents(ctx.cwd, {
241
338
  scope: config.agentScope,
242
339
  enabledNames: config.enabledAgents,
340
+ projectTrusted: ctx.isProjectTrusted?.() === true,
243
341
  });
244
-
245
- // Effective model precedence: setup override > current session model > frontmatter default.
246
- const sessionRef = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
247
- const agents: AgentConfig[] = discovery.agents.map((agent) => ({
248
- ...agent,
249
- model: config.agentModels[agent.name] ?? sessionRef ?? agent.model,
250
- }));
342
+ const sessionRef = currentModelRef(ctx);
343
+ const agents = discovery.agents;
251
344
 
252
345
  const hasTasks = (params.tasks?.length ?? 0) > 0;
253
346
  const hasSingle = Boolean(params.agent) && params.task !== undefined;
@@ -295,21 +388,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
295
388
  };
296
389
  }
297
390
 
298
- // A vision-flagged dispatch with a stale vision model asks the user for a
299
- // replacement before spawning (the persisted pick also fixes future runs).
300
- // Runs only after parameter validation, so an invalid call never pops a picker.
301
- const visionRequested = hasVisionTask(params);
302
- let visionModel = config.visionModel;
303
- if (visionRequested && visionModel !== undefined && !availableModelRefs(ctx).includes(visionModel.trim())) {
304
- visionModel = await repairVisionModelForDispatch(ctx, config, runtime.configPath);
305
- }
306
- // Vision-flagged dispatches run on the configured vision model, else the
307
- // main session's current model (the documented fallback), else the agent's
308
- // own model as the last resort.
309
- const visionRef = resolveVisionModelRef(ctx, visionModel);
310
- const withVision = (agent: AgentConfig, vision: boolean): AgentConfig =>
311
- vision && visionRef ? { ...agent, model: visionRef } : agent;
312
-
313
391
  /**
314
392
  * Dispatch one agent inside an auto-fix chain: tracked in the widget with a
315
393
  * groupId/relationLabel, but NOT delivered through the completion flow — the
@@ -318,45 +396,85 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
318
396
  const launchInLoop = async (
319
397
  agentName: string,
320
398
  task: string,
399
+ executionCwd: string,
321
400
  signal: AbortSignal,
322
401
  meta: RunChainMeta,
323
402
  vision = false,
324
403
  ): Promise<{ runId?: number; result: SingleResult }> => {
325
404
  const agent = agents.find((candidate) => candidate.name === agentName);
326
405
  if (!agent) return { result: failedStartResult(agentName, task, `Unknown agent: "${agentName}".`) };
327
- // A vision-flagged chain (e.g. a review of UI screenshots) keeps its rounds
328
- // on the vision model: the fix worker and re-review re-read the same images.
329
- const effectiveAgent = withVision(agent, vision);
406
+ // Vision chains keep the vision override as each round's primary while
407
+ // retaining that worker/reviewer's own configured backup pool.
408
+ const pool = resolveDispatchModelPool(agent, config, sessionRef, vision);
330
409
  const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
331
- const runId = monitor.addRun(agent.name, task, effectiveAgent.model, thinkingLevel, meta);
332
- const onLive = makeLiveHandler(runId);
410
+ const runId = monitor.addRun(agent.name, task, pool.agent.model, thinkingLevel, meta);
411
+ // Chain rounds are real threads: they get their own trajectory so the
412
+ // inspector can show each fix/re-review round's full story.
413
+ const chainState = inspectorStore.get(runId);
414
+ chainState.retainFrom({ agent: agent.name, task, status: "queued", model: pool.agent.model, thinking: thinkingLevel });
415
+ chainState.trajectory.append({
416
+ kind: "dispatch",
417
+ agent: agent.name,
418
+ task,
419
+ model: pool.agent.model,
420
+ thinking: thinkingLevel,
421
+ pool: pool.fallbackModelRefs,
422
+ vision,
423
+ isolation: "shared",
424
+ originalCwd: executionCwd,
425
+ isolationCwd: executionCwd,
426
+ });
427
+ const onLive = makeLiveHandler(runId, runId);
428
+ const onRecord = makeRecordHandler(runId);
333
429
  try {
334
430
  const result = await runSingleAgentWithModelFallback(
335
431
  {
336
- defaultCwd: ctx.cwd,
337
- agent: effectiveAgent,
432
+ defaultCwd: executionCwd,
433
+ cwd: executionCwd,
434
+ agent: pool.agent,
338
435
  agentName,
339
436
  task,
340
437
  thinkingLevel,
341
438
  signal,
342
439
  onLive,
440
+ onRecord,
343
441
  makeDetails: makeDetails("single", true),
344
442
  idleTimeoutMs: config.idleTimeoutSec * 1000,
345
443
  },
346
- sessionRef,
444
+ pool.fallbackModelRefs,
347
445
  );
446
+ result.runId = runId;
447
+ result.isolation = "shared";
448
+ result.originalCwd = executionCwd;
449
+ result.isolationCwd = executionCwd;
450
+ runtime.retainSession(result);
451
+ monitor.setModel(runId, result.model, result.modelFallbackFrom);
452
+ chainState.trajectory.append({
453
+ kind: "settled",
454
+ status: isFailedResult(result) ? "failed" : "done",
455
+ model: result.model,
456
+ });
348
457
  // Keep the finished round visible in the widget while the chain is
349
458
  // still running, with a one-line summary of what it did; the whole
350
459
  // group is dropped when the chain resolves (see removeChainGroup).
351
460
  monitor.setSummary(runId, summarizeChainResult(result));
352
461
  finishRun(runId, isFailedResult(result) ? "failed" : "done", { retain: true });
462
+ const retainedRun = monitor.findRun(runId);
463
+ if (retainedRun) chainState.retainFrom(retainedRun);
353
464
  runtime.registerRunResult(runId, result);
354
465
  return { runId, result };
355
466
  } catch (error) {
356
467
  finishRun(runId, "failed", { retain: true });
468
+ chainState.trajectory.append({ kind: "settled", status: "failed", model: pool.agent.model });
469
+ const retainedRun = monitor.findRun(runId);
470
+ if (retainedRun) chainState.retainFrom(retainedRun);
357
471
  const errorMessage = error instanceof Error ? error.message : String(error);
358
- const crashed = {
359
- ...queuedResult(agent, task, thinkingLevel),
472
+ const crashed: SingleResult = {
473
+ ...queuedResult(pool.agent, task, thinkingLevel),
474
+ runId,
475
+ isolation: "shared",
476
+ originalCwd: executionCwd,
477
+ isolationCwd: executionCwd,
360
478
  exitCode: 1,
361
479
  stderr: errorMessage,
362
480
  stopReason: signal.aborted ? "aborted" : "error",
@@ -388,10 +506,35 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
388
506
  initialReviewerResult: SingleResult,
389
507
  parentGroupId: string,
390
508
  parentRunId: number,
509
+ executionCwd: string,
391
510
  vision = false,
392
511
  ): void => {
393
- runtime.runControllers.set(parentRunId, runtime.backgroundQueue.enqueue(
394
- async (signal) => {
512
+ const parentThreadAtStart = runtime.threads.get(parentRunId);
513
+ if (!parentThreadAtStart) return;
514
+ const parentGeneration = parentThreadAtStart.generation;
515
+ const parentControl = parentThreadAtStart.control;
516
+ let fixController: AbortController | undefined;
517
+ const ownsParent = (): boolean => {
518
+ const current = runtime.threads.get(parentRunId);
519
+ return fixController !== undefined &&
520
+ current === parentThreadAtStart &&
521
+ current.generation === parentGeneration &&
522
+ current.control === parentControl &&
523
+ current.queueController === fixController &&
524
+ runtime.runControllers.get(parentRunId) === fixController;
525
+ };
526
+ const clearOwnedController = (): void => {
527
+ if (!fixController) return;
528
+ if (runtime.runControllers.get(parentRunId) === fixController) {
529
+ runtime.runControllers.delete(parentRunId);
530
+ }
531
+ const current = runtime.threads.get(parentRunId);
532
+ if (current === parentThreadAtStart && current.queueController === fixController) {
533
+ current.queueController = undefined;
534
+ }
535
+ };
536
+ fixController = runtime.backgroundQueue.enqueue(
537
+ serializeAutoFixChain(executionCwd, async (signal) => {
395
538
  const chain: ChainStep[] = [
396
539
  { runId: parentRunId, result: initialReviewerResult, relation: "initial review" },
397
540
  ];
@@ -399,17 +542,45 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
399
542
  for (let round = 1; round <= config.maxFixRounds; round++) {
400
543
  if (!runtime.sessionActive) break;
401
544
  const fixBrief = buildFixTaskBrief(lastReviewer, round, config.maxFixRounds);
402
- const workerStep = await launchInLoop("worker", fixBrief, signal, {
545
+ const workerStep = await launchInLoop("worker", fixBrief, executionCwd, signal, {
403
546
  groupId: parentGroupId,
404
547
  relationLabel: `fix round ${round}`,
405
548
  }, vision);
549
+ // Preserve the newest sub-step before checking chain ownership. A
550
+ // destructive stop invalidates ownsParent() while this child is
551
+ // aborting, and its partial output must become the parent's stopped
552
+ // result instead of falling back to the old triggering review.
553
+ if (
554
+ runtime.threads.get(parentRunId) === parentThreadAtStart &&
555
+ parentThreadAtStart.generation === parentGeneration
556
+ ) {
557
+ parentThreadAtStart.lastResult = workerStep.result;
558
+ parentThreadAtStart.agentName = workerStep.result.agent;
559
+ parentThreadAtStart.task = workerStep.result.task;
560
+ parentThreadAtStart.sessionId = workerStep.result.sessionId;
561
+ parentThreadAtStart.sessionDir = workerStep.result.sessionDir;
562
+ runtime.retainSession(workerStep.result);
563
+ }
564
+ if (!ownsParent()) return;
406
565
  chain.push({ ...workerStep, relation: `fix round ${round}` });
407
566
  if (!runtime.sessionActive || isFailedResult(workerStep.result)) break;
408
567
  const reReviewBrief = buildReReviewBrief(lastReviewer, round);
409
- const reviewStep = await launchInLoop("reviewer", reReviewBrief, signal, {
568
+ const reviewStep = await launchInLoop("reviewer", reReviewBrief, executionCwd, signal, {
410
569
  groupId: parentGroupId,
411
570
  relationLabel: `re-review round ${round}`,
412
571
  }, vision);
572
+ if (
573
+ runtime.threads.get(parentRunId) === parentThreadAtStart &&
574
+ parentThreadAtStart.generation === parentGeneration
575
+ ) {
576
+ parentThreadAtStart.lastResult = reviewStep.result;
577
+ parentThreadAtStart.agentName = reviewStep.result.agent;
578
+ parentThreadAtStart.task = reviewStep.result.task;
579
+ parentThreadAtStart.sessionId = reviewStep.result.sessionId;
580
+ parentThreadAtStart.sessionDir = reviewStep.result.sessionDir;
581
+ runtime.retainSession(reviewStep.result);
582
+ }
583
+ if (!ownsParent()) return;
413
584
  chain.push({ ...reviewStep, relation: `re-review round ${round}` });
414
585
  lastReviewer = reviewStep.result;
415
586
  // A crashed re-review must stop the chain like a crashed worker: its
@@ -418,27 +589,70 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
418
589
  if (!runtime.sessionActive || isFailedResult(reviewStep.result)) break;
419
590
  if (reviewVerdict(getResultOutput(reviewStep.result)) === "pass") break;
420
591
  }
592
+ // Every parent mutation is guarded by the exact generation, control, and
593
+ // queue controller that started this chain. A parked/resumed generation or
594
+ // destructive stop must make this old orchestration a no-op.
595
+ if (!ownsParent()) return;
596
+ const controlledParent = parentThreadAtStart;
597
+ if (controlledParent.retired || controlledParent.state === "stopped") {
598
+ clearOwnedController();
599
+ removeChainGroup(parentGroupId);
600
+ return;
601
+ }
602
+ // Parking an auto-fix chain aborts its in-flight child but preserves the
603
+ // parent's retained checkpoint and suppresses an aborted chain delivery.
604
+ if (controlledParent.state === "parked") {
605
+ clearOwnedController();
606
+ removeChainGroup(parentGroupId);
607
+ monitor.setRetained(parentRunId, false);
608
+ monitor.setStatus(parentRunId, "parked");
609
+ return;
610
+ }
421
611
  // The chain is done (success, exhaustion, or abort): drop the retained
422
612
  // parent row and its retained round rows, then deliver one condensed
423
613
  // summary. Register the parent's final state (the last chain result)
424
614
  // before removal so subagent_wait can resolve it.
425
- runtime.registerRunResult(parentRunId, chain[chain.length - 1].result);
426
- runtime.runControllers.delete(parentRunId);
615
+ const last = chain[chain.length - 1];
616
+ runtime.registerRunResult(parentRunId, last.result);
427
617
  removeChainGroup(parentGroupId);
428
618
  monitor.removeRun(parentRunId);
429
- if (!runtime.sessionActive) return;
619
+ runtime.retainSession(last.result);
620
+ const parentThread = parentThreadAtStart;
621
+ parentThread.agentName = last.result.agent;
622
+ parentThread.task = last.result.task;
623
+ parentThread.sessionId = last.result.sessionId;
624
+ parentThread.sessionDir = last.result.sessionDir;
625
+ parentThread.state = isFailedResult(last.result) ? "failed" : "completed";
626
+ // The chain outcome settles the parent thread's trajectory: the
627
+ // last chain step is its final state.
628
+ const parentInspection = inspectorStore.get(parentRunId);
629
+ parentInspection.trajectory.append({
630
+ kind: "settled",
631
+ status: parentThread.state === "failed" ? "failed" : "done",
632
+ model: last.result.model,
633
+ });
634
+ parentInspection.retainFrom({
635
+ agent: last.result.agent,
636
+ task: last.result.task,
637
+ model: last.result.model,
638
+ status: parentThread.state === "failed" ? "failed" : "done",
639
+ usage: last.result.usage,
640
+ });
641
+ if (!runtime.sessionActive) {
642
+ clearOwnedController();
643
+ return;
644
+ }
430
645
  // One compact message instead of every round's raw output: the summary
431
646
  // lines cover each step (verdict + what changed/found), and the final
432
647
  // step's full report is appended only when its detail is actionable
433
648
  // (a FAIL verdict, a crash, or a model-level failure the main agent
434
649
  // must take over). Everything else stays one `subagent_status #id`
435
650
  // call away.
436
- const last = chain[chain.length - 1];
437
651
  let block = formatChainSummary(chain);
438
652
  if (isFailedResult(last.result) && isModelLevelFailure(last.result)) {
439
- block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(last.result)}`;
653
+ block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, executionCwd)}\n\n${modelLevelTakeoverNote(last.result, { runId: parentRunId })}`;
440
654
  } else if (isFailedResult(last.result) || reviewVerdict(getResultOutput(last.result)) === "fail") {
441
- block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}`;
655
+ block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, executionCwd)}`;
442
656
  }
443
657
  runtime.sendCompletionGroup([
444
658
  {
@@ -448,25 +662,37 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
448
662
  },
449
663
  ]);
450
664
  runtime.completionBatcher.flush();
451
- },
665
+ clearOwnedController();
666
+ }),
452
667
  () => {
453
- // Cancelled before delivery: clean up the retained parent row and
454
- // every retained chain row (each in-flight chain run was already
455
- // finished by its launchInLoop path).
456
- runtime.runControllers.delete(parentRunId);
668
+ if (!ownsParent()) return;
669
+ const controlledParent = parentThreadAtStart;
670
+ clearOwnedController();
457
671
  removeChainGroup(parentGroupId);
458
- monitor.removeRun(parentRunId);
672
+ if (controlledParent.state === "parked") {
673
+ monitor.setRetained(parentRunId, false);
674
+ monitor.setStatus(parentRunId, "parked");
675
+ return;
676
+ }
677
+ if (!controlledParent.retired) monitor.removeRun(parentRunId);
459
678
  },
460
679
  (error) => {
461
680
  // A crash inside the chain orchestration (failed runs are caught by
462
- // launchInLoop and delivered as part of the chain) must not vanish:
463
- // drop the retained rows, notify, and deliver a failed result
464
- // so the main agent knows the chain never completed.
681
+ // launchInLoop and delivered as part of the chain) must not vanish, but
682
+ // an obsolete generation/controller must never publish it.
683
+ if (!ownsParent()) return;
684
+ if (parentThreadAtStart.retired || parentThreadAtStart.state === "stopped") {
685
+ clearOwnedController();
686
+ removeChainGroup(parentGroupId);
687
+ return;
688
+ }
465
689
  runtime.registerRunResult(parentRunId, initialReviewerResult);
466
- runtime.runControllers.delete(parentRunId);
467
690
  removeChainGroup(parentGroupId);
468
691
  monitor.removeRun(parentRunId);
469
- if (!runtime.sessionActive) return;
692
+ if (!runtime.sessionActive) {
693
+ clearOwnedController();
694
+ return;
695
+ }
470
696
  const errorMessage = error instanceof Error ? error.message : String(error);
471
697
  try {
472
698
  ctx.ui.notify(`✗ auto-fix chain dispatch failed: ${errorMessage}`, "error");
@@ -475,157 +701,1055 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
475
701
  runtime.sendCompletionGroup([
476
702
  {
477
703
  agent: initialReviewerResult.agent,
478
- block: `${formatCompletionBlock(initialReviewerResult, config.maxResultLines, ctx.cwd)}\n\nAuto-fix chain crashed before completion: ${errorMessage}. The planned fix rounds did not run; the review above is the triggering reviewer's full output.`,
704
+ block: `${formatCompletionBlock(initialReviewerResult, config.maxResultLines, executionCwd)}\n\nAuto-fix chain crashed before completion: ${errorMessage}. The planned fix rounds did not run; the review above is the triggering reviewer's full output.`,
479
705
  triggerTurn: true,
480
706
  },
481
707
  ]);
482
708
  runtime.completionBatcher.flush();
483
709
  } catch {
484
710
  /* a second delivery failure must not throw through the queue */
711
+ } finally {
712
+ clearOwnedController();
485
713
  }
486
714
  },
487
- ));
715
+ );
716
+ runtime.runControllers.set(parentRunId, fixController);
717
+ parentThreadAtStart.queueController = fixController;
718
+ const priorCompletion = parentThreadAtStart.generationCompletion;
719
+ parentThreadAtStart.generationCompletion = Promise.all([
720
+ priorCompletion,
721
+ runtime.backgroundQueue.waitForTask(fixController),
722
+ ]).then(() => undefined);
488
723
  };
489
724
 
490
- const startBackground = (agentName: string, task: string, cwd?: string, vision = false): SingleResult => {
491
- const agent = agents.find((candidate) => candidate.name === agentName);
725
+ interface SessionSeed {
726
+ sessionId?: string;
727
+ sessionDir?: string;
728
+ prompt?: string;
729
+ worktree?: WorktreeIsolation;
730
+ forkedFromRunId?: number;
731
+ forkObjective?: string;
732
+ modelPool?: string[];
733
+ thinkingLevel?: SubagentThread["thinkingLevel"];
734
+ }
735
+
736
+ interface ResumeReservation {
737
+ version: number;
738
+ generation: number;
739
+ sessionId?: string;
740
+ sessionDir?: string;
741
+ }
742
+
743
+ const ownsResumeReservation = (
744
+ thread: SubagentThread,
745
+ reservation: ResumeReservation,
746
+ ): boolean =>
747
+ runtime.sessionActive &&
748
+ runtime.threads.get(thread.id) === thread &&
749
+ !thread.retired &&
750
+ thread.lifecycleOperation === "resume" &&
751
+ thread.lifecycleVersion === reservation.version &&
752
+ thread.generation === reservation.generation &&
753
+ thread.sessionId === reservation.sessionId &&
754
+ thread.sessionDir === reservation.sessionDir;
755
+
756
+ const beginPreflight = (): (() => void) => {
757
+ let resolvePreflight!: () => void;
758
+ const preflight = new Promise<void>((resolve) => {
759
+ resolvePreflight = resolve;
760
+ });
761
+ runtime.preflightOperations.add(preflight);
762
+ return () => {
763
+ runtime.preflightOperations.delete(preflight);
764
+ resolvePreflight();
765
+ };
766
+ };
767
+
768
+ const startBackground = async (
769
+ agentName: string,
770
+ task: string,
771
+ cwd: string | undefined,
772
+ vision = false,
773
+ isolation: IsolationMode = "shared",
774
+ existingThread?: SubagentThread,
775
+ newObjectiveOnResume = false,
776
+ environment?: DispatchEnvironment,
777
+ seed?: SessionSeed,
778
+ resumeReservation?: ResumeReservation,
779
+ ): Promise<SingleResult> => {
780
+ if (!runtime.sessionActive) {
781
+ return failedStartResult(agentName, task, "Parent session shut down before this subagent generation could start.");
782
+ }
783
+ if (existingThread && (!resumeReservation || !ownsResumeReservation(existingThread, resumeReservation))) {
784
+ return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
785
+ }
786
+ const runCtx = environment?.ctx ?? ctx;
787
+ const runConfig = environment?.config ?? config;
788
+ const runAgents = environment?.agents ?? agents;
789
+ const runSessionRef = environment?.sessionRef ?? sessionRef;
790
+ const agent = runAgents.find((candidate) => candidate.name === agentName);
492
791
  if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
493
- // A vision-flagged task runs on the configured vision model (or the main
494
- // session's current model), overriding the agent's own model — the
495
- // per-agent model may not support images.
496
- const effectiveAgent = withVision(agent, vision);
792
+ if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
793
+ return {
794
+ ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to worker/write-capable agents.`),
795
+ isolation,
796
+ };
797
+ }
497
798
 
498
- // Effective strength: config override > agent frontmatter default > global default.
499
- const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
500
- const pending = queuedResult(effectiveAgent, task, thinkingLevel);
501
- const runId = monitor.addRun(agent.name, task, effectiveAgent.model, thinkingLevel);
502
- // Only a main-agent-dispatched reviewer can trigger an auto-fix chain, so
503
- // only its finish is deferred to the queue task (see startFixLoop).
504
- const onLive = makeLiveHandler(runId);
799
+ const originalCwd = resolve(cwd ?? runCtx.cwd);
800
+ const previousWorktree = existingThread?.worktree;
801
+ let worktree = seed?.worktree ?? previousWorktree;
802
+ if (isolation === "worktree") {
803
+ if (worktree && worktree.state !== "active") {
804
+ return {
805
+ ...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
806
+ isolation,
807
+ originalCwd,
808
+ integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
809
+ };
810
+ }
811
+ if (!worktree) {
812
+ try {
813
+ worktree = await createWorktreeIsolation(originalCwd);
814
+ } catch (error) {
815
+ return {
816
+ ...failedStartResult(agentName, task, error instanceof Error ? error.message : String(error)),
817
+ isolation,
818
+ originalCwd,
819
+ };
820
+ }
821
+ }
822
+ }
823
+ const executionCwd = worktree?.cwd ?? originalCwd;
824
+ const resolvedPool = resolveDispatchModelPool(agent, runConfig, runSessionRef, vision);
825
+ const inheritedPool = seed?.modelPool?.filter((ref) => ref.trim().length > 0) ?? [];
826
+ const rawPool = inheritedPool.length > 0
827
+ ? {
828
+ agent: { ...agent, model: inheritedPool[0] },
829
+ fallbackModelRefs: inheritedPool.slice(1),
830
+ }
831
+ : resolvedPool;
832
+ // Isolation is a persistent system-level invariant, not a one-shot task
833
+ // prefix: queued retargets, live retargets, resumes, and model fallbacks
834
+ // all keep the same worktree boundary.
835
+ const pool = isolation === "worktree"
836
+ ? { ...rawPool, agent: withWorktreeSystemPrompt(rawPool.agent) }
837
+ : rawPool;
838
+ const thinkingLevel = seed?.thinkingLevel ?? runConfig.agentThinkingLevels[agent.name] ?? agent.thinking ?? runConfig.thinkingLevel;
839
+ const modelPool = [pool.agent.model, ...pool.fallbackModelRefs].filter((ref): ref is string => Boolean(ref));
840
+ const priorTask = existingThread?.task;
841
+ const priorSessionId = seed?.sessionId ?? existingThread?.sessionId;
842
+ const priorSessionDir = seed?.sessionDir ?? existingThread?.sessionDir;
843
+ if (existingThread && resumeReservation && !ownsResumeReservation(existingThread, resumeReservation)) {
844
+ return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
845
+ }
846
+ const runId = existingThread?.id ?? monitor.addRun(agent.name, task, pool.agent.model, thinkingLevel, {
847
+ isolation,
848
+ ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
849
+ });
850
+ const generation = (existingThread?.generation ?? 0) + 1;
851
+ const pending: SingleResult = {
852
+ ...queuedResult(pool.agent, task, thinkingLevel),
853
+ runId,
854
+ isolation,
855
+ originalCwd,
856
+ isolationCwd: executionCwd,
857
+ ...(isolation === "worktree" ? { integrationStatus: "pending" as const } : {}),
858
+ ...(seed?.sessionId && seed.sessionDir
859
+ ? { sessionId: seed.sessionId, sessionDir: seed.sessionDir, resumed: true }
860
+ : {}),
861
+ ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
862
+ };
863
+ if (existingThread) {
864
+ monitor.restartRun(runId, agent.name, task, pool.agent.model, thinkingLevel, isolation);
865
+ runtime.settledRuns.delete(runId);
866
+ }
867
+
868
+ let thread!: SubagentThread;
869
+ const control = new RpcRunControl(task, generation, (phase) => {
870
+ if (runtime.threads.get(runId)?.generation !== generation || phase === "settled") return;
871
+ // Orchestration transitions are part of the trajectory (retrying →
872
+ // retry event, park/stop → terminal control events).
873
+ const trajectory = inspectorStore.get(runId).trajectory;
874
+ if (phase === "retrying") trajectory.append({ kind: "retry", reason: "retrying" });
875
+ else if (phase === "parked") trajectory.append({ kind: "park" });
876
+ else if (phase === "stopped") trajectory.append({ kind: "stop", reason: control.getStopMessage() });
877
+ const state: ThreadState =
878
+ phase === "queued" || phase === "starting"
879
+ ? "queued"
880
+ : phase === "steering"
881
+ ? "steering"
882
+ : phase === "interrupting"
883
+ ? "interrupting"
884
+ : phase === "parked"
885
+ ? "parked"
886
+ : phase === "stopped"
887
+ ? "stopped"
888
+ : "running";
889
+ thread.state = state;
890
+ if (state === "queued") monitor.setStatus(runId, "queued");
891
+ else if (state === "steering") monitor.setStatus(runId, "steering");
892
+ else if (state === "interrupting") monitor.setStatus(runId, "interrupting");
893
+ else if (state === "parked") monitor.setStatus(runId, "parked");
894
+ else if (state === "running") monitor.setStatus(runId, "running");
895
+ });
896
+
897
+ // Inspector projection for this thread id: on restart the append-only
898
+ // event history is kept (bumped generation), while the bounded
899
+ // transcript starts fresh for the new generation.
900
+ const inspectState = inspectorStore.get(runId);
901
+ if (existingThread) {
902
+ inspectState.trajectory.restart();
903
+ inspectState.transcript.clear();
904
+ inspectState.trajectory.append({
905
+ kind: "resume",
906
+ objective: newObjectiveOnResume ? task : undefined,
907
+ });
908
+ }
909
+ inspectState.retainFrom({ agent: agent.name, task, status: "queued", model: pool.agent.model, thinking: thinkingLevel });
910
+ if (seed?.forkedFromRunId !== undefined) {
911
+ inspectState.trajectory.append({
912
+ kind: "fork",
913
+ sourceRunId: seed.forkedFromRunId,
914
+ childRunId: runId,
915
+ objective: seed.forkObjective,
916
+ });
917
+ }
918
+ inspectState.trajectory.append({
919
+ kind: "dispatch",
920
+ agent: agent.name,
921
+ task,
922
+ model: pool.agent.model,
923
+ thinking: thinkingLevel,
924
+ pool: pool.fallbackModelRefs,
925
+ vision,
926
+ resumed: existingThread !== undefined || seed !== undefined,
927
+ isolation,
928
+ originalCwd,
929
+ isolationCwd: executionCwd,
930
+ });
931
+ if (worktree && worktree !== previousWorktree) {
932
+ inspectState.trajectory.append({
933
+ kind: "worktree",
934
+ status: "created",
935
+ originalCwd,
936
+ isolationCwd: executionCwd,
937
+ worktreePath: worktree.worktreePath,
938
+ });
939
+ }
940
+
941
+ if (existingThread) {
942
+ thread = existingThread;
943
+ thread.generation = generation;
944
+ thread.agentName = agent.name;
945
+ thread.task = task;
946
+ thread.cwd = originalCwd;
947
+ thread.executionCwd = executionCwd;
948
+ thread.vision = vision;
949
+ thread.modelPool = modelPool;
950
+ thread.thinkingLevel = thinkingLevel;
951
+ thread.isolation = isolation;
952
+ thread.worktree = worktree;
953
+ thread.state = "queued";
954
+ thread.control = control;
955
+ // A newly admitted generation owns no output yet. Keeping the prior
956
+ // generation here would make a queued stop publish stale task,
957
+ // transcript, and session metadata as this generation's partial.
958
+ thread.lastResult = undefined;
959
+ if (seed?.sessionId && seed.sessionDir) {
960
+ thread.sessionId = seed.sessionId;
961
+ thread.sessionDir = seed.sessionDir;
962
+ }
963
+ thread.retireOnSettle = false;
964
+ thread.isolationFailureNotified = false;
965
+ } else {
966
+ thread = {
967
+ id: runId,
968
+ generation,
969
+ agentName: agent.name,
970
+ task,
971
+ cwd: originalCwd,
972
+ executionCwd,
973
+ vision,
974
+ modelPool,
975
+ thinkingLevel,
976
+ isolation,
977
+ worktree,
978
+ state: "queued",
979
+ control,
980
+ generationCompletion: Promise.resolve(),
981
+ lifecycleVersion: 0,
982
+ sessionId: seed?.sessionId,
983
+ sessionDir: seed?.sessionDir,
984
+ forkedFromRunId: seed?.forkedFromRunId,
985
+ forkChildRunIds: [],
986
+ park: async () => {
987
+ throw new Error("Thread park was not initialized.");
988
+ },
989
+ resume: async () => failedStartResult(agent.name, task, "Thread resume was not initialized."),
990
+ fork: async () => failedStartResult(agent.name, task, "Thread fork was not initialized."),
991
+ finalizeIsolation: async () => undefined,
992
+ };
993
+ runtime.threads.set(runId, thread);
994
+ }
995
+ thread.notifyIsolationFailure = (finalization) => {
996
+ const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
997
+ runCtx.ui.notify(
998
+ `✗ worker worktree ${finalization.integrated ? "cleanup" : "integration"} failed${paths ? ` · retained ${paths}` : ""}: ${finalization.error ?? "unknown Git integration error"}`,
999
+ "error",
1000
+ );
1001
+ };
1002
+ thread.finalizeIsolation = async (
1003
+ expectedGeneration: number,
1004
+ result?: SingleResult,
1005
+ ): Promise<WorktreeFinalization | undefined> => {
1006
+ if (thread.isolation !== "worktree" || !thread.worktree) return undefined;
1007
+ if (thread.generation !== expectedGeneration) return undefined;
1008
+ const finalization = await thread.worktree.finalize();
1009
+ monitor.setIsolation(runId, "worktree", finalization.status);
1010
+ inspectState.trajectory.append({
1011
+ kind: "worktree",
1012
+ status: finalization.status,
1013
+ originalCwd: thread.cwd,
1014
+ isolationCwd: thread.executionCwd,
1015
+ worktreePath: finalization.worktreePath,
1016
+ patchPath: finalization.patchPath,
1017
+ integrated: finalization.integrated,
1018
+ error: finalization.error,
1019
+ });
1020
+ if (result) {
1021
+ result.runId = runId;
1022
+ result.isolation = "worktree";
1023
+ result.originalCwd = thread.cwd;
1024
+ result.isolationCwd = thread.executionCwd;
1025
+ result.integrationStatus = finalization.status;
1026
+ result.integrationApplied = finalization.integrated;
1027
+ result.integrationError = finalization.error;
1028
+ result.integrationWorktreePath = finalization.worktreePath;
1029
+ result.integrationPatchPath = finalization.patchPath;
1030
+ result.forkedFromRunId = thread.forkedFromRunId;
1031
+ result.forkChildRunIds = [...thread.forkChildRunIds];
1032
+ if (finalization.status === "retained") {
1033
+ const retained = [
1034
+ finalization.worktreePath ? `worktree ${finalization.worktreePath}` : undefined,
1035
+ finalization.patchPath ? `patch ${finalization.patchPath}` : undefined,
1036
+ ].filter(Boolean).join(", ");
1037
+ const integrationMessage = finalization.integrated
1038
+ ? `Worktree changes were applied, but cleanup failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git cleanup error"}`
1039
+ : `Worktree integration failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git integration error"}`;
1040
+ result.exitCode = 1;
1041
+ result.stopReason = "error";
1042
+ result.errorMessage = result.errorMessage
1043
+ ? `${result.errorMessage}\n${integrationMessage}`
1044
+ : integrationMessage;
1045
+ result.stderr = result.stderr ? `${result.stderr.trimEnd()}\n${integrationMessage}` : integrationMessage;
1046
+ }
1047
+ }
1048
+ if (finalization.status === "retained") {
1049
+ runtime.retainWorktreeArtifacts(finalization);
1050
+ if (!thread.isolationFailureNotified) {
1051
+ thread.isolationFailureNotified = true;
1052
+ try {
1053
+ thread.notifyIsolationFailure?.(finalization);
1054
+ } catch {
1055
+ /* notification failures do not hide retained artifacts */
1056
+ }
1057
+ }
1058
+ }
1059
+ return finalization;
1060
+ };
1061
+
1062
+ const cleanupTrackedSessionDir = async (sessionDir: string, action: string): Promise<void> => {
1063
+ try {
1064
+ await rm(sessionDir, { recursive: true, force: true });
1065
+ runtime.sessionDirs.delete(sessionDir);
1066
+ } catch (error) {
1067
+ // Keep ownership so shutdown can retry; losing the path here leaks a
1068
+ // cloned session containing retained model context on Windows locks.
1069
+ try {
1070
+ runCtx.ui.notify(
1071
+ `✗ ${action}; retained ${sessionDir} for shutdown cleanup: ${error instanceof Error ? error.message : String(error)}`,
1072
+ "error",
1073
+ );
1074
+ } catch {
1075
+ /* cleanup ownership remains tracked even if the UI is unavailable */
1076
+ }
1077
+ }
1078
+ };
1079
+
1080
+ const discardUnusedWorktree = async (candidate: WorktreeIsolation | undefined): Promise<void> => {
1081
+ if (!candidate) return;
1082
+ try {
1083
+ if (candidate.discard) {
1084
+ await candidate.discard();
1085
+ return;
1086
+ }
1087
+ // Compatibility for externally supplied/test handles. Production handles
1088
+ // expose discard(), so this fallback never integrates a seeded worktree.
1089
+ if (candidate.state === "active") await candidate.finalize();
1090
+ } catch (error) {
1091
+ const retainedPath = existsSync(candidate.worktreePath)
1092
+ ? candidate.worktreePath
1093
+ : existsSync(candidate.tempDir)
1094
+ ? candidate.tempDir
1095
+ : undefined;
1096
+ const finalization: WorktreeFinalization = {
1097
+ status: "retained",
1098
+ integrated: false,
1099
+ hadChanges: false,
1100
+ ...(retainedPath ? { worktreePath: retainedPath } : {}),
1101
+ ...(existsSync(candidate.patchPath) ? { patchPath: candidate.patchPath } : {}),
1102
+ error: `Discarding unused continuation failed: ${error instanceof Error ? error.message : String(error)}`,
1103
+ };
1104
+ runtime.retainWorktreeArtifacts(finalization);
1105
+ await persistRecoveryRecords(runtime.configPath, [
1106
+ recoveryRecordFromFinalization(runId, finalization),
1107
+ ]).catch(() => undefined);
1108
+ try {
1109
+ thread.notifyIsolationFailure?.(finalization);
1110
+ } catch {
1111
+ /* parent UI may already be shutting down */
1112
+ }
1113
+ }
1114
+ };
505
1115
 
506
- runtime.runControllers.set(runId, runtime.backgroundQueue.enqueue(
1116
+ const createContinuationWorktree = async (
1117
+ source: WorktreeIsolation,
1118
+ seedIsIntegrated: boolean,
1119
+ ): Promise<WorktreeIsolation> => {
1120
+ if (source.state === "finalizing") {
1121
+ throw new Error(`Run #${runId}'s worktree is still finalizing.`);
1122
+ }
1123
+ const seedCheckpoint = await source.snapshotCheckpoint();
1124
+ return createWorktreeIsolation(thread.cwd, {
1125
+ seedCheckpoint,
1126
+ seedIsIntegrated,
1127
+ });
1128
+ };
1129
+
1130
+ thread.park = async (): Promise<"queued" | "active"> => {
1131
+ if (thread.retired) throw new Error(`Run #${runId} was retired by subagent_stop.`);
1132
+ if (thread.lifecycleOperation) throw new Error(`Run #${runId} is already handling ${thread.lifecycleOperation}.`);
1133
+ if (thread.state === "parked") return "active";
1134
+ const phase = thread.control.getPhase();
1135
+ const queued = thread.state === "queued" && phase === "queued";
1136
+ if (
1137
+ !queued &&
1138
+ ((phase === "settled" && thread.state !== "running") ||
1139
+ !["starting", "running", "steering", "interrupting", "retrying", "settled"].includes(phase))
1140
+ ) {
1141
+ throw new Error(`Run #${runId} is ${thread.state}; only active work can be parked.`);
1142
+ }
1143
+
1144
+ const version = ++thread.lifecycleVersion;
1145
+ const generation = thread.generation;
1146
+ const completion = thread.generationCompletion;
1147
+ const controller = thread.queueController;
1148
+ thread.lifecycleOperation = "park";
1149
+ try {
1150
+ if (queued) {
1151
+ thread.control.parkPending();
1152
+ runtime.backgroundQueue.cancel(controller);
1153
+ } else {
1154
+ await thread.control.park();
1155
+ // Auto-fix orchestration has no live RPC attempt once its parent
1156
+ // review settled, so cancel its queue owner explicitly.
1157
+ if (phase === "settled") runtime.backgroundQueue.cancel(controller);
1158
+ }
1159
+ await completion;
1160
+ if (
1161
+ thread.generation !== generation ||
1162
+ thread.lifecycleVersion !== version ||
1163
+ thread.lifecycleOperation !== "park"
1164
+ ) {
1165
+ throw new Error(`Run #${runId} changed while parking.`);
1166
+ }
1167
+ thread.state = "parked";
1168
+ thread.queueController = undefined;
1169
+ runtime.runControllers.delete(runId);
1170
+ monitor.setStatus(runId, "parked");
1171
+ return queued ? "queued" : "active";
1172
+ } finally {
1173
+ if (thread.lifecycleVersion === version && thread.lifecycleOperation === "park") {
1174
+ thread.lifecycleOperation = undefined;
1175
+ }
1176
+ }
1177
+ };
1178
+
1179
+ thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
1180
+ const requestedObjective = objective?.trim();
1181
+ if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
1182
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
1183
+ }
1184
+ if (objective !== undefined && !requestedObjective) {
1185
+ return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
1186
+ }
1187
+ if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
1188
+ if (thread.lifecycleOperation) {
1189
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already ${thread.lifecycleOperation === "resume" ? "resuming" : "being forked"}.`);
1190
+ }
1191
+ if (!["parked", "completed", "failed"].includes(thread.state)) {
1192
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state}; it must be parked or settled before resume.`);
1193
+ }
1194
+
1195
+ // Lifecycle CAS: claim synchronously before the first await, then cancel
1196
+ // and fully quiesce any superseded queue/process before cloning or
1197
+ // reusing its session. A second resume/fork sees this claim immediately.
1198
+ const previousState = thread.state;
1199
+ const previousSessionId = thread.sessionId;
1200
+ const previousSessionDir = thread.sessionDir;
1201
+ const previousExecutionCwd = thread.executionCwd;
1202
+ const reservation: ResumeReservation = {
1203
+ version: ++thread.lifecycleVersion,
1204
+ generation: thread.generation,
1205
+ sessionId: previousSessionId,
1206
+ sessionDir: previousSessionDir,
1207
+ };
1208
+ thread.lifecycleOperation = "resume";
1209
+ thread.state = "resuming";
1210
+ const finishPreflight = beginPreflight();
1211
+ const supersededController = thread.queueController;
1212
+ runtime.backgroundQueue.cancel(supersededController);
1213
+ runtime.runControllers.delete(runId);
1214
+
1215
+ let continuationWorktree: WorktreeIsolation | undefined;
1216
+ let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
1217
+ try {
1218
+ await thread.generationCompletion;
1219
+ if (!ownsResumeReservation(thread, reservation)) {
1220
+ return failedStartResult(
1221
+ thread.agentName,
1222
+ thread.task,
1223
+ thread.retired
1224
+ ? `Run #${runId} was retired by subagent_stop; no new generation was started.`
1225
+ : `Run #${runId} changed while resume was preparing; no new generation was started.`,
1226
+ );
1227
+ }
1228
+ thread.state = "resuming";
1229
+ const currentCtx = resumeCtx ?? runCtx;
1230
+ let seed: SessionSeed | undefined;
1231
+ if (thread.isolation === "worktree" && thread.worktree?.state !== "active") {
1232
+ if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
1233
+ const seedAlreadyIntegrated =
1234
+ thread.worktree.state === "integrated" ||
1235
+ thread.worktree.state === "no_changes" ||
1236
+ thread.lastResult?.integrationApplied === true;
1237
+ continuationWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
1238
+ if (!ownsResumeReservation(thread, reservation)) {
1239
+ throw new Error(`Run #${runId} changed while its continuation worktree was being created.`);
1240
+ }
1241
+ seed = { worktree: continuationWorktree };
1242
+ if (previousSessionId && previousSessionDir) {
1243
+ clonedSession = await forkRetainedSession({
1244
+ cwd: previousExecutionCwd,
1245
+ targetCwd: continuationWorktree.cwd,
1246
+ sessionDir: previousSessionDir,
1247
+ sessionId: previousSessionId,
1248
+ });
1249
+ runtime.sessionDirs.add(clonedSession.sessionDir);
1250
+ if (!ownsResumeReservation(thread, reservation)) {
1251
+ throw new Error(`Run #${runId} changed while its retained session was being cloned.`);
1252
+ }
1253
+ seed.sessionId = clonedSession.sessionId;
1254
+ seed.sessionDir = clonedSession.sessionDir;
1255
+ }
1256
+ }
1257
+
1258
+ const currentConfig = await loadConfig(runtime.configPath);
1259
+ if (!ownsResumeReservation(thread, reservation)) {
1260
+ throw new Error(`Run #${runId} changed while resume configuration was loading.`);
1261
+ }
1262
+ runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
1263
+ const currentAgents = discoverAgents(currentCtx.cwd, {
1264
+ scope: currentConfig.agentScope,
1265
+ enabledNames: currentConfig.enabledAgents,
1266
+ projectTrusted: currentCtx.isProjectTrusted?.() === true,
1267
+ }).agents;
1268
+ const nextTask = requestedObjective ?? thread.task;
1269
+ const pending = await startBackground(
1270
+ thread.agentName,
1271
+ nextTask,
1272
+ thread.cwd,
1273
+ thread.vision,
1274
+ thread.isolation,
1275
+ thread,
1276
+ objective !== undefined,
1277
+ {
1278
+ ctx: currentCtx,
1279
+ config: currentConfig,
1280
+ agents: currentAgents,
1281
+ sessionRef: currentModelRef(currentCtx),
1282
+ },
1283
+ seed,
1284
+ reservation,
1285
+ );
1286
+ if (pending.exitCode !== -1) {
1287
+ if (clonedSession) {
1288
+ await cleanupTrackedSessionDir(
1289
+ clonedSession.sessionDir,
1290
+ `Could not discard failed resume session clone for run #${runId}`,
1291
+ );
1292
+ }
1293
+ await discardUnusedWorktree(continuationWorktree);
1294
+ if (ownsResumeReservation(thread, reservation)) thread.state = previousState;
1295
+ return pending;
1296
+ }
1297
+
1298
+ // The cloned branch replaces the removed-worktree session for this
1299
+ // logical id. Keep an undeletable old dir in runtime cleanup if needed.
1300
+ if (clonedSession && previousSessionDir && previousSessionDir !== clonedSession.sessionDir) {
1301
+ try {
1302
+ await rm(previousSessionDir, { recursive: true, force: true });
1303
+ runtime.sessionDirs.delete(previousSessionDir);
1304
+ } catch {
1305
+ /* shutdown retries cleanup of the old retained branch */
1306
+ }
1307
+ }
1308
+ return pending;
1309
+ } catch (error) {
1310
+ if (clonedSession) {
1311
+ await cleanupTrackedSessionDir(
1312
+ clonedSession.sessionDir,
1313
+ `Could not discard interrupted resume session clone for run #${runId}`,
1314
+ );
1315
+ }
1316
+ await discardUnusedWorktree(continuationWorktree);
1317
+ if (ownsResumeReservation(thread, reservation)) {
1318
+ thread.state = previousState;
1319
+ thread.sessionId = previousSessionId;
1320
+ thread.sessionDir = previousSessionDir;
1321
+ thread.executionCwd = previousExecutionCwd;
1322
+ }
1323
+ return failedStartResult(
1324
+ thread.agentName,
1325
+ requestedObjective ?? thread.task,
1326
+ `Could not resume run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
1327
+ );
1328
+ } finally {
1329
+ finishPreflight();
1330
+ if (
1331
+ thread.lifecycleOperation === "resume" &&
1332
+ thread.lifecycleVersion === reservation.version
1333
+ ) {
1334
+ thread.lifecycleOperation = undefined;
1335
+ }
1336
+ }
1337
+ };
1338
+
1339
+ thread.fork = async (objective?: string, forkCtx?: ExtensionContext): Promise<SingleResult> => {
1340
+ const forkObjective = objective?.trim();
1341
+ if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
1342
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
1343
+ }
1344
+ if (objective !== undefined && !forkObjective) {
1345
+ return failedStartResult(thread.agentName, thread.task, "fork objective must be non-blank when provided.");
1346
+ }
1347
+ if (thread.retired || thread.state === "stopped") {
1348
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop and cannot be forked.`);
1349
+ }
1350
+ if (thread.lifecycleOperation) {
1351
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already ${thread.lifecycleOperation === "resume" ? "resuming" : "being forked"}.`);
1352
+ }
1353
+ if (thread.state === "queued" && !thread.sessionId) {
1354
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is queued and has no retained session to fork.`);
1355
+ }
1356
+ if (["queued", "running", "steering", "interrupting"].includes(thread.state)) {
1357
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is active; park it first with subagent_control { action: "park", id: ${runId} }, then fork the stable session.`);
1358
+ }
1359
+ if (!["parked", "completed", "failed"].includes(thread.state)) {
1360
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state} and has no forkable retained checkpoint.`);
1361
+ }
1362
+ if (!thread.sessionId || !thread.sessionDir) {
1363
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} has no retained session to fork (it may have been parked before starting).`);
1364
+ }
1365
+ if (thread.isolation === "worktree") {
1366
+ const worktreeState = thread.worktree?.state;
1367
+ const seedIntegrated =
1368
+ worktreeState === "integrated" ||
1369
+ worktreeState === "no_changes" ||
1370
+ thread.lastResult?.integrationApplied === true;
1371
+ if (!seedIntegrated) {
1372
+ return failedStartResult(
1373
+ thread.agentName,
1374
+ thread.task,
1375
+ `Run #${runId}'s isolated checkpoint has not been integrated. Resume and settle it before forking so its seed is applied exactly once.`,
1376
+ );
1377
+ }
1378
+ }
1379
+
1380
+ // Same lifecycle CAS as resume: a concurrent resume/fork cannot consume
1381
+ // or clone this session while the branch copy is in progress.
1382
+ const forkVersion = ++thread.lifecycleVersion;
1383
+ const forkGeneration = thread.generation;
1384
+ const forkSessionId = thread.sessionId;
1385
+ const forkSessionDir = thread.sessionDir;
1386
+ const ownsFork = (): boolean =>
1387
+ runtime.sessionActive &&
1388
+ runtime.threads.get(runId) === thread &&
1389
+ !thread.retired &&
1390
+ thread.lifecycleOperation === "fork" &&
1391
+ thread.lifecycleVersion === forkVersion &&
1392
+ thread.generation === forkGeneration &&
1393
+ thread.sessionId === forkSessionId &&
1394
+ thread.sessionDir === forkSessionDir;
1395
+ thread.lifecycleOperation = "fork";
1396
+ const finishPreflight = beginPreflight();
1397
+ let childWorktree: WorktreeIsolation | undefined;
1398
+ let forkedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
1399
+ try {
1400
+ await thread.generationCompletion;
1401
+ if (!ownsFork()) {
1402
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} changed while fork was preparing; no child was started.`);
1403
+ }
1404
+ const currentCtx = forkCtx ?? runCtx;
1405
+ if (thread.isolation === "worktree") {
1406
+ if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
1407
+ const seedAlreadyIntegrated =
1408
+ thread.worktree.state === "integrated" ||
1409
+ thread.worktree.state === "no_changes" ||
1410
+ thread.lastResult?.integrationApplied === true;
1411
+ childWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
1412
+ if (!ownsFork()) throw new Error(`Run #${runId} changed while its fork worktree was being created.`);
1413
+ }
1414
+ forkedSession = await forkRetainedSession({
1415
+ cwd: thread.executionCwd,
1416
+ targetCwd: childWorktree?.cwd ?? thread.cwd,
1417
+ sessionDir: thread.sessionDir,
1418
+ sessionId: thread.sessionId,
1419
+ });
1420
+ runtime.sessionDirs.add(forkedSession.sessionDir);
1421
+ if (!ownsFork()) throw new Error(`Run #${runId} changed while its retained session was being forked.`);
1422
+ const currentConfig = await loadConfig(runtime.configPath);
1423
+ if (!ownsFork()) throw new Error(`Run #${runId} changed while fork configuration was loading.`);
1424
+ runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
1425
+ const currentAgents = discoverAgents(currentCtx.cwd, {
1426
+ scope: currentConfig.agentScope,
1427
+ enabledNames: currentConfig.enabledAgents,
1428
+ projectTrusted: currentCtx.isProjectTrusted?.() === true,
1429
+ }).agents;
1430
+ if (!ownsFork()) throw new Error(`Run #${runId} changed while fork was preparing; no child was started.`);
1431
+ const childTask = forkObjective ?? thread.task;
1432
+ const child = await startBackground(
1433
+ thread.agentName,
1434
+ childTask,
1435
+ thread.cwd,
1436
+ thread.vision,
1437
+ thread.isolation,
1438
+ undefined,
1439
+ false,
1440
+ {
1441
+ ctx: currentCtx,
1442
+ config: currentConfig,
1443
+ agents: currentAgents,
1444
+ sessionRef: currentModelRef(currentCtx),
1445
+ },
1446
+ {
1447
+ sessionId: forkedSession.sessionId,
1448
+ sessionDir: forkedSession.sessionDir,
1449
+ prompt: forkObjective ?? FORK_CONTINUATION_PROMPT,
1450
+ worktree: childWorktree,
1451
+ forkedFromRunId: runId,
1452
+ forkObjective,
1453
+ modelPool: [...thread.modelPool],
1454
+ thinkingLevel: thread.thinkingLevel,
1455
+ },
1456
+ );
1457
+ if (child.exitCode !== -1 || child.runId === undefined) {
1458
+ await cleanupTrackedSessionDir(
1459
+ forkedSession.sessionDir,
1460
+ `Could not discard failed fork session clone for run #${runId}`,
1461
+ );
1462
+ await discardUnusedWorktree(childWorktree);
1463
+ return child;
1464
+ }
1465
+
1466
+ // Once the independent child is enqueued it remains valid even if the
1467
+ // source is retired; just skip source-side relationship mutation.
1468
+ if (!ownsFork()) return child;
1469
+ const childRunId = child.runId;
1470
+ if (!thread.forkChildRunIds.includes(childRunId)) thread.forkChildRunIds.push(childRunId);
1471
+ const childThread = runtime.threads.get(childRunId);
1472
+ if (childThread) childThread.forkedFromRunId = runId;
1473
+ monitor.setForkRelation(runId, childRunId);
1474
+ inspectorStore.get(runId).trajectory.append({
1475
+ kind: "fork",
1476
+ sourceRunId: runId,
1477
+ childRunId,
1478
+ objective: forkObjective,
1479
+ });
1480
+ const sourceResult = runtime.settledRuns.get(runId) ?? thread.lastResult;
1481
+ if (sourceResult) sourceResult.forkChildRunIds = [...thread.forkChildRunIds];
1482
+ return child;
1483
+ } catch (error) {
1484
+ if (forkedSession) {
1485
+ await cleanupTrackedSessionDir(
1486
+ forkedSession.sessionDir,
1487
+ `Could not discard interrupted fork session clone for run #${runId}`,
1488
+ );
1489
+ }
1490
+ await discardUnusedWorktree(childWorktree);
1491
+ return failedStartResult(
1492
+ thread.agentName,
1493
+ forkObjective ?? thread.task,
1494
+ `Could not fork retained session for run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
1495
+ );
1496
+ } finally {
1497
+ finishPreflight();
1498
+ if (thread.lifecycleVersion === forkVersion && thread.lifecycleOperation === "fork") {
1499
+ thread.lifecycleOperation = undefined;
1500
+ }
1501
+ }
1502
+ };
1503
+
1504
+ const onLive = makeLiveHandler(runId, runId, generation);
1505
+ const onRecord = makeRecordHandler(runId, generation);
1506
+ const queueController = runtime.backgroundQueue.enqueue(
507
1507
  async (backgroundSignal) => {
1508
+ if (runtime.threads.get(runId)?.generation !== generation) return;
508
1509
  let result: SingleResult;
509
1510
  try {
510
1511
  result = await runSingleAgentWithModelFallback(
511
1512
  {
512
- defaultCwd: ctx.cwd,
513
- agent: effectiveAgent,
1513
+ defaultCwd: executionCwd,
1514
+ agent: pool.agent,
514
1515
  agentName,
515
1516
  task,
516
- cwd,
1517
+ cwd: executionCwd,
517
1518
  thinkingLevel,
518
1519
  signal: backgroundSignal,
519
1520
  onLive,
1521
+ onRecord,
1522
+ control,
520
1523
  makeDetails: makeDetails("single", true),
521
- idleTimeoutMs: config.idleTimeoutSec * 1000,
1524
+ idleTimeoutMs: runConfig.idleTimeoutSec * 1000,
1525
+ ...(priorSessionId && priorSessionDir
1526
+ ? {
1527
+ sessionId: priorSessionId,
1528
+ sessionDir: priorSessionDir,
1529
+ stdinText: seed?.prompt ?? (newObjectiveOnResume
1530
+ ? task
1531
+ : buildResumePrompt(priorTask ?? task, buildFallbackResumeReason())),
1532
+ }
1533
+ : {}),
522
1534
  },
523
- sessionRef,
1535
+ pool.fallbackModelRefs,
524
1536
  );
525
1537
  } catch (error) {
526
1538
  const errorMessage = error instanceof Error ? error.message : String(error);
527
1539
  result = {
528
1540
  ...pending,
1541
+ task: control.getObjective(),
529
1542
  exitCode: 1,
530
1543
  stderr: errorMessage,
531
1544
  stopReason: backgroundSignal.aborted ? "aborted" : "error",
532
1545
  errorMessage,
533
1546
  dispatchFailed: true,
534
1547
  };
535
- // The dedicated dispatch-failure notification below replaces the generic
536
- // failure toast for dispatch crashes, so finish silently here.
537
- finishRun(runId, "failed", { silent: true });
538
- runtime.registerRunResult(runId, result);
539
- runtime.runControllers.delete(runId);
540
1548
  }
541
1549
 
542
- if (!runtime.sessionActive) return;
543
- // Auto-fix loop: a REVIEW_FAIL from a main-agent-dispatched reviewer
544
- // triggers a worker→reviewer chain (up to maxFixRounds) without waking
545
- // the main agent. Loop-internal re-reviews never reach here (they are
546
- // awaited inside launchInLoop); the initial review is delivered with
547
- // the chain at the end. While the chain runs, the triggering review
548
- // stays in the widget (annotated) so the chain rows have an obvious
549
- // parent; no premature "done" notification is shown.
550
- if (shouldTriggerFixLoop(result, config)) {
551
- // The session is known active here (checked above), so the chain
552
- // always starts: keep the triggering review in the widget
553
- // (annotated) without a premature "done" notification, and let
554
- // startFixLoop deliver the whole chain and drop the parent row.
1550
+ // A stale process/generation may finish after a park/resume race. It owns
1551
+ // no monitor mutation, result registration, or completion delivery.
1552
+ if (runtime.threads.get(runId)?.generation !== generation) return;
1553
+ result.runId = runId;
1554
+ result.isolation = isolation;
1555
+ result.originalCwd = originalCwd;
1556
+ result.isolationCwd = executionCwd;
1557
+ result.forkedFromRunId = thread.forkedFromRunId;
1558
+ result.forkChildRunIds = [...thread.forkChildRunIds];
1559
+ thread.queueController = undefined;
1560
+ runtime.runControllers.delete(runId);
1561
+ thread.task = result.task;
1562
+ thread.sessionId = result.sessionId;
1563
+ thread.sessionDir = result.sessionDir;
1564
+ thread.lastResult = result;
1565
+ runtime.retainSession(result);
1566
+ monitor.setModel(runId, result.model, result.modelFallbackFrom);
1567
+
1568
+ // Destructive stop owns publication once it has synchronously claimed
1569
+ // the lifecycle. Leave the partial result/session on the thread; the
1570
+ // stop path waits for this queue task, finalizes isolation, and emits
1571
+ // exactly one aborted result.
1572
+ if (thread.lifecycleOperation === "stop") return;
1573
+
1574
+ if (result.parked) {
1575
+ thread.state = "parked";
1576
+ monitor.setStatus(runId, "parked");
1577
+ const parkedRun = monitor.findRun(runId);
1578
+ if (parkedRun) inspectState.retainFrom({ ...parkedRun, task: result.task, usage: result.usage });
1579
+ runtime.settledRuns.delete(runId);
1580
+ return;
1581
+ }
1582
+
1583
+ if (thread.retireOnSettle) runtime.retireThreadSession(thread);
1584
+ const wantsFixLoop = shouldTriggerFixLoop(result, runConfig);
1585
+ if (wantsFixLoop && isolation === "shared" && runtime.sessionActive) {
1586
+ thread.state = "running";
555
1587
  finishRun(runId, "done", { silent: true, retain: true });
556
1588
  monitor.setAnnotation(runId, "auto-fix chain running");
557
- startFixLoop(result, `fix-${runId}`, runId, vision);
1589
+ startFixLoop(result, `fix-${runId}`, runId, thread.executionCwd, vision);
558
1590
  return;
559
1591
  }
560
- const failed = isFailedResult(result);
561
- // Model-level failures and dispatch crashes get their own dedicated
562
- // dispatch-failure notification below, so finishRun's generic failure toast is
563
- // silenced for them (computed before finishRun for that reason).
564
- const modelLevel = failed && isModelLevelFailure(result);
565
- const dispatchFailed = result.dispatchFailed === true;
566
- finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
567
- // Register before delivery so a concurrent subagent_wait resolves with
568
- // the result even though the run row is already gone from the monitor.
569
- runtime.registerRunResult(runId, result);
570
- runtime.runControllers.delete(runId);
571
- if (!runtime.sessionActive) return;
572
- // Model-level failure: the configured model is unavailable or broke
573
- // and the retry with the main-window model (when distinct) also
574
- // failed. Instead of leaving a dead failure, hand the task to the
575
- // main window the main agent executes it itself with its own tools.
576
- const completion: CompletionMessageItem = {
577
- agent: result.agent,
578
- block: modelLevel
579
- ? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result)}`
580
- : formatCompletionBlock(result, config.maxResultLines, ctx.cwd),
581
- triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
582
- };
583
- if (modelLevel) {
584
- ctx.ui.notify(`✗ ${result.agent} dispatch failed: model unavailable or broken — task handed to the main window`, "error");
585
- } else if (dispatchFailed) {
586
- // An exception inside the dispatch layer (spawn infra, temp-file/fs
587
- // errors, ...): the main agent must know so it can re-dispatch.
588
- ctx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
589
- }
590
- if (failed) {
591
- // Failures never wait and never hide behind a success turn: deliver
592
- // first so the wake-up leads with the failure; held successes follow.
593
- runtime.sendCompletionGroup([completion]);
594
- runtime.completionBatcher.flush();
595
- } else {
596
- runtime.completionBatcher.push(completion);
1592
+ // Claim terminal settlement synchronously before the first slow await.
1593
+ // Park therefore either wins while RPC is still active, or is rejected
1594
+ // once settlement owns the generation. Destructive stop may supersede
1595
+ // this reservation; publication is revalidated after Git finalization.
1596
+ const settlementVersion = ++thread.lifecycleVersion;
1597
+ thread.lifecycleOperation = "settle";
1598
+ const ownsSettlement = (): boolean =>
1599
+ runtime.threads.get(runId) === thread &&
1600
+ thread.generation === generation &&
1601
+ thread.lifecycleVersion === settlementVersion &&
1602
+ thread.lifecycleOperation === "settle" &&
1603
+ !thread.retired;
1604
+ try {
1605
+ // Worktree isolation is rejected for reviewers, the only role that can
1606
+ // trigger auto-fix. Keep that invariant explicit: an isolated result is
1607
+ // finalized once here and can never start a chain that would integrate
1608
+ // the same worktree early.
1609
+ await thread.finalizeIsolation(generation, result);
1610
+ if (!ownsSettlement()) return;
1611
+
1612
+ const failed = isFailedResult(result);
1613
+ thread.state = failed ? "failed" : "completed";
1614
+ // Stamp the terminal monitor state before projecting it. This gives every
1615
+ // path a fixed endedAt even when the row is removed immediately.
1616
+ monitor.setStatus(runId, failed ? "failed" : "done");
1617
+ inspectState.trajectory.append({
1618
+ kind: "settled",
1619
+ status: failed ? "failed" : "done",
1620
+ model: result.model,
1621
+ isolation,
1622
+ ...(result.integrationStatus && result.integrationStatus !== "pending"
1623
+ ? { integrationStatus: result.integrationStatus }
1624
+ : {}),
1625
+ });
1626
+ const terminalRun = monitor.findRun(runId);
1627
+ inspectState.retainFrom(terminalRun
1628
+ ? { ...terminalRun, task: result.task, model: result.model ?? terminalRun.model, usage: result.usage }
1629
+ : {
1630
+ agent: pool.agent.name,
1631
+ task: result.task,
1632
+ model: result.model,
1633
+ thinking: thinkingLevel,
1634
+ status: failed ? "failed" : "done",
1635
+ endedAt: inspectState.trajectory.summary().endedAt,
1636
+ usage: result.usage,
1637
+ });
1638
+ if (!runtime.sessionActive || !ownsSettlement()) return;
1639
+
1640
+ const modelLevel = failed && isModelLevelFailure(result);
1641
+ const dispatchFailed = result.dispatchFailed === true;
1642
+ finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
1643
+ runtime.registerRunResult(runId, result);
1644
+ const completion: CompletionMessageItem = {
1645
+ agent: result.agent,
1646
+ block: modelLevel
1647
+ ? `${formatCompletionBlock(result, runConfig.maxResultLines, runCtx.cwd)}\n\n${modelLevelTakeoverNote(result, { runId })}`
1648
+ : formatCompletionBlock(result, runConfig.maxResultLines, runCtx.cwd),
1649
+ triggerTurn: completionTriggersTurn(result, runConfig.notifyOnReviewPass),
1650
+ };
1651
+ if (modelLevel) {
1652
+ runCtx.ui.notify(`✗ ${result.agent} dispatch failed: model unavailable or broken — task handed to the main window`, "error");
1653
+ } else if (dispatchFailed) {
1654
+ runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
1655
+ }
1656
+ if (failed) {
1657
+ runtime.sendCompletionGroup([completion]);
1658
+ runtime.completionBatcher.flush();
1659
+ } else {
1660
+ runtime.completionBatcher.push(completion);
1661
+ }
1662
+ } finally {
1663
+ if (ownsSettlement()) thread.lifecycleOperation = undefined;
597
1664
  }
598
1665
  },
599
1666
  () => {
1667
+ if (runtime.threads.get(runId)?.generation !== generation) return;
1668
+ // Queued park/stop owns publication and may still be finalizing an
1669
+ // isolated worktree. Do not expose a terminal monitor/trajectory state
1670
+ // before that owner records the checkpoint or aborted result.
1671
+ if (thread.lifecycleOperation === "park" || thread.lifecycleOperation === "stop") return;
600
1672
  runtime.runControllers.delete(runId);
1673
+ thread.queueController = undefined;
1674
+ if (thread.state === "parked") {
1675
+ monitor.setStatus(runId, "parked");
1676
+ return;
1677
+ }
1678
+ thread.state = "stopped";
1679
+ monitor.setStatus(runId, "failed");
1680
+ inspectState.trajectory.append({ kind: "settled", status: "stopped", model: monitor.findRun(runId)?.model, isolation });
1681
+ const stoppedRun = monitor.findRun(runId);
1682
+ if (stoppedRun) inspectState.retainFrom(stoppedRun);
1683
+ if (!runtime.sessionActive) {
1684
+ monitor.removeRun(runId);
1685
+ return;
1686
+ }
601
1687
  finishRun(runId, "failed");
602
1688
  },
603
- (error) => {
604
- // The task body converts sub-agent failures into delivered results; an
605
- // exception escaping it (spawn infra, delivery API, ...) must not
606
- // vanish: notify the user and deliver a failed result so the main
607
- // agent knows the dispatch failed and can re-dispatch.
608
- const crashed = dispatchFailedResult(agent, task, error, thinkingLevel);
609
- finishRun(runId, "failed", { silent: true });
610
- runtime.registerRunResult(runId, crashed);
611
- runtime.runControllers.delete(runId);
612
- if (!runtime.sessionActive) return;
1689
+ async (error) => {
1690
+ if (runtime.threads.get(runId)?.generation !== generation) return;
1691
+ // Queue-level crashes use the same settlement reservation as ordinary
1692
+ // results. A concurrent destructive stop may supersede it while slow
1693
+ // worktree finalization is running, in which case stop publishes once.
1694
+ if (thread.lifecycleOperation === "stop") return;
1695
+ const settlementVersion = ++thread.lifecycleVersion;
1696
+ thread.lifecycleOperation = "settle";
1697
+ const ownsSettlement = (): boolean =>
1698
+ runtime.threads.get(runId) === thread &&
1699
+ thread.generation === generation &&
1700
+ thread.lifecycleVersion === settlementVersion &&
1701
+ thread.lifecycleOperation === "settle" &&
1702
+ !thread.retired;
613
1703
  try {
614
- ctx.ui.notify(`✗ ${agent.name} dispatch failed: ${crashed.errorMessage}`, "error");
615
- runtime.sendCompletionGroup([
616
- {
617
- agent: agent.name,
618
- block: formatCompletionBlock(crashed, config.maxResultLines, ctx.cwd),
619
- triggerTurn: true,
620
- },
621
- ]);
622
- runtime.completionBatcher.flush();
623
- } catch {
624
- /* a second delivery failure must not throw through the queue */
1704
+ const crashed: SingleResult = {
1705
+ ...dispatchFailedResult(pool.agent, control.getObjective(), error, thinkingLevel),
1706
+ runId,
1707
+ isolation,
1708
+ originalCwd,
1709
+ isolationCwd: executionCwd,
1710
+ forkedFromRunId: thread.forkedFromRunId,
1711
+ };
1712
+ await thread.finalizeIsolation(generation, crashed);
1713
+ if (!ownsSettlement()) return;
1714
+ thread.state = "failed";
1715
+ monitor.setStatus(runId, "failed");
1716
+ inspectState.trajectory.append({
1717
+ kind: "settled",
1718
+ status: "failed",
1719
+ model: crashed.model,
1720
+ isolation,
1721
+ ...(crashed.integrationStatus && crashed.integrationStatus !== "pending"
1722
+ ? { integrationStatus: crashed.integrationStatus }
1723
+ : {}),
1724
+ });
1725
+ const crashedRun = monitor.findRun(runId);
1726
+ if (crashedRun) inspectState.retainFrom({ ...crashedRun, usage: crashed.usage });
1727
+ finishRun(runId, "failed", { silent: true });
1728
+ runtime.registerRunResult(runId, crashed);
1729
+ runtime.runControllers.delete(runId);
1730
+ thread.queueController = undefined;
1731
+ if (!runtime.sessionActive || !ownsSettlement()) return;
1732
+ try {
1733
+ runCtx.ui.notify(`✗ ${agent.name} dispatch failed: ${crashed.errorMessage}`, "error");
1734
+ runtime.sendCompletionGroup([
1735
+ {
1736
+ agent: agent.name,
1737
+ block: formatCompletionBlock(crashed, runConfig.maxResultLines, runCtx.cwd),
1738
+ triggerTurn: true,
1739
+ },
1740
+ ]);
1741
+ runtime.completionBatcher.flush();
1742
+ } catch {
1743
+ /* a second delivery failure must not throw through the queue */
1744
+ }
1745
+ } finally {
1746
+ if (ownsSettlement()) thread.lifecycleOperation = undefined;
625
1747
  }
626
1748
  },
627
- ));
628
-
1749
+ );
1750
+ thread.queueController = queueController;
1751
+ thread.generationCompletion = runtime.backgroundQueue.waitForTask(queueController);
1752
+ runtime.runControllers.set(runId, queueController);
629
1753
  return pending;
630
1754
  };
631
1755
 
@@ -644,32 +1768,53 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
644
1768
  };
645
1769
  }
646
1770
 
647
- const results = params.tasks.map((task) => startBackground(task.agent, task.task, task.cwd, task.vision === true));
1771
+ const results: SingleResult[] = [];
1772
+ // Preserve caller order (and deterministic completion batching) while
1773
+ // preparing each isolated filesystem before its queue entry can start.
1774
+ for (const item of params.tasks) {
1775
+ results.push(await startBackground(
1776
+ item.agent,
1777
+ item.task,
1778
+ item.cwd,
1779
+ item.vision === true,
1780
+ defaultIsolationMode("parallel", item.agent, item.isolation as IsolationMode | undefined),
1781
+ ));
1782
+ }
648
1783
  const started = results.filter((result) => result.exitCode === -1).length;
649
- const failures = results.filter((result) => result.exitCode !== -1);
1784
+ const failureLines = results.flatMap((result, index) => {
1785
+ if (result.exitCode === -1) return [];
1786
+ const reason = getResultOutput(result).trim() || "unknown startup failure";
1787
+ return [
1788
+ `- tasks[${index}] (${params.tasks![index]!.agent}) failed to start: ${reason.replace(/\n/g, "\n ")}`,
1789
+ ];
1790
+ });
1791
+ if (started === 0) {
1792
+ // Pi marks custom-tool failures only when execute throws; returning an
1793
+ // `isError` property is still a successful AgentToolResult.
1794
+ throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
1795
+ }
1796
+ const text = [
1797
+ `Started ${started} background subagent${started === 1 ? "" : "s"}. Results will automatically resume the main agent when ready.`,
1798
+ ...(failureLines.length > 0
1799
+ ? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
1800
+ : []),
1801
+ ].join("\n");
650
1802
  return {
651
- content: [
652
- {
653
- type: "text",
654
- text:
655
- started > 0
656
- ? `Started ${started} background subagent${started === 1 ? "" : "s"}. Results will automatically resume the main agent when ready.`
657
- : failures.map((result) => getResultOutput(result)).join("\n"),
658
- },
659
- ],
1803
+ content: [{ type: "text", text }],
660
1804
  details: makeDetails("parallel", true)(results),
661
- isError: failures.length > 0,
662
1805
  terminate: true,
663
1806
  };
664
1807
  }
665
1808
 
666
- const result = startBackground(params.agent as string, params.task as string, params.cwd, params.vision === true);
1809
+ const result = await startBackground(
1810
+ params.agent as string,
1811
+ params.task as string,
1812
+ params.cwd,
1813
+ params.vision === true,
1814
+ defaultIsolationMode("single", params.agent as string, params.isolation as IsolationMode | undefined),
1815
+ );
667
1816
  if (result.exitCode !== -1) {
668
- return {
669
- content: [{ type: "text", text: getResultOutput(result) }],
670
- details: makeDetails("single")([result]),
671
- isError: true,
672
- };
1817
+ throw new Error(getResultOutput(result));
673
1818
  }
674
1819
  return {
675
1820
  content: [{ type: "text", text: `Started ${result.agent} in the background. Its result will automatically resume the main agent when ready.` }],
@@ -684,15 +1829,17 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
684
1829
  let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
685
1830
  for (const t of args.tasks.slice(0, 4)) {
686
1831
  const preview = formatTaskSummary(t.task, 48);
687
- text += `\n ${theme.fg("accent", t.agent)} ${theme.fg("dim", preview)}`;
1832
+ const isolation = defaultIsolationMode("parallel", t.agent, t.isolation) === "worktree" ? " [worktree]" : "";
1833
+ text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`;
688
1834
  }
689
1835
  if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
690
1836
  return new Text(text, 0, 0);
691
1837
  }
692
1838
  const task: string = args.task ?? "";
693
1839
  const preview = formatTaskSummary(task, 60);
1840
+ const isolation = args.isolation === "worktree" ? " [worktree]" : "";
694
1841
  return new Text(
695
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")} ${theme.fg("dim", preview)}`,
1842
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`,
696
1843
  0,
697
1844
  0,
698
1845
  );
@@ -707,8 +1854,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
707
1854
  const pending = r.exitCode === -1;
708
1855
  const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
709
1856
  const usage = formatUsage(r.usage);
710
- const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (fell back from ${r.modelFallbackFrom})` : ""}`;
711
- const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
1857
+ const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (pool fallback from ${r.modelFallbackFrom})` : ""}`;
1858
+ const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
1859
+ const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
712
1860
  return new Text(line, 0, 0);
713
1861
  }
714
1862
 
@@ -720,8 +1868,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
720
1868
  const pending = r.exitCode === -1;
721
1869
  const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
722
1870
  const usage = formatUsage(r.usage);
723
- const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (fell back from ${r.modelFallbackFrom})` : ""}`;
724
- lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
1871
+ const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (pool fallback from ${r.modelFallbackFrom})` : ""}`;
1872
+ const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : "";
1873
+ lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
725
1874
  }
726
1875
  return new Text(lines.join("\n"), 0, 0);
727
1876
  },