@cr1ms0n/pi-subagent 0.8.8 → 0.8.9
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 +3 -5
- package/README.md +7 -20
- package/docs/ARCHITECTURE.md +125 -132
- package/docs/SECURITY.md +88 -97
- package/package.json +1 -1
- package/skills/subagent/SKILL.md +113 -121
- package/src/agents.ts +282 -288
- package/src/extension.ts +6 -33
- package/src/orchestrator.ts +247 -312
- package/src/policy.ts +531 -561
- package/src/schema.ts +189 -189
- package/src/context-policy.ts +0 -169
package/src/orchestrator.ts
CHANGED
|
@@ -1,312 +1,247 @@
|
|
|
1
|
-
import * as fs from "node:fs/promises";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
import { defaultConfig } from "./config.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
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
|
+
|
|
98
|
+
try {
|
|
99
|
+
for (let index = 0; index < specs.length; index++) {
|
|
100
|
+
if (options.signal?.aborted) throw new Error("Subagent run cancelled before worktree setup");
|
|
101
|
+
const spec = { ...specs[index]! };
|
|
102
|
+
if (spec.isolation === "worktree") {
|
|
103
|
+
const handle = await worktrees.create(spec.cwd || process.cwd(), spec.task.slice(0, 20), options.signal, {
|
|
104
|
+
includeWip: spec.includeWip === true,
|
|
105
|
+
});
|
|
106
|
+
handles[index] = handle;
|
|
107
|
+
spec.cwd = handle.cwd;
|
|
108
|
+
// Announce the worktree immediately so live runs can shield it from GC sweeps.
|
|
109
|
+
options.onTaskProgress?.(index, {
|
|
110
|
+
worktree: { cwd: handle.cwd, branch: handle.branch, baseCommit: handle.baseCommit, changed: false },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
prepared.push(spec);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// Setup failure: remove only unchanged worktrees. Preserve modified ones.
|
|
117
|
+
for (const handle of handles.filter((h): h is WorktreeHandle => !!h)) {
|
|
118
|
+
await worktrees.finalize(handle).catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
if (options.signal?.aborted) {
|
|
121
|
+
const results = specs.map<TaskResult>((spec, index) => ({
|
|
122
|
+
label: `task-${index + 1}`,
|
|
123
|
+
task: spec.task,
|
|
124
|
+
model: spec.model,
|
|
125
|
+
state: "cancelled",
|
|
126
|
+
exitCode: 1,
|
|
127
|
+
messages: [],
|
|
128
|
+
stderr: "",
|
|
129
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
130
|
+
stopReason: "cancelled",
|
|
131
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
132
|
+
thinking: spec.thinking,
|
|
133
|
+
profile: spec.profile,
|
|
134
|
+
canWrite: spec.canWrite,
|
|
135
|
+
outputFile: spec.output,
|
|
136
|
+
outputMode: spec.outputMode,
|
|
137
|
+
protocol: { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
|
|
138
|
+
}));
|
|
139
|
+
return { mode: specs.length > 1 ? "parallel" : "single", results, state: "cancelled", summary: "Subagent run cancelled during setup" };
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const runOne = async (index: number): Promise<TaskResult> => {
|
|
145
|
+
const spec = prepared[index]!;
|
|
146
|
+
// Per-task durable id stays unique under a multi-task run by appending index.
|
|
147
|
+
const taskRunId = options.runId
|
|
148
|
+
? (specs.length > 1 ? `${options.runId}:${index}` : options.runId)
|
|
149
|
+
: undefined;
|
|
150
|
+
|
|
151
|
+
// Retry with model fallback on transient failures. Attempt N uses the
|
|
152
|
+
// N-1th fallback model (attempt 1 = primary). Usage accumulates across
|
|
153
|
+
// attempts so the cost ledger reflects everything billed.
|
|
154
|
+
const fallbacks = spec.fallbackModels ?? [];
|
|
155
|
+
const maxRetries = spec.maxRetries ?? options.maxRetries ?? defaultConfig.maxRetries;
|
|
156
|
+
// Providing fallback models implies wanting them all tried; otherwise
|
|
157
|
+
// maxRetries bounds same-model retries.
|
|
158
|
+
const maxAttempts = 1 + Math.max(maxRetries, fallbacks.length);
|
|
159
|
+
const attemptedModels: string[] = [];
|
|
160
|
+
let priorUsage: ReturnType<typeof addUsage> | undefined;
|
|
161
|
+
let result!: TaskResult;
|
|
162
|
+
|
|
163
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
164
|
+
const model = attempt === 1 ? spec.model : (fallbacks[attempt - 2] ?? spec.model);
|
|
165
|
+
if (model) attemptedModels.push(model);
|
|
166
|
+
const attemptSpec: TaskSpec = { ...spec, model };
|
|
167
|
+
const runner = new ChildRunner(
|
|
168
|
+
semaphore,
|
|
169
|
+
options.getPiCommand ?? createGetPiCommand(),
|
|
170
|
+
options.sessionDir,
|
|
171
|
+
(partial) => options.onTaskProgress?.(index, {
|
|
172
|
+
...partial,
|
|
173
|
+
attempts: attempt > 1 ? attempt : undefined,
|
|
174
|
+
usage: partial.usage && priorUsage ? addUsage(priorUsage, partial.usage) : partial.usage,
|
|
175
|
+
}),
|
|
176
|
+
options.killGraceMs,
|
|
177
|
+
options.locks,
|
|
178
|
+
taskRunId,
|
|
179
|
+
options.parentSessionKey,
|
|
180
|
+
undefined,
|
|
181
|
+
{ graceTurns: options.graceTurns, stallAfterMs: options.stallAfterMs, stallKillAfterMs: options.stallKillAfterMs },
|
|
182
|
+
);
|
|
183
|
+
options.onRunnerCreated?.(index, runner);
|
|
184
|
+
result = await runner.run(attemptSpec, options.signal);
|
|
185
|
+
if (priorUsage) result.usage = addUsage(priorUsage, result.usage);
|
|
186
|
+
|
|
187
|
+
const canRetry = attempt < maxAttempts && !options.signal?.aborted && isTransientFailure(result);
|
|
188
|
+
if (!canRetry) break;
|
|
189
|
+
priorUsage = result.usage;
|
|
190
|
+
const nextModel = fallbacks[attempt - 1];
|
|
191
|
+
options.onTaskProgress?.(index, {
|
|
192
|
+
state: "queued",
|
|
193
|
+
model: nextModel ?? spec.model,
|
|
194
|
+
attempts: attempt + 1,
|
|
195
|
+
liveText: `Attempt ${attempt} ${result.stopReason ?? result.state}; retrying${nextModel ? ` on ${nextModel}` : ""}…`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (attemptedModels.length > 1) {
|
|
200
|
+
result.attempts = attemptedModels.length;
|
|
201
|
+
result.attemptedModels = attemptedModels;
|
|
202
|
+
if (result.errorMessage && (result.state === "failed" || result.state === "timeout")) {
|
|
203
|
+
result.errorMessage += ` (after ${attemptedModels.length} attempts: ${attemptedModels.join(" → ")})`;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
result.index = index;
|
|
207
|
+
result.label = spec.label || `task-${index + 1}`;
|
|
208
|
+
result.outputMode = spec.outputMode;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await writeArtifact(spec, result);
|
|
212
|
+
} catch (error: any) {
|
|
213
|
+
result.errorMessage = `${result.errorMessage ? `${result.errorMessage}; ` : ""}Artifact write failed: ${error?.message ?? error}`;
|
|
214
|
+
if (result.state === "completed") result.state = "partial";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const handle = handles[index];
|
|
218
|
+
if (handle) {
|
|
219
|
+
try {
|
|
220
|
+
// Finalize even on cancellation: it either preserves changed work or
|
|
221
|
+
// removes an unchanged worktree, and both are quick local git calls.
|
|
222
|
+
const final = await worktrees.finalize(handle);
|
|
223
|
+
if (final.changed) {
|
|
224
|
+
result.worktree = {
|
|
225
|
+
cwd: final.cwd,
|
|
226
|
+
branch: final.branch,
|
|
227
|
+
baseCommit: final.baseCommit,
|
|
228
|
+
changed: true,
|
|
229
|
+
diffSummary: final.diffSummary,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
} catch (error: any) {
|
|
233
|
+
result.errorMessage = `${result.errorMessage ? `${result.errorMessage}; ` : ""}Worktree finalization failed: ${error?.message ?? error}`;
|
|
234
|
+
if (result.state === "completed") result.state = "partial";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return result;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const results = await Promise.all(prepared.map((_, index) => runOne(index)));
|
|
241
|
+
return {
|
|
242
|
+
mode: results.length > 1 ? "parallel" : "single",
|
|
243
|
+
results,
|
|
244
|
+
state: aggregateState(results),
|
|
245
|
+
summary: summarize(results),
|
|
246
|
+
};
|
|
247
|
+
}
|