@ferris1225/pi-subagents 0.31.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/README.md +122 -71
- package/package.json +1 -1
- package/src/agents.ts +8 -13
- package/src/background.ts +59 -6
- package/src/config.ts +14 -2
- package/src/dispatch.ts +1429 -396
- package/src/format.ts +29 -4
- package/src/index.ts +7 -4
- package/src/inspector-panel.ts +363 -0
- package/src/inspector.ts +369 -0
- package/src/models.ts +192 -50
- package/src/monitor.ts +114 -7
- package/src/prompt.ts +3 -2
- package/src/recovery.ts +145 -0
- package/src/rpc-run.ts +1016 -0
- package/src/runtime.ts +167 -27
- package/src/session-fork.ts +84 -0
- package/src/setup.ts +271 -184
- package/src/spawn.ts +562 -977
- package/src/tools.ts +404 -65
- package/src/trajectory.ts +503 -0
- package/src/ui.ts +32 -16
- package/src/widget.ts +17 -4
- package/src/worktree.ts +687 -0
package/src/dispatch.ts
CHANGED
|
@@ -1,27 +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
|
-
*
|
|
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`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
import { rm } from "node:fs/promises";
|
|
17
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";
|
|
18
18
|
import { Type } from "typebox";
|
|
19
19
|
import { discoverAgents, type AgentConfig } from "./agents.ts";
|
|
20
20
|
import {
|
|
21
21
|
completionTriggersTurn,
|
|
22
22
|
type CompletionMessageItem,
|
|
23
23
|
} from "./completion.ts";
|
|
24
|
-
import { loadConfig,
|
|
24
|
+
import { loadConfig, type SubagentsConfig } from "./config.ts";
|
|
25
25
|
import {
|
|
26
26
|
dispatchFailedResult,
|
|
27
27
|
failedStartResult,
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
summarizeChainResult,
|
|
39
39
|
type ChainStep,
|
|
40
40
|
} from "./fixloop.ts";
|
|
41
|
-
import {
|
|
41
|
+
import { currentModelRef, resolveAgentModelPool } from "./models.ts";
|
|
42
42
|
import {
|
|
43
43
|
formatTaskSummary,
|
|
44
44
|
formatToolActivity,
|
|
@@ -46,10 +46,13 @@ import {
|
|
|
46
46
|
statusIcon,
|
|
47
47
|
type RunChainMeta,
|
|
48
48
|
} from "./monitor.ts";
|
|
49
|
-
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";
|
|
50
52
|
import {
|
|
51
53
|
buildFallbackResumeReason,
|
|
52
54
|
buildResumePrompt,
|
|
55
|
+
RpcRunControl,
|
|
53
56
|
getResultOutput,
|
|
54
57
|
isFailedResult,
|
|
55
58
|
isModelLevelFailure,
|
|
@@ -58,13 +61,50 @@ import {
|
|
|
58
61
|
type SingleResult,
|
|
59
62
|
type SubagentDetails,
|
|
60
63
|
type SubagentLiveEvent,
|
|
64
|
+
type SubagentRecordEvent,
|
|
61
65
|
} from "./spawn.ts";
|
|
62
|
-
import {
|
|
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";
|
|
63
74
|
|
|
64
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
|
+
}
|
|
65
98
|
|
|
66
99
|
const VISION_DESCRIPTION =
|
|
67
|
-
"Set true when the task may require viewing images (screenshots, mockups, designs) — the
|
|
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
|
+
);
|
|
68
108
|
|
|
69
109
|
const TaskItem = Type.Object({
|
|
70
110
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
@@ -74,6 +114,7 @@ const TaskItem = Type.Object({
|
|
|
74
114
|
}),
|
|
75
115
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
76
116
|
vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
|
|
117
|
+
isolation: IsolationSchema,
|
|
77
118
|
});
|
|
78
119
|
|
|
79
120
|
const SubagentParams = Type.Object({
|
|
@@ -84,63 +125,79 @@ const SubagentParams = Type.Object({
|
|
|
84
125
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
|
|
85
126
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
86
127
|
vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
|
|
87
|
-
|
|
88
|
-
Type.Number({
|
|
89
|
-
description:
|
|
90
|
-
"Resume a handed-back run by its id: continue a sub-agent whose model hit a quota/auth limit, picking up its preserved context without re-scanning. Use the run id from a model-level handback message.",
|
|
91
|
-
}),
|
|
92
|
-
),
|
|
128
|
+
isolation: IsolationSchema,
|
|
93
129
|
});
|
|
94
130
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return
|
|
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";
|
|
98
134
|
}
|
|
99
135
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const refs = availableModelRefs(ctx);
|
|
114
|
-
if (refs.includes(configured)) return configured;
|
|
115
|
-
|
|
116
|
-
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 {
|
|
117
149
|
try {
|
|
118
|
-
|
|
119
|
-
ctx,
|
|
120
|
-
`Vision model "${configured}" is unavailable. Pick a replacement?`,
|
|
121
|
-
"Type to filter • ↑/↓ • Enter selects • Esc falls back to the main session's model",
|
|
122
|
-
refs.map((ref) => ({ value: ref, label: ref })),
|
|
123
|
-
);
|
|
124
|
-
if (picked !== undefined) {
|
|
125
|
-
try {
|
|
126
|
-
await saveConfig({ ...config, visionModel: picked }, configPath);
|
|
127
|
-
ctx.ui.notify(`Vision model switched to ${picked}.`, "info");
|
|
128
|
-
} catch {
|
|
129
|
-
/* persistence failure is non-fatal; the pick still applies this dispatch */
|
|
130
|
-
}
|
|
131
|
-
return picked;
|
|
132
|
-
}
|
|
150
|
+
return await realpath(resolve(cwd));
|
|
133
151
|
} catch {
|
|
134
|
-
|
|
152
|
+
return resolve(cwd);
|
|
135
153
|
}
|
|
136
|
-
ctx.ui.notify(`Vision model left as "${configured}"; this dispatch runs without the vision override.`, "warning");
|
|
137
|
-
return undefined;
|
|
138
154
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
};
|
|
144
201
|
}
|
|
145
202
|
|
|
146
203
|
export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
@@ -151,11 +208,12 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
151
208
|
"Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
|
|
152
209
|
"Agents: explore (read-only codebase recon), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
|
|
153
210
|
"Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
|
|
154
|
-
"
|
|
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.",
|
|
155
213
|
"It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
|
|
156
214
|
"Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
|
|
157
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).",
|
|
158
|
-
"Vision: set vision: true when the task may require viewing images (screenshots, mockups, design files — e.g. frontend work) — the
|
|
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.",
|
|
159
217
|
].join(" "),
|
|
160
218
|
promptSnippet:
|
|
161
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.",
|
|
@@ -165,37 +223,20 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
165
223
|
"Use subagent with agent 'worker' for a self-contained implementation task worth a separate context; it plans internally.",
|
|
166
224
|
"Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
|
|
167
225
|
"subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
|
|
168
|
-
"Run independent tasks in parallel by passing a tasks array to subagent;
|
|
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.",
|
|
169
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.",
|
|
170
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.",
|
|
171
|
-
"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
|
|
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.",
|
|
172
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.",
|
|
173
232
|
],
|
|
174
233
|
parameters: SubagentParams,
|
|
175
234
|
|
|
176
235
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
177
236
|
monitor.beginTurn();
|
|
178
|
-
|
|
237
|
+
const config = await loadConfig(runtime.configPath);
|
|
179
238
|
// Pick up concurrency changes from /subagents-setup without a restart.
|
|
180
239
|
runtime.backgroundQueue.setConcurrency(config.maxConcurrency);
|
|
181
|
-
const repairedModels = repairUnavailableModelOverrides(ctx, config.agentModels);
|
|
182
|
-
if (repairedModels.changed) {
|
|
183
|
-
config = { ...config, agentModels: repairedModels.agentModels };
|
|
184
|
-
try {
|
|
185
|
-
await saveConfig(config, runtime.configPath);
|
|
186
|
-
ctx.ui.notify(
|
|
187
|
-
repairedModels.fallbackRef
|
|
188
|
-
? `Unavailable sub-agent models switched to ${repairedModels.fallbackRef} and saved to config.`
|
|
189
|
-
: "Unavailable sub-agent model overrides removed; no main-window model is available.",
|
|
190
|
-
"warning",
|
|
191
|
-
);
|
|
192
|
-
} catch (error) {
|
|
193
|
-
ctx.ui.notify(
|
|
194
|
-
`Could not persist repaired sub-agent model config: ${error instanceof Error ? error.message : String(error)}`,
|
|
195
|
-
"warning",
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
240
|
|
|
200
241
|
// Finished runs leave the widget immediately. Their final findings are sent
|
|
201
242
|
// back as a custom message that automatically starts a follow-up turn.
|
|
@@ -214,57 +255,95 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
214
255
|
};
|
|
215
256
|
|
|
216
257
|
// Live sub-agent activity → concise one-line status ("thinking",
|
|
217
|
-
// "read src/index.ts", ...), never a raw args blob.
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
};
|
|
251
337
|
const discovery = discoverAgents(ctx.cwd, {
|
|
252
338
|
scope: config.agentScope,
|
|
253
339
|
enabledNames: config.enabledAgents,
|
|
340
|
+
projectTrusted: ctx.isProjectTrusted?.() === true,
|
|
254
341
|
});
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const sessionRef = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
258
|
-
const agents: AgentConfig[] = discovery.agents.map((agent) => ({
|
|
259
|
-
...agent,
|
|
260
|
-
model: config.agentModels[agent.name] ?? sessionRef ?? agent.model,
|
|
261
|
-
}));
|
|
342
|
+
const sessionRef = currentModelRef(ctx);
|
|
343
|
+
const agents = discovery.agents;
|
|
262
344
|
|
|
263
345
|
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
264
346
|
const hasSingle = Boolean(params.agent) && params.task !== undefined;
|
|
265
|
-
// `resume` is its own exclusive mode (it re-dispatches a handed-back run
|
|
266
|
-
// from its preserved session), so it bypasses the single/parallel check.
|
|
267
|
-
const hasResume = typeof params.resume === "number";
|
|
268
347
|
|
|
269
348
|
const makeDetails =
|
|
270
349
|
(mode: "single" | "parallel", background = false) =>
|
|
@@ -272,7 +351,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
272
351
|
|
|
273
352
|
const catalog = agents.map((a) => a.name).join(", ") || "none";
|
|
274
353
|
|
|
275
|
-
if (
|
|
354
|
+
if (Number(hasTasks) + Number(hasSingle) !== 1) {
|
|
276
355
|
return {
|
|
277
356
|
content: [
|
|
278
357
|
{
|
|
@@ -309,21 +388,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
309
388
|
};
|
|
310
389
|
}
|
|
311
390
|
|
|
312
|
-
// A vision-flagged dispatch with a stale vision model asks the user for a
|
|
313
|
-
// replacement before spawning (the persisted pick also fixes future runs).
|
|
314
|
-
// Runs only after parameter validation, so an invalid call never pops a picker.
|
|
315
|
-
const visionRequested = hasVisionTask(params);
|
|
316
|
-
let visionModel = config.visionModel;
|
|
317
|
-
if (visionRequested && visionModel !== undefined && !availableModelRefs(ctx).includes(visionModel.trim())) {
|
|
318
|
-
visionModel = await repairVisionModelForDispatch(ctx, config, runtime.configPath);
|
|
319
|
-
}
|
|
320
|
-
// Vision-flagged dispatches run on the configured vision model, else the
|
|
321
|
-
// main session's current model (the documented fallback), else the agent's
|
|
322
|
-
// own model as the last resort.
|
|
323
|
-
const visionRef = resolveVisionModelRef(ctx, visionModel);
|
|
324
|
-
const withVision = (agent: AgentConfig, vision: boolean): AgentConfig =>
|
|
325
|
-
vision && visionRef ? { ...agent, model: visionRef } : agent;
|
|
326
|
-
|
|
327
391
|
/**
|
|
328
392
|
* Dispatch one agent inside an auto-fix chain: tracked in the widget with a
|
|
329
393
|
* groupId/relationLabel, but NOT delivered through the completion flow — the
|
|
@@ -332,45 +396,85 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
332
396
|
const launchInLoop = async (
|
|
333
397
|
agentName: string,
|
|
334
398
|
task: string,
|
|
399
|
+
executionCwd: string,
|
|
335
400
|
signal: AbortSignal,
|
|
336
401
|
meta: RunChainMeta,
|
|
337
402
|
vision = false,
|
|
338
403
|
): Promise<{ runId?: number; result: SingleResult }> => {
|
|
339
404
|
const agent = agents.find((candidate) => candidate.name === agentName);
|
|
340
405
|
if (!agent) return { result: failedStartResult(agentName, task, `Unknown agent: "${agentName}".`) };
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
const
|
|
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);
|
|
344
409
|
const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
|
|
345
|
-
const runId = monitor.addRun(agent.name, task,
|
|
346
|
-
|
|
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);
|
|
347
429
|
try {
|
|
348
430
|
const result = await runSingleAgentWithModelFallback(
|
|
349
431
|
{
|
|
350
|
-
defaultCwd:
|
|
351
|
-
|
|
432
|
+
defaultCwd: executionCwd,
|
|
433
|
+
cwd: executionCwd,
|
|
434
|
+
agent: pool.agent,
|
|
352
435
|
agentName,
|
|
353
436
|
task,
|
|
354
437
|
thinkingLevel,
|
|
355
438
|
signal,
|
|
356
439
|
onLive,
|
|
440
|
+
onRecord,
|
|
357
441
|
makeDetails: makeDetails("single", true),
|
|
358
442
|
idleTimeoutMs: config.idleTimeoutSec * 1000,
|
|
359
443
|
},
|
|
360
|
-
|
|
444
|
+
pool.fallbackModelRefs,
|
|
361
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
|
+
});
|
|
362
457
|
// Keep the finished round visible in the widget while the chain is
|
|
363
458
|
// still running, with a one-line summary of what it did; the whole
|
|
364
459
|
// group is dropped when the chain resolves (see removeChainGroup).
|
|
365
460
|
monitor.setSummary(runId, summarizeChainResult(result));
|
|
366
461
|
finishRun(runId, isFailedResult(result) ? "failed" : "done", { retain: true });
|
|
462
|
+
const retainedRun = monitor.findRun(runId);
|
|
463
|
+
if (retainedRun) chainState.retainFrom(retainedRun);
|
|
367
464
|
runtime.registerRunResult(runId, result);
|
|
368
465
|
return { runId, result };
|
|
369
466
|
} catch (error) {
|
|
370
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);
|
|
371
471
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
372
|
-
const crashed = {
|
|
373
|
-
...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,
|
|
374
478
|
exitCode: 1,
|
|
375
479
|
stderr: errorMessage,
|
|
376
480
|
stopReason: signal.aborted ? "aborted" : "error",
|
|
@@ -402,10 +506,35 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
402
506
|
initialReviewerResult: SingleResult,
|
|
403
507
|
parentGroupId: string,
|
|
404
508
|
parentRunId: number,
|
|
509
|
+
executionCwd: string,
|
|
405
510
|
vision = false,
|
|
406
511
|
): void => {
|
|
407
|
-
runtime.
|
|
408
|
-
|
|
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) => {
|
|
409
538
|
const chain: ChainStep[] = [
|
|
410
539
|
{ runId: parentRunId, result: initialReviewerResult, relation: "initial review" },
|
|
411
540
|
];
|
|
@@ -413,17 +542,45 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
413
542
|
for (let round = 1; round <= config.maxFixRounds; round++) {
|
|
414
543
|
if (!runtime.sessionActive) break;
|
|
415
544
|
const fixBrief = buildFixTaskBrief(lastReviewer, round, config.maxFixRounds);
|
|
416
|
-
const workerStep = await launchInLoop("worker", fixBrief, signal, {
|
|
545
|
+
const workerStep = await launchInLoop("worker", fixBrief, executionCwd, signal, {
|
|
417
546
|
groupId: parentGroupId,
|
|
418
547
|
relationLabel: `fix round ${round}`,
|
|
419
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;
|
|
420
565
|
chain.push({ ...workerStep, relation: `fix round ${round}` });
|
|
421
566
|
if (!runtime.sessionActive || isFailedResult(workerStep.result)) break;
|
|
422
567
|
const reReviewBrief = buildReReviewBrief(lastReviewer, round);
|
|
423
|
-
const reviewStep = await launchInLoop("reviewer", reReviewBrief, signal, {
|
|
568
|
+
const reviewStep = await launchInLoop("reviewer", reReviewBrief, executionCwd, signal, {
|
|
424
569
|
groupId: parentGroupId,
|
|
425
570
|
relationLabel: `re-review round ${round}`,
|
|
426
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;
|
|
427
584
|
chain.push({ ...reviewStep, relation: `re-review round ${round}` });
|
|
428
585
|
lastReviewer = reviewStep.result;
|
|
429
586
|
// A crashed re-review must stop the chain like a crashed worker: its
|
|
@@ -432,36 +589,70 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
432
589
|
if (!runtime.sessionActive || isFailedResult(reviewStep.result)) break;
|
|
433
590
|
if (reviewVerdict(getResultOutput(reviewStep.result)) === "pass") break;
|
|
434
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
|
+
}
|
|
435
611
|
// The chain is done (success, exhaustion, or abort): drop the retained
|
|
436
612
|
// parent row and its retained round rows, then deliver one condensed
|
|
437
613
|
// summary. Register the parent's final state (the last chain result)
|
|
438
614
|
// before removal so subagent_wait can resolve it.
|
|
439
|
-
|
|
440
|
-
runtime.
|
|
615
|
+
const last = chain[chain.length - 1];
|
|
616
|
+
runtime.registerRunResult(parentRunId, last.result);
|
|
441
617
|
removeChainGroup(parentGroupId);
|
|
442
618
|
monitor.removeRun(parentRunId);
|
|
443
|
-
|
|
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
|
+
}
|
|
444
645
|
// One compact message instead of every round's raw output: the summary
|
|
445
646
|
// lines cover each step (verdict + what changed/found), and the final
|
|
446
647
|
// step's full report is appended only when its detail is actionable
|
|
447
648
|
// (a FAIL verdict, a crash, or a model-level failure the main agent
|
|
448
649
|
// must take over). Everything else stays one `subagent_status #id`
|
|
449
650
|
// call away.
|
|
450
|
-
const last = chain[chain.length - 1];
|
|
451
651
|
let block = formatChainSummary(chain);
|
|
452
652
|
if (isFailedResult(last.result) && isModelLevelFailure(last.result)) {
|
|
453
|
-
|
|
454
|
-
runtime.preservedSessions.set(parentRunId, {
|
|
455
|
-
sessionId: last.result.sessionId,
|
|
456
|
-
sessionDir: last.result.sessionDir,
|
|
457
|
-
agentName: last.result.agent,
|
|
458
|
-
task: last.result.task,
|
|
459
|
-
vision,
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(last.result, { runId: parentRunId })}`;
|
|
653
|
+
block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, executionCwd)}\n\n${modelLevelTakeoverNote(last.result, { runId: parentRunId })}`;
|
|
463
654
|
} else if (isFailedResult(last.result) || reviewVerdict(getResultOutput(last.result)) === "fail") {
|
|
464
|
-
block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines,
|
|
655
|
+
block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, executionCwd)}`;
|
|
465
656
|
}
|
|
466
657
|
runtime.sendCompletionGroup([
|
|
467
658
|
{
|
|
@@ -471,25 +662,37 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
471
662
|
},
|
|
472
663
|
]);
|
|
473
664
|
runtime.completionBatcher.flush();
|
|
474
|
-
|
|
665
|
+
clearOwnedController();
|
|
666
|
+
}),
|
|
475
667
|
() => {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
runtime.runControllers.delete(parentRunId);
|
|
668
|
+
if (!ownsParent()) return;
|
|
669
|
+
const controlledParent = parentThreadAtStart;
|
|
670
|
+
clearOwnedController();
|
|
480
671
|
removeChainGroup(parentGroupId);
|
|
481
|
-
|
|
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);
|
|
482
678
|
},
|
|
483
679
|
(error) => {
|
|
484
680
|
// A crash inside the chain orchestration (failed runs are caught by
|
|
485
|
-
// launchInLoop and delivered as part of the chain) must not vanish
|
|
486
|
-
//
|
|
487
|
-
|
|
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
|
+
}
|
|
488
689
|
runtime.registerRunResult(parentRunId, initialReviewerResult);
|
|
489
|
-
runtime.runControllers.delete(parentRunId);
|
|
490
690
|
removeChainGroup(parentGroupId);
|
|
491
691
|
monitor.removeRun(parentRunId);
|
|
492
|
-
if (!runtime.sessionActive)
|
|
692
|
+
if (!runtime.sessionActive) {
|
|
693
|
+
clearOwnedController();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
493
696
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
494
697
|
try {
|
|
495
698
|
ctx.ui.notify(`✗ auto-fix chain dispatch failed: ${errorMessage}`, "error");
|
|
@@ -498,253 +701,1058 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
498
701
|
runtime.sendCompletionGroup([
|
|
499
702
|
{
|
|
500
703
|
agent: initialReviewerResult.agent,
|
|
501
|
-
block: `${formatCompletionBlock(initialReviewerResult, config.maxResultLines,
|
|
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.`,
|
|
502
705
|
triggerTurn: true,
|
|
503
706
|
},
|
|
504
707
|
]);
|
|
505
708
|
runtime.completionBatcher.flush();
|
|
506
709
|
} catch {
|
|
507
710
|
/* a second delivery failure must not throw through the queue */
|
|
711
|
+
} finally {
|
|
712
|
+
clearOwnedController();
|
|
508
713
|
}
|
|
509
714
|
},
|
|
510
|
-
)
|
|
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);
|
|
511
723
|
};
|
|
512
724
|
|
|
513
|
-
|
|
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 (
|
|
514
769
|
agentName: string,
|
|
515
770
|
task: string,
|
|
516
|
-
cwd
|
|
771
|
+
cwd: string | undefined,
|
|
517
772
|
vision = false,
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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);
|
|
521
791
|
if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
+
}
|
|
526
798
|
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
+
});
|
|
534
896
|
|
|
535
|
-
|
|
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
|
+
};
|
|
1115
|
+
|
|
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(
|
|
536
1507
|
async (backgroundSignal) => {
|
|
1508
|
+
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
537
1509
|
let result: SingleResult;
|
|
538
1510
|
try {
|
|
539
1511
|
result = await runSingleAgentWithModelFallback(
|
|
540
1512
|
{
|
|
541
|
-
defaultCwd:
|
|
542
|
-
agent:
|
|
1513
|
+
defaultCwd: executionCwd,
|
|
1514
|
+
agent: pool.agent,
|
|
543
1515
|
agentName,
|
|
544
1516
|
task,
|
|
545
|
-
cwd,
|
|
1517
|
+
cwd: executionCwd,
|
|
546
1518
|
thinkingLevel,
|
|
547
1519
|
signal: backgroundSignal,
|
|
548
1520
|
onLive,
|
|
1521
|
+
onRecord,
|
|
1522
|
+
control,
|
|
549
1523
|
makeDetails: makeDetails("single", true),
|
|
550
|
-
idleTimeoutMs:
|
|
551
|
-
|
|
552
|
-
// model-level failure) so it continues in-context instead of
|
|
553
|
-
// re-scanning. The wrapper detects the existing session file and
|
|
554
|
-
// resumes it; the continuation prompt steers the model to pick up.
|
|
555
|
-
...(resumeSession
|
|
1524
|
+
idleTimeoutMs: runConfig.idleTimeoutSec * 1000,
|
|
1525
|
+
...(priorSessionId && priorSessionDir
|
|
556
1526
|
? {
|
|
557
|
-
sessionId:
|
|
558
|
-
sessionDir:
|
|
559
|
-
stdinText:
|
|
1527
|
+
sessionId: priorSessionId,
|
|
1528
|
+
sessionDir: priorSessionDir,
|
|
1529
|
+
stdinText: seed?.prompt ?? (newObjectiveOnResume
|
|
1530
|
+
? task
|
|
1531
|
+
: buildResumePrompt(priorTask ?? task, buildFallbackResumeReason())),
|
|
560
1532
|
}
|
|
561
1533
|
: {}),
|
|
562
1534
|
},
|
|
563
|
-
|
|
1535
|
+
pool.fallbackModelRefs,
|
|
564
1536
|
);
|
|
565
1537
|
} catch (error) {
|
|
566
1538
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
567
1539
|
result = {
|
|
568
1540
|
...pending,
|
|
1541
|
+
task: control.getObjective(),
|
|
569
1542
|
exitCode: 1,
|
|
570
1543
|
stderr: errorMessage,
|
|
571
1544
|
stopReason: backgroundSignal.aborted ? "aborted" : "error",
|
|
572
1545
|
errorMessage,
|
|
573
1546
|
dispatchFailed: true,
|
|
574
1547
|
};
|
|
575
|
-
// The dedicated dispatch-failure notification below replaces the generic
|
|
576
|
-
// failure toast for dispatch crashes, so finish silently here.
|
|
577
|
-
finishRun(runId, "failed", { silent: true });
|
|
578
|
-
runtime.registerRunResult(runId, result);
|
|
579
|
-
runtime.runControllers.delete(runId);
|
|
580
1548
|
}
|
|
581
1549
|
|
|
582
|
-
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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";
|
|
595
1587
|
finishRun(runId, "done", { silent: true, retain: true });
|
|
596
1588
|
monitor.setAnnotation(runId, "auto-fix chain running");
|
|
597
|
-
startFixLoop(result, `fix-${runId}`, runId, vision);
|
|
1589
|
+
startFixLoop(result, `fix-${runId}`, runId, thread.executionCwd, vision);
|
|
598
1590
|
return;
|
|
599
1591
|
}
|
|
600
|
-
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
+
: {}),
|
|
630
1625
|
});
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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;
|
|
658
1664
|
}
|
|
659
1665
|
},
|
|
660
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;
|
|
661
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
|
+
}
|
|
662
1687
|
finishRun(runId, "failed");
|
|
663
1688
|
},
|
|
664
|
-
(error) => {
|
|
665
|
-
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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;
|
|
674
1703
|
try {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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;
|
|
686
1747
|
}
|
|
687
1748
|
},
|
|
688
|
-
)
|
|
689
|
-
|
|
1749
|
+
);
|
|
1750
|
+
thread.queueController = queueController;
|
|
1751
|
+
thread.generationCompletion = runtime.backgroundQueue.waitForTask(queueController);
|
|
1752
|
+
runtime.runControllers.set(runId, queueController);
|
|
690
1753
|
return pending;
|
|
691
1754
|
};
|
|
692
1755
|
|
|
693
|
-
// Resume mode (exclusive): continue a handed-back run in its preserved
|
|
694
|
-
// session on the agent's configured model, picking up the prior context
|
|
695
|
-
// instead of re-scanning. Triggered by a model-level handback that named
|
|
696
|
-
// the run id, after the user has a working model again.
|
|
697
|
-
if (typeof params.resume === "number") {
|
|
698
|
-
const preserved = runtime.preservedSessions.get(params.resume);
|
|
699
|
-
if (!preserved) {
|
|
700
|
-
return {
|
|
701
|
-
content: [
|
|
702
|
-
{
|
|
703
|
-
type: "text",
|
|
704
|
-
text: `No preservable session for run #${params.resume}. It completed normally, was not a model-level handback, or the session has ended.`,
|
|
705
|
-
},
|
|
706
|
-
],
|
|
707
|
-
details: makeDetails("single")([]),
|
|
708
|
-
isError: true,
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
const resumeAgent = agents.find((a) => a.name === preserved.agentName);
|
|
712
|
-
if (!resumeAgent) {
|
|
713
|
-
return {
|
|
714
|
-
content: [
|
|
715
|
-
{
|
|
716
|
-
type: "text",
|
|
717
|
-
text: `Cannot resume run #${params.resume}: agent "${preserved.agentName}" is not enabled. Re-enable it (or run /subagents-setup) and resume again.`,
|
|
718
|
-
},
|
|
719
|
-
],
|
|
720
|
-
details: makeDetails("single")([]),
|
|
721
|
-
isError: true,
|
|
722
|
-
};
|
|
723
|
-
}
|
|
724
|
-
const pending = startBackground(preserved.agentName, preserved.task, undefined, preserved.vision, {
|
|
725
|
-
sessionId: preserved.sessionId,
|
|
726
|
-
sessionDir: preserved.sessionDir,
|
|
727
|
-
preservedRunId: params.resume,
|
|
728
|
-
});
|
|
729
|
-
if (pending.exitCode !== -1) {
|
|
730
|
-
return {
|
|
731
|
-
content: [{ type: "text", text: getResultOutput(pending) }],
|
|
732
|
-
details: makeDetails("single")([pending]),
|
|
733
|
-
isError: true,
|
|
734
|
-
};
|
|
735
|
-
}
|
|
736
|
-
return {
|
|
737
|
-
content: [
|
|
738
|
-
{
|
|
739
|
-
type: "text",
|
|
740
|
-
text: `Resuming ${preserved.agentName} (run #${params.resume}) in the background on its configured model, picking up its preserved context. Its result will automatically resume the main agent when ready.`,
|
|
741
|
-
},
|
|
742
|
-
],
|
|
743
|
-
details: makeDetails("single", true)([pending]),
|
|
744
|
-
terminate: true,
|
|
745
|
-
};
|
|
746
|
-
}
|
|
747
|
-
|
|
748
1756
|
// Sub-agents intentionally detach from the foreground turn. This makes the
|
|
749
1757
|
// editor available immediately; completion messages later wake the main agent.
|
|
750
1758
|
if (params.tasks && params.tasks.length > 0) {
|
|
@@ -760,32 +1768,53 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
760
1768
|
};
|
|
761
1769
|
}
|
|
762
1770
|
|
|
763
|
-
const results =
|
|
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
|
+
}
|
|
764
1783
|
const started = results.filter((result) => result.exitCode === -1).length;
|
|
765
|
-
const
|
|
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");
|
|
766
1802
|
return {
|
|
767
|
-
content: [
|
|
768
|
-
{
|
|
769
|
-
type: "text",
|
|
770
|
-
text:
|
|
771
|
-
started > 0
|
|
772
|
-
? `Started ${started} background subagent${started === 1 ? "" : "s"}. Results will automatically resume the main agent when ready.`
|
|
773
|
-
: failures.map((result) => getResultOutput(result)).join("\n"),
|
|
774
|
-
},
|
|
775
|
-
],
|
|
1803
|
+
content: [{ type: "text", text }],
|
|
776
1804
|
details: makeDetails("parallel", true)(results),
|
|
777
|
-
isError: failures.length > 0,
|
|
778
1805
|
terminate: true,
|
|
779
1806
|
};
|
|
780
1807
|
}
|
|
781
1808
|
|
|
782
|
-
const result = startBackground(
|
|
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
|
+
);
|
|
783
1816
|
if (result.exitCode !== -1) {
|
|
784
|
-
|
|
785
|
-
content: [{ type: "text", text: getResultOutput(result) }],
|
|
786
|
-
details: makeDetails("single")([result]),
|
|
787
|
-
isError: true,
|
|
788
|
-
};
|
|
1817
|
+
throw new Error(getResultOutput(result));
|
|
789
1818
|
}
|
|
790
1819
|
return {
|
|
791
1820
|
content: [{ type: "text", text: `Started ${result.agent} in the background. Its result will automatically resume the main agent when ready.` }],
|
|
@@ -800,15 +1829,17 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
800
1829
|
let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
|
|
801
1830
|
for (const t of args.tasks.slice(0, 4)) {
|
|
802
1831
|
const preview = formatTaskSummary(t.task, 48);
|
|
803
|
-
|
|
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)}`;
|
|
804
1834
|
}
|
|
805
1835
|
if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
|
|
806
1836
|
return new Text(text, 0, 0);
|
|
807
1837
|
}
|
|
808
1838
|
const task: string = args.task ?? "";
|
|
809
1839
|
const preview = formatTaskSummary(task, 60);
|
|
1840
|
+
const isolation = args.isolation === "worktree" ? " [worktree]" : "";
|
|
810
1841
|
return new Text(
|
|
811
|
-
`${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)}`,
|
|
812
1843
|
0,
|
|
813
1844
|
0,
|
|
814
1845
|
);
|
|
@@ -823,8 +1854,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
823
1854
|
const pending = r.exitCode === -1;
|
|
824
1855
|
const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
|
|
825
1856
|
const usage = formatUsage(r.usage);
|
|
826
|
-
const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (
|
|
827
|
-
const
|
|
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}` : ""}`)}`;
|
|
828
1860
|
return new Text(line, 0, 0);
|
|
829
1861
|
}
|
|
830
1862
|
|
|
@@ -836,8 +1868,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
836
1868
|
const pending = r.exitCode === -1;
|
|
837
1869
|
const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
|
|
838
1870
|
const usage = formatUsage(r.usage);
|
|
839
|
-
const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (
|
|
840
|
-
|
|
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}` : ""}`)}`);
|
|
841
1874
|
}
|
|
842
1875
|
return new Text(lines.join("\n"), 0, 0);
|
|
843
1876
|
},
|