@bermudi/pi-delegate 0.1.11 → 0.1.13

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.
@@ -0,0 +1,857 @@
1
+ import { execFile } from "node:child_process";
2
+ import * as crypto from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import type { ResolvedTask, TaskIntegration, TaskResult } from "./types.ts";
7
+
8
+ const GIT_TIMEOUT_MS = 5 * 60 * 1000;
9
+ const PROCESS_GRACE_MS = 500;
10
+ let artifactBaseOverrideForTesting: string | undefined;
11
+
12
+ /** @internal Keep tests out of the developer's real ~/.pi directory. */
13
+ export function _setIsolatedArtifactRootForTesting(
14
+ root: string | undefined,
15
+ ): void {
16
+ artifactBaseOverrideForTesting = root;
17
+ }
18
+
19
+ interface CommandResult {
20
+ stdout: string;
21
+ stderr: string;
22
+ }
23
+
24
+ class GitCommandError extends Error {
25
+ constructor(
26
+ message: string,
27
+ readonly stderr: string,
28
+ ) {
29
+ super(message);
30
+ }
31
+ }
32
+
33
+ function gitEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
34
+ const env = { ...process.env, ...extra };
35
+ delete env.GIT_DIR;
36
+ delete env.GIT_WORK_TREE;
37
+ delete env.GIT_COMMON_DIR;
38
+ delete env.GIT_INDEX_FILE;
39
+ return { ...env, ...extra };
40
+ }
41
+
42
+ function run(
43
+ file: string,
44
+ args: string[],
45
+ options: {
46
+ cwd?: string;
47
+ env?: NodeJS.ProcessEnv;
48
+ signal?: AbortSignal;
49
+ input?: string;
50
+ } = {},
51
+ ): Promise<CommandResult> {
52
+ return new Promise((resolve, reject) => {
53
+ const child = execFile(
54
+ file,
55
+ args,
56
+ {
57
+ cwd: options.cwd,
58
+ env: options.env,
59
+ signal: options.signal,
60
+ timeout: GIT_TIMEOUT_MS,
61
+ maxBuffer: 32 * 1024 * 1024,
62
+ },
63
+ (error, stdout, stderr) => {
64
+ if (error) {
65
+ reject(
66
+ new GitCommandError(
67
+ `${file} ${args.join(" ")} failed${stderr.trim() ? `: ${stderr.trim()}` : ""}`,
68
+ stderr,
69
+ ),
70
+ );
71
+ return;
72
+ }
73
+ resolve({ stdout, stderr });
74
+ },
75
+ );
76
+ if (options.input !== undefined) child.stdin?.end(options.input);
77
+ });
78
+ }
79
+
80
+ function git(
81
+ args: string[],
82
+ options: Parameters<typeof run>[2] = {},
83
+ ): Promise<CommandResult> {
84
+ return run("git", args, {
85
+ ...options,
86
+ env: gitEnv(options.env),
87
+ });
88
+ }
89
+
90
+ function isWithin(root: string, candidate: string): boolean {
91
+ const relative = path.relative(root, candidate);
92
+ return (
93
+ relative === "" ||
94
+ (!relative.startsWith(`..${path.sep}`) &&
95
+ relative !== ".." &&
96
+ !path.isAbsolute(relative))
97
+ );
98
+ }
99
+
100
+ async function repositoryRoot(cwd: string): Promise<string> {
101
+ const physicalCwd = await fs.promises.realpath(cwd);
102
+ let root: string;
103
+ try {
104
+ root = (
105
+ await git(["rev-parse", "--show-toplevel"], { cwd: physicalCwd })
106
+ ).stdout.trim();
107
+ } catch (error) {
108
+ throw new Error(
109
+ `workspace "isolated" requires a Git repository: ${error instanceof Error ? error.message : String(error)}`,
110
+ );
111
+ }
112
+ const physicalRoot = await fs.promises.realpath(root);
113
+ if (!isWithin(physicalRoot, physicalCwd)) {
114
+ throw new Error("Could not map the isolated task cwd into its Git root.");
115
+ }
116
+ await git(["rev-parse", "--verify", "HEAD^{commit}"], { cwd: physicalRoot });
117
+ if (fs.existsSync(path.join(physicalRoot, ".gitmodules"))) {
118
+ throw new Error(
119
+ 'workspace "isolated" does not yet support repositories with submodules.',
120
+ );
121
+ }
122
+ return physicalRoot;
123
+ }
124
+
125
+ function privateRef(batchId: string, suffix: string): string {
126
+ return `refs/pi-delegate/batches/${batchId}/${suffix}`;
127
+ }
128
+
129
+ async function snapshotTree(
130
+ root: string,
131
+ baseCommit: string,
132
+ indexPath: string,
133
+ signal?: AbortSignal,
134
+ ): Promise<string> {
135
+ await fs.promises.rm(indexPath, { force: true });
136
+ const env = {
137
+ GIT_INDEX_FILE: indexPath,
138
+ GIT_WORK_TREE: root,
139
+ };
140
+ await git(["read-tree", baseCommit], { cwd: root, env, signal });
141
+ await git(["add", "-A", "--", "."], { cwd: root, env, signal });
142
+ return (await git(["write-tree"], { cwd: root, env, signal })).stdout.trim();
143
+ }
144
+
145
+ async function commitTree(
146
+ root: string,
147
+ tree: string,
148
+ parent: string,
149
+ message: string,
150
+ signal?: AbortSignal,
151
+ ): Promise<string> {
152
+ return (
153
+ await git(["commit-tree", tree, "-p", parent, "-m", message], {
154
+ cwd: root,
155
+ signal,
156
+ env: {
157
+ GIT_AUTHOR_NAME: "Pi Delegate",
158
+ GIT_AUTHOR_EMAIL: "delegate@localhost",
159
+ GIT_COMMITTER_NAME: "Pi Delegate",
160
+ GIT_COMMITTER_EMAIL: "delegate@localhost",
161
+ },
162
+ })
163
+ ).stdout.trim();
164
+ }
165
+
166
+ async function addWorktree(
167
+ root: string,
168
+ destination: string,
169
+ commit: string,
170
+ signal?: AbortSignal,
171
+ ): Promise<void> {
172
+ await fs.promises.mkdir(path.dirname(destination), { recursive: true });
173
+ await git(["worktree", "add", "--force", "--detach", destination, commit], {
174
+ cwd: root,
175
+ signal,
176
+ });
177
+ }
178
+
179
+ async function removeWorktree(
180
+ root: string,
181
+ destination: string,
182
+ ): Promise<boolean> {
183
+ try {
184
+ await git(["worktree", "remove", "--force", destination], { cwd: root });
185
+ return true;
186
+ } catch (error) {
187
+ let stillRegistered = true;
188
+ try {
189
+ const listed = await git(["worktree", "list", "--porcelain", "-z"], {
190
+ cwd: root,
191
+ });
192
+ stillRegistered = listed.stdout
193
+ .split("\0")
194
+ .some(
195
+ (entry) =>
196
+ entry.startsWith("worktree ") &&
197
+ path.resolve(entry.slice("worktree ".length)) ===
198
+ path.resolve(destination),
199
+ );
200
+ } catch {
201
+ // Fail closed: if registration cannot be checked, preserve the files.
202
+ }
203
+ if (!stillRegistered) return true;
204
+ console.error(
205
+ `[delegate] failed to remove isolated worktree '${destination}'`,
206
+ error,
207
+ );
208
+ return false;
209
+ }
210
+ }
211
+
212
+ async function changedFiles(
213
+ root: string,
214
+ from: string,
215
+ to: string,
216
+ ): Promise<string[]> {
217
+ const output = await git(
218
+ ["diff", "--name-only", "-z", "--no-renames", from, to],
219
+ { cwd: root },
220
+ );
221
+ return output.stdout.split("\0").filter(Boolean);
222
+ }
223
+
224
+ async function writePatch(
225
+ root: string,
226
+ from: string,
227
+ to: string,
228
+ destination: string,
229
+ ): Promise<void> {
230
+ const output = await git(
231
+ ["diff", "--binary", "--full-index", "--no-renames", from, to],
232
+ { cwd: root },
233
+ );
234
+ await fs.promises.writeFile(destination, output.stdout, { mode: 0o600 });
235
+ }
236
+
237
+ async function processesIn(root: string): Promise<number[]> {
238
+ if (process.platform !== "linux" || !fs.existsSync("/proc")) return [];
239
+ const pids: number[] = [];
240
+ for (const entry of await fs.promises.readdir("/proc")) {
241
+ if (!/^\d+$/.test(entry)) continue;
242
+ const pid = Number(entry);
243
+ if (pid === process.pid) continue;
244
+ try {
245
+ const cwd = await fs.promises.realpath(path.join("/proc", entry, "cwd"));
246
+ if (isWithin(root, cwd)) pids.push(pid);
247
+ } catch {
248
+ // Processes can exit or become unreadable while /proc is scanned.
249
+ }
250
+ }
251
+ return pids;
252
+ }
253
+
254
+ function signalProcesses(
255
+ pids: readonly number[],
256
+ signal: NodeJS.Signals,
257
+ ): void {
258
+ for (const pid of pids) {
259
+ try {
260
+ process.kill(pid, signal);
261
+ } catch (error) {
262
+ if (!(
263
+ error instanceof Error &&
264
+ "code" in error &&
265
+ (error as NodeJS.ErrnoException).code === "ESRCH"
266
+ )) {
267
+ console.error(
268
+ `[delegate] failed to signal isolated process ${pid}`,
269
+ error,
270
+ );
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ async function stopWorkspaceProcesses(root: string): Promise<void> {
277
+ let pids = await processesIn(root);
278
+ if (!pids.length) return;
279
+ console.error(
280
+ `[delegate] terminating ${pids.length} process(es) left in isolated workspace '${root}'`,
281
+ );
282
+ signalProcesses(pids, "SIGTERM");
283
+ await new Promise((resolve) => setTimeout(resolve, PROCESS_GRACE_MS));
284
+ pids = await processesIn(root);
285
+ signalProcesses(pids, "SIGKILL");
286
+ await new Promise((resolve) => setTimeout(resolve, 50));
287
+ const survivors = await processesIn(root);
288
+ if (survivors.length) {
289
+ throw new Error(
290
+ `Could not quiesce isolated workspace; process(es) ${survivors.join(", ")} remain.`,
291
+ );
292
+ }
293
+ }
294
+
295
+ interface IsolatedGroup {
296
+ sourceRoot: string;
297
+ sourceHead: string;
298
+ artifactRoot: string;
299
+ baselineCommit: string;
300
+ baselineRef: string;
301
+ taskIndexes: number[];
302
+ }
303
+
304
+ interface IsolatedWorker {
305
+ group: IsolatedGroup;
306
+ workerRoot: string;
307
+ cwd: string;
308
+ proposalRef: string;
309
+ patchPath: string;
310
+ }
311
+
312
+ export interface PreparedIsolatedBatch {
313
+ resolved: ResolvedTask[];
314
+ reconcile(results: TaskResult[]): Promise<TaskResult[]>;
315
+ }
316
+
317
+ async function restoreAfterFailedApply(
318
+ sourceRoot: string,
319
+ baselineRoot: string,
320
+ changed: readonly string[],
321
+ recoveryRoot: string,
322
+ ): Promise<void> {
323
+ for (const relative of changed) {
324
+ const source = path.join(sourceRoot, relative);
325
+ const baseline = path.join(baselineRoot, relative);
326
+ const recovery = path.join(recoveryRoot, relative);
327
+ if (
328
+ fs.existsSync(source) ||
329
+ (await fs.promises.lstat(source).catch(() => null))
330
+ ) {
331
+ await fs.promises.mkdir(path.dirname(recovery), { recursive: true });
332
+ await fs.promises.rename(source, recovery);
333
+ }
334
+ const baselineStat = await fs.promises.lstat(baseline).catch(() => null);
335
+ if (baselineStat) {
336
+ await fs.promises.mkdir(path.dirname(source), { recursive: true });
337
+ await fs.promises.cp(baseline, source, {
338
+ recursive: baselineStat.isDirectory(),
339
+ dereference: false,
340
+ preserveTimestamps: true,
341
+ });
342
+ }
343
+ }
344
+ }
345
+
346
+ async function reconcileGroup(
347
+ group: IsolatedGroup,
348
+ workers: Map<number, IsolatedWorker>,
349
+ results: TaskResult[],
350
+ ): Promise<void> {
351
+ let integratedCommit = group.baselineCommit;
352
+ const accepted = new Map<number, string[]>();
353
+ const pristineRoot = path.join(group.artifactRoot, "pristine");
354
+ await addWorktree(group.sourceRoot, pristineRoot, group.baselineCommit);
355
+
356
+ for (const taskIndex of group.taskIndexes) {
357
+ const worker = workers.get(taskIndex)!;
358
+ const result = results[taskIndex]!;
359
+ result.workspace = "isolated";
360
+ result.touchedFiles = result.touchedFiles.map((candidate) => {
361
+ const absolute = path.resolve(worker.cwd, candidate);
362
+ return isWithin(worker.workerRoot, absolute)
363
+ ? path.join(
364
+ group.sourceRoot,
365
+ path.relative(worker.workerRoot, absolute),
366
+ )
367
+ : absolute;
368
+ });
369
+ // Writes inside the worktree did not touch the source concurrently.
370
+ // Preserve only explicitly attributed paths that escaped the worktree.
371
+ result.attributedFiles = (result.attributedFiles ?? [])
372
+ .map((candidate) => path.resolve(worker.cwd, candidate))
373
+ .filter((candidate) => !isWithin(worker.workerRoot, candidate));
374
+ if (result.error) {
375
+ result.integration = {
376
+ status: "discarded",
377
+ proposedFiles: [],
378
+ appliedFiles: [],
379
+ };
380
+ await removeWorktree(group.sourceRoot, worker.workerRoot);
381
+ continue;
382
+ }
383
+
384
+ let proposedFiles: string[] = [];
385
+ try {
386
+ await stopWorkspaceProcesses(worker.workerRoot);
387
+ const proposalTree = await snapshotTree(
388
+ worker.workerRoot,
389
+ group.baselineCommit,
390
+ path.join(group.artifactRoot, `proposal-${taskIndex}.index`),
391
+ );
392
+ const proposalCommit = await commitTree(
393
+ group.sourceRoot,
394
+ proposalTree,
395
+ group.baselineCommit,
396
+ `pi-delegate isolated proposal ${taskIndex + 1}`,
397
+ );
398
+ await git(["update-ref", worker.proposalRef, proposalCommit], {
399
+ cwd: group.sourceRoot,
400
+ });
401
+ await writePatch(
402
+ group.sourceRoot,
403
+ group.baselineCommit,
404
+ proposalCommit,
405
+ worker.patchPath,
406
+ );
407
+ proposedFiles = await changedFiles(
408
+ group.sourceRoot,
409
+ group.baselineCommit,
410
+ proposalCommit,
411
+ );
412
+ await removeWorktree(group.sourceRoot, worker.workerRoot);
413
+
414
+ if (!proposedFiles.length) {
415
+ result.integration = {
416
+ status: "no_changes",
417
+ proposedFiles,
418
+ appliedFiles: [],
419
+ };
420
+ continue;
421
+ }
422
+
423
+ const candidateRoot = path.join(
424
+ group.artifactRoot,
425
+ `candidate-${taskIndex}`,
426
+ );
427
+ await addWorktree(group.sourceRoot, candidateRoot, integratedCommit);
428
+ try {
429
+ await git(["apply", "--3way", "--index", worker.patchPath], {
430
+ cwd: candidateRoot,
431
+ });
432
+ const tree = (
433
+ await git(["write-tree"], { cwd: candidateRoot })
434
+ ).stdout.trim();
435
+ integratedCommit = await commitTree(
436
+ group.sourceRoot,
437
+ tree,
438
+ integratedCommit,
439
+ `pi-delegate integrate proposal ${taskIndex + 1}`,
440
+ );
441
+ accepted.set(taskIndex, proposedFiles);
442
+ result.integration = {
443
+ status: "applied_unverified",
444
+ proposedFiles,
445
+ appliedFiles: proposedFiles,
446
+ };
447
+ await removeWorktree(group.sourceRoot, candidateRoot);
448
+ } catch (error) {
449
+ const reason =
450
+ error instanceof GitCommandError
451
+ ? error.stderr.trim() || error.message
452
+ : error instanceof Error
453
+ ? error.message
454
+ : String(error);
455
+ result.integration = {
456
+ status: "conflict",
457
+ proposedFiles,
458
+ appliedFiles: [],
459
+ conflicts: [{ path: "(proposal)", reason }],
460
+ baselineRef: group.baselineRef,
461
+ proposalRef: worker.proposalRef,
462
+ patchPath: worker.patchPath,
463
+ worktreePath: candidateRoot,
464
+ };
465
+ }
466
+ } catch (error) {
467
+ const proposalExists = await privateRefExists(
468
+ group.sourceRoot,
469
+ worker.proposalRef,
470
+ );
471
+ const patchExists = fs.existsSync(worker.patchPath);
472
+ const worktreeExists = fs.existsSync(worker.workerRoot);
473
+ result.integration = {
474
+ status: "apply_failed",
475
+ proposedFiles,
476
+ appliedFiles: [],
477
+ conflicts: [
478
+ {
479
+ path: "(workspace)",
480
+ reason: error instanceof Error ? error.message : String(error),
481
+ },
482
+ ],
483
+ baselineRef: group.baselineRef,
484
+ ...(proposalExists ? { proposalRef: worker.proposalRef } : {}),
485
+ ...(patchExists ? { patchPath: worker.patchPath } : {}),
486
+ ...(worktreeExists ? { worktreePath: worker.workerRoot } : {}),
487
+ };
488
+ }
489
+ }
490
+
491
+ if (integratedCommit === group.baselineCommit) {
492
+ await removeWorktree(group.sourceRoot, pristineRoot);
493
+ return;
494
+ }
495
+
496
+ const currentTree = await snapshotTree(
497
+ group.sourceRoot,
498
+ group.sourceHead,
499
+ path.join(group.artifactRoot, "revalidate.index"),
500
+ );
501
+ const baselineTree = (
502
+ await git(["rev-parse", `${group.baselineCommit}^{tree}`], {
503
+ cwd: group.sourceRoot,
504
+ })
505
+ ).stdout.trim();
506
+ if (currentTree !== baselineTree) {
507
+ for (const [taskIndex] of accepted) {
508
+ const integration = results[taskIndex]!.integration!;
509
+ const worker = workers.get(taskIndex)!;
510
+ results[taskIndex]!.integration = {
511
+ status: "apply_failed",
512
+ proposedFiles: integration.proposedFiles,
513
+ appliedFiles: [],
514
+ conflicts: [
515
+ {
516
+ path: "(source tree)",
517
+ reason:
518
+ "The source tree changed during isolated execution; no proposal was applied.",
519
+ },
520
+ ],
521
+ baselineRef: group.baselineRef,
522
+ proposalRef: worker.proposalRef,
523
+ patchPath: worker.patchPath,
524
+ };
525
+ }
526
+ await removeWorktree(group.sourceRoot, pristineRoot);
527
+ return;
528
+ }
529
+
530
+ const finalPatch = path.join(group.artifactRoot, "integrated.patch");
531
+ await writePatch(
532
+ group.sourceRoot,
533
+ group.baselineCommit,
534
+ integratedCommit,
535
+ finalPatch,
536
+ );
537
+ const allChanged = await changedFiles(
538
+ group.sourceRoot,
539
+ group.baselineCommit,
540
+ integratedCommit,
541
+ );
542
+ const applyIndex = path.join(group.artifactRoot, "apply.index");
543
+ await fs.promises.rm(applyIndex, { force: true });
544
+ const env = {
545
+ GIT_INDEX_FILE: applyIndex,
546
+ GIT_WORK_TREE: group.sourceRoot,
547
+ };
548
+ try {
549
+ await git(["read-tree", group.baselineCommit], {
550
+ cwd: group.sourceRoot,
551
+ env,
552
+ });
553
+ await git(["update-index", "--refresh"], {
554
+ cwd: group.sourceRoot,
555
+ env,
556
+ });
557
+ await git(["apply", "--binary", "--index", finalPatch], {
558
+ cwd: group.sourceRoot,
559
+ env,
560
+ });
561
+ } catch (error) {
562
+ const recoveryRoot = path.join(group.artifactRoot, "failed-apply-files");
563
+ try {
564
+ await restoreAfterFailedApply(
565
+ group.sourceRoot,
566
+ pristineRoot,
567
+ allChanged,
568
+ recoveryRoot,
569
+ );
570
+ } catch (rollbackError) {
571
+ console.error(
572
+ "[delegate] isolated apply rollback failed; recovery artifacts retained",
573
+ rollbackError,
574
+ );
575
+ }
576
+ for (const [taskIndex] of accepted) {
577
+ const integration = results[taskIndex]!.integration!;
578
+ const worker = workers.get(taskIndex)!;
579
+ results[taskIndex]!.integration = {
580
+ status: "apply_failed",
581
+ proposedFiles: integration.proposedFiles,
582
+ appliedFiles: [],
583
+ conflicts: [
584
+ {
585
+ path: "(source tree)",
586
+ reason: error instanceof Error ? error.message : String(error),
587
+ },
588
+ ],
589
+ baselineRef: group.baselineRef,
590
+ proposalRef: worker.proposalRef,
591
+ patchPath: worker.patchPath,
592
+ worktreePath: pristineRoot,
593
+ };
594
+ }
595
+ return;
596
+ }
597
+
598
+ await removeWorktree(group.sourceRoot, pristineRoot);
599
+ }
600
+
601
+ async function deletePrivateRefs(
602
+ sourceRoot: string,
603
+ refs: readonly string[],
604
+ ): Promise<void> {
605
+ for (const ref of refs) {
606
+ await git(["update-ref", "-d", ref], { cwd: sourceRoot });
607
+ }
608
+ }
609
+
610
+ async function privateRefExists(
611
+ sourceRoot: string,
612
+ ref: string,
613
+ ): Promise<boolean> {
614
+ try {
615
+ await git(["rev-parse", "--verify", `${ref}^{commit}`], {
616
+ cwd: sourceRoot,
617
+ });
618
+ return true;
619
+ } catch {
620
+ return false;
621
+ }
622
+ }
623
+
624
+ async function markGroupReconciliationFailure(
625
+ group: IsolatedGroup,
626
+ workers: Map<number, IsolatedWorker>,
627
+ results: TaskResult[],
628
+ error: unknown,
629
+ ): Promise<void> {
630
+ const reason = error instanceof Error ? error.message : String(error);
631
+ const baselineExists = await privateRefExists(
632
+ group.sourceRoot,
633
+ group.baselineRef,
634
+ );
635
+ const pristineRoot = path.join(group.artifactRoot, "pristine");
636
+
637
+ for (const taskIndex of group.taskIndexes) {
638
+ const result = results[taskIndex]!;
639
+ const current = result.integration;
640
+ if (
641
+ current?.status === "conflict" ||
642
+ current?.status === "apply_failed" ||
643
+ current?.status === "no_changes" ||
644
+ current?.status === "discarded"
645
+ ) {
646
+ continue;
647
+ }
648
+
649
+ const worker = workers.get(taskIndex)!;
650
+ const proposalExists = await privateRefExists(
651
+ group.sourceRoot,
652
+ worker.proposalRef,
653
+ );
654
+ const patchExists = fs.existsSync(worker.patchPath);
655
+ const recoveryWorktree = fs.existsSync(worker.workerRoot)
656
+ ? worker.workerRoot
657
+ : fs.existsSync(pristineRoot)
658
+ ? pristineRoot
659
+ : undefined;
660
+ result.workspace = "isolated";
661
+ result.integration = {
662
+ status: "apply_failed",
663
+ proposedFiles: current?.proposedFiles ?? [],
664
+ appliedFiles: [],
665
+ conflicts: [{ path: "(batch)", reason }],
666
+ ...(baselineExists ? { baselineRef: group.baselineRef } : {}),
667
+ ...(proposalExists ? { proposalRef: worker.proposalRef } : {}),
668
+ ...(patchExists ? { patchPath: worker.patchPath } : {}),
669
+ ...(recoveryWorktree ? { worktreePath: recoveryWorktree } : {}),
670
+ };
671
+ }
672
+ }
673
+
674
+ async function cleanupCompletedGroupRefs(
675
+ group: IsolatedGroup,
676
+ workers: Map<number, IsolatedWorker>,
677
+ results: readonly TaskResult[],
678
+ ): Promise<void> {
679
+ const disposableProposalRefs: string[] = [];
680
+ let retainsRecoveryArtifacts = false;
681
+
682
+ for (const taskIndex of group.taskIndexes) {
683
+ const status = results[taskIndex]?.integration?.status;
684
+ if (status === "conflict" || status === "apply_failed") {
685
+ retainsRecoveryArtifacts = true;
686
+ } else if (
687
+ status === "applied_unverified" ||
688
+ status === "no_changes" ||
689
+ status === "discarded"
690
+ ) {
691
+ disposableProposalRefs.push(workers.get(taskIndex)!.proposalRef);
692
+ }
693
+ }
694
+
695
+ const refs = retainsRecoveryArtifacts
696
+ ? disposableProposalRefs
697
+ : [...disposableProposalRefs, group.baselineRef];
698
+ try {
699
+ await deletePrivateRefs(group.sourceRoot, refs);
700
+ } catch (error) {
701
+ // Integration already completed. Ref cleanup must be visible, but must not
702
+ // turn a successfully applied source change into a reported failure.
703
+ console.error("[delegate] failed to clean completed isolated refs", error);
704
+ }
705
+ }
706
+
707
+ /** Prepare detached worktrees from one synthetic commit per Git root. The
708
+ * user's index and branch are never touched. */
709
+ export async function prepareIsolatedBatch(
710
+ resolved: ResolvedTask[],
711
+ signal?: AbortSignal,
712
+ ): Promise<PreparedIsolatedBatch | undefined> {
713
+ const isolatedIndexes = resolved
714
+ .map((task, index) => (task.workspace === "isolated" ? index : -1))
715
+ .filter((index) => index >= 0);
716
+ if (!isolatedIndexes.length) return undefined;
717
+
718
+ const batchId = crypto.randomUUID();
719
+ const batchArtifactRoot = path.join(
720
+ artifactBaseOverrideForTesting ??
721
+ path.join(os.homedir(), ".pi", "agent", "delegate-isolated"),
722
+ batchId,
723
+ );
724
+ const groupsByRoot = new Map<string, IsolatedGroup>();
725
+ const workers = new Map<number, IsolatedWorker>();
726
+ const translated = [...resolved];
727
+
728
+ try {
729
+ for (const taskIndex of isolatedIndexes) {
730
+ const task = resolved[taskIndex]!;
731
+ const sourceRoot = await repositoryRoot(task.cwd);
732
+ let group = groupsByRoot.get(sourceRoot);
733
+ if (!group) {
734
+ const sourceHead = (
735
+ await git(["rev-parse", "HEAD"], { cwd: sourceRoot, signal })
736
+ ).stdout.trim();
737
+ const artifactRoot = path.join(
738
+ batchArtifactRoot,
739
+ crypto
740
+ .createHash("sha256")
741
+ .update(sourceRoot)
742
+ .digest("hex")
743
+ .slice(0, 12),
744
+ );
745
+ await fs.promises.mkdir(artifactRoot, {
746
+ recursive: true,
747
+ mode: 0o700,
748
+ });
749
+ const baselineTree = await snapshotTree(
750
+ sourceRoot,
751
+ sourceHead,
752
+ path.join(artifactRoot, "baseline.index"),
753
+ signal,
754
+ );
755
+ const baselineCommit = await commitTree(
756
+ sourceRoot,
757
+ baselineTree,
758
+ sourceHead,
759
+ "pi-delegate isolated baseline",
760
+ signal,
761
+ );
762
+ const baselineRef = privateRef(
763
+ batchId,
764
+ `${groupsByRoot.size}/baseline`,
765
+ );
766
+ await git(["update-ref", baselineRef, baselineCommit], {
767
+ cwd: sourceRoot,
768
+ signal,
769
+ });
770
+ group = {
771
+ sourceRoot,
772
+ sourceHead,
773
+ artifactRoot,
774
+ baselineCommit,
775
+ baselineRef,
776
+ taskIndexes: [],
777
+ };
778
+ groupsByRoot.set(sourceRoot, group);
779
+ }
780
+
781
+ const sourceCwd = await fs.promises.realpath(task.cwd);
782
+ const workerRoot = path.join(group.artifactRoot, `worker-${taskIndex}`);
783
+ const cwd = path.join(workerRoot, path.relative(sourceRoot, sourceCwd));
784
+ const proposalRef = privateRef(batchId, `${taskIndex}/proposal`);
785
+ const patchPath = path.join(
786
+ group.artifactRoot,
787
+ `proposal-${taskIndex}.patch`,
788
+ );
789
+ workers.set(taskIndex, {
790
+ group,
791
+ workerRoot,
792
+ cwd,
793
+ proposalRef,
794
+ patchPath,
795
+ });
796
+ await addWorktree(sourceRoot, workerRoot, group.baselineCommit, signal);
797
+ group.taskIndexes.push(taskIndex);
798
+ translated[taskIndex] = { ...task, cwd };
799
+ }
800
+ } catch (error) {
801
+ let worktreeCleanupFailed = false;
802
+ for (const worker of workers.values()) {
803
+ const removed = await removeWorktree(
804
+ worker.group.sourceRoot,
805
+ worker.workerRoot,
806
+ );
807
+ if (!removed) {
808
+ worktreeCleanupFailed = true;
809
+ }
810
+ }
811
+ await Promise.all(
812
+ [...groupsByRoot.values()].map(async (group) => {
813
+ try {
814
+ await deletePrivateRefs(group.sourceRoot, [group.baselineRef]);
815
+ } catch (cleanupError) {
816
+ console.error(
817
+ "[delegate] failed to clean isolated baseline ref after preparation error",
818
+ cleanupError,
819
+ );
820
+ }
821
+ }),
822
+ );
823
+ if (!worktreeCleanupFailed) {
824
+ try {
825
+ await fs.promises.rm(batchArtifactRoot, {
826
+ recursive: true,
827
+ force: true,
828
+ });
829
+ } catch (cleanupError) {
830
+ console.error(
831
+ "[delegate] failed to remove isolated artifacts after preparation error",
832
+ cleanupError,
833
+ );
834
+ }
835
+ }
836
+ throw error;
837
+ }
838
+
839
+ return {
840
+ resolved: translated,
841
+ async reconcile(results: TaskResult[]): Promise<TaskResult[]> {
842
+ for (const group of groupsByRoot.values()) {
843
+ try {
844
+ await reconcileGroup(group, workers, results);
845
+ } catch (error) {
846
+ console.error(
847
+ "[delegate] isolated group reconciliation failed",
848
+ error,
849
+ );
850
+ await markGroupReconciliationFailure(group, workers, results, error);
851
+ }
852
+ await cleanupCompletedGroupRefs(group, workers, results);
853
+ }
854
+ return results;
855
+ },
856
+ };
857
+ }