@cr1ms0n/pi-subagent 0.10.0 → 0.11.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,303 +1,522 @@
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
- }
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 {
6
+ attemptOutputPreview,
7
+ classifyProviderError,
8
+ decideRankedAttempt,
9
+ earlierAttemptOutputNote,
10
+ mergeToolActivity,
11
+ rankedMaxAttempts,
12
+ resolveAttemptActivity,
13
+ trimAttemptPreviews,
14
+ validateAttemptPlan,
15
+ } from "./model-failover.js";
16
+ import type { ProcessLockManager } from "./process-lock.js";
17
+ import { ChildRunner, type GetPiCommand } from "./runner.js";
18
+ import { Semaphore } from "./semaphore.js";
19
+ import type {
20
+ ModelAttemptRecord,
21
+ ModelAttemptSpec,
22
+ RunMode,
23
+ RunState,
24
+ TaskResult,
25
+ TaskSpec,
26
+ ToolActivity,
27
+ UsageStats,
28
+ } from "./types.js";
29
+ import { emptyUsage } from "./types.js";
30
+ import { addUsage } from "./usage.js";
31
+ import { WorktreeManager, type WorktreeHandle } from "./worktree.js";
32
+
33
+ export interface OrchestratorDeps {
34
+ semaphore?: Semaphore;
35
+ getPiCommand?: GetPiCommand;
36
+ sessionDir?: string;
37
+ worktrees?: WorktreeManager;
38
+ killGraceMs?: number;
39
+ locks?: ProcessLockManager;
40
+ runId?: string;
41
+ parentSessionKey?: string;
42
+ onTaskProgress?: (index: number, partial: Partial<TaskResult>) => void;
43
+ /** Exposes each task's live runner (for mid-run steering). Re-fires per retry attempt. */
44
+ onRunnerCreated?: (index: number, runner: ChildRunner) => void;
45
+ /** Wrap-up grace turns after budget breach (per-spec graceTurns overrides). */
46
+ graceTurns?: number;
47
+ /** Stall watchdog windows (0 disables). */
48
+ stallAfterMs?: number;
49
+ stallKillAfterMs?: number;
50
+ /** Default extra attempts on transient failures (per-spec maxRetries overrides). */
51
+ maxRetries?: number;
52
+ }
53
+
54
+ /**
55
+ * Transient failures are infrastructure problems, not task problems: the same
56
+ * spec is safe to retry without duplicating side effects because no meaningful
57
+ * work happened (queued timeout) or the child died from environment causes
58
+ * (stall, provider error, protocol truncation).
59
+ *
60
+ * Never retried: real task failures (nonzero exit with complete protocol),
61
+ * cancellations, budget stops, and running timeouts (work may be half-done).
62
+ *
63
+ * This is the LEGACY unranked SDK predicate. Ranked extension tasks never use
64
+ * it: their availability advancement is decided exclusively by
65
+ * `model-failover.decideRankedAttempt` from settled provider-error evidence.
66
+ */
67
+ export function isTransientFailure(result: TaskResult): boolean {
68
+ if (result.state === "timeout" && result.timeoutPhase === "queued") return true;
69
+ if (result.stopReason === "stalled") return true;
70
+ if (result.stopReason === "spawn_error") return true;
71
+ // Provider/stream errors: stopReason "error" comes from provider-reported
72
+ // failure or the fatal RPC path; both are retry-with-fallback candidates.
73
+ if (result.state === "failed" && ["error", "protocol_error", "unexpected_signal"].includes(result.stopReason ?? "")) return true;
74
+ return false;
75
+ }
76
+
77
+ export interface OrchestratedRun {
78
+ mode: RunMode;
79
+ results: TaskResult[];
80
+ state: RunState;
81
+ summary: string;
82
+ }
83
+
84
+ function aggregateState(results: TaskResult[]): RunState {
85
+ if (results.every((r) => r.state === "completed")) return "completed";
86
+ // Budget-stopped / truncated tasks ("partial") carry useful output.
87
+ if (results.some((r) => r.state === "completed" || r.state === "partial")) return "partial";
88
+ if (results.every((r) => r.state === "cancelled")) return "cancelled";
89
+ if (results.every((r) => r.state === "timeout" || r.state === "cancelled")) return "timeout";
90
+ if (results.some((r) => r.state === "timeout") && results.every((r) => ["timeout", "cancelled", "failed"].includes(r.state))) {
91
+ return results.every((r) => r.state === "timeout") ? "timeout" : "failed";
92
+ }
93
+ return "failed";
94
+ }
95
+
96
+ /** Terminal-failure body with the attributed earlier-attempt preview when the
97
+ * final attempt produced no text of its own (ranked failover retention). */
98
+ function deliveryBody(result: Partial<TaskResult> & { finalOutput?: string }): string {
99
+ const primary = result.liveText || result.finalOutput;
100
+ if (primary) return primary;
101
+ const failure = result.errorMessage || result.stderr || "(no output)";
102
+ const note = earlierAttemptOutputNote(result as Parameters<typeof earlierAttemptOutputNote>[0]);
103
+ return note ? `${failure}\n\n${note}` : failure;
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
+ : deliveryBody(r);
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 = deliveryBody(result);
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: OrchestratorDeps & { signal?: AbortSignal } = {},
127
+ ): Promise<OrchestratedRun> {
128
+ const semaphore = options.semaphore ?? new Semaphore(defaultConfig.maxActiveProcesses, defaultConfig.maxQueuedTasks);
129
+ const worktrees = options.worktrees ?? new WorktreeManager();
130
+ const handles: Array<WorktreeHandle | undefined> = new Array(specs.length);
131
+ const prepared: TaskSpec[] = [];
132
+ let setupTimedOut = false;
133
+ const progress: Partial<TaskResult>[] = [];
134
+ const checkpoint = (index: number, partial: Partial<TaskResult>) => {
135
+ progress[index] = { ...progress[index], ...partial };
136
+ options.onTaskProgress?.(index, partial);
137
+ };
138
+
139
+ try {
140
+ for (let index = 0; index < specs.length; index++) {
141
+ if (options.signal?.aborted) throw new Error("Subagent run cancelled before worktree setup");
142
+ const spec = { ...specs[index]! };
143
+ const setupController = new AbortController();
144
+ const setupSignal = options.signal ? AbortSignal.any([options.signal, setupController.signal]) : setupController.signal;
145
+ let setupTimer: NodeJS.Timeout | undefined;
146
+ if (spec.deadline !== undefined) {
147
+ const remaining = spec.deadline - Date.now();
148
+ if (remaining <= 0) {
149
+ setupTimedOut = true;
150
+ throw new Error("Subagent deadline expired before setup");
151
+ }
152
+ setupTimer = setTimeout(() => { setupTimedOut = true; setupController.abort(); }, remaining);
153
+ setupTimer.unref?.();
154
+ }
155
+ try {
156
+ if (spec.isolation === "worktree") {
157
+ const handle = await worktrees.create(spec.cwd || process.cwd(), spec.task.slice(0, 20), setupSignal, {
158
+ includeWip: spec.includeWip === true,
159
+ });
160
+ handles[index] = handle;
161
+ spec.cwd = handle.cwd;
162
+ // Announce the worktree immediately so live runs can shield it from GC sweeps.
163
+ checkpoint(index, {
164
+ worktree: { cwd: handle.cwd, branch: handle.branch, baseCommit: handle.baseCommit, changed: false },
165
+ });
166
+ }
167
+ if (setupSignal.aborted) throw new Error("Subagent setup was cancelled or exceeded its task deadline");
168
+ prepared.push(spec);
169
+ } finally { if (setupTimer) clearTimeout(setupTimer); }
170
+ }
171
+ } catch (error) {
172
+ // Keep retained worktree pointers and report cleanup failures per task.
173
+ const setupErrors: Array<string | undefined> = [];
174
+ for (let index = 0; index < handles.length; index++) {
175
+ const handle = handles[index];
176
+ if (!handle) continue;
177
+ try {
178
+ const final = await worktrees.finalize(handle);
179
+ progress[index] = { ...progress[index], worktree: final.changed
180
+ ? { cwd: final.cwd, branch: final.branch, baseCommit: final.baseCommit, changed: true, diffSummary: final.diffSummary }
181
+ : undefined };
182
+ } catch (error) {
183
+ setupErrors[index] = `Worktree finalization failed: ${error instanceof Error ? error.message : String(error)}`;
184
+ // Its state is uncertain; preserve the handle for inspection/recovery.
185
+ progress[index] = { ...progress[index], worktree: { cwd: handle.cwd, branch: handle.branch, baseCommit: handle.baseCommit, changed: true } };
186
+ }
187
+ }
188
+ {
189
+ const setupState: RunState = setupTimedOut ? "timeout" : options.signal?.aborted ? "cancelled" : "failed";
190
+ const results = specs.map<TaskResult>((spec, index) => ({
191
+ ...progress[index],
192
+ index,
193
+ label: spec.label || `task-${index + 1}`,
194
+ task: spec.task,
195
+ model: spec.model,
196
+ routing: spec.routing,
197
+ state: setupState,
198
+ exitCode: 1,
199
+ messages: progress[index]?.messages ?? [],
200
+ stderr: progress[index]?.stderr ?? "",
201
+ usage: progress[index]?.usage ?? emptyUsage(),
202
+ stopReason: setupTimedOut ? "timeout" : options.signal?.aborted ? "cancelled" : "setup_error",
203
+ timeoutPhase: setupTimedOut ? "starting" : undefined,
204
+ errorMessage: [error instanceof Error ? error.message : String(error), setupErrors[index]].filter(Boolean).join("; "),
205
+ thinking: spec.thinking,
206
+ profile: spec.profile,
207
+ backend: spec.backend ?? "pi",
208
+ canWrite: spec.canWrite,
209
+ outputFile: spec.output,
210
+ outputMode: spec.outputMode,
211
+ protocol: { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
212
+ }));
213
+ return { mode: specs.length > 1 ? "parallel" : "single", results, state: setupState, summary: summarize(results) };
214
+ }
215
+ }
216
+
217
+ /**
218
+ * A present-but-malformed attempt plan is a fail-closed refusal: no child may
219
+ * launch and the spec is never silently reinterpreted as an unranked legacy
220
+ * task (which would drop the pre-tool evidence guards).
221
+ */
222
+ const malformedPlanResult = (spec: TaskSpec, index: number): TaskResult => ({
223
+ label: spec.label || `task-${index + 1}`,
224
+ task: spec.task,
225
+ index,
226
+ state: "failed",
227
+ exitCode: 1,
228
+ messages: [],
229
+ stderr: "",
230
+ usage: emptyUsage(),
231
+ model: spec.model,
232
+ routing: spec.routing,
233
+ thinking: spec.thinking,
234
+ profile: spec.profile,
235
+ backend: spec.backend ?? "pi",
236
+ canWrite: spec.canWrite,
237
+ outputFile: spec.output,
238
+ outputMode: spec.outputMode,
239
+ stopReason: "invalid_attempt_plan",
240
+ errorMessage: "The ranked model attempt plan is malformed or was not locally finalized; refusing to launch (fail closed). No child was started.",
241
+ toolActivity: "unknown",
242
+ protocol: { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
243
+ });
244
+
245
+ /** One settled ranked attempt → the next pre-tool failover action. */
246
+ const runRankedAttempts = async (
247
+ index: number,
248
+ spec: TaskSpec,
249
+ plan: readonly ModelAttemptSpec[],
250
+ taskRunId: string | undefined,
251
+ ): Promise<TaskResult> => {
252
+ const maxRetries = spec.maxRetries ?? options.maxRetries ?? defaultConfig.maxRetries;
253
+ const maxAttempts = rankedMaxAttempts(maxRetries);
254
+ let candidateIndex = 0;
255
+ let attempt = 0;
256
+ let prior = emptyUsage();
257
+ let stickyActivity: ToolActivity = "none";
258
+ const records: ModelAttemptRecord[] = [];
259
+ let settled: TaskResult | undefined;
260
+
261
+ for (;;) {
262
+ const candidate = plan[candidateIndex]!;
263
+ const attemptSpec: TaskSpec = { ...spec, model: candidate.model, thinking: candidate.thinking, fallbackModels: [] };
264
+ attempt++;
265
+ const beforeAttempt = prior;
266
+ const runner = new ChildRunner(
267
+ semaphore,
268
+ options.getPiCommand ?? createGetPiCommand(),
269
+ options.sessionDir,
270
+ (partial) => checkpoint(index, {
271
+ ...partial,
272
+ attempts: attempt > 1 ? attempt : undefined,
273
+ usage: partial.usage ? addUsage(beforeAttempt, partial.usage) : partial.usage,
274
+ }),
275
+ options.killGraceMs,
276
+ options.locks,
277
+ taskRunId,
278
+ options.parentSessionKey,
279
+ undefined,
280
+ {
281
+ graceTurns: options.graceTurns,
282
+ stallAfterMs: options.stallAfterMs,
283
+ stallKillAfterMs: options.stallKillAfterMs,
284
+ priorUsage: attempt > 1 ? beforeAttempt : undefined,
285
+ deferRunTerminal: true,
286
+ },
287
+ );
288
+ options.onRunnerCreated?.(index, runner);
289
+ // Await the complete child result AND its cleanup: no replacement
290
+ // process may overlap a prior one, and ownership is never released early.
291
+ const result = await runner.run(attemptSpec, options.signal);
292
+ settled = result;
293
+
294
+ const activity = resolveAttemptActivity(result);
295
+ stickyActivity = mergeToolActivity(stickyActivity, activity);
296
+ const category = result.state === "failed" && result.stopReason === "error"
297
+ ? classifyProviderError(result.providerError)
298
+ : null;
299
+ // Sum each attempt's reported usage exactly once. The runner returns only
300
+ // its own attempt usage; this loop alone owns the cumulative figure.
301
+ prior = addUsage(prior, result.usage);
302
+ result.usage = prior;
303
+
304
+ records.push({
305
+ attempt,
306
+ rank: candidateIndex,
307
+ model: result.model ?? candidate.model,
308
+ probability: candidate.probability,
309
+ outcome: result.state,
310
+ ...(result.stopReason === undefined ? {} : { stopReason: result.stopReason }),
311
+ ...(category === null || category === "unknown" ? {} : { failureCategory: category }),
312
+ toolActivity: activity,
313
+ ...(result.sessionId === undefined ? {} : { sessionId: result.sessionId }),
314
+ outputPreview: attemptOutputPreview(result.liveText),
315
+ });
316
+ trimAttemptPreviews(records);
317
+
318
+ const costCeilingReached = spec.maxCost !== undefined && prior.cost >= spec.maxCost;
319
+ const turnCeilingReached = spec.maxTurns !== undefined && prior.turns >= spec.maxTurns;
320
+ const decision = decideRankedAttempt({
321
+ attempt,
322
+ maxAttempts,
323
+ candidateIndex,
324
+ candidateCount: plan.length,
325
+ activity,
326
+ category,
327
+ state: result.state,
328
+ cancelled: options.signal?.aborted === true,
329
+ deadlineExceeded: spec.deadline !== undefined && Date.now() >= spec.deadline,
330
+ infraPreWork: result.preWorkInfraFailure === true,
331
+ costCeilingReached,
332
+ turnCeilingReached,
333
+ });
334
+ if (decision.action === "finish") {
335
+ // A met cumulative ceiling that would otherwise have allowed another
336
+ // launch is reported as the corresponding budget stop while the true
337
+ // terminal failure state/model/session stay preserved.
338
+ if (result.state === "failed" && attempt < maxAttempts && activity === "none" && (costCeilingReached || turnCeilingReached)) {
339
+ const ceiling = costCeilingReached ? "max_cost" : "max_turns";
340
+ result.errorMessage = `${result.errorMessage ?? "Attempt failed"} (further attempts refused: cumulative reported usage reached the ${ceiling} ceiling)`;
341
+ }
342
+ break;
343
+ }
344
+ // Advance the ranking (availability) or keep the candidate (conclusive
345
+ // pre-work infrastructure failure). Both consume the same total budget
346
+ // under the same absolute deadline; cancel/deadline were just rechecked
347
+ // inside the decision, and they gate this replacement launch.
348
+ if (decision.action === "advance") candidateIndex++;
349
+ checkpoint(index, {
350
+ state: "queued",
351
+ model: plan[candidateIndex]!.model,
352
+ attempts: attempt + 1,
353
+ toolActivity: stickyActivity,
354
+ attemptedModels: records.map((record) => record.model),
355
+ modelAttempts: [...records],
356
+ });
357
+ }
358
+
359
+ const result = settled ?? malformedPlanResult(spec, index);
360
+ result.toolActivity = stickyActivity;
361
+ result.modelAttempts = records;
362
+ if (records.length > 1) {
363
+ result.attempts = records.length;
364
+ result.attemptedModels = records.map((record) => record.model);
365
+ if (result.errorMessage && (result.state === "failed" || result.state === "timeout")) {
366
+ result.errorMessage += ` (after ${records.length} attempts: ${records.map((record) => record.model).join(" → ")})`;
367
+ }
368
+ }
369
+ return result;
370
+ };
371
+
372
+ /** Trusted unranked SDK path: original loop, unchanged semantics. */
373
+ const runUnrankedLoop = async (
374
+ index: number,
375
+ spec: TaskSpec,
376
+ taskRunId: string | undefined,
377
+ ): Promise<TaskResult> => {
378
+ // Retry with model fallback on transient failures. Attempt N uses the
379
+ // N-1th fallback model (attempt 1 = primary). Usage accumulates across
380
+ // attempts so the cost ledger reflects everything billed.
381
+ const fallbacks = spec.fallbackModels ?? [];
382
+ const maxRetries = spec.maxRetries ?? options.maxRetries ?? defaultConfig.maxRetries;
383
+ // Providing fallback models implies wanting them all tried; otherwise
384
+ // maxRetries bounds same-model retries.
385
+ const maxAttempts = 1 + Math.max(maxRetries, fallbacks.length);
386
+ const attemptedModels: string[] = [];
387
+ let priorUsage: UsageStats | undefined;
388
+ let result!: TaskResult;
389
+
390
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
391
+ const model = attempt === 1 ? spec.model : (fallbacks[attempt - 2] ?? spec.model);
392
+ if (model) attemptedModels.push(model);
393
+ const attemptSpec: TaskSpec = { ...spec, model };
394
+ const runner = new ChildRunner(
395
+ semaphore,
396
+ options.getPiCommand ?? createGetPiCommand(),
397
+ options.sessionDir,
398
+ (partial) => checkpoint(index, {
399
+ ...partial,
400
+ attempts: attempt > 1 ? attempt : undefined,
401
+ usage: partial.usage && priorUsage ? addUsage(priorUsage, partial.usage) : partial.usage,
402
+ }),
403
+ options.killGraceMs,
404
+ options.locks,
405
+ taskRunId,
406
+ options.parentSessionKey,
407
+ undefined,
408
+ { graceTurns: options.graceTurns, stallAfterMs: options.stallAfterMs, stallKillAfterMs: options.stallKillAfterMs },
409
+ );
410
+ options.onRunnerCreated?.(index, runner);
411
+ result = await runner.run(attemptSpec, options.signal);
412
+ if (priorUsage) result.usage = addUsage(priorUsage, result.usage);
413
+
414
+ const canRetry = attempt < maxAttempts && !options.signal?.aborted && (spec.deadline === undefined || Date.now() < spec.deadline) && isTransientFailure(result);
415
+ if (!canRetry) break;
416
+ priorUsage = result.usage;
417
+ const nextModel = fallbacks[attempt - 1];
418
+ checkpoint(index, {
419
+ state: "queued",
420
+ model: nextModel ?? spec.model,
421
+ attempts: attempt + 1,
422
+ liveText: `Attempt ${attempt} ${result.stopReason ?? result.state}; retrying${nextModel ? ` on ${nextModel}` : ""}…`,
423
+ });
424
+ }
425
+
426
+ if (attemptedModels.length > 1) {
427
+ result.attempts = attemptedModels.length;
428
+ result.attemptedModels = attemptedModels;
429
+ if (result.errorMessage && (result.state === "failed" || result.state === "timeout")) {
430
+ result.errorMessage += ` (after ${attemptedModels.length} attempts: ${attemptedModels.join(" → ")})`;
431
+ }
432
+ }
433
+ return result;
434
+ };
435
+
436
+ const runOne = async (index: number): Promise<TaskResult> => {
437
+ const spec = prepared[index]!;
438
+ // Per-task durable id stays unique under a multi-task run by appending index.
439
+ const taskRunId = options.runId
440
+ ? (specs.length > 1 ? `${options.runId}:${index}` : options.runId)
441
+ : undefined;
442
+
443
+ let finalState: RunState = "failed";
444
+ try {
445
+ let result: TaskResult;
446
+ if (spec.modelAttemptPlan !== undefined) {
447
+ // Ranked extension path. A malformed present plan fails closed; it never
448
+ // falls through to the legacy loop.
449
+ result = validateAttemptPlan(spec.modelAttemptPlan, spec.model)
450
+ ? await runRankedAttempts(index, spec, spec.modelAttemptPlan, taskRunId)
451
+ : malformedPlanResult(spec, index);
452
+ } else {
453
+ result = await runUnrankedLoop(index, spec, taskRunId);
454
+ }
455
+
456
+ result.routing = spec.routing;
457
+ result.index = index;
458
+ result.label = spec.label || `task-${index + 1}`;
459
+ result.outputMode = spec.outputMode;
460
+
461
+ try {
462
+ await writeArtifact(spec, result);
463
+ } catch (error: any) {
464
+ result.errorMessage = `${result.errorMessage ? `${result.errorMessage}; ` : ""}Artifact write failed: ${error?.message ?? error}`;
465
+ if (result.state === "completed") result.state = "partial";
466
+ }
467
+
468
+ const handle = handles[index];
469
+ if (handle) {
470
+ try {
471
+ // Finalize even on cancellation: it either preserves changed work or
472
+ // removes an unchanged worktree, and both are quick local git calls.
473
+ const final = await worktrees.finalize(handle);
474
+ if (final.changed) {
475
+ result.worktree = {
476
+ cwd: final.cwd,
477
+ branch: final.branch,
478
+ baseCommit: final.baseCommit,
479
+ changed: true,
480
+ diffSummary: final.diffSummary,
481
+ };
482
+ }
483
+ } catch (error: any) {
484
+ result.errorMessage = `${result.errorMessage ? `${result.errorMessage}; ` : ""}Worktree finalization failed: ${error?.message ?? error}`;
485
+ if (result.state === "completed") result.state = "partial";
486
+ }
487
+ }
488
+ finalState = result.state;
489
+ return result;
490
+ } finally {
491
+ // Process slots belong to each child; durable task ownership spans the
492
+ // entire ranked chain, artifact writes and worktree finalization.
493
+ if (spec.modelAttemptPlan !== undefined && options.locks && taskRunId) {
494
+ options.locks.markRunTerminal(taskRunId, finalState);
495
+ }
496
+ }
497
+ };
498
+
499
+ // One unexpected task failure must not release run ownership while siblings still
500
+ // execute, or discard their results. Each task settles before the aggregate does.
501
+ const results = await Promise.all(prepared.map(async (spec, index): Promise<TaskResult> => {
502
+ try { return await runOne(index); }
503
+ catch (error) {
504
+ const partial = progress[index];
505
+ return {
506
+ ...partial, index, label: spec.label || `task-${index + 1}`, task: spec.task,
507
+ model: partial?.model ?? spec.model, routing: spec.routing, thinking: spec.thinking,
508
+ profile: spec.profile, backend: spec.backend ?? "pi", canWrite: spec.canWrite,
509
+ outputFile: spec.output, outputMode: spec.outputMode,
510
+ state: "failed", exitCode: 1, messages: partial?.messages ?? [], stderr: partial?.stderr ?? "",
511
+ usage: partial?.usage ?? emptyUsage(), stopReason: "error", errorMessage: error instanceof Error ? error.message : String(error),
512
+ protocol: partial?.protocol ?? { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
513
+ };
514
+ }
515
+ }));
516
+ return {
517
+ mode: results.length > 1 ? "parallel" : "single",
518
+ results,
519
+ state: aggregateState(results),
520
+ summary: summarize(results),
521
+ };
522
+ }