@ferris1225/pi-subagents 4.2.7 → 4.2.8

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,943 +1,974 @@
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, and resumes. 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 { copyFile, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
14
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
15
- import { writeTempOwnerMarker } from "./temp-hygiene.ts";
16
-
17
- export type IsolationMode = "shared" | "worktree";
18
-
19
- const WORKTREE_TEMP_DIR_PREFIX = "pi-subagent-worktree-";
20
-
21
- /** Short stable identity of one isolated worktree group (the mkdtemp suffix).
22
- * Continuation generations create a fresh worktree, so the identity
23
- * visibly changes when the group's filesystem boundary changes. */
24
- export function worktreeGroupId(worktree: Pick<WorktreeIsolation, "tempDir">): string {
25
- const base = worktree.tempDir.split(/[\\/]/).filter(Boolean).pop() ?? worktree.tempDir;
26
- return base.startsWith(WORKTREE_TEMP_DIR_PREFIX)
27
- ? base.slice(WORKTREE_TEMP_DIR_PREFIX.length)
28
- : base;
29
- }
30
-
31
- export interface CommandRunOptions {
32
- cwd: string;
33
- input?: Buffer;
34
- signal?: AbortSignal;
35
- timeoutMs?: number;
36
- maxOutputBytes?: number;
37
- /** Extra environment entries merged over the inherited environment. */
38
- env?: Record<string, string>;
39
- }
40
-
41
- export interface CommandResult {
42
- code: number;
43
- stdout: Buffer;
44
- stderr: Buffer;
45
- }
46
-
47
- /** Injectable, shell-free command runner used by every Git operation. */
48
- export type CommandRunner = (
49
- command: string,
50
- args: readonly string[],
51
- options: CommandRunOptions,
52
- ) => Promise<CommandResult>;
53
-
54
- export const GIT_COMMAND_TIMEOUT_MS = 120_000;
55
- export const GIT_COMMAND_KILL_GRACE_MS = 2_000;
56
- export const GIT_OUTPUT_MAX_BYTES = 64 * 1024 * 1024;
57
- export const WORKTREE_PATCH_MAX_BYTES = GIT_OUTPUT_MAX_BYTES;
58
-
59
- /** Git apply validates and writes in one process. The repository lane
60
- * (thread-lifecycle) already serializes every finalize against all writers of
61
- * the same canonical checkout, so applies never race each other here. */
62
- function terminateCommandTree(child: ChildProcess, force: boolean, processGroup: boolean): void {
63
- if (process.platform === "win32" && child.pid !== undefined) {
64
- const fallback = (): void => {
65
- try {
66
- child.kill(force ? "SIGKILL" : "SIGTERM");
67
- } catch {
68
- /* process may already be gone */
69
- }
70
- };
71
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
72
- stdio: "ignore",
73
- windowsHide: true,
74
- });
75
- killer.once("error", fallback);
76
- killer.once("close", (code) => {
77
- if (code !== 0) fallback();
78
- });
79
- return;
80
- }
81
- try {
82
- if (processGroup && child.pid !== undefined) {
83
- process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
84
- } else {
85
- child.kill(force ? "SIGKILL" : "SIGTERM");
86
- }
87
- } catch {
88
- /* process may already be gone */
89
- }
90
- }
91
-
92
- /** Default argument-safe runner. Output is bounded before binary patches enter
93
- * memory, and timeout/abort terminates the complete checkout-filter process tree. */
94
- export const runCommand: CommandRunner = (command, args, options) =>
95
- new Promise<CommandResult>((resolveResult, reject) => {
96
- if (options.signal?.aborted) {
97
- reject(new Error(`Command aborted before start: ${command}`));
98
- return;
99
- }
100
- const usePosixProcessGroup = process.platform !== "win32";
101
- const child = spawn(command, [...args], {
102
- cwd: options.cwd,
103
- shell: false,
104
- windowsHide: true,
105
- stdio: ["pipe", "pipe", "pipe"],
106
- detached: usePosixProcessGroup,
107
- ...(options.env ? { env: { ...process.env, ...options.env } } : {}),
108
- });
109
- const stdout: Buffer[] = [];
110
- const stderr: Buffer[] = [];
111
- const maxOutputBytes = options.maxOutputBytes ?? GIT_OUTPUT_MAX_BYTES;
112
- let outputBytes = 0;
113
- let finished = false;
114
- let failure: Error | undefined;
115
- let timeout: ReturnType<typeof setTimeout> | undefined;
116
- let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
117
-
118
- const terminate = (): void => {
119
- terminateCommandTree(child, false, usePosixProcessGroup);
120
- if (!forceKillTimer) {
121
- forceKillTimer = setTimeout(
122
- () => terminateCommandTree(child, true, usePosixProcessGroup),
123
- GIT_COMMAND_KILL_GRACE_MS,
124
- );
125
- if (typeof forceKillTimer.unref === "function") forceKillTimer.unref();
126
- }
127
- };
128
- const fail = (error: Error): void => {
129
- if (failure || finished) return;
130
- failure = error;
131
- terminate();
132
- };
133
- const append = (target: Buffer[], chunk: Buffer | string): void => {
134
- if (failure || finished) return;
135
- const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
136
- outputBytes += value.length;
137
- if (outputBytes > maxOutputBytes) {
138
- fail(new Error(`Command output exceeded ${maxOutputBytes} bytes: ${command}`));
139
- return;
140
- }
141
- target.push(value);
142
- };
143
- const onAbort = (): void => fail(new Error(`Command aborted: ${command}`));
144
- options.signal?.addEventListener("abort", onAbort, { once: true });
145
- if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
146
- timeout = setTimeout(
147
- () => fail(new Error(`Command timed out after ${options.timeoutMs}ms: ${command}`)),
148
- options.timeoutMs,
149
- );
150
- if (typeof timeout.unref === "function") timeout.unref();
151
- }
152
-
153
- child.stdout?.on("data", (chunk: Buffer | string) => append(stdout, chunk));
154
- child.stderr?.on("data", (chunk: Buffer | string) => append(stderr, chunk));
155
- child.once("error", (error) => fail(error));
156
- child.once("close", (code) => {
157
- if (finished) return;
158
- finished = true;
159
- if (timeout) clearTimeout(timeout);
160
- if (forceKillTimer) clearTimeout(forceKillTimer);
161
- options.signal?.removeEventListener("abort", onAbort);
162
- if (failure) {
163
- reject(failure);
164
- return;
165
- }
166
- resolveResult({
167
- code: code ?? 1,
168
- stdout: Buffer.concat(stdout),
169
- stderr: Buffer.concat(stderr),
170
- });
171
- });
172
- child.stdin?.once("error", () => undefined);
173
- child.stdin?.end(options.input);
174
- });
175
-
176
- export interface WorktreeTarget {
177
- /** Canonical cwd requested by the caller. */
178
- originalCwd: string;
179
- /** Canonical top-level directory of the source Git worktree. */
180
- originalRoot: string;
181
- /** Path from originalRoot to originalCwd (empty for the root). */
182
- relativeCwd: string;
183
- head: string;
184
- }
185
-
186
- export interface WorktreeCheckpoint {
187
- /** Commit checked out when this isolated generation began. */
188
- baseHead: string;
189
- /** Synthetic commit whose tree is the generation's complete final state. */
190
- commit: string;
191
- /** Binary delta from baseHead, retained for size checks and diagnostics. */
192
- patch: Buffer;
193
- }
194
-
195
- export interface WorktreeCheckpointRef {
196
- baseHead: string;
197
- commit: string;
198
- }
199
-
200
- /** Persistable projection of one worktree handle: enough to rebuild the
201
- * handle after a reload or restart. Patch bytes are deliberately omitted —
202
- * only the checkpoint commit, which lives in the shared repository object
203
- * store, is needed to seed a continuation. */
204
- export interface WorktreeSnapshot {
205
- originalCwd: string;
206
- originalRoot: string;
207
- cwd: string;
208
- worktreePath: string;
209
- tempDir: string;
210
- patchPath: string;
211
- head: string;
212
- integrationBaseHead: string;
213
- state: "active" | "retained" | "integrated" | "no_changes";
214
- checkpoint?: WorktreeCheckpointRef;
215
- }
216
-
217
- export interface WorktreeCreateOptions {
218
- runner?: CommandRunner;
219
- /** Parent directory for the worktree group: the project-scoped durable
220
- * worktrees root, so isolation never lands in the OS temp directory. */
221
- tempBaseDir: string;
222
- /** Complete source generation checkpoint merged onto the current HEAD. */
223
- seedCheckpoint?: WorktreeCheckpoint;
224
- /** The seed is already present in the parent checkout, so only later edits
225
- * should be integrated when this continuation settles. */
226
- seedIsIntegrated?: boolean;
227
- }
228
-
229
- export type WorktreeFinalizationStatus = "integrated" | "no_changes" | "retained";
230
-
231
- export interface WorktreeFinalization {
232
- status: WorktreeFinalizationStatus;
233
- /** True once the patch was successfully applied to the original worktree. */
234
- integrated: boolean;
235
- hadChanges: boolean;
236
- /** Repository a recovery path can prune stale worktree metadata against. */
237
- originalRoot?: string;
238
- worktreePath?: string;
239
- patchPath?: string;
240
- error?: string;
241
- }
242
-
243
- export interface WorktreeIsolation {
244
- readonly originalCwd: string;
245
- readonly originalRoot: string;
246
- readonly cwd: string;
247
- readonly worktreePath: string;
248
- readonly tempDir: string;
249
- readonly patchPath: string;
250
- readonly head: string;
251
- /** Diff base for final integration; a continuation baseline commit when
252
- * the generation was seeded with already-integrated work. */
253
- readonly integrationBaseHead: string;
254
- readonly state: "active" | "finalizing" | WorktreeFinalizationStatus;
255
- /** Checkpoint retained after finalization for continuation resumes. */
256
- getContinuationCheckpoint(): WorktreeCheckpoint | undefined;
257
- /** Capture the complete isolated filesystem state for a fresh continuation.
258
- * The synthetic commit lets Git merge an already-committed seed without
259
- * attempting to apply the same patch twice. */
260
- snapshotCheckpoint(): Promise<WorktreeCheckpoint>;
261
- /** Remove a newly-created continuation that failed before it was dispatched. */
262
- discard(): Promise<void>;
263
- /** Whether anything is pending against the integration base — the same
264
- * question finalization answers as `hadChanges`, asked before settlement so
265
- * policy can tell a run that produced a diff from one that produced none. */
266
- hasPendingChanges(): Promise<boolean>;
267
- /** Idempotent across stale generations and repeated stop/shutdown paths. */
268
- finalize(): Promise<WorktreeFinalization>;
269
- }
270
-
271
- export class WorktreeSetupError extends Error {
272
- constructor(
273
- message: string,
274
- readonly retainedPaths: readonly string[] = [],
275
- ) {
276
- super(message);
277
- this.name = "WorktreeSetupError";
278
- }
279
- }
280
-
281
- const ERROR_OUTPUT_MAX = 8_000;
282
-
283
- function cloneCheckpoint(checkpoint: WorktreeCheckpoint): WorktreeCheckpoint {
284
- return { ...checkpoint, patch: Buffer.from(checkpoint.patch) };
285
- }
286
-
287
- function outputText(result: CommandResult): string {
288
- const text = (result.stderr.length > 0 ? result.stderr : result.stdout).toString("utf8").trim();
289
- if (text.length <= ERROR_OUTPUT_MAX) return text;
290
- return `${text.slice(0, ERROR_OUTPUT_MAX - 1)}…`;
291
- }
292
-
293
- function commandFailure(action: string, result: CommandResult): Error {
294
- const detail = outputText(result);
295
- return new Error(`${action} failed (exit ${result.code})${detail ? `: ${detail}` : "."}`);
296
- }
297
-
298
- async function runGit(
299
- runner: CommandRunner,
300
- cwd: string,
301
- args: readonly string[],
302
- action: string,
303
- input?: Buffer,
304
- env?: Record<string, string>,
305
- ): Promise<CommandResult> {
306
- let result: CommandResult;
307
- try {
308
- result = await runner("git", args, {
309
- cwd,
310
- input,
311
- env,
312
- timeoutMs: GIT_COMMAND_TIMEOUT_MS,
313
- maxOutputBytes: GIT_OUTPUT_MAX_BYTES,
314
- });
315
- } catch (error) {
316
- throw new Error(`${action} failed: ${error instanceof Error ? error.message : String(error)}`);
317
- }
318
- if (result.code !== 0) throw commandFailure(action, result);
319
- return result;
320
- }
321
-
322
- /** True only when candidate is root itself or a descendant (cross-platform). */
323
- export function isPathInside(root: string, candidate: string): boolean {
324
- const rel = relative(root, candidate);
325
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
326
- }
327
-
328
- /** The temp group directory backing a worktree path, when the path actually
329
- * names our `<group>/worktree` layout. Recovery deletes only paths read back
330
- * from the manifest through this guard. */
331
- export function worktreeGroupDir(worktreePath: string): string | undefined {
332
- const group = dirname(worktreePath);
333
- return basename(worktreePath) === "worktree" && basename(group).startsWith(WORKTREE_TEMP_DIR_PREFIX)
334
- ? group
335
- : undefined;
336
- }
337
-
338
- /** Delete one isolated worktree group: Git's own removal keeps metadata
339
- * authoritative, but Git on Windows cannot always delete deep checkouts
340
- * ("Filename too long"), so the Node removal decides the outcome and the prune
341
- * clears any stale registration left behind. Returns an error string when
342
- * artifacts still exist afterwards. */
343
- export async function removeWorktreeGroup(
344
- paths: { originalRoot?: string; tempDir: string; worktreePath: string },
345
- runner: CommandRunner = runCommand,
346
- ): Promise<string | undefined> {
347
- let removeError: string | undefined;
348
- if (paths.originalRoot && existsSync(paths.worktreePath)) {
349
- // core.longpaths only exists in Git for Windows, and some POSIX builds
350
- // reject unknown -c core keys, so the flag stays platform-gated.
351
- const longPaths = process.platform === "win32" ? ["-c", "core.longpaths=true"] : [];
352
- try {
353
- await runGit(
354
- runner,
355
- paths.originalRoot,
356
- [...longPaths, "worktree", "remove", "--force", paths.worktreePath],
357
- `Removing isolated worktree ${paths.worktreePath}`,
358
- );
359
- } catch (error) {
360
- removeError = error instanceof Error ? error.message : String(error);
361
- }
362
- }
363
- try {
364
- await rm(paths.tempDir, { recursive: true, force: true });
365
- } catch (error) {
366
- const rmError = error instanceof Error ? error.message : String(error);
367
- return removeError
368
- ? `${removeError}; removing temporary directory failed: ${rmError}`
369
- : `Removing temporary directory failed: ${rmError}`;
370
- }
371
- if (!paths.originalRoot) return undefined;
372
- try {
373
- await runGit(
374
- runner,
375
- paths.originalRoot,
376
- ["worktree", "prune"],
377
- `Pruning Git worktree metadata for ${paths.originalRoot}`,
378
- );
379
- } catch (error) {
380
- return error instanceof Error ? error.message : String(error);
381
- }
382
- return undefined;
383
- }
384
-
385
- interface RepositoryLocation {
386
- originalCwd: string;
387
- originalRoot: string;
388
- }
389
-
390
- /** Resolve the canonical repository root without requiring a committed HEAD.
391
- * Managed repository lanes use this for empty repositories as well as normal
392
- * worktrees; worktree creation validates HEAD separately below. */
393
- async function resolveRepositoryLocation(
394
- cwd: string,
395
- runner: CommandRunner,
396
- ): Promise<RepositoryLocation> {
397
- const requested = resolve(cwd);
398
- try {
399
- if (!(await stat(requested)).isDirectory()) throw new Error("not a directory");
400
- } catch (error) {
401
- throw new WorktreeSetupError(
402
- `Worktree isolation requires an existing directory; cwd ${requested} is unavailable (${error instanceof Error ? error.message : String(error)}).`,
403
- );
404
- }
405
- const originalCwd = await realpath(requested);
406
- let topLevel: CommandResult;
407
- try {
408
- topLevel = await runGit(
409
- runner,
410
- originalCwd,
411
- ["rev-parse", "--show-toplevel"],
412
- `Git repository discovery for ${originalCwd}`,
413
- );
414
- } catch (error) {
415
- throw new WorktreeSetupError(
416
- `Worktree isolation requires cwd to be inside a Git worktree/repository: ${error instanceof Error ? error.message : String(error)}`,
417
- );
418
- }
419
- const reportedRoot = topLevel.stdout.toString("utf8").trim();
420
- if (!reportedRoot) {
421
- throw new WorktreeSetupError(`Git repository discovery for ${originalCwd} returned no top-level path.`);
422
- }
423
- const originalRoot = await realpath(resolve(reportedRoot));
424
- if (!isPathInside(originalRoot, originalCwd)) {
425
- throw new WorktreeSetupError(
426
- `Requested cwd ${originalCwd} is not inside Git worktree root ${originalRoot}.`,
427
- );
428
- }
429
- return { originalCwd, originalRoot };
430
- }
431
-
432
- export async function resolveRepositoryRoot(
433
- cwd: string,
434
- runner: CommandRunner = runCommand,
435
- ): Promise<string> {
436
- return (await resolveRepositoryLocation(cwd, runner)).originalRoot;
437
- }
438
-
439
- /** Resolve and validate the Git repository/worktree that contains cwd. */
440
- export async function resolveWorktreeTarget(
441
- cwd: string,
442
- runner: CommandRunner = runCommand,
443
- ): Promise<WorktreeTarget> {
444
- const { originalCwd, originalRoot } = await resolveRepositoryLocation(cwd, runner);
445
- let headResult: CommandResult;
446
- try {
447
- headResult = await runGit(
448
- runner,
449
- originalRoot,
450
- ["rev-parse", "--verify", "HEAD"],
451
- `Resolving HEAD for ${originalRoot}`,
452
- );
453
- } catch (error) {
454
- throw new WorktreeSetupError(
455
- `Worktree isolation requires a repository with a committed HEAD: ${error instanceof Error ? error.message : String(error)}`,
456
- );
457
- }
458
- return {
459
- originalCwd,
460
- originalRoot,
461
- relativeCwd: relative(originalRoot, originalCwd),
462
- head: headResult.stdout.toString("utf8").trim(),
463
- };
464
- }
465
-
466
- class GitWorktreeIsolation implements WorktreeIsolation {
467
- private currentState: WorktreeIsolation["state"] = "active";
468
- private finalization?: Promise<WorktreeFinalization>;
469
- private discardPromise?: Promise<void>;
470
- /** Full workspace checkpoint relative to the generation's starting HEAD,
471
- * retained after cleanup so settled threads can continue safely. */
472
- private continuationCheckpoint?: WorktreeCheckpoint;
473
-
474
- constructor(
475
- readonly originalCwd: string,
476
- readonly originalRoot: string,
477
- readonly cwd: string,
478
- readonly worktreePath: string,
479
- readonly tempDir: string,
480
- readonly patchPath: string,
481
- readonly head: string,
482
- private readonly runner: CommandRunner,
483
- /** May be a synthetic tree commit representing a seed that the parent
484
- * checkout already contains. Finalization then integrates only new edits. */
485
- readonly integrationBaseHead: string = head,
486
- restored?: {
487
- state: WorktreeIsolation["state"];
488
- checkpoint?: WorktreeCheckpoint;
489
- },
490
- ) {
491
- if (restored) {
492
- this.currentState = restored.state;
493
- if (restored.checkpoint) this.continuationCheckpoint = cloneCheckpoint(restored.checkpoint);
494
- }
495
- }
496
-
497
- get state(): WorktreeIsolation["state"] {
498
- return this.currentState;
499
- }
500
-
501
- getContinuationCheckpoint(): WorktreeCheckpoint | undefined {
502
- return this.continuationCheckpoint ? cloneCheckpoint(this.continuationCheckpoint) : undefined;
503
- }
504
-
505
- async snapshotCheckpoint(): Promise<WorktreeCheckpoint> {
506
- if (this.continuationCheckpoint) return cloneCheckpoint(this.continuationCheckpoint);
507
- if (this.currentState === "no_changes") {
508
- return { baseHead: this.head, commit: this.head, patch: Buffer.alloc(0) };
509
- }
510
- if (!existsSync(this.worktreePath)) {
511
- throw new Error(`Cannot snapshot isolated worktree after it was removed: ${this.worktreePath}`);
512
- }
513
- const snapshot = await this.collectChanges(this.head);
514
- this.continuationCheckpoint = await this.createCheckpoint(snapshot.stdout);
515
- return cloneCheckpoint(this.continuationCheckpoint);
516
- }
517
-
518
- discard(): Promise<void> {
519
- if (this.discardPromise) return this.discardPromise;
520
- if (this.finalization) {
521
- return this.finalization.then(() => undefined);
522
- }
523
- this.currentState = "finalizing";
524
- this.discardPromise = this.removeAndPrune().then((error) => {
525
- if (error) {
526
- this.currentState = "retained";
527
- throw new Error(error);
528
- }
529
- this.currentState = "no_changes";
530
- });
531
- return this.discardPromise;
532
- }
533
-
534
- async hasPendingChanges(): Promise<boolean> {
535
- // A settled worktree already recorded the answer; asking Git again after
536
- // removal would fail. Diffing the integration base (not HEAD) keeps a
537
- // continuation honest: only this generation's own work counts.
538
- if (this.currentState === "no_changes") return false;
539
- if (this.currentState !== "active" || !existsSync(this.worktreePath)) return true;
540
- const diff = await this.collectChanges(this.integrationBaseHead);
541
- return diff.stdout.length > 0;
542
- }
543
-
544
- finalize(): Promise<WorktreeFinalization> {
545
- if (this.finalization) return this.finalization;
546
- this.currentState = "finalizing";
547
- this.finalization = this.finalizeOnce().then((result) => {
548
- this.currentState = result.status;
549
- return result;
550
- });
551
- return this.finalization;
552
- }
553
-
554
- private async collectChanges(baseHead: string): Promise<CommandResult> {
555
- await runGit(
556
- this.runner,
557
- this.worktreePath,
558
- ["add", "-N", "--", "."],
559
- `Collecting untracked files in isolated worktree ${this.worktreePath}`,
560
- );
561
- return runGit(
562
- this.runner,
563
- this.worktreePath,
564
- ["diff", "--binary", baseHead, "--"],
565
- `Collecting isolated changes from ${this.worktreePath}`,
566
- );
567
- }
568
-
569
- private async createCheckpoint(patch: Buffer): Promise<WorktreeCheckpoint> {
570
- if (patch.length === 0) {
571
- return { baseHead: this.head, commit: this.head, patch: Buffer.alloc(0) };
572
- }
573
- await runGit(
574
- this.runner,
575
- this.worktreePath,
576
- ["add", "-A", "--", "."],
577
- `Preparing isolated checkpoint in ${this.worktreePath}`,
578
- );
579
- const tree = await runGit(
580
- this.runner,
581
- this.worktreePath,
582
- ["write-tree"],
583
- `Writing isolated checkpoint tree in ${this.worktreePath}`,
584
- );
585
- const treeId = tree.stdout.toString("utf8").trim();
586
- if (!treeId) throw new Error("Git returned no isolated checkpoint tree id.");
587
- const commit = await runGit(
588
- this.runner,
589
- this.worktreePath,
590
- [
591
- "-c", "user.name=pi-subagents",
592
- "-c", "user.email=pi-subagents@example.invalid",
593
- "commit-tree", treeId,
594
- "-p", this.head,
595
- "-m", "pi-subagents isolated checkpoint",
596
- ],
597
- `Creating isolated checkpoint commit in ${this.worktreePath}`,
598
- );
599
- const commitId = commit.stdout.toString("utf8").trim();
600
- if (!commitId) throw new Error("Git returned no isolated checkpoint commit id.");
601
- return { baseHead: this.head, commit: commitId, patch: Buffer.from(patch) };
602
- }
603
-
604
- private async finalizeOnce(): Promise<WorktreeFinalization> {
605
- let hadChanges = false;
606
- let patchWritten = false;
607
- let integrated = false;
608
- try {
609
- const diff = await this.collectChanges(this.integrationBaseHead);
610
- const snapshot = this.integrationBaseHead === this.head
611
- ? diff
612
- : await this.collectChanges(this.head);
613
- this.continuationCheckpoint = await this.createCheckpoint(snapshot.stdout);
614
- hadChanges = diff.stdout.length > 0;
615
- if (hadChanges) {
616
- await writeFile(this.patchPath, diff.stdout, { flag: "wx" });
617
- patchWritten = true;
618
- integrated = await this.applyPatchThreeWay();
619
- }
620
-
621
- const cleanupError = await this.removeAndPrune();
622
- if (cleanupError) {
623
- return this.retainedResult(hadChanges, integrated, patchWritten, cleanupError);
624
- }
625
- return {
626
- status: hadChanges ? "integrated" : "no_changes",
627
- integrated,
628
- hadChanges,
629
- };
630
- } catch (error) {
631
- return this.retainedResult(
632
- hadChanges,
633
- integrated,
634
- patchWritten,
635
- error instanceof Error ? error.message : String(error),
636
- );
637
- }
638
- }
639
-
640
- private retainedResult(
641
- hadChanges: boolean,
642
- integrated: boolean,
643
- patchWritten: boolean,
644
- error: string,
645
- ): WorktreeFinalization {
646
- return {
647
- status: "retained",
648
- integrated,
649
- hadChanges,
650
- originalRoot: this.originalRoot,
651
- ...(existsSync(this.worktreePath) ? { worktreePath: this.worktreePath } : {}),
652
- ...(patchWritten && existsSync(this.patchPath) ? { patchPath: this.patchPath } : {}),
653
- error,
654
- };
655
- }
656
-
657
- /** Apply the patch as a three-way merge against its recorded preimage
658
- * blobs, so parallel workers that touched disjoint regions (or disjoint
659
- * files) of the same checkout integrate cleanly instead of the whole patch
660
- * failing on context drift. A genuine overlap still fails and retains the
661
- * artifacts, with conflict markers left in place for the main model to
662
- * resolve. `--3way` implies `--index` and demands a working tree matching
663
- * that index, so everything runs against a private copy of the checkout's
664
- * index: the copy first absorbs the current unstaged state (`add -A`),
665
- * making the working tree "ours" of the merge, and the user's real staged
666
- * state is never touched. */
667
- private async applyPatchThreeWay(): Promise<boolean> {
668
- const indexCopy = join(this.tempDir, "apply-index");
669
- try {
670
- const indexPath = resolve(
671
- this.originalRoot,
672
- (await runGit(
673
- this.runner,
674
- this.originalRoot,
675
- ["rev-parse", "--git-path", "index"],
676
- `Resolving index path for ${this.originalRoot}`,
677
- )).stdout.toString("utf8").trim(),
678
- );
679
- if (!existsSync(indexPath)) {
680
- await runGit(
681
- this.runner,
682
- this.originalRoot,
683
- ["apply", "--binary", "--whitespace=nowarn", this.patchPath],
684
- `Applying isolated patch to ${this.originalRoot}`,
685
- );
686
- return true;
687
- }
688
- await copyFile(indexPath, indexCopy);
689
- await runGit(
690
- this.runner,
691
- this.originalRoot,
692
- ["add", "-A", "--", "."],
693
- `Staging checkout state for isolated merge in ${this.originalRoot}`,
694
- undefined,
695
- { GIT_INDEX_FILE: indexCopy },
696
- );
697
- await runGit(
698
- this.runner,
699
- this.originalRoot,
700
- ["apply", "--binary", "--3way", "--whitespace=nowarn", this.patchPath],
701
- `Three-way applying isolated patch to ${this.originalRoot}`,
702
- undefined,
703
- { GIT_INDEX_FILE: indexCopy },
704
- );
705
- return true;
706
- } finally {
707
- await rm(indexCopy, { force: true }).catch(() => undefined);
708
- }
709
- }
710
-
711
- /** Return an error string instead of throwing so applied work is never retried. */
712
- private removeAndPrune(): Promise<string | undefined> {
713
- return removeWorktreeGroup(
714
- { originalRoot: this.originalRoot, worktreePath: this.worktreePath, tempDir: this.tempDir },
715
- this.runner,
716
- );
717
- }
718
- }
719
-
720
- /**
721
- * Create a detached worktree at repository HEAD. The returned cwd mirrors the
722
- * caller's subdirectory inside that new worktree.
723
- */
724
- export async function createWorktreeIsolation(
725
- cwd: string,
726
- options: WorktreeCreateOptions,
727
- ): Promise<WorktreeIsolation> {
728
- const runner = options.runner ?? runCommand;
729
- const target = await resolveWorktreeTarget(cwd, runner);
730
- const tempBase = resolve(options.tempBaseDir);
731
- await mkdir(tempBase, { recursive: true });
732
- const tempDir = await mkdtemp(join(tempBase, "pi-subagent-worktree-"));
733
- // Ownership is how a later load tells a worktree still in use from one a
734
- // crash abandoned, since neither has a durable record until it checkpoints.
735
- writeTempOwnerMarker(tempDir);
736
- const worktreePath = join(tempDir, "worktree");
737
- const patchPath = join(tempDir, "changes.patch");
738
- let added = false;
739
- try {
740
- await runGit(
741
- runner,
742
- target.originalRoot,
743
- ["worktree", "add", "--detach", worktreePath, "HEAD"],
744
- `Creating detached worktree from ${target.originalRoot}`,
745
- );
746
- added = true;
747
- const isolatedCwd = target.relativeCwd ? join(worktreePath, target.relativeCwd) : worktreePath;
748
- // A requested subdirectory can be untracked/empty in the source worktree;
749
- // recreate the directory so the child still starts at the equivalent path.
750
- await mkdir(isolatedCwd, { recursive: true });
751
-
752
- let integrationBaseHead = target.head;
753
- const checkpoint = options.seedCheckpoint;
754
- if (checkpoint && checkpoint.patch.length > WORKTREE_PATCH_MAX_BYTES) {
755
- throw new Error(
756
- `Isolated checkpoint exceeds the ${WORKTREE_PATCH_MAX_BYTES}-byte patch limit (${checkpoint.patch.length} bytes).`,
757
- );
758
- }
759
- if (checkpoint && checkpoint.patch.length > 0) {
760
- // Merge the checkpoint commit with today's HEAD instead of blindly
761
- // applying its old patch. If the parent committed generation one after
762
- // integration, Git recognizes the equivalent tree and produces HEAD
763
- // unchanged; unrelated newer commits are preserved by the three-way merge.
764
- const merged = await runGit(
765
- runner,
766
- worktreePath,
767
- ["merge-tree", "--write-tree", "--messages", target.head, checkpoint.commit],
768
- `Merging isolated checkpoint into continuation ${worktreePath}`,
769
- );
770
- const mergedTree = merged.stdout.toString("utf8").split(/\r?\n/, 1)[0]?.trim();
771
- if (!mergedTree) throw new Error("Git returned no merged continuation tree id.");
772
- await runGit(
773
- runner,
774
- worktreePath,
775
- ["read-tree", "--reset", "-u", mergedTree],
776
- `Materializing isolated checkpoint in ${worktreePath}`,
777
- );
778
- if (options.seedIsIntegrated) {
779
- const commit = await runGit(
780
- runner,
781
- worktreePath,
782
- [
783
- "-c", "user.name=pi-subagents",
784
- "-c", "user.email=pi-subagents@example.invalid",
785
- "commit-tree", mergedTree,
786
- "-p", target.head,
787
- "-m", "pi-subagents continuation baseline",
788
- ],
789
- `Creating continuation baseline commit in ${worktreePath}`,
790
- );
791
- integrationBaseHead = commit.stdout.toString("utf8").trim();
792
- if (!integrationBaseHead) throw new Error("Git returned no continuation baseline commit id.");
793
- }
794
- }
795
-
796
- return new GitWorktreeIsolation(
797
- target.originalCwd,
798
- target.originalRoot,
799
- isolatedCwd,
800
- worktreePath,
801
- tempDir,
802
- patchPath,
803
- target.head,
804
- runner,
805
- integrationBaseHead,
806
- );
807
- } catch (error) {
808
- const rollbackErrors: string[] = [];
809
- if (added || existsSync(worktreePath)) {
810
- try {
811
- await runGit(
812
- runner,
813
- target.originalRoot,
814
- ["worktree", "remove", "--force", worktreePath],
815
- `Rolling back isolated worktree ${worktreePath}`,
816
- );
817
- } catch (rollbackError) {
818
- rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
819
- }
820
- }
821
- try {
822
- await runGit(
823
- runner,
824
- target.originalRoot,
825
- ["worktree", "prune"],
826
- `Pruning Git worktree metadata after setup failure`,
827
- );
828
- } catch (rollbackError) {
829
- rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
830
- }
831
- try {
832
- await rm(tempDir, { recursive: true, force: true });
833
- } catch (rollbackError) {
834
- rollbackErrors.push(`Removing ${tempDir} failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
835
- }
836
- const retainedPaths = [worktreePath, patchPath, tempDir].filter((path) => existsSync(path));
837
- const cause = error instanceof Error ? error.message : String(error);
838
- throw new WorktreeSetupError(
839
- `Could not create isolated Git worktree: ${cause}${rollbackErrors.length > 0 ? ` Rollback errors: ${rollbackErrors.join("; ")}` : ""}${retainedPaths.length > 0 ? ` Retained artifacts: ${retainedPaths.join(", ")}` : ""}`,
840
- retainedPaths,
841
- );
842
- }
843
- }
844
-
845
- /** Snapshot only states whose filesystem or repository objects still exist.
846
- * Transient (`finalizing`) and discarded handles are not persistable. */
847
- export function worktreeSnapshot(worktree: WorktreeIsolation): WorktreeSnapshot | undefined {
848
- const state = worktree.state;
849
- if (state !== "active" && state !== "retained" && state !== "integrated" && state !== "no_changes") {
850
- return undefined;
851
- }
852
- const checkpoint = worktree.getContinuationCheckpoint();
853
- return {
854
- originalCwd: worktree.originalCwd,
855
- originalRoot: worktree.originalRoot,
856
- cwd: worktree.cwd,
857
- worktreePath: worktree.worktreePath,
858
- tempDir: worktree.tempDir,
859
- patchPath: worktree.patchPath,
860
- head: worktree.head,
861
- integrationBaseHead: worktree.integrationBaseHead,
862
- state,
863
- ...(checkpoint && checkpoint.patch.length > 0
864
- ? { checkpoint: { baseHead: checkpoint.baseHead, commit: checkpoint.commit } }
865
- : {}),
866
- };
867
- }
868
-
869
- /** Validate an untrusted persisted snapshot; null when it is unusable. */
870
- export function normalizeWorktreeSnapshot(value: unknown): WorktreeSnapshot | null {
871
- if (!value || typeof value !== "object") return null;
872
- const raw = value as Record<string, unknown>;
873
- const fields: Record<string, string> = {};
874
- for (const key of [
875
- "originalCwd",
876
- "originalRoot",
877
- "cwd",
878
- "worktreePath",
879
- "tempDir",
880
- "patchPath",
881
- "head",
882
- "integrationBaseHead",
883
- ] as const) {
884
- if (typeof raw[key] !== "string" || !raw[key]) return null;
885
- fields[key] = raw[key] as string;
886
- }
887
- if (raw.state !== "active" && raw.state !== "retained" && raw.state !== "integrated" && raw.state !== "no_changes") {
888
- return null;
889
- }
890
- let checkpoint: WorktreeCheckpointRef | undefined;
891
- if (raw.checkpoint && typeof raw.checkpoint === "object") {
892
- const rawCheckpoint = raw.checkpoint as Record<string, unknown>;
893
- if (typeof rawCheckpoint.baseHead !== "string" || !rawCheckpoint.baseHead) return null;
894
- if (typeof rawCheckpoint.commit !== "string" || !rawCheckpoint.commit) return null;
895
- checkpoint = { baseHead: rawCheckpoint.baseHead, commit: rawCheckpoint.commit };
896
- }
897
- return {
898
- originalCwd: fields.originalCwd!,
899
- originalRoot: fields.originalRoot!,
900
- cwd: fields.cwd!,
901
- worktreePath: fields.worktreePath!,
902
- tempDir: fields.tempDir!,
903
- patchPath: fields.patchPath!,
904
- head: fields.head!,
905
- integrationBaseHead: fields.integrationBaseHead!,
906
- state: raw.state,
907
- ...(checkpoint ? { checkpoint } : {}),
908
- };
909
- }
910
-
911
- /** Rebuild a handle from a persisted snapshot. Returns undefined when the
912
- * on-disk worktree that an active/retained snapshot promises is gone; settled
913
- * states (integrated/no_changes) intentionally need no filesystem. The
914
- * restored checkpoint carries no patch bytes — only its commit is consumed by
915
- * continuation seeds. */
916
- export async function restoreWorktreeIsolation(
917
- snapshot: WorktreeSnapshot,
918
- options: { runner?: CommandRunner } = {},
919
- ): Promise<WorktreeIsolation | undefined> {
920
- if (
921
- (snapshot.state === "active" || snapshot.state === "retained") &&
922
- !existsSync(snapshot.worktreePath)
923
- ) {
924
- return undefined;
925
- }
926
- return new GitWorktreeIsolation(
927
- snapshot.originalCwd,
928
- snapshot.originalRoot,
929
- snapshot.cwd,
930
- snapshot.worktreePath,
931
- snapshot.tempDir,
932
- snapshot.patchPath,
933
- snapshot.head,
934
- options.runner ?? runCommand,
935
- snapshot.integrationBaseHead,
936
- {
937
- state: snapshot.state,
938
- ...(snapshot.checkpoint
939
- ? { checkpoint: { ...snapshot.checkpoint, patch: Buffer.alloc(0) } }
940
- : {}),
941
- },
942
- );
943
- }
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, and resumes. 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, symlinkSync } from "node:fs";
13
+ import { copyFile, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
14
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
15
+ import { writeTempOwnerMarker } from "./temp-hygiene.ts";
16
+
17
+ export type IsolationMode = "shared" | "worktree";
18
+
19
+ const WORKTREE_TEMP_DIR_PREFIX = "pi-subagent-worktree-";
20
+
21
+ /** Short stable identity of one isolated worktree group (the mkdtemp suffix).
22
+ * Continuation generations create a fresh worktree, so the identity
23
+ * visibly changes when the group's filesystem boundary changes. */
24
+ export function worktreeGroupId(worktree: Pick<WorktreeIsolation, "tempDir">): string {
25
+ const base = worktree.tempDir.split(/[\\/]/).filter(Boolean).pop() ?? worktree.tempDir;
26
+ return base.startsWith(WORKTREE_TEMP_DIR_PREFIX)
27
+ ? base.slice(WORKTREE_TEMP_DIR_PREFIX.length)
28
+ : base;
29
+ }
30
+
31
+ export interface CommandRunOptions {
32
+ cwd: string;
33
+ input?: Buffer;
34
+ signal?: AbortSignal;
35
+ timeoutMs?: number;
36
+ maxOutputBytes?: number;
37
+ /** Extra environment entries merged over the inherited environment. */
38
+ env?: Record<string, string>;
39
+ }
40
+
41
+ export interface CommandResult {
42
+ code: number;
43
+ stdout: Buffer;
44
+ stderr: Buffer;
45
+ }
46
+
47
+ /** Injectable, shell-free command runner used by every Git operation. */
48
+ export type CommandRunner = (
49
+ command: string,
50
+ args: readonly string[],
51
+ options: CommandRunOptions,
52
+ ) => Promise<CommandResult>;
53
+
54
+ export const GIT_COMMAND_TIMEOUT_MS = 120_000;
55
+ export const GIT_COMMAND_KILL_GRACE_MS = 2_000;
56
+ export const GIT_OUTPUT_MAX_BYTES = 64 * 1024 * 1024;
57
+ export const WORKTREE_PATCH_MAX_BYTES = GIT_OUTPUT_MAX_BYTES;
58
+
59
+ /** Git apply validates and writes in one process. The repository lane
60
+ * (thread-lifecycle) already serializes every finalize against all writers of
61
+ * the same canonical checkout, so applies never race each other here. */
62
+ function terminateCommandTree(child: ChildProcess, force: boolean, processGroup: boolean): void {
63
+ if (process.platform === "win32" && child.pid !== undefined) {
64
+ const fallback = (): void => {
65
+ try {
66
+ child.kill(force ? "SIGKILL" : "SIGTERM");
67
+ } catch {
68
+ /* process may already be gone */
69
+ }
70
+ };
71
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
72
+ stdio: "ignore",
73
+ windowsHide: true,
74
+ });
75
+ killer.once("error", fallback);
76
+ killer.once("close", (code) => {
77
+ if (code !== 0) fallback();
78
+ });
79
+ return;
80
+ }
81
+ try {
82
+ if (processGroup && child.pid !== undefined) {
83
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
84
+ } else {
85
+ child.kill(force ? "SIGKILL" : "SIGTERM");
86
+ }
87
+ } catch {
88
+ /* process may already be gone */
89
+ }
90
+ }
91
+
92
+ /** Default argument-safe runner. Output is bounded before binary patches enter
93
+ * memory, and timeout/abort terminates the complete checkout-filter process tree. */
94
+ export const runCommand: CommandRunner = (command, args, options) =>
95
+ new Promise<CommandResult>((resolveResult, reject) => {
96
+ if (options.signal?.aborted) {
97
+ reject(new Error(`Command aborted before start: ${command}`));
98
+ return;
99
+ }
100
+ const usePosixProcessGroup = process.platform !== "win32";
101
+ const child = spawn(command, [...args], {
102
+ cwd: options.cwd,
103
+ shell: false,
104
+ windowsHide: true,
105
+ stdio: ["pipe", "pipe", "pipe"],
106
+ detached: usePosixProcessGroup,
107
+ ...(options.env ? { env: { ...process.env, ...options.env } } : {}),
108
+ });
109
+ const stdout: Buffer[] = [];
110
+ const stderr: Buffer[] = [];
111
+ const maxOutputBytes = options.maxOutputBytes ?? GIT_OUTPUT_MAX_BYTES;
112
+ let outputBytes = 0;
113
+ let finished = false;
114
+ let failure: Error | undefined;
115
+ let timeout: ReturnType<typeof setTimeout> | undefined;
116
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
117
+
118
+ const terminate = (): void => {
119
+ terminateCommandTree(child, false, usePosixProcessGroup);
120
+ if (!forceKillTimer) {
121
+ forceKillTimer = setTimeout(
122
+ () => terminateCommandTree(child, true, usePosixProcessGroup),
123
+ GIT_COMMAND_KILL_GRACE_MS,
124
+ );
125
+ if (typeof forceKillTimer.unref === "function") forceKillTimer.unref();
126
+ }
127
+ };
128
+ const fail = (error: Error): void => {
129
+ if (failure || finished) return;
130
+ failure = error;
131
+ terminate();
132
+ };
133
+ const append = (target: Buffer[], chunk: Buffer | string): void => {
134
+ if (failure || finished) return;
135
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
136
+ outputBytes += value.length;
137
+ if (outputBytes > maxOutputBytes) {
138
+ fail(new Error(`Command output exceeded ${maxOutputBytes} bytes: ${command}`));
139
+ return;
140
+ }
141
+ target.push(value);
142
+ };
143
+ const onAbort = (): void => fail(new Error(`Command aborted: ${command}`));
144
+ options.signal?.addEventListener("abort", onAbort, { once: true });
145
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
146
+ timeout = setTimeout(
147
+ () => fail(new Error(`Command timed out after ${options.timeoutMs}ms: ${command}`)),
148
+ options.timeoutMs,
149
+ );
150
+ if (typeof timeout.unref === "function") timeout.unref();
151
+ }
152
+
153
+ child.stdout?.on("data", (chunk: Buffer | string) => append(stdout, chunk));
154
+ child.stderr?.on("data", (chunk: Buffer | string) => append(stderr, chunk));
155
+ child.once("error", (error) => fail(error));
156
+ child.once("close", (code) => {
157
+ if (finished) return;
158
+ finished = true;
159
+ if (timeout) clearTimeout(timeout);
160
+ if (forceKillTimer) clearTimeout(forceKillTimer);
161
+ options.signal?.removeEventListener("abort", onAbort);
162
+ if (failure) {
163
+ reject(failure);
164
+ return;
165
+ }
166
+ resolveResult({
167
+ code: code ?? 1,
168
+ stdout: Buffer.concat(stdout),
169
+ stderr: Buffer.concat(stderr),
170
+ });
171
+ });
172
+ child.stdin?.once("error", () => undefined);
173
+ child.stdin?.end(options.input);
174
+ });
175
+
176
+ export interface WorktreeTarget {
177
+ /** Canonical cwd requested by the caller. */
178
+ originalCwd: string;
179
+ /** Canonical top-level directory of the source Git worktree. */
180
+ originalRoot: string;
181
+ /** Path from originalRoot to originalCwd (empty for the root). */
182
+ relativeCwd: string;
183
+ head: string;
184
+ }
185
+
186
+ export interface WorktreeCheckpoint {
187
+ /** Commit checked out when this isolated generation began. */
188
+ baseHead: string;
189
+ /** Synthetic commit whose tree is the generation's complete final state. */
190
+ commit: string;
191
+ /** Binary delta from baseHead, retained for size checks and diagnostics. */
192
+ patch: Buffer;
193
+ }
194
+
195
+ export interface WorktreeCheckpointRef {
196
+ baseHead: string;
197
+ commit: string;
198
+ }
199
+
200
+ /** Persistable projection of one worktree handle: enough to rebuild the
201
+ * handle after a reload or restart. Patch bytes are deliberately omitted —
202
+ * only the checkpoint commit, which lives in the shared repository object
203
+ * store, is needed to seed a continuation. */
204
+ export interface WorktreeSnapshot {
205
+ originalCwd: string;
206
+ originalRoot: string;
207
+ cwd: string;
208
+ worktreePath: string;
209
+ tempDir: string;
210
+ patchPath: string;
211
+ head: string;
212
+ integrationBaseHead: string;
213
+ state: "active" | "retained" | "integrated" | "no_changes";
214
+ checkpoint?: WorktreeCheckpointRef;
215
+ }
216
+
217
+ export interface WorktreeCreateOptions {
218
+ runner?: CommandRunner;
219
+ /** Parent directory for the worktree group: the project-scoped durable
220
+ * worktrees root, so isolation never lands in the OS temp directory. */
221
+ tempBaseDir: string;
222
+ /** Complete source generation checkpoint merged onto the current HEAD. */
223
+ seedCheckpoint?: WorktreeCheckpoint;
224
+ /** The seed is already present in the parent checkout, so only later edits
225
+ * should be integrated when this continuation settles. */
226
+ seedIsIntegrated?: boolean;
227
+ }
228
+
229
+ export type WorktreeFinalizationStatus = "integrated" | "no_changes" | "retained";
230
+
231
+ export interface WorktreeFinalization {
232
+ status: WorktreeFinalizationStatus;
233
+ /** True once the patch was successfully applied to the original worktree. */
234
+ integrated: boolean;
235
+ hadChanges: boolean;
236
+ /** Repository a recovery path can prune stale worktree metadata against. */
237
+ originalRoot?: string;
238
+ worktreePath?: string;
239
+ patchPath?: string;
240
+ error?: string;
241
+ }
242
+
243
+ export interface WorktreeIsolation {
244
+ readonly originalCwd: string;
245
+ readonly originalRoot: string;
246
+ readonly cwd: string;
247
+ readonly worktreePath: string;
248
+ readonly tempDir: string;
249
+ readonly patchPath: string;
250
+ readonly head: string;
251
+ /** Diff base for final integration; a continuation baseline commit when
252
+ * the generation was seeded with already-integrated work. */
253
+ readonly integrationBaseHead: string;
254
+ readonly state: "active" | "finalizing" | WorktreeFinalizationStatus;
255
+ /** Checkpoint retained after finalization for continuation resumes. */
256
+ getContinuationCheckpoint(): WorktreeCheckpoint | undefined;
257
+ /** Capture the complete isolated filesystem state for a fresh continuation.
258
+ * The synthetic commit lets Git merge an already-committed seed without
259
+ * attempting to apply the same patch twice. */
260
+ snapshotCheckpoint(): Promise<WorktreeCheckpoint>;
261
+ /** Remove a newly-created continuation that failed before it was dispatched. */
262
+ discard(): Promise<void>;
263
+ /** Whether anything is pending against the integration base — the same
264
+ * question finalization answers as `hadChanges`, asked before settlement so
265
+ * policy can tell a run that produced a diff from one that produced none. */
266
+ hasPendingChanges(): Promise<boolean>;
267
+ /** Idempotent across stale generations and repeated stop/shutdown paths. */
268
+ finalize(): Promise<WorktreeFinalization>;
269
+ }
270
+
271
+ export class WorktreeSetupError extends Error {
272
+ constructor(
273
+ message: string,
274
+ readonly retainedPaths: readonly string[] = [],
275
+ ) {
276
+ super(message);
277
+ this.name = "WorktreeSetupError";
278
+ }
279
+ }
280
+
281
+ const ERROR_OUTPUT_MAX = 8_000;
282
+
283
+ function cloneCheckpoint(checkpoint: WorktreeCheckpoint): WorktreeCheckpoint {
284
+ return { ...checkpoint, patch: Buffer.from(checkpoint.patch) };
285
+ }
286
+
287
+ function outputText(result: CommandResult): string {
288
+ const text = (result.stderr.length > 0 ? result.stderr : result.stdout).toString("utf8").trim();
289
+ if (text.length <= ERROR_OUTPUT_MAX) return text;
290
+ return `${text.slice(0, ERROR_OUTPUT_MAX - 1)}…`;
291
+ }
292
+
293
+ function commandFailure(action: string, result: CommandResult): Error {
294
+ const detail = outputText(result);
295
+ return new Error(`${action} failed (exit ${result.code})${detail ? `: ${detail}` : "."}`);
296
+ }
297
+
298
+ async function runGit(
299
+ runner: CommandRunner,
300
+ cwd: string,
301
+ args: readonly string[],
302
+ action: string,
303
+ input?: Buffer,
304
+ env?: Record<string, string>,
305
+ ): Promise<CommandResult> {
306
+ let result: CommandResult;
307
+ try {
308
+ result = await runner("git", args, {
309
+ cwd,
310
+ input,
311
+ env,
312
+ timeoutMs: GIT_COMMAND_TIMEOUT_MS,
313
+ maxOutputBytes: GIT_OUTPUT_MAX_BYTES,
314
+ });
315
+ } catch (error) {
316
+ throw new Error(`${action} failed: ${error instanceof Error ? error.message : String(error)}`);
317
+ }
318
+ if (result.code !== 0) throw commandFailure(action, result);
319
+ return result;
320
+ }
321
+
322
+ /** True only when candidate is root itself or a descendant (cross-platform). */
323
+ export function isPathInside(root: string, candidate: string): boolean {
324
+ const rel = relative(root, candidate);
325
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
326
+ }
327
+
328
+ /** The temp group directory backing a worktree path, when the path actually
329
+ * names our `<group>/worktree` layout. Recovery deletes only paths read back
330
+ * from the manifest through this guard. */
331
+ export function worktreeGroupDir(worktreePath: string): string | undefined {
332
+ const group = dirname(worktreePath);
333
+ return basename(worktreePath) === "worktree" && basename(group).startsWith(WORKTREE_TEMP_DIR_PREFIX)
334
+ ? group
335
+ : undefined;
336
+ }
337
+
338
+ /** Delete one isolated worktree group: Git's own removal keeps metadata
339
+ * authoritative, but Git on Windows cannot always delete deep checkouts
340
+ * ("Filename too long"), so the Node removal decides the outcome and the prune
341
+ * clears any stale registration left behind. Returns an error string when
342
+ * artifacts still exist afterwards. */
343
+ export async function removeWorktreeGroup(
344
+ paths: { originalRoot?: string; tempDir: string; worktreePath: string },
345
+ runner: CommandRunner = runCommand,
346
+ ): Promise<string | undefined> {
347
+ let removeError: string | undefined;
348
+ if (paths.originalRoot && existsSync(paths.worktreePath)) {
349
+ // core.longpaths only exists in Git for Windows, and some POSIX builds
350
+ // reject unknown -c core keys, so the flag stays platform-gated.
351
+ const longPaths = process.platform === "win32" ? ["-c", "core.longpaths=true"] : [];
352
+ try {
353
+ await runGit(
354
+ runner,
355
+ paths.originalRoot,
356
+ [...longPaths, "worktree", "remove", "--force", paths.worktreePath],
357
+ `Removing isolated worktree ${paths.worktreePath}`,
358
+ );
359
+ } catch (error) {
360
+ removeError = error instanceof Error ? error.message : String(error);
361
+ }
362
+ }
363
+ try {
364
+ await rm(paths.tempDir, { recursive: true, force: true });
365
+ } catch (error) {
366
+ const rmError = error instanceof Error ? error.message : String(error);
367
+ return removeError
368
+ ? `${removeError}; removing temporary directory failed: ${rmError}`
369
+ : `Removing temporary directory failed: ${rmError}`;
370
+ }
371
+ if (!paths.originalRoot) return undefined;
372
+ try {
373
+ await runGit(
374
+ runner,
375
+ paths.originalRoot,
376
+ ["worktree", "prune"],
377
+ `Pruning Git worktree metadata for ${paths.originalRoot}`,
378
+ );
379
+ } catch (error) {
380
+ return error instanceof Error ? error.message : String(error);
381
+ }
382
+ return undefined;
383
+ }
384
+
385
+ interface RepositoryLocation {
386
+ originalCwd: string;
387
+ originalRoot: string;
388
+ }
389
+
390
+ /** Resolve the canonical repository root without requiring a committed HEAD.
391
+ * Managed repository lanes use this for empty repositories as well as normal
392
+ * worktrees; worktree creation validates HEAD separately below. */
393
+ async function resolveRepositoryLocation(
394
+ cwd: string,
395
+ runner: CommandRunner,
396
+ ): Promise<RepositoryLocation> {
397
+ const requested = resolve(cwd);
398
+ try {
399
+ if (!(await stat(requested)).isDirectory()) throw new Error("not a directory");
400
+ } catch (error) {
401
+ throw new WorktreeSetupError(
402
+ `Worktree isolation requires an existing directory; cwd ${requested} is unavailable (${error instanceof Error ? error.message : String(error)}).`,
403
+ );
404
+ }
405
+ const originalCwd = await realpath(requested);
406
+ let topLevel: CommandResult;
407
+ try {
408
+ topLevel = await runGit(
409
+ runner,
410
+ originalCwd,
411
+ ["rev-parse", "--show-toplevel"],
412
+ `Git repository discovery for ${originalCwd}`,
413
+ );
414
+ } catch (error) {
415
+ throw new WorktreeSetupError(
416
+ `Worktree isolation requires cwd to be inside a Git worktree/repository: ${error instanceof Error ? error.message : String(error)}`,
417
+ );
418
+ }
419
+ const reportedRoot = topLevel.stdout.toString("utf8").trim();
420
+ if (!reportedRoot) {
421
+ throw new WorktreeSetupError(`Git repository discovery for ${originalCwd} returned no top-level path.`);
422
+ }
423
+ const originalRoot = await realpath(resolve(reportedRoot));
424
+ if (!isPathInside(originalRoot, originalCwd)) {
425
+ throw new WorktreeSetupError(
426
+ `Requested cwd ${originalCwd} is not inside Git worktree root ${originalRoot}.`,
427
+ );
428
+ }
429
+ return { originalCwd, originalRoot };
430
+ }
431
+
432
+ export async function resolveRepositoryRoot(
433
+ cwd: string,
434
+ runner: CommandRunner = runCommand,
435
+ ): Promise<string> {
436
+ return (await resolveRepositoryLocation(cwd, runner)).originalRoot;
437
+ }
438
+
439
+ /** Resolve and validate the Git repository/worktree that contains cwd. */
440
+ export async function resolveWorktreeTarget(
441
+ cwd: string,
442
+ runner: CommandRunner = runCommand,
443
+ ): Promise<WorktreeTarget> {
444
+ const { originalCwd, originalRoot } = await resolveRepositoryLocation(cwd, runner);
445
+ let headResult: CommandResult;
446
+ try {
447
+ headResult = await runGit(
448
+ runner,
449
+ originalRoot,
450
+ ["rev-parse", "--verify", "HEAD"],
451
+ `Resolving HEAD for ${originalRoot}`,
452
+ );
453
+ } catch (error) {
454
+ throw new WorktreeSetupError(
455
+ `Worktree isolation requires a repository with a committed HEAD: ${error instanceof Error ? error.message : String(error)}`,
456
+ );
457
+ }
458
+ return {
459
+ originalCwd,
460
+ originalRoot,
461
+ relativeCwd: relative(originalRoot, originalCwd),
462
+ head: headResult.stdout.toString("utf8").trim(),
463
+ };
464
+ }
465
+
466
+ class GitWorktreeIsolation implements WorktreeIsolation {
467
+ private currentState: WorktreeIsolation["state"] = "active";
468
+ private finalization?: Promise<WorktreeFinalization>;
469
+ private discardPromise?: Promise<void>;
470
+ /** Full workspace checkpoint relative to the generation's starting HEAD,
471
+ * retained after cleanup so settled threads can continue safely. */
472
+ private continuationCheckpoint?: WorktreeCheckpoint;
473
+
474
+ constructor(
475
+ readonly originalCwd: string,
476
+ readonly originalRoot: string,
477
+ readonly cwd: string,
478
+ readonly worktreePath: string,
479
+ readonly tempDir: string,
480
+ readonly patchPath: string,
481
+ readonly head: string,
482
+ private readonly runner: CommandRunner,
483
+ /** May be a synthetic tree commit representing a seed that the parent
484
+ * checkout already contains. Finalization then integrates only new edits. */
485
+ readonly integrationBaseHead: string = head,
486
+ restored?: {
487
+ state: WorktreeIsolation["state"];
488
+ checkpoint?: WorktreeCheckpoint;
489
+ },
490
+ ) {
491
+ if (restored) {
492
+ this.currentState = restored.state;
493
+ if (restored.checkpoint) this.continuationCheckpoint = cloneCheckpoint(restored.checkpoint);
494
+ }
495
+ }
496
+
497
+ get state(): WorktreeIsolation["state"] {
498
+ return this.currentState;
499
+ }
500
+
501
+ getContinuationCheckpoint(): WorktreeCheckpoint | undefined {
502
+ return this.continuationCheckpoint ? cloneCheckpoint(this.continuationCheckpoint) : undefined;
503
+ }
504
+
505
+ async snapshotCheckpoint(): Promise<WorktreeCheckpoint> {
506
+ if (this.continuationCheckpoint) return cloneCheckpoint(this.continuationCheckpoint);
507
+ if (this.currentState === "no_changes") {
508
+ return { baseHead: this.head, commit: this.head, patch: Buffer.alloc(0) };
509
+ }
510
+ if (!existsSync(this.worktreePath)) {
511
+ throw new Error(`Cannot snapshot isolated worktree after it was removed: ${this.worktreePath}`);
512
+ }
513
+ const snapshot = await this.collectChanges(this.head);
514
+ this.continuationCheckpoint = await this.createCheckpoint(snapshot.stdout);
515
+ return cloneCheckpoint(this.continuationCheckpoint);
516
+ }
517
+
518
+ discard(): Promise<void> {
519
+ if (this.discardPromise) return this.discardPromise;
520
+ if (this.finalization) {
521
+ return this.finalization.then(() => undefined);
522
+ }
523
+ this.currentState = "finalizing";
524
+ this.discardPromise = this.removeAndPrune().then((error) => {
525
+ if (error) {
526
+ this.currentState = "retained";
527
+ throw new Error(error);
528
+ }
529
+ this.currentState = "no_changes";
530
+ });
531
+ return this.discardPromise;
532
+ }
533
+
534
+ async hasPendingChanges(): Promise<boolean> {
535
+ // A settled worktree already recorded the answer; asking Git again after
536
+ // removal would fail. Diffing the integration base (not HEAD) keeps a
537
+ // continuation honest: only this generation's own work counts.
538
+ if (this.currentState === "no_changes") return false;
539
+ if (this.currentState !== "active" || !existsSync(this.worktreePath)) return true;
540
+ const diff = await this.collectChanges(this.integrationBaseHead);
541
+ return diff.stdout.length > 0;
542
+ }
543
+
544
+ finalize(): Promise<WorktreeFinalization> {
545
+ if (this.finalization) return this.finalization;
546
+ this.currentState = "finalizing";
547
+ this.finalization = this.finalizeOnce().then((result) => {
548
+ this.currentState = result.status;
549
+ return result;
550
+ });
551
+ return this.finalization;
552
+ }
553
+
554
+ private async collectChanges(baseHead: string): Promise<CommandResult> {
555
+ await runGit(
556
+ this.runner,
557
+ this.worktreePath,
558
+ ["add", "-N", "--", "."],
559
+ `Collecting untracked files in isolated worktree ${this.worktreePath}`,
560
+ );
561
+ return runGit(
562
+ this.runner,
563
+ this.worktreePath,
564
+ ["diff", "--binary", baseHead, "--"],
565
+ `Collecting isolated changes from ${this.worktreePath}`,
566
+ );
567
+ }
568
+
569
+ private async createCheckpoint(patch: Buffer): Promise<WorktreeCheckpoint> {
570
+ if (patch.length === 0) {
571
+ return { baseHead: this.head, commit: this.head, patch: Buffer.alloc(0) };
572
+ }
573
+ await runGit(
574
+ this.runner,
575
+ this.worktreePath,
576
+ ["add", "-A", "--", "."],
577
+ `Preparing isolated checkpoint in ${this.worktreePath}`,
578
+ );
579
+ const tree = await runGit(
580
+ this.runner,
581
+ this.worktreePath,
582
+ ["write-tree"],
583
+ `Writing isolated checkpoint tree in ${this.worktreePath}`,
584
+ );
585
+ const treeId = tree.stdout.toString("utf8").trim();
586
+ if (!treeId) throw new Error("Git returned no isolated checkpoint tree id.");
587
+ const commit = await runGit(
588
+ this.runner,
589
+ this.worktreePath,
590
+ [
591
+ "-c", "user.name=pi-subagents",
592
+ "-c", "user.email=pi-subagents@example.invalid",
593
+ "commit-tree", treeId,
594
+ "-p", this.head,
595
+ "-m", "pi-subagents isolated checkpoint",
596
+ ],
597
+ `Creating isolated checkpoint commit in ${this.worktreePath}`,
598
+ );
599
+ const commitId = commit.stdout.toString("utf8").trim();
600
+ if (!commitId) throw new Error("Git returned no isolated checkpoint commit id.");
601
+ return { baseHead: this.head, commit: commitId, patch: Buffer.from(patch) };
602
+ }
603
+
604
+ private async finalizeOnce(): Promise<WorktreeFinalization> {
605
+ let hadChanges = false;
606
+ let patchWritten = false;
607
+ let integrated = false;
608
+ try {
609
+ const diff = await this.collectChanges(this.integrationBaseHead);
610
+ const snapshot = this.integrationBaseHead === this.head
611
+ ? diff
612
+ : await this.collectChanges(this.head);
613
+ this.continuationCheckpoint = await this.createCheckpoint(snapshot.stdout);
614
+ hadChanges = diff.stdout.length > 0;
615
+ if (hadChanges) {
616
+ await writeFile(this.patchPath, diff.stdout, { flag: "wx" });
617
+ patchWritten = true;
618
+ integrated = await this.applyPatchThreeWay();
619
+ }
620
+
621
+ const cleanupError = await this.removeAndPrune();
622
+ if (cleanupError) {
623
+ return this.retainedResult(hadChanges, integrated, patchWritten, cleanupError);
624
+ }
625
+ return {
626
+ status: hadChanges ? "integrated" : "no_changes",
627
+ integrated,
628
+ hadChanges,
629
+ };
630
+ } catch (error) {
631
+ return this.retainedResult(
632
+ hadChanges,
633
+ integrated,
634
+ patchWritten,
635
+ error instanceof Error ? error.message : String(error),
636
+ );
637
+ }
638
+ }
639
+
640
+ private retainedResult(
641
+ hadChanges: boolean,
642
+ integrated: boolean,
643
+ patchWritten: boolean,
644
+ error: string,
645
+ ): WorktreeFinalization {
646
+ return {
647
+ status: "retained",
648
+ integrated,
649
+ hadChanges,
650
+ originalRoot: this.originalRoot,
651
+ ...(existsSync(this.worktreePath) ? { worktreePath: this.worktreePath } : {}),
652
+ ...(patchWritten && existsSync(this.patchPath) ? { patchPath: this.patchPath } : {}),
653
+ error,
654
+ };
655
+ }
656
+
657
+ /** Apply the patch as a three-way merge against its recorded preimage
658
+ * blobs, so parallel workers that touched disjoint regions (or disjoint
659
+ * files) of the same checkout integrate cleanly instead of the whole patch
660
+ * failing on context drift. A genuine overlap still fails and retains the
661
+ * artifacts, with conflict markers left in place for the main model to
662
+ * resolve. `--3way` implies `--index` and demands a working tree matching
663
+ * that index, so everything runs against a private copy of the checkout's
664
+ * index: the copy first absorbs the current unstaged state (`add -A`),
665
+ * making the working tree "ours" of the merge, and the user's real staged
666
+ * state is never touched. */
667
+ private async applyPatchThreeWay(): Promise<boolean> {
668
+ const indexCopy = join(this.tempDir, "apply-index");
669
+ try {
670
+ const indexPath = resolve(
671
+ this.originalRoot,
672
+ (await runGit(
673
+ this.runner,
674
+ this.originalRoot,
675
+ ["rev-parse", "--git-path", "index"],
676
+ `Resolving index path for ${this.originalRoot}`,
677
+ )).stdout.toString("utf8").trim(),
678
+ );
679
+ if (!existsSync(indexPath)) {
680
+ await runGit(
681
+ this.runner,
682
+ this.originalRoot,
683
+ ["apply", "--binary", "--whitespace=nowarn", this.patchPath],
684
+ `Applying isolated patch to ${this.originalRoot}`,
685
+ );
686
+ return true;
687
+ }
688
+ await copyFile(indexPath, indexCopy);
689
+ await runGit(
690
+ this.runner,
691
+ this.originalRoot,
692
+ ["add", "-A", "--", "."],
693
+ `Staging checkout state for isolated merge in ${this.originalRoot}`,
694
+ undefined,
695
+ { GIT_INDEX_FILE: indexCopy },
696
+ );
697
+ await runGit(
698
+ this.runner,
699
+ this.originalRoot,
700
+ ["apply", "--binary", "--3way", "--whitespace=nowarn", this.patchPath],
701
+ `Three-way applying isolated patch to ${this.originalRoot}`,
702
+ undefined,
703
+ { GIT_INDEX_FILE: indexCopy },
704
+ );
705
+ return true;
706
+ } finally {
707
+ await rm(indexCopy, { force: true }).catch(() => undefined);
708
+ }
709
+ }
710
+
711
+ /** Return an error string instead of throwing so applied work is never retried. */
712
+ private removeAndPrune(): Promise<string | undefined> {
713
+ return removeWorktreeGroup(
714
+ { originalRoot: this.originalRoot, worktreePath: this.worktreePath, tempDir: this.tempDir },
715
+ this.runner,
716
+ );
717
+ }
718
+ }
719
+
720
+ /** Link the origin checkout's `node_modules` into a fresh worktree, because a
721
+ * worktree holds only tracked files and the verification commands a child is
722
+ * asked to run (`npm run check`, `npm test`) would otherwise fail on missing
723
+ * dependencies. A junction is used on Windows: unlike a directory symlink it
724
+ * needs neither administrator rights nor Developer Mode. The link is skipped
725
+ * unless Git ignores the directory, since Git descends into it and an unignored
726
+ * `node_modules` would enter the integration patch and be written back over the
727
+ * real dependency tree. Best effort — a worktree without the link is still
728
+ * usable, so failure must never abort isolation. */
729
+ async function linkNodeModules(
730
+ runner: CommandRunner,
731
+ originalRoot: string,
732
+ worktreePath: string,
733
+ ): Promise<void> {
734
+ const target = join(originalRoot, "node_modules");
735
+ const link = join(worktreePath, "node_modules");
736
+ if (!existsSync(target) || existsSync(link)) return;
737
+ try {
738
+ const ignored = await runner("git", ["check-ignore", "-q", "--", "node_modules"], {
739
+ cwd: originalRoot,
740
+ timeoutMs: GIT_COMMAND_TIMEOUT_MS,
741
+ maxOutputBytes: GIT_OUTPUT_MAX_BYTES,
742
+ });
743
+ if (ignored.code !== 0) return;
744
+ symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
745
+ } catch {
746
+ /* dependency linking is an optimization, never a setup requirement */
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Create a detached worktree at repository HEAD. The returned cwd mirrors the
752
+ * caller's subdirectory inside that new worktree.
753
+ */
754
+ export async function createWorktreeIsolation(
755
+ cwd: string,
756
+ options: WorktreeCreateOptions,
757
+ ): Promise<WorktreeIsolation> {
758
+ const runner = options.runner ?? runCommand;
759
+ const target = await resolveWorktreeTarget(cwd, runner);
760
+ const tempBase = resolve(options.tempBaseDir);
761
+ await mkdir(tempBase, { recursive: true });
762
+ const tempDir = await mkdtemp(join(tempBase, "pi-subagent-worktree-"));
763
+ // Ownership is how a later load tells a worktree still in use from one a
764
+ // crash abandoned, since neither has a durable record until it checkpoints.
765
+ writeTempOwnerMarker(tempDir);
766
+ const worktreePath = join(tempDir, "worktree");
767
+ const patchPath = join(tempDir, "changes.patch");
768
+ let added = false;
769
+ try {
770
+ await runGit(
771
+ runner,
772
+ target.originalRoot,
773
+ ["worktree", "add", "--detach", worktreePath, "HEAD"],
774
+ `Creating detached worktree from ${target.originalRoot}`,
775
+ );
776
+ added = true;
777
+ const isolatedCwd = target.relativeCwd ? join(worktreePath, target.relativeCwd) : worktreePath;
778
+ // A requested subdirectory can be untracked/empty in the source worktree;
779
+ // recreate the directory so the child still starts at the equivalent path.
780
+ await mkdir(isolatedCwd, { recursive: true });
781
+ await linkNodeModules(runner, target.originalRoot, worktreePath);
782
+
783
+ let integrationBaseHead = target.head;
784
+ const checkpoint = options.seedCheckpoint;
785
+ if (checkpoint && checkpoint.patch.length > WORKTREE_PATCH_MAX_BYTES) {
786
+ throw new Error(
787
+ `Isolated checkpoint exceeds the ${WORKTREE_PATCH_MAX_BYTES}-byte patch limit (${checkpoint.patch.length} bytes).`,
788
+ );
789
+ }
790
+ if (checkpoint && checkpoint.patch.length > 0) {
791
+ // Merge the checkpoint commit with today's HEAD instead of blindly
792
+ // applying its old patch. If the parent committed generation one after
793
+ // integration, Git recognizes the equivalent tree and produces HEAD
794
+ // unchanged; unrelated newer commits are preserved by the three-way merge.
795
+ const merged = await runGit(
796
+ runner,
797
+ worktreePath,
798
+ ["merge-tree", "--write-tree", "--messages", target.head, checkpoint.commit],
799
+ `Merging isolated checkpoint into continuation ${worktreePath}`,
800
+ );
801
+ const mergedTree = merged.stdout.toString("utf8").split(/\r?\n/, 1)[0]?.trim();
802
+ if (!mergedTree) throw new Error("Git returned no merged continuation tree id.");
803
+ await runGit(
804
+ runner,
805
+ worktreePath,
806
+ ["read-tree", "--reset", "-u", mergedTree],
807
+ `Materializing isolated checkpoint in ${worktreePath}`,
808
+ );
809
+ if (options.seedIsIntegrated) {
810
+ const commit = await runGit(
811
+ runner,
812
+ worktreePath,
813
+ [
814
+ "-c", "user.name=pi-subagents",
815
+ "-c", "user.email=pi-subagents@example.invalid",
816
+ "commit-tree", mergedTree,
817
+ "-p", target.head,
818
+ "-m", "pi-subagents continuation baseline",
819
+ ],
820
+ `Creating continuation baseline commit in ${worktreePath}`,
821
+ );
822
+ integrationBaseHead = commit.stdout.toString("utf8").trim();
823
+ if (!integrationBaseHead) throw new Error("Git returned no continuation baseline commit id.");
824
+ }
825
+ }
826
+
827
+ return new GitWorktreeIsolation(
828
+ target.originalCwd,
829
+ target.originalRoot,
830
+ isolatedCwd,
831
+ worktreePath,
832
+ tempDir,
833
+ patchPath,
834
+ target.head,
835
+ runner,
836
+ integrationBaseHead,
837
+ );
838
+ } catch (error) {
839
+ const rollbackErrors: string[] = [];
840
+ if (added || existsSync(worktreePath)) {
841
+ try {
842
+ await runGit(
843
+ runner,
844
+ target.originalRoot,
845
+ ["worktree", "remove", "--force", worktreePath],
846
+ `Rolling back isolated worktree ${worktreePath}`,
847
+ );
848
+ } catch (rollbackError) {
849
+ rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
850
+ }
851
+ }
852
+ try {
853
+ await runGit(
854
+ runner,
855
+ target.originalRoot,
856
+ ["worktree", "prune"],
857
+ `Pruning Git worktree metadata after setup failure`,
858
+ );
859
+ } catch (rollbackError) {
860
+ rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
861
+ }
862
+ try {
863
+ await rm(tempDir, { recursive: true, force: true });
864
+ } catch (rollbackError) {
865
+ rollbackErrors.push(`Removing ${tempDir} failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
866
+ }
867
+ const retainedPaths = [worktreePath, patchPath, tempDir].filter((path) => existsSync(path));
868
+ const cause = error instanceof Error ? error.message : String(error);
869
+ throw new WorktreeSetupError(
870
+ `Could not create isolated Git worktree: ${cause}${rollbackErrors.length > 0 ? ` Rollback errors: ${rollbackErrors.join("; ")}` : ""}${retainedPaths.length > 0 ? ` Retained artifacts: ${retainedPaths.join(", ")}` : ""}`,
871
+ retainedPaths,
872
+ );
873
+ }
874
+ }
875
+
876
+ /** Snapshot only states whose filesystem or repository objects still exist.
877
+ * Transient (`finalizing`) and discarded handles are not persistable. */
878
+ export function worktreeSnapshot(worktree: WorktreeIsolation): WorktreeSnapshot | undefined {
879
+ const state = worktree.state;
880
+ if (state !== "active" && state !== "retained" && state !== "integrated" && state !== "no_changes") {
881
+ return undefined;
882
+ }
883
+ const checkpoint = worktree.getContinuationCheckpoint();
884
+ return {
885
+ originalCwd: worktree.originalCwd,
886
+ originalRoot: worktree.originalRoot,
887
+ cwd: worktree.cwd,
888
+ worktreePath: worktree.worktreePath,
889
+ tempDir: worktree.tempDir,
890
+ patchPath: worktree.patchPath,
891
+ head: worktree.head,
892
+ integrationBaseHead: worktree.integrationBaseHead,
893
+ state,
894
+ ...(checkpoint && checkpoint.patch.length > 0
895
+ ? { checkpoint: { baseHead: checkpoint.baseHead, commit: checkpoint.commit } }
896
+ : {}),
897
+ };
898
+ }
899
+
900
+ /** Validate an untrusted persisted snapshot; null when it is unusable. */
901
+ export function normalizeWorktreeSnapshot(value: unknown): WorktreeSnapshot | null {
902
+ if (!value || typeof value !== "object") return null;
903
+ const raw = value as Record<string, unknown>;
904
+ const fields: Record<string, string> = {};
905
+ for (const key of [
906
+ "originalCwd",
907
+ "originalRoot",
908
+ "cwd",
909
+ "worktreePath",
910
+ "tempDir",
911
+ "patchPath",
912
+ "head",
913
+ "integrationBaseHead",
914
+ ] as const) {
915
+ if (typeof raw[key] !== "string" || !raw[key]) return null;
916
+ fields[key] = raw[key] as string;
917
+ }
918
+ if (raw.state !== "active" && raw.state !== "retained" && raw.state !== "integrated" && raw.state !== "no_changes") {
919
+ return null;
920
+ }
921
+ let checkpoint: WorktreeCheckpointRef | undefined;
922
+ if (raw.checkpoint && typeof raw.checkpoint === "object") {
923
+ const rawCheckpoint = raw.checkpoint as Record<string, unknown>;
924
+ if (typeof rawCheckpoint.baseHead !== "string" || !rawCheckpoint.baseHead) return null;
925
+ if (typeof rawCheckpoint.commit !== "string" || !rawCheckpoint.commit) return null;
926
+ checkpoint = { baseHead: rawCheckpoint.baseHead, commit: rawCheckpoint.commit };
927
+ }
928
+ return {
929
+ originalCwd: fields.originalCwd!,
930
+ originalRoot: fields.originalRoot!,
931
+ cwd: fields.cwd!,
932
+ worktreePath: fields.worktreePath!,
933
+ tempDir: fields.tempDir!,
934
+ patchPath: fields.patchPath!,
935
+ head: fields.head!,
936
+ integrationBaseHead: fields.integrationBaseHead!,
937
+ state: raw.state,
938
+ ...(checkpoint ? { checkpoint } : {}),
939
+ };
940
+ }
941
+
942
+ /** Rebuild a handle from a persisted snapshot. Returns undefined when the
943
+ * on-disk worktree that an active/retained snapshot promises is gone; settled
944
+ * states (integrated/no_changes) intentionally need no filesystem. The
945
+ * restored checkpoint carries no patch bytes — only its commit is consumed by
946
+ * continuation seeds. */
947
+ export async function restoreWorktreeIsolation(
948
+ snapshot: WorktreeSnapshot,
949
+ options: { runner?: CommandRunner } = {},
950
+ ): Promise<WorktreeIsolation | undefined> {
951
+ if (
952
+ (snapshot.state === "active" || snapshot.state === "retained") &&
953
+ !existsSync(snapshot.worktreePath)
954
+ ) {
955
+ return undefined;
956
+ }
957
+ return new GitWorktreeIsolation(
958
+ snapshot.originalCwd,
959
+ snapshot.originalRoot,
960
+ snapshot.cwd,
961
+ snapshot.worktreePath,
962
+ snapshot.tempDir,
963
+ snapshot.patchPath,
964
+ snapshot.head,
965
+ options.runner ?? runCommand,
966
+ snapshot.integrationBaseHead,
967
+ {
968
+ state: snapshot.state,
969
+ ...(snapshot.checkpoint
970
+ ? { checkpoint: { ...snapshot.checkpoint, patch: Buffer.alloc(0) } }
971
+ : {}),
972
+ },
973
+ );
974
+ }