@ferris1225/pi-subagents 2.0.3 → 2.1.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/src/worktree.ts CHANGED
@@ -1,687 +1,687 @@
1
- /**
2
- * Detached Git worktree isolation for write-capable sub-agents.
3
- *
4
- * A handle is created before a child is queued and stays owned by the logical
5
- * thread across retries, model candidates, retargets, and park/resume. Finalize
6
- * is idempotent: it records a binary patch, applies it to the original working
7
- * tree without touching its index, then removes/prunes the temporary worktree.
8
- * Failed integration deliberately retains both the worktree and patch.
9
- */
10
-
11
- import { spawn, type ChildProcess } from "node:child_process";
12
- import { existsSync } from "node:fs";
13
- import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
14
- import { tmpdir } from "node:os";
15
- import { isAbsolute, join, relative, resolve } from "node:path";
16
-
17
- export type IsolationMode = "shared" | "worktree";
18
-
19
- export interface CommandRunOptions {
20
- cwd: string;
21
- input?: Buffer;
22
- signal?: AbortSignal;
23
- timeoutMs?: number;
24
- maxOutputBytes?: number;
25
- }
26
-
27
- export interface CommandResult {
28
- code: number;
29
- stdout: Buffer;
30
- stderr: Buffer;
31
- }
32
-
33
- /** Injectable, shell-free command runner used by every Git operation. */
34
- export type CommandRunner = (
35
- command: string,
36
- args: readonly string[],
37
- options: CommandRunOptions,
38
- ) => Promise<CommandResult>;
39
-
40
- export const GIT_COMMAND_TIMEOUT_MS = 120_000;
41
- export const GIT_COMMAND_KILL_GRACE_MS = 2_000;
42
- export const GIT_OUTPUT_MAX_BYTES = 64 * 1024 * 1024;
43
- export const WORKTREE_PATCH_MAX_BYTES = GIT_OUTPUT_MAX_BYTES;
44
-
45
- /** Git apply validates and writes in one process, but two apply processes can
46
- * validate the same old bytes concurrently before either writes. Chain applies
47
- * per canonical source worktree so an overlapping later patch conflicts instead
48
- * of silently winning a last-writer race. */
49
- const originalRootApplyTails = new Map<string, Promise<void>>();
50
-
51
- async function withSerializedOriginalRootApply<T>(
52
- originalRoot: string,
53
- operation: () => Promise<T>,
54
- ): Promise<T> {
55
- const key = process.platform === "win32" ? originalRoot.toLowerCase() : originalRoot;
56
- const previous = originalRootApplyTails.get(key) ?? Promise.resolve();
57
- let release!: () => void;
58
- const gate = new Promise<void>((resolveGate) => {
59
- release = resolveGate;
60
- });
61
- const tail = previous.catch(() => undefined).then(() => gate);
62
- originalRootApplyTails.set(key, tail);
63
- await previous.catch(() => undefined);
64
- try {
65
- return await operation();
66
- } finally {
67
- release();
68
- if (originalRootApplyTails.get(key) === tail) originalRootApplyTails.delete(key);
69
- }
70
- }
71
-
72
- function terminateCommandTree(child: ChildProcess, force: boolean, processGroup: boolean): void {
73
- if (process.platform === "win32" && child.pid !== undefined) {
74
- const fallback = (): void => {
75
- try {
76
- child.kill(force ? "SIGKILL" : "SIGTERM");
77
- } catch {
78
- /* process may already be gone */
79
- }
80
- };
81
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
82
- stdio: "ignore",
83
- windowsHide: true,
84
- });
85
- killer.once("error", fallback);
86
- killer.once("close", (code) => {
87
- if (code !== 0) fallback();
88
- });
89
- return;
90
- }
91
- try {
92
- if (processGroup && child.pid !== undefined) {
93
- process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
94
- } else {
95
- child.kill(force ? "SIGKILL" : "SIGTERM");
96
- }
97
- } catch {
98
- /* process may already be gone */
99
- }
100
- }
101
-
102
- /** Default argument-safe runner. Output is bounded before binary patches enter
103
- * memory, and timeout/abort terminates the complete checkout-filter process tree. */
104
- export const runCommand: CommandRunner = (command, args, options) =>
105
- new Promise<CommandResult>((resolveResult, reject) => {
106
- if (options.signal?.aborted) {
107
- reject(new Error(`Command aborted before start: ${command}`));
108
- return;
109
- }
110
- const usePosixProcessGroup = process.platform !== "win32";
111
- const child = spawn(command, [...args], {
112
- cwd: options.cwd,
113
- shell: false,
114
- windowsHide: true,
115
- stdio: ["pipe", "pipe", "pipe"],
116
- detached: usePosixProcessGroup,
117
- });
118
- const stdout: Buffer[] = [];
119
- const stderr: Buffer[] = [];
120
- const maxOutputBytes = options.maxOutputBytes ?? GIT_OUTPUT_MAX_BYTES;
121
- let outputBytes = 0;
122
- let finished = false;
123
- let failure: Error | undefined;
124
- let timeout: ReturnType<typeof setTimeout> | undefined;
125
- let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
126
-
127
- const terminate = (): void => {
128
- terminateCommandTree(child, false, usePosixProcessGroup);
129
- if (!forceKillTimer) {
130
- forceKillTimer = setTimeout(
131
- () => terminateCommandTree(child, true, usePosixProcessGroup),
132
- GIT_COMMAND_KILL_GRACE_MS,
133
- );
134
- if (typeof forceKillTimer.unref === "function") forceKillTimer.unref();
135
- }
136
- };
137
- const fail = (error: Error): void => {
138
- if (failure || finished) return;
139
- failure = error;
140
- terminate();
141
- };
142
- const append = (target: Buffer[], chunk: Buffer | string): void => {
143
- if (failure || finished) return;
144
- const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
145
- outputBytes += value.length;
146
- if (outputBytes > maxOutputBytes) {
147
- fail(new Error(`Command output exceeded ${maxOutputBytes} bytes: ${command}`));
148
- return;
149
- }
150
- target.push(value);
151
- };
152
- const onAbort = (): void => fail(new Error(`Command aborted: ${command}`));
153
- options.signal?.addEventListener("abort", onAbort, { once: true });
154
- if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
155
- timeout = setTimeout(
156
- () => fail(new Error(`Command timed out after ${options.timeoutMs}ms: ${command}`)),
157
- options.timeoutMs,
158
- );
159
- if (typeof timeout.unref === "function") timeout.unref();
160
- }
161
-
162
- child.stdout?.on("data", (chunk: Buffer | string) => append(stdout, chunk));
163
- child.stderr?.on("data", (chunk: Buffer | string) => append(stderr, chunk));
164
- child.once("error", (error) => fail(error));
165
- child.once("close", (code) => {
166
- if (finished) return;
167
- finished = true;
168
- if (timeout) clearTimeout(timeout);
169
- if (forceKillTimer) clearTimeout(forceKillTimer);
170
- options.signal?.removeEventListener("abort", onAbort);
171
- if (failure) {
172
- reject(failure);
173
- return;
174
- }
175
- resolveResult({
176
- code: code ?? 1,
177
- stdout: Buffer.concat(stdout),
178
- stderr: Buffer.concat(stderr),
179
- });
180
- });
181
- child.stdin?.once("error", () => undefined);
182
- child.stdin?.end(options.input);
183
- });
184
-
185
- export interface WorktreeTarget {
186
- /** Canonical cwd requested by the caller. */
187
- originalCwd: string;
188
- /** Canonical top-level directory of the source Git worktree. */
189
- originalRoot: string;
190
- /** Path from originalRoot to originalCwd (empty for the root). */
191
- relativeCwd: string;
192
- head: string;
193
- }
194
-
195
- export interface WorktreeCheckpoint {
196
- /** Commit checked out when this isolated generation began. */
197
- baseHead: string;
198
- /** Synthetic commit whose tree is the generation's complete final state. */
199
- commit: string;
200
- /** Binary delta from baseHead, retained for size checks and diagnostics. */
201
- patch: Buffer;
202
- }
203
-
204
- export interface WorktreeCreateOptions {
205
- runner?: CommandRunner;
206
- /** Test hook; production uses the OS temp directory. */
207
- tempBaseDir?: string;
208
- /** Complete source generation checkpoint merged onto the current HEAD. */
209
- seedCheckpoint?: WorktreeCheckpoint;
210
- /** The seed is already present in the parent checkout, so only later edits
211
- * should be integrated when this continuation settles. */
212
- seedIsIntegrated?: boolean;
213
- }
214
-
215
- export type WorktreeFinalizationStatus = "integrated" | "no_changes" | "retained";
216
-
217
- export interface WorktreeFinalization {
218
- status: WorktreeFinalizationStatus;
219
- /** True once the patch was successfully applied to the original worktree. */
220
- integrated: boolean;
221
- hadChanges: boolean;
222
- worktreePath?: string;
223
- patchPath?: string;
224
- error?: string;
225
- }
226
-
227
- export interface WorktreeIsolation {
228
- readonly originalCwd: string;
229
- readonly originalRoot: string;
230
- readonly cwd: string;
231
- readonly worktreePath: string;
232
- readonly tempDir: string;
233
- readonly patchPath: string;
234
- readonly head: string;
235
- readonly state: "active" | "finalizing" | WorktreeFinalizationStatus;
236
- /** Capture the complete isolated filesystem state for a fresh continuation.
237
- * The synthetic commit lets Git merge an already-committed seed without
238
- * attempting to apply the same patch twice. */
239
- snapshotCheckpoint(): Promise<WorktreeCheckpoint>;
240
- /** Remove a newly-created continuation that failed before it was dispatched. */
241
- discard(): Promise<void>;
242
- /** Idempotent across stale generations and repeated stop/shutdown paths. */
243
- finalize(): Promise<WorktreeFinalization>;
244
- }
245
-
246
- export class WorktreeSetupError extends Error {
247
- constructor(
248
- message: string,
249
- readonly retainedPaths: readonly string[] = [],
250
- ) {
251
- super(message);
252
- this.name = "WorktreeSetupError";
253
- }
254
- }
255
-
256
- const ERROR_OUTPUT_MAX = 8_000;
257
-
258
- function cloneCheckpoint(checkpoint: WorktreeCheckpoint): WorktreeCheckpoint {
259
- return { ...checkpoint, patch: Buffer.from(checkpoint.patch) };
260
- }
261
-
262
- function outputText(result: CommandResult): string {
263
- const text = (result.stderr.length > 0 ? result.stderr : result.stdout).toString("utf8").trim();
264
- if (text.length <= ERROR_OUTPUT_MAX) return text;
265
- return `${text.slice(0, ERROR_OUTPUT_MAX - 1)}…`;
266
- }
267
-
268
- function commandFailure(action: string, result: CommandResult): Error {
269
- const detail = outputText(result);
270
- return new Error(`${action} failed (exit ${result.code})${detail ? `: ${detail}` : "."}`);
271
- }
272
-
273
- async function runGit(
274
- runner: CommandRunner,
275
- cwd: string,
276
- args: readonly string[],
277
- action: string,
278
- input?: Buffer,
279
- ): Promise<CommandResult> {
280
- let result: CommandResult;
281
- try {
282
- result = await runner("git", args, {
283
- cwd,
284
- input,
285
- timeoutMs: GIT_COMMAND_TIMEOUT_MS,
286
- maxOutputBytes: GIT_OUTPUT_MAX_BYTES,
287
- });
288
- } catch (error) {
289
- throw new Error(`${action} failed: ${error instanceof Error ? error.message : String(error)}`);
290
- }
291
- if (result.code !== 0) throw commandFailure(action, result);
292
- return result;
293
- }
294
-
295
- /** True only when candidate is root itself or a descendant (cross-platform). */
296
- export function isPathInside(root: string, candidate: string): boolean {
297
- const rel = relative(root, candidate);
298
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
299
- }
300
-
301
- /** Resolve and validate the Git repository/worktree that contains cwd. */
302
- export async function resolveWorktreeTarget(
303
- cwd: string,
304
- runner: CommandRunner = runCommand,
305
- ): Promise<WorktreeTarget> {
306
- const requested = resolve(cwd);
307
- try {
308
- if (!(await stat(requested)).isDirectory()) throw new Error("not a directory");
309
- } catch (error) {
310
- throw new WorktreeSetupError(
311
- `Worktree isolation requires an existing directory; cwd ${requested} is unavailable (${error instanceof Error ? error.message : String(error)}).`,
312
- );
313
- }
314
- const originalCwd = await realpath(requested);
315
- let topLevel: CommandResult;
316
- try {
317
- topLevel = await runGit(
318
- runner,
319
- originalCwd,
320
- ["rev-parse", "--show-toplevel"],
321
- `Git repository discovery for ${originalCwd}`,
322
- );
323
- } catch (error) {
324
- throw new WorktreeSetupError(
325
- `Worktree isolation requires cwd to be inside a Git worktree/repository: ${error instanceof Error ? error.message : String(error)}`,
326
- );
327
- }
328
- const reportedRoot = topLevel.stdout.toString("utf8").trim();
329
- if (!reportedRoot) {
330
- throw new WorktreeSetupError(`Git repository discovery for ${originalCwd} returned no top-level path.`);
331
- }
332
- const originalRoot = await realpath(resolve(reportedRoot));
333
- if (!isPathInside(originalRoot, originalCwd)) {
334
- throw new WorktreeSetupError(
335
- `Requested cwd ${originalCwd} is not inside Git worktree root ${originalRoot}.`,
336
- );
337
- }
338
- let headResult: CommandResult;
339
- try {
340
- headResult = await runGit(
341
- runner,
342
- originalRoot,
343
- ["rev-parse", "--verify", "HEAD"],
344
- `Resolving HEAD for ${originalRoot}`,
345
- );
346
- } catch (error) {
347
- throw new WorktreeSetupError(
348
- `Worktree isolation requires a repository with a committed HEAD: ${error instanceof Error ? error.message : String(error)}`,
349
- );
350
- }
351
- return {
352
- originalCwd,
353
- originalRoot,
354
- relativeCwd: relative(originalRoot, originalCwd),
355
- head: headResult.stdout.toString("utf8").trim(),
356
- };
357
- }
358
-
359
- class GitWorktreeIsolation implements WorktreeIsolation {
360
- private currentState: WorktreeIsolation["state"] = "active";
361
- private finalization?: Promise<WorktreeFinalization>;
362
- private discardPromise?: Promise<void>;
363
- /** Full workspace checkpoint relative to the generation's starting HEAD,
364
- * retained after cleanup so settled threads can continue safely. */
365
- private continuationCheckpoint?: WorktreeCheckpoint;
366
-
367
- constructor(
368
- readonly originalCwd: string,
369
- readonly originalRoot: string,
370
- readonly cwd: string,
371
- readonly worktreePath: string,
372
- readonly tempDir: string,
373
- readonly patchPath: string,
374
- readonly head: string,
375
- private readonly runner: CommandRunner,
376
- /** May be a synthetic tree commit representing a seed that the parent
377
- * checkout already contains. Finalization then integrates only new edits. */
378
- private readonly integrationBaseHead: string = head,
379
- ) {}
380
-
381
- get state(): WorktreeIsolation["state"] {
382
- return this.currentState;
383
- }
384
-
385
- async snapshotCheckpoint(): Promise<WorktreeCheckpoint> {
386
- if (this.continuationCheckpoint) return cloneCheckpoint(this.continuationCheckpoint);
387
- if (this.currentState === "no_changes") {
388
- return { baseHead: this.head, commit: this.head, patch: Buffer.alloc(0) };
389
- }
390
- if (!existsSync(this.worktreePath)) {
391
- throw new Error(`Cannot snapshot isolated worktree after it was removed: ${this.worktreePath}`);
392
- }
393
- const snapshot = await this.collectChanges(this.head);
394
- this.continuationCheckpoint = await this.createCheckpoint(snapshot.stdout);
395
- return cloneCheckpoint(this.continuationCheckpoint);
396
- }
397
-
398
- discard(): Promise<void> {
399
- if (this.discardPromise) return this.discardPromise;
400
- if (this.finalization) {
401
- return this.finalization.then(() => undefined);
402
- }
403
- this.currentState = "finalizing";
404
- this.discardPromise = this.removeAndPrune().then((error) => {
405
- if (error) {
406
- this.currentState = "retained";
407
- throw new Error(error);
408
- }
409
- this.currentState = "no_changes";
410
- });
411
- return this.discardPromise;
412
- }
413
-
414
- finalize(): Promise<WorktreeFinalization> {
415
- if (this.finalization) return this.finalization;
416
- this.currentState = "finalizing";
417
- this.finalization = this.finalizeOnce().then((result) => {
418
- this.currentState = result.status;
419
- return result;
420
- });
421
- return this.finalization;
422
- }
423
-
424
- private async collectChanges(baseHead: string): Promise<CommandResult> {
425
- await runGit(
426
- this.runner,
427
- this.worktreePath,
428
- ["add", "-N", "--", "."],
429
- `Collecting untracked files in isolated worktree ${this.worktreePath}`,
430
- );
431
- return runGit(
432
- this.runner,
433
- this.worktreePath,
434
- ["diff", "--binary", baseHead, "--"],
435
- `Collecting isolated changes from ${this.worktreePath}`,
436
- );
437
- }
438
-
439
- private async createCheckpoint(patch: Buffer): Promise<WorktreeCheckpoint> {
440
- if (patch.length === 0) {
441
- return { baseHead: this.head, commit: this.head, patch: Buffer.alloc(0) };
442
- }
443
- await runGit(
444
- this.runner,
445
- this.worktreePath,
446
- ["add", "-A", "--", "."],
447
- `Preparing isolated checkpoint in ${this.worktreePath}`,
448
- );
449
- const tree = await runGit(
450
- this.runner,
451
- this.worktreePath,
452
- ["write-tree"],
453
- `Writing isolated checkpoint tree in ${this.worktreePath}`,
454
- );
455
- const treeId = tree.stdout.toString("utf8").trim();
456
- if (!treeId) throw new Error("Git returned no isolated checkpoint tree id.");
457
- const commit = await runGit(
458
- this.runner,
459
- this.worktreePath,
460
- [
461
- "-c", "user.name=pi-subagents",
462
- "-c", "user.email=pi-subagents@example.invalid",
463
- "commit-tree", treeId,
464
- "-p", this.head,
465
- "-m", "pi-subagents isolated checkpoint",
466
- ],
467
- `Creating isolated checkpoint commit in ${this.worktreePath}`,
468
- );
469
- const commitId = commit.stdout.toString("utf8").trim();
470
- if (!commitId) throw new Error("Git returned no isolated checkpoint commit id.");
471
- return { baseHead: this.head, commit: commitId, patch: Buffer.from(patch) };
472
- }
473
-
474
- private async finalizeOnce(): Promise<WorktreeFinalization> {
475
- let hadChanges = false;
476
- let patchWritten = false;
477
- let integrated = false;
478
- try {
479
- const diff = await this.collectChanges(this.integrationBaseHead);
480
- const snapshot = this.integrationBaseHead === this.head
481
- ? diff
482
- : await this.collectChanges(this.head);
483
- this.continuationCheckpoint = await this.createCheckpoint(snapshot.stdout);
484
- hadChanges = diff.stdout.length > 0;
485
- if (hadChanges) {
486
- await writeFile(this.patchPath, diff.stdout, { flag: "wx" });
487
- patchWritten = true;
488
- await withSerializedOriginalRootApply(this.originalRoot, () =>
489
- runGit(
490
- this.runner,
491
- this.originalRoot,
492
- ["apply", "--binary", "--whitespace=nowarn", this.patchPath],
493
- `Applying isolated patch to ${this.originalRoot}`,
494
- ),
495
- );
496
- integrated = true;
497
- }
498
-
499
- const cleanupError = await this.removeAndPrune();
500
- if (cleanupError) {
501
- return this.retainedResult(hadChanges, integrated, patchWritten, cleanupError);
502
- }
503
- return {
504
- status: hadChanges ? "integrated" : "no_changes",
505
- integrated,
506
- hadChanges,
507
- };
508
- } catch (error) {
509
- return this.retainedResult(
510
- hadChanges,
511
- integrated,
512
- patchWritten,
513
- error instanceof Error ? error.message : String(error),
514
- );
515
- }
516
- }
517
-
518
- private retainedResult(
519
- hadChanges: boolean,
520
- integrated: boolean,
521
- patchWritten: boolean,
522
- error: string,
523
- ): WorktreeFinalization {
524
- return {
525
- status: "retained",
526
- integrated,
527
- hadChanges,
528
- ...(existsSync(this.worktreePath) ? { worktreePath: this.worktreePath } : {}),
529
- ...(patchWritten && existsSync(this.patchPath) ? { patchPath: this.patchPath } : {}),
530
- error,
531
- };
532
- }
533
-
534
- /** Return an error string instead of throwing so applied work is never retried. */
535
- private async removeAndPrune(): Promise<string | undefined> {
536
- try {
537
- await runGit(
538
- this.runner,
539
- this.originalRoot,
540
- ["worktree", "remove", "--force", this.worktreePath],
541
- `Removing isolated worktree ${this.worktreePath}`,
542
- );
543
- } catch (error) {
544
- return error instanceof Error ? error.message : String(error);
545
- }
546
- let pruneError: string | undefined;
547
- try {
548
- await runGit(
549
- this.runner,
550
- this.originalRoot,
551
- ["worktree", "prune"],
552
- `Pruning Git worktree metadata for ${this.originalRoot}`,
553
- );
554
- } catch (error) {
555
- pruneError = error instanceof Error ? error.message : String(error);
556
- }
557
- try {
558
- await rm(this.tempDir, { recursive: true, force: true });
559
- } catch (error) {
560
- const rmError = error instanceof Error ? error.message : String(error);
561
- return pruneError ? `${pruneError}; removing temporary directory failed: ${rmError}` : `Removing temporary directory failed: ${rmError}`;
562
- }
563
- return pruneError;
564
- }
565
- }
566
-
567
- /**
568
- * Create a detached worktree at repository HEAD. The returned cwd mirrors the
569
- * caller's subdirectory inside that new worktree.
570
- */
571
- export async function createWorktreeIsolation(
572
- cwd: string,
573
- options: WorktreeCreateOptions = {},
574
- ): Promise<WorktreeIsolation> {
575
- const runner = options.runner ?? runCommand;
576
- const target = await resolveWorktreeTarget(cwd, runner);
577
- const tempBase = options.tempBaseDir ? resolve(options.tempBaseDir) : tmpdir();
578
- await mkdir(tempBase, { recursive: true });
579
- const tempDir = await mkdtemp(join(tempBase, "pi-subagent-worktree-"));
580
- const worktreePath = join(tempDir, "worktree");
581
- const patchPath = join(tempDir, "changes.patch");
582
- let added = false;
583
- try {
584
- await runGit(
585
- runner,
586
- target.originalRoot,
587
- ["worktree", "add", "--detach", worktreePath, "HEAD"],
588
- `Creating detached worktree from ${target.originalRoot}`,
589
- );
590
- added = true;
591
- const isolatedCwd = target.relativeCwd ? join(worktreePath, target.relativeCwd) : worktreePath;
592
- // A requested subdirectory can be untracked/empty in the source worktree;
593
- // recreate the directory so the child still starts at the equivalent path.
594
- await mkdir(isolatedCwd, { recursive: true });
595
-
596
- let integrationBaseHead = target.head;
597
- const checkpoint = options.seedCheckpoint;
598
- if (checkpoint && checkpoint.patch.length > WORKTREE_PATCH_MAX_BYTES) {
599
- throw new Error(
600
- `Isolated checkpoint exceeds the ${WORKTREE_PATCH_MAX_BYTES}-byte patch limit (${checkpoint.patch.length} bytes).`,
601
- );
602
- }
603
- if (checkpoint && checkpoint.patch.length > 0) {
604
- // Merge the checkpoint commit with today's HEAD instead of blindly
605
- // applying its old patch. If the parent committed generation one after
606
- // integration, Git recognizes the equivalent tree and produces HEAD
607
- // unchanged; unrelated newer commits are preserved by the three-way merge.
608
- const merged = await runGit(
609
- runner,
610
- worktreePath,
611
- ["merge-tree", "--write-tree", "--messages", target.head, checkpoint.commit],
612
- `Merging isolated checkpoint into continuation ${worktreePath}`,
613
- );
614
- const mergedTree = merged.stdout.toString("utf8").split(/\r?\n/, 1)[0]?.trim();
615
- if (!mergedTree) throw new Error("Git returned no merged continuation tree id.");
616
- await runGit(
617
- runner,
618
- worktreePath,
619
- ["read-tree", "--reset", "-u", mergedTree],
620
- `Materializing isolated checkpoint in ${worktreePath}`,
621
- );
622
- if (options.seedIsIntegrated) {
623
- const commit = await runGit(
624
- runner,
625
- worktreePath,
626
- [
627
- "-c", "user.name=pi-subagents",
628
- "-c", "user.email=pi-subagents@example.invalid",
629
- "commit-tree", mergedTree,
630
- "-p", target.head,
631
- "-m", "pi-subagents continuation baseline",
632
- ],
633
- `Creating continuation baseline commit in ${worktreePath}`,
634
- );
635
- integrationBaseHead = commit.stdout.toString("utf8").trim();
636
- if (!integrationBaseHead) throw new Error("Git returned no continuation baseline commit id.");
637
- }
638
- }
639
-
640
- return new GitWorktreeIsolation(
641
- target.originalCwd,
642
- target.originalRoot,
643
- isolatedCwd,
644
- worktreePath,
645
- tempDir,
646
- patchPath,
647
- target.head,
648
- runner,
649
- integrationBaseHead,
650
- );
651
- } catch (error) {
652
- const rollbackErrors: string[] = [];
653
- if (added || existsSync(worktreePath)) {
654
- try {
655
- await runGit(
656
- runner,
657
- target.originalRoot,
658
- ["worktree", "remove", "--force", worktreePath],
659
- `Rolling back isolated worktree ${worktreePath}`,
660
- );
661
- } catch (rollbackError) {
662
- rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
663
- }
664
- }
665
- try {
666
- await runGit(
667
- runner,
668
- target.originalRoot,
669
- ["worktree", "prune"],
670
- `Pruning Git worktree metadata after setup failure`,
671
- );
672
- } catch (rollbackError) {
673
- rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
674
- }
675
- try {
676
- await rm(tempDir, { recursive: true, force: true });
677
- } catch (rollbackError) {
678
- rollbackErrors.push(`Removing ${tempDir} failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
679
- }
680
- const retainedPaths = [worktreePath, patchPath, tempDir].filter((path) => existsSync(path));
681
- const cause = error instanceof Error ? error.message : String(error);
682
- throw new WorktreeSetupError(
683
- `Could not create isolated Git worktree: ${cause}${rollbackErrors.length > 0 ? ` Rollback errors: ${rollbackErrors.join("; ")}` : ""}${retainedPaths.length > 0 ? ` Retained artifacts: ${retainedPaths.join(", ")}` : ""}`,
684
- retainedPaths,
685
- );
686
- }
687
- }
1
+ /**
2
+ * Detached Git worktree isolation for write-capable sub-agents.
3
+ *
4
+ * A handle is created before a child is queued and stays owned by the logical
5
+ * thread across retries, model candidates, retargets, and park/resume. Finalize
6
+ * is idempotent: it records a binary patch, applies it to the original working
7
+ * tree without touching its index, then removes/prunes the temporary worktree.
8
+ * Failed integration deliberately retains both the worktree and patch.
9
+ */
10
+
11
+ import { spawn, type ChildProcess } from "node:child_process";
12
+ import { existsSync } from "node:fs";
13
+ import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { isAbsolute, join, relative, resolve } from "node:path";
16
+
17
+ export type IsolationMode = "shared" | "worktree";
18
+
19
+ export interface CommandRunOptions {
20
+ cwd: string;
21
+ input?: Buffer;
22
+ signal?: AbortSignal;
23
+ timeoutMs?: number;
24
+ maxOutputBytes?: number;
25
+ }
26
+
27
+ export interface CommandResult {
28
+ code: number;
29
+ stdout: Buffer;
30
+ stderr: Buffer;
31
+ }
32
+
33
+ /** Injectable, shell-free command runner used by every Git operation. */
34
+ export type CommandRunner = (
35
+ command: string,
36
+ args: readonly string[],
37
+ options: CommandRunOptions,
38
+ ) => Promise<CommandResult>;
39
+
40
+ export const GIT_COMMAND_TIMEOUT_MS = 120_000;
41
+ export const GIT_COMMAND_KILL_GRACE_MS = 2_000;
42
+ export const GIT_OUTPUT_MAX_BYTES = 64 * 1024 * 1024;
43
+ export const WORKTREE_PATCH_MAX_BYTES = GIT_OUTPUT_MAX_BYTES;
44
+
45
+ /** Git apply validates and writes in one process, but two apply processes can
46
+ * validate the same old bytes concurrently before either writes. Chain applies
47
+ * per canonical source worktree so an overlapping later patch conflicts instead
48
+ * of silently winning a last-writer race. */
49
+ const originalRootApplyTails = new Map<string, Promise<void>>();
50
+
51
+ async function withSerializedOriginalRootApply<T>(
52
+ originalRoot: string,
53
+ operation: () => Promise<T>,
54
+ ): Promise<T> {
55
+ const key = process.platform === "win32" ? originalRoot.toLowerCase() : originalRoot;
56
+ const previous = originalRootApplyTails.get(key) ?? Promise.resolve();
57
+ let release!: () => void;
58
+ const gate = new Promise<void>((resolveGate) => {
59
+ release = resolveGate;
60
+ });
61
+ const tail = previous.catch(() => undefined).then(() => gate);
62
+ originalRootApplyTails.set(key, tail);
63
+ await previous.catch(() => undefined);
64
+ try {
65
+ return await operation();
66
+ } finally {
67
+ release();
68
+ if (originalRootApplyTails.get(key) === tail) originalRootApplyTails.delete(key);
69
+ }
70
+ }
71
+
72
+ function terminateCommandTree(child: ChildProcess, force: boolean, processGroup: boolean): void {
73
+ if (process.platform === "win32" && child.pid !== undefined) {
74
+ const fallback = (): void => {
75
+ try {
76
+ child.kill(force ? "SIGKILL" : "SIGTERM");
77
+ } catch {
78
+ /* process may already be gone */
79
+ }
80
+ };
81
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
82
+ stdio: "ignore",
83
+ windowsHide: true,
84
+ });
85
+ killer.once("error", fallback);
86
+ killer.once("close", (code) => {
87
+ if (code !== 0) fallback();
88
+ });
89
+ return;
90
+ }
91
+ try {
92
+ if (processGroup && child.pid !== undefined) {
93
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
94
+ } else {
95
+ child.kill(force ? "SIGKILL" : "SIGTERM");
96
+ }
97
+ } catch {
98
+ /* process may already be gone */
99
+ }
100
+ }
101
+
102
+ /** Default argument-safe runner. Output is bounded before binary patches enter
103
+ * memory, and timeout/abort terminates the complete checkout-filter process tree. */
104
+ export const runCommand: CommandRunner = (command, args, options) =>
105
+ new Promise<CommandResult>((resolveResult, reject) => {
106
+ if (options.signal?.aborted) {
107
+ reject(new Error(`Command aborted before start: ${command}`));
108
+ return;
109
+ }
110
+ const usePosixProcessGroup = process.platform !== "win32";
111
+ const child = spawn(command, [...args], {
112
+ cwd: options.cwd,
113
+ shell: false,
114
+ windowsHide: true,
115
+ stdio: ["pipe", "pipe", "pipe"],
116
+ detached: usePosixProcessGroup,
117
+ });
118
+ const stdout: Buffer[] = [];
119
+ const stderr: Buffer[] = [];
120
+ const maxOutputBytes = options.maxOutputBytes ?? GIT_OUTPUT_MAX_BYTES;
121
+ let outputBytes = 0;
122
+ let finished = false;
123
+ let failure: Error | undefined;
124
+ let timeout: ReturnType<typeof setTimeout> | undefined;
125
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
126
+
127
+ const terminate = (): void => {
128
+ terminateCommandTree(child, false, usePosixProcessGroup);
129
+ if (!forceKillTimer) {
130
+ forceKillTimer = setTimeout(
131
+ () => terminateCommandTree(child, true, usePosixProcessGroup),
132
+ GIT_COMMAND_KILL_GRACE_MS,
133
+ );
134
+ if (typeof forceKillTimer.unref === "function") forceKillTimer.unref();
135
+ }
136
+ };
137
+ const fail = (error: Error): void => {
138
+ if (failure || finished) return;
139
+ failure = error;
140
+ terminate();
141
+ };
142
+ const append = (target: Buffer[], chunk: Buffer | string): void => {
143
+ if (failure || finished) return;
144
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
145
+ outputBytes += value.length;
146
+ if (outputBytes > maxOutputBytes) {
147
+ fail(new Error(`Command output exceeded ${maxOutputBytes} bytes: ${command}`));
148
+ return;
149
+ }
150
+ target.push(value);
151
+ };
152
+ const onAbort = (): void => fail(new Error(`Command aborted: ${command}`));
153
+ options.signal?.addEventListener("abort", onAbort, { once: true });
154
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
155
+ timeout = setTimeout(
156
+ () => fail(new Error(`Command timed out after ${options.timeoutMs}ms: ${command}`)),
157
+ options.timeoutMs,
158
+ );
159
+ if (typeof timeout.unref === "function") timeout.unref();
160
+ }
161
+
162
+ child.stdout?.on("data", (chunk: Buffer | string) => append(stdout, chunk));
163
+ child.stderr?.on("data", (chunk: Buffer | string) => append(stderr, chunk));
164
+ child.once("error", (error) => fail(error));
165
+ child.once("close", (code) => {
166
+ if (finished) return;
167
+ finished = true;
168
+ if (timeout) clearTimeout(timeout);
169
+ if (forceKillTimer) clearTimeout(forceKillTimer);
170
+ options.signal?.removeEventListener("abort", onAbort);
171
+ if (failure) {
172
+ reject(failure);
173
+ return;
174
+ }
175
+ resolveResult({
176
+ code: code ?? 1,
177
+ stdout: Buffer.concat(stdout),
178
+ stderr: Buffer.concat(stderr),
179
+ });
180
+ });
181
+ child.stdin?.once("error", () => undefined);
182
+ child.stdin?.end(options.input);
183
+ });
184
+
185
+ export interface WorktreeTarget {
186
+ /** Canonical cwd requested by the caller. */
187
+ originalCwd: string;
188
+ /** Canonical top-level directory of the source Git worktree. */
189
+ originalRoot: string;
190
+ /** Path from originalRoot to originalCwd (empty for the root). */
191
+ relativeCwd: string;
192
+ head: string;
193
+ }
194
+
195
+ export interface WorktreeCheckpoint {
196
+ /** Commit checked out when this isolated generation began. */
197
+ baseHead: string;
198
+ /** Synthetic commit whose tree is the generation's complete final state. */
199
+ commit: string;
200
+ /** Binary delta from baseHead, retained for size checks and diagnostics. */
201
+ patch: Buffer;
202
+ }
203
+
204
+ export interface WorktreeCreateOptions {
205
+ runner?: CommandRunner;
206
+ /** Test hook; production uses the OS temp directory. */
207
+ tempBaseDir?: string;
208
+ /** Complete source generation checkpoint merged onto the current HEAD. */
209
+ seedCheckpoint?: WorktreeCheckpoint;
210
+ /** The seed is already present in the parent checkout, so only later edits
211
+ * should be integrated when this continuation settles. */
212
+ seedIsIntegrated?: boolean;
213
+ }
214
+
215
+ export type WorktreeFinalizationStatus = "integrated" | "no_changes" | "retained";
216
+
217
+ export interface WorktreeFinalization {
218
+ status: WorktreeFinalizationStatus;
219
+ /** True once the patch was successfully applied to the original worktree. */
220
+ integrated: boolean;
221
+ hadChanges: boolean;
222
+ worktreePath?: string;
223
+ patchPath?: string;
224
+ error?: string;
225
+ }
226
+
227
+ export interface WorktreeIsolation {
228
+ readonly originalCwd: string;
229
+ readonly originalRoot: string;
230
+ readonly cwd: string;
231
+ readonly worktreePath: string;
232
+ readonly tempDir: string;
233
+ readonly patchPath: string;
234
+ readonly head: string;
235
+ readonly state: "active" | "finalizing" | WorktreeFinalizationStatus;
236
+ /** Capture the complete isolated filesystem state for a fresh continuation.
237
+ * The synthetic commit lets Git merge an already-committed seed without
238
+ * attempting to apply the same patch twice. */
239
+ snapshotCheckpoint(): Promise<WorktreeCheckpoint>;
240
+ /** Remove a newly-created continuation that failed before it was dispatched. */
241
+ discard(): Promise<void>;
242
+ /** Idempotent across stale generations and repeated stop/shutdown paths. */
243
+ finalize(): Promise<WorktreeFinalization>;
244
+ }
245
+
246
+ export class WorktreeSetupError extends Error {
247
+ constructor(
248
+ message: string,
249
+ readonly retainedPaths: readonly string[] = [],
250
+ ) {
251
+ super(message);
252
+ this.name = "WorktreeSetupError";
253
+ }
254
+ }
255
+
256
+ const ERROR_OUTPUT_MAX = 8_000;
257
+
258
+ function cloneCheckpoint(checkpoint: WorktreeCheckpoint): WorktreeCheckpoint {
259
+ return { ...checkpoint, patch: Buffer.from(checkpoint.patch) };
260
+ }
261
+
262
+ function outputText(result: CommandResult): string {
263
+ const text = (result.stderr.length > 0 ? result.stderr : result.stdout).toString("utf8").trim();
264
+ if (text.length <= ERROR_OUTPUT_MAX) return text;
265
+ return `${text.slice(0, ERROR_OUTPUT_MAX - 1)}…`;
266
+ }
267
+
268
+ function commandFailure(action: string, result: CommandResult): Error {
269
+ const detail = outputText(result);
270
+ return new Error(`${action} failed (exit ${result.code})${detail ? `: ${detail}` : "."}`);
271
+ }
272
+
273
+ async function runGit(
274
+ runner: CommandRunner,
275
+ cwd: string,
276
+ args: readonly string[],
277
+ action: string,
278
+ input?: Buffer,
279
+ ): Promise<CommandResult> {
280
+ let result: CommandResult;
281
+ try {
282
+ result = await runner("git", args, {
283
+ cwd,
284
+ input,
285
+ timeoutMs: GIT_COMMAND_TIMEOUT_MS,
286
+ maxOutputBytes: GIT_OUTPUT_MAX_BYTES,
287
+ });
288
+ } catch (error) {
289
+ throw new Error(`${action} failed: ${error instanceof Error ? error.message : String(error)}`);
290
+ }
291
+ if (result.code !== 0) throw commandFailure(action, result);
292
+ return result;
293
+ }
294
+
295
+ /** True only when candidate is root itself or a descendant (cross-platform). */
296
+ export function isPathInside(root: string, candidate: string): boolean {
297
+ const rel = relative(root, candidate);
298
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
299
+ }
300
+
301
+ /** Resolve and validate the Git repository/worktree that contains cwd. */
302
+ export async function resolveWorktreeTarget(
303
+ cwd: string,
304
+ runner: CommandRunner = runCommand,
305
+ ): Promise<WorktreeTarget> {
306
+ const requested = resolve(cwd);
307
+ try {
308
+ if (!(await stat(requested)).isDirectory()) throw new Error("not a directory");
309
+ } catch (error) {
310
+ throw new WorktreeSetupError(
311
+ `Worktree isolation requires an existing directory; cwd ${requested} is unavailable (${error instanceof Error ? error.message : String(error)}).`,
312
+ );
313
+ }
314
+ const originalCwd = await realpath(requested);
315
+ let topLevel: CommandResult;
316
+ try {
317
+ topLevel = await runGit(
318
+ runner,
319
+ originalCwd,
320
+ ["rev-parse", "--show-toplevel"],
321
+ `Git repository discovery for ${originalCwd}`,
322
+ );
323
+ } catch (error) {
324
+ throw new WorktreeSetupError(
325
+ `Worktree isolation requires cwd to be inside a Git worktree/repository: ${error instanceof Error ? error.message : String(error)}`,
326
+ );
327
+ }
328
+ const reportedRoot = topLevel.stdout.toString("utf8").trim();
329
+ if (!reportedRoot) {
330
+ throw new WorktreeSetupError(`Git repository discovery for ${originalCwd} returned no top-level path.`);
331
+ }
332
+ const originalRoot = await realpath(resolve(reportedRoot));
333
+ if (!isPathInside(originalRoot, originalCwd)) {
334
+ throw new WorktreeSetupError(
335
+ `Requested cwd ${originalCwd} is not inside Git worktree root ${originalRoot}.`,
336
+ );
337
+ }
338
+ let headResult: CommandResult;
339
+ try {
340
+ headResult = await runGit(
341
+ runner,
342
+ originalRoot,
343
+ ["rev-parse", "--verify", "HEAD"],
344
+ `Resolving HEAD for ${originalRoot}`,
345
+ );
346
+ } catch (error) {
347
+ throw new WorktreeSetupError(
348
+ `Worktree isolation requires a repository with a committed HEAD: ${error instanceof Error ? error.message : String(error)}`,
349
+ );
350
+ }
351
+ return {
352
+ originalCwd,
353
+ originalRoot,
354
+ relativeCwd: relative(originalRoot, originalCwd),
355
+ head: headResult.stdout.toString("utf8").trim(),
356
+ };
357
+ }
358
+
359
+ class GitWorktreeIsolation implements WorktreeIsolation {
360
+ private currentState: WorktreeIsolation["state"] = "active";
361
+ private finalization?: Promise<WorktreeFinalization>;
362
+ private discardPromise?: Promise<void>;
363
+ /** Full workspace checkpoint relative to the generation's starting HEAD,
364
+ * retained after cleanup so settled threads can continue safely. */
365
+ private continuationCheckpoint?: WorktreeCheckpoint;
366
+
367
+ constructor(
368
+ readonly originalCwd: string,
369
+ readonly originalRoot: string,
370
+ readonly cwd: string,
371
+ readonly worktreePath: string,
372
+ readonly tempDir: string,
373
+ readonly patchPath: string,
374
+ readonly head: string,
375
+ private readonly runner: CommandRunner,
376
+ /** May be a synthetic tree commit representing a seed that the parent
377
+ * checkout already contains. Finalization then integrates only new edits. */
378
+ private readonly integrationBaseHead: string = head,
379
+ ) {}
380
+
381
+ get state(): WorktreeIsolation["state"] {
382
+ return this.currentState;
383
+ }
384
+
385
+ async snapshotCheckpoint(): Promise<WorktreeCheckpoint> {
386
+ if (this.continuationCheckpoint) return cloneCheckpoint(this.continuationCheckpoint);
387
+ if (this.currentState === "no_changes") {
388
+ return { baseHead: this.head, commit: this.head, patch: Buffer.alloc(0) };
389
+ }
390
+ if (!existsSync(this.worktreePath)) {
391
+ throw new Error(`Cannot snapshot isolated worktree after it was removed: ${this.worktreePath}`);
392
+ }
393
+ const snapshot = await this.collectChanges(this.head);
394
+ this.continuationCheckpoint = await this.createCheckpoint(snapshot.stdout);
395
+ return cloneCheckpoint(this.continuationCheckpoint);
396
+ }
397
+
398
+ discard(): Promise<void> {
399
+ if (this.discardPromise) return this.discardPromise;
400
+ if (this.finalization) {
401
+ return this.finalization.then(() => undefined);
402
+ }
403
+ this.currentState = "finalizing";
404
+ this.discardPromise = this.removeAndPrune().then((error) => {
405
+ if (error) {
406
+ this.currentState = "retained";
407
+ throw new Error(error);
408
+ }
409
+ this.currentState = "no_changes";
410
+ });
411
+ return this.discardPromise;
412
+ }
413
+
414
+ finalize(): Promise<WorktreeFinalization> {
415
+ if (this.finalization) return this.finalization;
416
+ this.currentState = "finalizing";
417
+ this.finalization = this.finalizeOnce().then((result) => {
418
+ this.currentState = result.status;
419
+ return result;
420
+ });
421
+ return this.finalization;
422
+ }
423
+
424
+ private async collectChanges(baseHead: string): Promise<CommandResult> {
425
+ await runGit(
426
+ this.runner,
427
+ this.worktreePath,
428
+ ["add", "-N", "--", "."],
429
+ `Collecting untracked files in isolated worktree ${this.worktreePath}`,
430
+ );
431
+ return runGit(
432
+ this.runner,
433
+ this.worktreePath,
434
+ ["diff", "--binary", baseHead, "--"],
435
+ `Collecting isolated changes from ${this.worktreePath}`,
436
+ );
437
+ }
438
+
439
+ private async createCheckpoint(patch: Buffer): Promise<WorktreeCheckpoint> {
440
+ if (patch.length === 0) {
441
+ return { baseHead: this.head, commit: this.head, patch: Buffer.alloc(0) };
442
+ }
443
+ await runGit(
444
+ this.runner,
445
+ this.worktreePath,
446
+ ["add", "-A", "--", "."],
447
+ `Preparing isolated checkpoint in ${this.worktreePath}`,
448
+ );
449
+ const tree = await runGit(
450
+ this.runner,
451
+ this.worktreePath,
452
+ ["write-tree"],
453
+ `Writing isolated checkpoint tree in ${this.worktreePath}`,
454
+ );
455
+ const treeId = tree.stdout.toString("utf8").trim();
456
+ if (!treeId) throw new Error("Git returned no isolated checkpoint tree id.");
457
+ const commit = await runGit(
458
+ this.runner,
459
+ this.worktreePath,
460
+ [
461
+ "-c", "user.name=pi-subagents",
462
+ "-c", "user.email=pi-subagents@example.invalid",
463
+ "commit-tree", treeId,
464
+ "-p", this.head,
465
+ "-m", "pi-subagents isolated checkpoint",
466
+ ],
467
+ `Creating isolated checkpoint commit in ${this.worktreePath}`,
468
+ );
469
+ const commitId = commit.stdout.toString("utf8").trim();
470
+ if (!commitId) throw new Error("Git returned no isolated checkpoint commit id.");
471
+ return { baseHead: this.head, commit: commitId, patch: Buffer.from(patch) };
472
+ }
473
+
474
+ private async finalizeOnce(): Promise<WorktreeFinalization> {
475
+ let hadChanges = false;
476
+ let patchWritten = false;
477
+ let integrated = false;
478
+ try {
479
+ const diff = await this.collectChanges(this.integrationBaseHead);
480
+ const snapshot = this.integrationBaseHead === this.head
481
+ ? diff
482
+ : await this.collectChanges(this.head);
483
+ this.continuationCheckpoint = await this.createCheckpoint(snapshot.stdout);
484
+ hadChanges = diff.stdout.length > 0;
485
+ if (hadChanges) {
486
+ await writeFile(this.patchPath, diff.stdout, { flag: "wx" });
487
+ patchWritten = true;
488
+ await withSerializedOriginalRootApply(this.originalRoot, () =>
489
+ runGit(
490
+ this.runner,
491
+ this.originalRoot,
492
+ ["apply", "--binary", "--whitespace=nowarn", this.patchPath],
493
+ `Applying isolated patch to ${this.originalRoot}`,
494
+ ),
495
+ );
496
+ integrated = true;
497
+ }
498
+
499
+ const cleanupError = await this.removeAndPrune();
500
+ if (cleanupError) {
501
+ return this.retainedResult(hadChanges, integrated, patchWritten, cleanupError);
502
+ }
503
+ return {
504
+ status: hadChanges ? "integrated" : "no_changes",
505
+ integrated,
506
+ hadChanges,
507
+ };
508
+ } catch (error) {
509
+ return this.retainedResult(
510
+ hadChanges,
511
+ integrated,
512
+ patchWritten,
513
+ error instanceof Error ? error.message : String(error),
514
+ );
515
+ }
516
+ }
517
+
518
+ private retainedResult(
519
+ hadChanges: boolean,
520
+ integrated: boolean,
521
+ patchWritten: boolean,
522
+ error: string,
523
+ ): WorktreeFinalization {
524
+ return {
525
+ status: "retained",
526
+ integrated,
527
+ hadChanges,
528
+ ...(existsSync(this.worktreePath) ? { worktreePath: this.worktreePath } : {}),
529
+ ...(patchWritten && existsSync(this.patchPath) ? { patchPath: this.patchPath } : {}),
530
+ error,
531
+ };
532
+ }
533
+
534
+ /** Return an error string instead of throwing so applied work is never retried. */
535
+ private async removeAndPrune(): Promise<string | undefined> {
536
+ try {
537
+ await runGit(
538
+ this.runner,
539
+ this.originalRoot,
540
+ ["worktree", "remove", "--force", this.worktreePath],
541
+ `Removing isolated worktree ${this.worktreePath}`,
542
+ );
543
+ } catch (error) {
544
+ return error instanceof Error ? error.message : String(error);
545
+ }
546
+ let pruneError: string | undefined;
547
+ try {
548
+ await runGit(
549
+ this.runner,
550
+ this.originalRoot,
551
+ ["worktree", "prune"],
552
+ `Pruning Git worktree metadata for ${this.originalRoot}`,
553
+ );
554
+ } catch (error) {
555
+ pruneError = error instanceof Error ? error.message : String(error);
556
+ }
557
+ try {
558
+ await rm(this.tempDir, { recursive: true, force: true });
559
+ } catch (error) {
560
+ const rmError = error instanceof Error ? error.message : String(error);
561
+ return pruneError ? `${pruneError}; removing temporary directory failed: ${rmError}` : `Removing temporary directory failed: ${rmError}`;
562
+ }
563
+ return pruneError;
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Create a detached worktree at repository HEAD. The returned cwd mirrors the
569
+ * caller's subdirectory inside that new worktree.
570
+ */
571
+ export async function createWorktreeIsolation(
572
+ cwd: string,
573
+ options: WorktreeCreateOptions = {},
574
+ ): Promise<WorktreeIsolation> {
575
+ const runner = options.runner ?? runCommand;
576
+ const target = await resolveWorktreeTarget(cwd, runner);
577
+ const tempBase = options.tempBaseDir ? resolve(options.tempBaseDir) : tmpdir();
578
+ await mkdir(tempBase, { recursive: true });
579
+ const tempDir = await mkdtemp(join(tempBase, "pi-subagent-worktree-"));
580
+ const worktreePath = join(tempDir, "worktree");
581
+ const patchPath = join(tempDir, "changes.patch");
582
+ let added = false;
583
+ try {
584
+ await runGit(
585
+ runner,
586
+ target.originalRoot,
587
+ ["worktree", "add", "--detach", worktreePath, "HEAD"],
588
+ `Creating detached worktree from ${target.originalRoot}`,
589
+ );
590
+ added = true;
591
+ const isolatedCwd = target.relativeCwd ? join(worktreePath, target.relativeCwd) : worktreePath;
592
+ // A requested subdirectory can be untracked/empty in the source worktree;
593
+ // recreate the directory so the child still starts at the equivalent path.
594
+ await mkdir(isolatedCwd, { recursive: true });
595
+
596
+ let integrationBaseHead = target.head;
597
+ const checkpoint = options.seedCheckpoint;
598
+ if (checkpoint && checkpoint.patch.length > WORKTREE_PATCH_MAX_BYTES) {
599
+ throw new Error(
600
+ `Isolated checkpoint exceeds the ${WORKTREE_PATCH_MAX_BYTES}-byte patch limit (${checkpoint.patch.length} bytes).`,
601
+ );
602
+ }
603
+ if (checkpoint && checkpoint.patch.length > 0) {
604
+ // Merge the checkpoint commit with today's HEAD instead of blindly
605
+ // applying its old patch. If the parent committed generation one after
606
+ // integration, Git recognizes the equivalent tree and produces HEAD
607
+ // unchanged; unrelated newer commits are preserved by the three-way merge.
608
+ const merged = await runGit(
609
+ runner,
610
+ worktreePath,
611
+ ["merge-tree", "--write-tree", "--messages", target.head, checkpoint.commit],
612
+ `Merging isolated checkpoint into continuation ${worktreePath}`,
613
+ );
614
+ const mergedTree = merged.stdout.toString("utf8").split(/\r?\n/, 1)[0]?.trim();
615
+ if (!mergedTree) throw new Error("Git returned no merged continuation tree id.");
616
+ await runGit(
617
+ runner,
618
+ worktreePath,
619
+ ["read-tree", "--reset", "-u", mergedTree],
620
+ `Materializing isolated checkpoint in ${worktreePath}`,
621
+ );
622
+ if (options.seedIsIntegrated) {
623
+ const commit = await runGit(
624
+ runner,
625
+ worktreePath,
626
+ [
627
+ "-c", "user.name=pi-subagents",
628
+ "-c", "user.email=pi-subagents@example.invalid",
629
+ "commit-tree", mergedTree,
630
+ "-p", target.head,
631
+ "-m", "pi-subagents continuation baseline",
632
+ ],
633
+ `Creating continuation baseline commit in ${worktreePath}`,
634
+ );
635
+ integrationBaseHead = commit.stdout.toString("utf8").trim();
636
+ if (!integrationBaseHead) throw new Error("Git returned no continuation baseline commit id.");
637
+ }
638
+ }
639
+
640
+ return new GitWorktreeIsolation(
641
+ target.originalCwd,
642
+ target.originalRoot,
643
+ isolatedCwd,
644
+ worktreePath,
645
+ tempDir,
646
+ patchPath,
647
+ target.head,
648
+ runner,
649
+ integrationBaseHead,
650
+ );
651
+ } catch (error) {
652
+ const rollbackErrors: string[] = [];
653
+ if (added || existsSync(worktreePath)) {
654
+ try {
655
+ await runGit(
656
+ runner,
657
+ target.originalRoot,
658
+ ["worktree", "remove", "--force", worktreePath],
659
+ `Rolling back isolated worktree ${worktreePath}`,
660
+ );
661
+ } catch (rollbackError) {
662
+ rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
663
+ }
664
+ }
665
+ try {
666
+ await runGit(
667
+ runner,
668
+ target.originalRoot,
669
+ ["worktree", "prune"],
670
+ `Pruning Git worktree metadata after setup failure`,
671
+ );
672
+ } catch (rollbackError) {
673
+ rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
674
+ }
675
+ try {
676
+ await rm(tempDir, { recursive: true, force: true });
677
+ } catch (rollbackError) {
678
+ rollbackErrors.push(`Removing ${tempDir} failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
679
+ }
680
+ const retainedPaths = [worktreePath, patchPath, tempDir].filter((path) => existsSync(path));
681
+ const cause = error instanceof Error ? error.message : String(error);
682
+ throw new WorktreeSetupError(
683
+ `Could not create isolated Git worktree: ${cause}${rollbackErrors.length > 0 ? ` Rollback errors: ${rollbackErrors.join("; ")}` : ""}${retainedPaths.length > 0 ? ` Retained artifacts: ${retainedPaths.join(", ")}` : ""}`,
684
+ retainedPaths,
685
+ );
686
+ }
687
+ }