@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.
- package/CHANGELOG.md +8 -1
- package/README.md +11 -101
- package/README.zh-CN.md +42 -132
- package/docs/ARCHITECTURE.md +12 -24
- package/docs/COST-ACCOUNTING.md +6 -7
- package/docs/REFERENCE.md +25 -7
- package/docs/UX.md +8 -12
- package/package.json +1 -1
- package/skills/subagent/SKILL.md +9 -8
- package/src/backend.ts +16 -1
- package/src/extension.ts +33 -15
- package/src/format.ts +98 -3
- package/src/jev-router.ts +56 -16
- package/src/model-failover.ts +445 -0
- package/src/notifications.ts +2 -0
- package/src/orchestrator.ts +522 -303
- package/src/output.ts +9 -4
- package/src/persistence.ts +127 -5
- package/src/policy.ts +26 -3
- package/src/process-lock.ts +16 -0
- package/src/protocol.ts +208 -11
- package/src/registry.ts +33 -7
- package/src/routing-policy.ts +1 -1
- package/src/routing-types.ts +22 -0
- package/src/runner.ts +101 -15
- package/src/schema.ts +3 -3
- package/src/types.ts +88 -1
package/src/orchestrator.ts
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
+
}
|