@cr1ms0n/pi-subagent 0.8.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,312 +1,303 @@
1
- import * as fs from "node:fs/promises";
2
- import * as path from "node:path";
3
- import { defaultConfig } from "./config.js";
4
- import { filterContextToolsForModel, type ContextManagementPolicy } from "./context-policy.js";
5
- import { createGetPiCommand } from "./launch.js";
6
- import type { ProcessLockManager } from "./process-lock.js";
7
- import { ChildRunner, type GetPiCommand } from "./runner.js";
8
- import { Semaphore } from "./semaphore.js";
9
- import type { RunMode, RunState, TaskResult, TaskSpec } from "./types.js";
10
- import { addUsage } from "./usage.js";
11
- import { WorktreeManager, type WorktreeHandle } from "./worktree.js";
12
-
13
- export interface OrchestratorDeps {
14
- semaphore?: Semaphore;
15
- getPiCommand?: GetPiCommand;
16
- sessionDir?: string;
17
- worktrees?: WorktreeManager;
18
- killGraceMs?: number;
19
- locks?: ProcessLockManager;
20
- runId?: string;
21
- parentSessionKey?: string;
22
- onTaskProgress?: (index: number, partial: Partial<TaskResult>) => void;
23
- /** Exposes each task's live runner (for mid-run steering). Re-fires per retry attempt. */
24
- onRunnerCreated?: (index: number, runner: ChildRunner) => void;
25
- /** Wrap-up grace turns after budget breach (per-spec graceTurns overrides). */
26
- graceTurns?: number;
27
- /** Stall watchdog windows (0 disables). */
28
- stallAfterMs?: number;
29
- stallKillAfterMs?: number;
30
- /** Default extra attempts on transient failures (per-spec maxRetries overrides). */
31
- maxRetries?: number;
32
- }
33
-
34
- /** Internal-only capability snapshot; deliberately absent from the stable SDK type. */
35
- interface ContextOrchestratorDeps {
36
- /** Remote Context allowlist captured once for the parent dispatch. */
37
- contextPolicy?: ContextManagementPolicy;
38
- /** Parent-exposed context-manager tool names in canonical order. */
39
- parentContextTools?: readonly string[];
40
- }
41
-
42
- type RunTasksOptions = OrchestratorDeps & { signal?: AbortSignal };
43
- type InternalRunTasksOptions = RunTasksOptions & ContextOrchestratorDeps;
44
-
45
- /** Internal projection used by the stable public entry and offline checks. */
46
- export function stripContextOrchestratorOptions(options: RunTasksOptions): InternalRunTasksOptions {
47
- return {
48
- ...options,
49
- contextPolicy: undefined,
50
- parentContextTools: undefined,
51
- };
52
- }
53
-
54
- /** Resolve the actual tool list that one retry attempt will receive. */
55
- export function toolsForAttempt(
56
- spec: Pick<TaskSpec, "backend" | "tools">,
57
- model: string | undefined,
58
- options: ContextOrchestratorDeps,
59
- ): string[] | undefined {
60
- return filterContextToolsForModel(spec.tools, {
61
- backend: spec.backend ?? "pi",
62
- model,
63
- policy: options.contextPolicy,
64
- parentExposed: options.parentContextTools,
65
- });
66
- }
67
-
68
- /**
69
- * Transient failures are infrastructure problems, not task problems: the same
70
- * spec is safe to retry without duplicating side effects because no meaningful
71
- * work happened (queued timeout) or the child died from environment causes
72
- * (stall, provider error, spawn error, unexpected signal).
73
- *
74
- * Never retried: real task failures (nonzero exit with complete protocol),
75
- * cancellations, budget stops, and running timeouts (work may be half-done).
76
- */
77
- export function isTransientFailure(result: TaskResult): boolean {
78
- if (result.state === "timeout" && result.timeoutPhase === "queued") return true;
79
- if (result.stopReason === "stalled") return true;
80
- if (result.stopReason === "spawn_error") return true;
81
- // Provider/stream errors: stopReason "error" comes from provider-reported
82
- // failure or the fatal RPC path; both are retry-with-fallback candidates.
83
- if (result.state === "failed" && ["error", "protocol_error", "unexpected_signal"].includes(result.stopReason ?? "")) return true;
84
- return false;
85
- }
86
-
87
- export interface OrchestratedRun {
88
- mode: RunMode;
89
- results: TaskResult[];
90
- state: RunState;
91
- summary: string;
92
- }
93
-
94
- function aggregateState(results: TaskResult[]): RunState {
95
- if (results.every((r) => r.state === "completed")) return "completed";
96
- // Budget-stopped / truncated tasks ("partial") carry useful output.
97
- if (results.some((r) => r.state === "completed" || r.state === "partial")) return "partial";
98
- if (results.every((r) => r.state === "cancelled")) return "cancelled";
99
- if (results.every((r) => r.state === "timeout" || r.state === "cancelled")) return "timeout";
100
- if (results.some((r) => r.state === "timeout") && results.every((r) => ["timeout", "cancelled", "failed"].includes(r.state))) {
101
- return results.every((r) => r.state === "timeout") ? "timeout" : "failed";
102
- }
103
- return "failed";
104
- }
105
-
106
- function summarize(results: TaskResult[]): string {
107
- return results.map((r) => {
108
- const body = r.outputMode === "file-only"
109
- ? r.outputFile ? `Output written to ${r.outputFile}` : "No output artifact"
110
- : r.liveText || r.errorMessage || r.stderr || "(no output)";
111
- return `[${r.label}] ${r.state}\n${body}`;
112
- }).join("\n\n");
113
- }
114
-
115
- async function writeArtifact(spec: TaskSpec, result: TaskResult): Promise<void> {
116
- if (!spec.output) return;
117
- const text = result.liveText || result.errorMessage || result.stderr || "(no output)";
118
- await fs.mkdir(path.dirname(spec.output), { recursive: true });
119
- await fs.writeFile(spec.output, text, "utf8");
120
- result.outputFile = spec.output;
121
- result.outputMode = spec.outputMode;
122
- }
123
-
124
- export async function runTasks(
125
- specs: TaskSpec[],
126
- options: RunTasksOptions = {},
127
- ): Promise<OrchestratedRun> {
128
- // Deliberately erase any runtime-only fields supplied by JavaScript callers
129
- // or `as any` casts. Only the extension-only entry below may carry the
130
- // operator-owned context snapshot into the attempt loop.
131
- return runTasksInternal(specs, stripContextOrchestratorOptions(options));
132
- }
133
-
134
- /**
135
- * Extension-only orchestration entry that carries the trusted, per-dispatch
136
- * Remote Context snapshot. It is intentionally not re-exported from src/index.ts;
137
- * stable SDK callers cannot forge operator-owned context authorization.
138
- */
139
- export async function runTasksWithContextPolicy(
140
- specs: TaskSpec[],
141
- options: InternalRunTasksOptions,
142
- ): Promise<OrchestratedRun> {
143
- return runTasksInternal(specs, options);
144
- }
145
-
146
- async function runTasksInternal(
147
- specs: TaskSpec[],
148
- options: InternalRunTasksOptions,
149
- ): Promise<OrchestratedRun> {
150
- const semaphore = options.semaphore ?? new Semaphore(defaultConfig.maxActiveProcesses, defaultConfig.maxQueuedTasks);
151
- const worktrees = options.worktrees ?? new WorktreeManager();
152
- const handles: Array<WorktreeHandle | undefined> = new Array(specs.length);
153
- const prepared: TaskSpec[] = [];
154
-
155
- try {
156
- for (let index = 0; index < specs.length; index++) {
157
- if (options.signal?.aborted) throw new Error("Subagent run cancelled before worktree setup");
158
- const spec = { ...specs[index]! };
159
- if (spec.isolation === "worktree") {
160
- const handle = await worktrees.create(spec.cwd || process.cwd(), spec.task.slice(0, 20), options.signal, {
161
- includeWip: spec.includeWip === true,
162
- });
163
- handles[index] = handle;
164
- spec.cwd = handle.cwd;
165
- // Announce the worktree immediately so live runs can shield it from GC sweeps.
166
- options.onTaskProgress?.(index, {
167
- worktree: { cwd: handle.cwd, branch: handle.branch, baseCommit: handle.baseCommit, changed: false },
168
- });
169
- }
170
- prepared.push(spec);
171
- }
172
- } catch (error) {
173
- // Setup failure: remove only unchanged worktrees. Preserve modified ones.
174
- for (const handle of handles.filter((h): h is WorktreeHandle => !!h)) {
175
- await worktrees.finalize(handle).catch(() => {});
176
- }
177
- if (options.signal?.aborted) {
178
- const results = specs.map<TaskResult>((spec, index) => ({
179
- label: `task-${index + 1}`,
180
- task: spec.task,
181
- model: spec.model,
182
- state: "cancelled",
183
- exitCode: 1,
184
- messages: [],
185
- stderr: "",
186
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0, contextTokens: 0, turns: 0 },
187
- stopReason: "cancelled",
188
- errorMessage: error instanceof Error ? error.message : String(error),
189
- thinking: spec.thinking,
190
- profile: spec.profile,
191
- canWrite: spec.canWrite,
192
- outputFile: spec.output,
193
- outputMode: spec.outputMode,
194
- protocol: { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
195
- }));
196
- return { mode: specs.length > 1 ? "parallel" : "single", results, state: "cancelled", summary: "Subagent run cancelled during setup" };
197
- }
198
- throw error;
199
- }
200
-
201
- const runOne = async (index: number): Promise<TaskResult> => {
202
- const spec = prepared[index]!;
203
- // Per-task durable id stays unique under a multi-task run by appending index.
204
- const taskRunId = options.runId
205
- ? (specs.length > 1 ? `${options.runId}:${index}` : options.runId)
206
- : undefined;
207
-
208
- // Retry with model fallback on transient failures. Attempt N uses the
209
- // N-1th fallback model (attempt 1 = primary). Usage accumulates across
210
- // attempts so the cost ledger reflects everything billed.
211
- const fallbacks = spec.fallbackModels ?? [];
212
- const maxRetries = spec.maxRetries ?? options.maxRetries ?? defaultConfig.maxRetries;
213
- // Providing fallback models implies wanting them all tried; otherwise
214
- // maxRetries bounds same-model retries.
215
- const maxAttempts = 1 + Math.max(maxRetries, fallbacks.length);
216
- const attemptedModels: string[] = [];
217
- let priorUsage: ReturnType<typeof addUsage> | undefined;
218
- let result!: TaskResult;
219
-
220
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
221
- const model = attempt === 1 ? spec.model : (fallbacks[attempt - 2] ?? spec.model);
222
- if (model) attemptedModels.push(model);
223
- const attemptSpec: TaskSpec = {
224
- ...spec,
225
- model,
226
- // Retry safety: a fallback model may differ in Remote Context
227
- // eligibility, so context tools are re-derived per attempt from the
228
- // original validated list. Without a dispatch snapshot this removes
229
- // them entirely (fail closed) instead of leaking the primary's.
230
- tools: toolsForAttempt(spec, model, options),
231
- };
232
- const runner = new ChildRunner(
233
- semaphore,
234
- options.getPiCommand ?? createGetPiCommand(),
235
- options.sessionDir,
236
- (partial) => options.onTaskProgress?.(index, {
237
- ...partial,
238
- attempts: attempt > 1 ? attempt : undefined,
239
- usage: partial.usage && priorUsage ? addUsage(priorUsage, partial.usage) : partial.usage,
240
- }),
241
- options.killGraceMs,
242
- options.locks,
243
- taskRunId,
244
- options.parentSessionKey,
245
- undefined,
246
- { graceTurns: options.graceTurns, stallAfterMs: options.stallAfterMs, stallKillAfterMs: options.stallKillAfterMs },
247
- );
248
- options.onRunnerCreated?.(index, runner);
249
- result = await runner.run(attemptSpec, options.signal);
250
- if (priorUsage) result.usage = addUsage(priorUsage, result.usage);
251
-
252
- const canRetry = attempt < maxAttempts && !options.signal?.aborted && isTransientFailure(result);
253
- if (!canRetry) break;
254
- priorUsage = result.usage;
255
- const nextModel = fallbacks[attempt - 1];
256
- options.onTaskProgress?.(index, {
257
- state: "queued",
258
- model: nextModel ?? spec.model,
259
- attempts: attempt + 1,
260
- liveText: `Attempt ${attempt} ${result.stopReason ?? result.state}; retrying${nextModel ? ` on ${nextModel}` : ""}…`,
261
- });
262
- }
263
-
264
- if (attemptedModels.length > 1) {
265
- result.attempts = attemptedModels.length;
266
- result.attemptedModels = attemptedModels;
267
- if (result.errorMessage && (result.state === "failed" || result.state === "timeout")) {
268
- result.errorMessage += ` (after ${attemptedModels.length} attempts: ${attemptedModels.join(" → ")})`;
269
- }
270
- }
271
- result.index = index;
272
- result.label = spec.label || `task-${index + 1}`;
273
- result.outputMode = spec.outputMode;
274
-
275
- try {
276
- await writeArtifact(spec, result);
277
- } catch (error: any) {
278
- result.errorMessage = `${result.errorMessage ? `${result.errorMessage}; ` : ""}Artifact write failed: ${error?.message ?? error}`;
279
- if (result.state === "completed") result.state = "partial";
280
- }
281
-
282
- const handle = handles[index];
283
- if (handle) {
284
- try {
285
- // Finalize even on cancellation: it either preserves changed work or
286
- // removes an unchanged worktree, and both are quick local git calls.
287
- const final = await worktrees.finalize(handle);
288
- if (final.changed) {
289
- result.worktree = {
290
- cwd: final.cwd,
291
- branch: final.branch,
292
- baseCommit: final.baseCommit,
293
- changed: true,
294
- diffSummary: final.diffSummary,
295
- };
296
- }
297
- } catch (error: any) {
298
- result.errorMessage = `${result.errorMessage ? `${result.errorMessage}; ` : ""}Worktree finalization failed: ${error?.message ?? error}`;
299
- if (result.state === "completed") result.state = "partial";
300
- }
301
- }
302
- return result;
303
- };
304
-
305
- const results = await Promise.all(prepared.map((_, index) => runOne(index)));
306
- return {
307
- mode: results.length > 1 ? "parallel" : "single",
308
- results,
309
- state: aggregateState(results),
310
- summary: summarize(results),
311
- };
312
- }
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { defaultConfig } from "./config.js";
4
+ import { createGetPiCommand } from "./launch.js";
5
+ import type { ProcessLockManager } from "./process-lock.js";
6
+ import { ChildRunner, type GetPiCommand } from "./runner.js";
7
+ import { Semaphore } from "./semaphore.js";
8
+ import type { RunMode, RunState, TaskResult, TaskSpec } from "./types.js";
9
+ import { addUsage } from "./usage.js";
10
+ import { WorktreeManager, type WorktreeHandle } from "./worktree.js";
11
+
12
+ export interface OrchestratorDeps {
13
+ semaphore?: Semaphore;
14
+ getPiCommand?: GetPiCommand;
15
+ sessionDir?: string;
16
+ worktrees?: WorktreeManager;
17
+ killGraceMs?: number;
18
+ locks?: ProcessLockManager;
19
+ runId?: string;
20
+ parentSessionKey?: string;
21
+ onTaskProgress?: (index: number, partial: Partial<TaskResult>) => void;
22
+ /** Exposes each task's live runner (for mid-run steering). Re-fires per retry attempt. */
23
+ onRunnerCreated?: (index: number, runner: ChildRunner) => void;
24
+ /** Wrap-up grace turns after budget breach (per-spec graceTurns overrides). */
25
+ graceTurns?: number;
26
+ /** Stall watchdog windows (0 disables). */
27
+ stallAfterMs?: number;
28
+ stallKillAfterMs?: number;
29
+ /** Default extra attempts on transient failures (per-spec maxRetries overrides). */
30
+ maxRetries?: number;
31
+ }
32
+
33
+ /**
34
+ * Transient failures are infrastructure problems, not task problems: the same
35
+ * spec is safe to retry without duplicating side effects because no meaningful
36
+ * work happened (queued timeout) or the child died from environment causes
37
+ * (stall, provider error, spawn error, unexpected signal).
38
+ *
39
+ * Never retried: real task failures (nonzero exit with complete protocol),
40
+ * cancellations, budget stops, and running timeouts (work may be half-done).
41
+ */
42
+ export function isTransientFailure(result: TaskResult): boolean {
43
+ if (result.state === "timeout" && result.timeoutPhase === "queued") return true;
44
+ if (result.stopReason === "stalled") return true;
45
+ if (result.stopReason === "spawn_error") return true;
46
+ // Provider/stream errors: stopReason "error" comes from provider-reported
47
+ // failure or the fatal RPC path; both are retry-with-fallback candidates.
48
+ if (result.state === "failed" && ["error", "protocol_error", "unexpected_signal"].includes(result.stopReason ?? "")) return true;
49
+ return false;
50
+ }
51
+
52
+ export interface OrchestratedRun {
53
+ mode: RunMode;
54
+ results: TaskResult[];
55
+ state: RunState;
56
+ summary: string;
57
+ }
58
+
59
+ function aggregateState(results: TaskResult[]): RunState {
60
+ if (results.every((r) => r.state === "completed")) return "completed";
61
+ // Budget-stopped / truncated tasks ("partial") carry useful output.
62
+ if (results.some((r) => r.state === "completed" || r.state === "partial")) return "partial";
63
+ if (results.every((r) => r.state === "cancelled")) return "cancelled";
64
+ if (results.every((r) => r.state === "timeout" || r.state === "cancelled")) return "timeout";
65
+ if (results.some((r) => r.state === "timeout") && results.every((r) => ["timeout", "cancelled", "failed"].includes(r.state))) {
66
+ return results.every((r) => r.state === "timeout") ? "timeout" : "failed";
67
+ }
68
+ return "failed";
69
+ }
70
+
71
+ function summarize(results: TaskResult[]): string {
72
+ return results.map((r) => {
73
+ const body = r.outputMode === "file-only"
74
+ ? r.outputFile ? `Output written to ${r.outputFile}` : "No output artifact"
75
+ : r.liveText || r.errorMessage || r.stderr || "(no output)";
76
+ return `[${r.label}] ${r.state}\n${body}`;
77
+ }).join("\n\n");
78
+ }
79
+
80
+ async function writeArtifact(spec: TaskSpec, result: TaskResult): Promise<void> {
81
+ if (!spec.output) return;
82
+ const text = result.liveText || result.errorMessage || result.stderr || "(no output)";
83
+ await fs.mkdir(path.dirname(spec.output), { recursive: true });
84
+ await fs.writeFile(spec.output, text, "utf8");
85
+ result.outputFile = spec.output;
86
+ result.outputMode = spec.outputMode;
87
+ }
88
+
89
+ export async function runTasks(
90
+ specs: TaskSpec[],
91
+ options: OrchestratorDeps & { signal?: AbortSignal } = {},
92
+ ): Promise<OrchestratedRun> {
93
+ const semaphore = options.semaphore ?? new Semaphore(defaultConfig.maxActiveProcesses, defaultConfig.maxQueuedTasks);
94
+ const worktrees = options.worktrees ?? new WorktreeManager();
95
+ const handles: Array<WorktreeHandle | undefined> = new Array(specs.length);
96
+ const prepared: TaskSpec[] = [];
97
+ let setupTimedOut = false;
98
+ const progress: Partial<TaskResult>[] = [];
99
+ const checkpoint = (index: number, partial: Partial<TaskResult>) => {
100
+ progress[index] = { ...progress[index], ...partial };
101
+ options.onTaskProgress?.(index, partial);
102
+ };
103
+
104
+ try {
105
+ for (let index = 0; index < specs.length; index++) {
106
+ if (options.signal?.aborted) throw new Error("Subagent run cancelled before worktree setup");
107
+ const spec = { ...specs[index]! };
108
+ const setupController = new AbortController();
109
+ const setupSignal = options.signal ? AbortSignal.any([options.signal, setupController.signal]) : setupController.signal;
110
+ let setupTimer: NodeJS.Timeout | undefined;
111
+ if (spec.deadline !== undefined) {
112
+ const remaining = spec.deadline - Date.now();
113
+ if (remaining <= 0) {
114
+ setupTimedOut = true;
115
+ throw new Error("Subagent deadline expired before setup");
116
+ }
117
+ setupTimer = setTimeout(() => { setupTimedOut = true; setupController.abort(); }, remaining);
118
+ setupTimer.unref?.();
119
+ }
120
+ try {
121
+ if (spec.isolation === "worktree") {
122
+ const handle = await worktrees.create(spec.cwd || process.cwd(), spec.task.slice(0, 20), setupSignal, {
123
+ includeWip: spec.includeWip === true,
124
+ });
125
+ handles[index] = handle;
126
+ spec.cwd = handle.cwd;
127
+ // Announce the worktree immediately so live runs can shield it from GC sweeps.
128
+ checkpoint(index, {
129
+ worktree: { cwd: handle.cwd, branch: handle.branch, baseCommit: handle.baseCommit, changed: false },
130
+ });
131
+ }
132
+ if (setupSignal.aborted) throw new Error("Subagent setup was cancelled or exceeded its task deadline");
133
+ prepared.push(spec);
134
+ } finally { if (setupTimer) clearTimeout(setupTimer); }
135
+ }
136
+ } catch (error) {
137
+ // Keep retained worktree pointers and report cleanup failures per task.
138
+ const setupErrors: Array<string | undefined> = [];
139
+ for (let index = 0; index < handles.length; index++) {
140
+ const handle = handles[index];
141
+ if (!handle) continue;
142
+ try {
143
+ const final = await worktrees.finalize(handle);
144
+ progress[index] = { ...progress[index], worktree: final.changed
145
+ ? { cwd: final.cwd, branch: final.branch, baseCommit: final.baseCommit, changed: true, diffSummary: final.diffSummary }
146
+ : undefined };
147
+ } catch (error) {
148
+ setupErrors[index] = `Worktree finalization failed: ${error instanceof Error ? error.message : String(error)}`;
149
+ // Its state is uncertain; preserve the handle for inspection/recovery.
150
+ progress[index] = { ...progress[index], worktree: { cwd: handle.cwd, branch: handle.branch, baseCommit: handle.baseCommit, changed: true } };
151
+ }
152
+ }
153
+ {
154
+ const setupState: RunState = setupTimedOut ? "timeout" : options.signal?.aborted ? "cancelled" : "failed";
155
+ const results = specs.map<TaskResult>((spec, index) => ({
156
+ ...progress[index],
157
+ index,
158
+ label: spec.label || `task-${index + 1}`,
159
+ task: spec.task,
160
+ model: spec.model,
161
+ routing: spec.routing,
162
+ state: setupState,
163
+ exitCode: 1,
164
+ messages: progress[index]?.messages ?? [],
165
+ stderr: progress[index]?.stderr ?? "",
166
+ usage: progress[index]?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0, contextTokens: 0, turns: 0 },
167
+ stopReason: setupTimedOut ? "timeout" : options.signal?.aborted ? "cancelled" : "setup_error",
168
+ timeoutPhase: setupTimedOut ? "starting" : undefined,
169
+ errorMessage: [error instanceof Error ? error.message : String(error), setupErrors[index]].filter(Boolean).join("; "),
170
+ thinking: spec.thinking,
171
+ profile: spec.profile,
172
+ backend: spec.backend ?? "pi",
173
+ canWrite: spec.canWrite,
174
+ outputFile: spec.output,
175
+ outputMode: spec.outputMode,
176
+ protocol: { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
177
+ }));
178
+ return { mode: specs.length > 1 ? "parallel" : "single", results, state: setupState, summary: summarize(results) };
179
+ }
180
+ }
181
+
182
+ const runOne = async (index: number): Promise<TaskResult> => {
183
+ const spec = prepared[index]!;
184
+ // Per-task durable id stays unique under a multi-task run by appending index.
185
+ const taskRunId = options.runId
186
+ ? (specs.length > 1 ? `${options.runId}:${index}` : options.runId)
187
+ : undefined;
188
+
189
+ // Retry with model fallback on transient failures. Attempt N uses the
190
+ // N-1th fallback model (attempt 1 = primary). Usage accumulates across
191
+ // attempts so the cost ledger reflects everything billed.
192
+ const fallbacks = spec.fallbackModels ?? [];
193
+ const maxRetries = spec.maxRetries ?? options.maxRetries ?? defaultConfig.maxRetries;
194
+ // Providing fallback models implies wanting them all tried; otherwise
195
+ // maxRetries bounds same-model retries.
196
+ const maxAttempts = 1 + Math.max(maxRetries, fallbacks.length);
197
+ const attemptedModels: string[] = [];
198
+ let priorUsage: ReturnType<typeof addUsage> | undefined;
199
+ let result!: TaskResult;
200
+
201
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
202
+ const model = attempt === 1 ? spec.model : (fallbacks[attempt - 2] ?? spec.model);
203
+ if (model) attemptedModels.push(model);
204
+ const attemptSpec: TaskSpec = { ...spec, model };
205
+ const runner = new ChildRunner(
206
+ semaphore,
207
+ options.getPiCommand ?? createGetPiCommand(),
208
+ options.sessionDir,
209
+ (partial) => checkpoint(index, {
210
+ ...partial,
211
+ attempts: attempt > 1 ? attempt : undefined,
212
+ usage: partial.usage && priorUsage ? addUsage(priorUsage, partial.usage) : partial.usage,
213
+ }),
214
+ options.killGraceMs,
215
+ options.locks,
216
+ taskRunId,
217
+ options.parentSessionKey,
218
+ undefined,
219
+ { graceTurns: options.graceTurns, stallAfterMs: options.stallAfterMs, stallKillAfterMs: options.stallKillAfterMs },
220
+ );
221
+ options.onRunnerCreated?.(index, runner);
222
+ result = await runner.run(attemptSpec, options.signal);
223
+ if (priorUsage) result.usage = addUsage(priorUsage, result.usage);
224
+
225
+ const canRetry = attempt < maxAttempts && !options.signal?.aborted && (spec.deadline === undefined || Date.now() < spec.deadline) && isTransientFailure(result);
226
+ if (!canRetry) break;
227
+ priorUsage = result.usage;
228
+ const nextModel = fallbacks[attempt - 1];
229
+ checkpoint(index, {
230
+ state: "queued",
231
+ model: nextModel ?? spec.model,
232
+ attempts: attempt + 1,
233
+ liveText: `Attempt ${attempt} ${result.stopReason ?? result.state}; retrying${nextModel ? ` on ${nextModel}` : ""}…`,
234
+ });
235
+ }
236
+
237
+ if (attemptedModels.length > 1) {
238
+ result.attempts = attemptedModels.length;
239
+ result.attemptedModels = attemptedModels;
240
+ if (result.errorMessage && (result.state === "failed" || result.state === "timeout")) {
241
+ result.errorMessage += ` (after ${attemptedModels.length} attempts: ${attemptedModels.join(" → ")})`;
242
+ }
243
+ }
244
+ result.routing = spec.routing;
245
+ result.index = index;
246
+ result.label = spec.label || `task-${index + 1}`;
247
+ result.outputMode = spec.outputMode;
248
+
249
+ try {
250
+ await writeArtifact(spec, result);
251
+ } catch (error: any) {
252
+ result.errorMessage = `${result.errorMessage ? `${result.errorMessage}; ` : ""}Artifact write failed: ${error?.message ?? error}`;
253
+ if (result.state === "completed") result.state = "partial";
254
+ }
255
+
256
+ const handle = handles[index];
257
+ if (handle) {
258
+ try {
259
+ // Finalize even on cancellation: it either preserves changed work or
260
+ // removes an unchanged worktree, and both are quick local git calls.
261
+ const final = await worktrees.finalize(handle);
262
+ if (final.changed) {
263
+ result.worktree = {
264
+ cwd: final.cwd,
265
+ branch: final.branch,
266
+ baseCommit: final.baseCommit,
267
+ changed: true,
268
+ diffSummary: final.diffSummary,
269
+ };
270
+ }
271
+ } catch (error: any) {
272
+ result.errorMessage = `${result.errorMessage ? `${result.errorMessage}; ` : ""}Worktree finalization failed: ${error?.message ?? error}`;
273
+ if (result.state === "completed") result.state = "partial";
274
+ }
275
+ }
276
+ return result;
277
+ };
278
+
279
+ // One unexpected task failure must not release run ownership while siblings still
280
+ // execute, or discard their results. Each task settles before the aggregate does.
281
+ const results = await Promise.all(prepared.map(async (spec, index): Promise<TaskResult> => {
282
+ try { return await runOne(index); }
283
+ catch (error) {
284
+ const partial = progress[index];
285
+ return {
286
+ ...partial, index, label: spec.label || `task-${index + 1}`, task: spec.task,
287
+ model: partial?.model ?? spec.model, routing: spec.routing, thinking: spec.thinking,
288
+ profile: spec.profile, backend: spec.backend ?? "pi", canWrite: spec.canWrite,
289
+ outputFile: spec.output, outputMode: spec.outputMode,
290
+ state: "failed", exitCode: 1, messages: partial?.messages ?? [], stderr: partial?.stderr ?? "",
291
+ usage: partial?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
292
+ stopReason: "error", errorMessage: error instanceof Error ? error.message : String(error),
293
+ protocol: partial?.protocol ?? { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
294
+ };
295
+ }
296
+ }));
297
+ return {
298
+ mode: results.length > 1 ? "parallel" : "single",
299
+ results,
300
+ state: aggregateState(results),
301
+ summary: summarize(results),
302
+ };
303
+ }