pi-subagents 0.65.1 → 0.66.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +16 -2
  5. package/docs/configuration.md +18 -0
  6. package/docs/extension-api.md +91 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +3 -3
  10. package/docs/workflows.md +14 -7
  11. package/package.json +2 -1
  12. package/skills/pi-subagents/references/execution-controls.md +14 -1
  13. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  14. package/src/agents/advertised-agent-prompt.ts +63 -0
  15. package/src/agents/agent-management.ts +14 -1
  16. package/src/agents/agent-serializer.ts +2 -0
  17. package/src/agents/agents.ts +8 -0
  18. package/src/api/shared-types.ts +1 -1
  19. package/src/api/workflow-resources.ts +6 -0
  20. package/src/extension/index.ts +40 -2
  21. package/src/extension/public-execution.ts +0 -1
  22. package/src/extension/rpc.ts +4 -21
  23. package/src/extension/schemas.ts +8 -6
  24. package/src/extension/tool-description.ts +6 -5
  25. package/src/intercom/native-supervisor-channel.ts +82 -54
  26. package/src/runs/background/active-async-capacity.ts +18 -18
  27. package/src/runs/background/async-job-tracker.ts +35 -3
  28. package/src/runs/background/async-status-snapshot.ts +10 -12
  29. package/src/runs/background/async-status.ts +17 -9
  30. package/src/runs/background/auto-drain.ts +40 -29
  31. package/src/runs/background/chain-root-attachment.ts +8 -0
  32. package/src/runs/background/control-channel.ts +78 -44
  33. package/src/runs/background/notify.ts +86 -12
  34. package/src/runs/background/owned-process-tree.ts +6 -6
  35. package/src/runs/background/process-terminal.ts +23 -23
  36. package/src/runs/background/run-child-session.ts +60 -32
  37. package/src/runs/background/run-status.ts +75 -5
  38. package/src/runs/background/runner-aliases.ts +18 -7
  39. package/src/runs/background/runner-child-launch.ts +86 -0
  40. package/src/runs/background/stale-run-reconciler.ts +3 -1
  41. package/src/runs/background/subagent-runner.ts +412 -209
  42. package/src/runs/background/subagent-wait.ts +3 -0
  43. package/src/runs/background/wait-completions.ts +4 -0
  44. package/src/runs/foreground/async-steering-action.ts +19 -0
  45. package/src/runs/foreground/execution.ts +101 -25
  46. package/src/runs/foreground/subagent-executor.ts +474 -197
  47. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  48. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  49. package/src/runs/shared/acceptance.ts +2 -2
  50. package/src/runs/shared/agent-contract.ts +1 -1
  51. package/src/runs/shared/async-status-projection.ts +47 -47
  52. package/src/runs/shared/child-hooks.ts +151 -2
  53. package/src/runs/shared/child-launch.ts +18 -13
  54. package/src/runs/shared/child-session.ts +42 -6
  55. package/src/runs/shared/child-tool-plan.ts +2 -2
  56. package/src/runs/shared/completion-evidence.ts +2 -2
  57. package/src/runs/shared/completion-guard.ts +1 -0
  58. package/src/runs/shared/host-step-status.ts +11 -11
  59. package/src/runs/shared/llm-intent-arbiter.ts +10 -9
  60. package/src/runs/shared/model-fallback.ts +10 -6
  61. package/src/runs/shared/nested-events.ts +5 -5
  62. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  63. package/src/runs/shared/parallel-handoff.ts +57 -12
  64. package/src/runs/shared/parallel-utils.ts +2 -2
  65. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  66. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  67. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  68. package/src/runs/shared/run-fanout-budget.ts +8 -8
  69. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  70. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  71. package/src/runs/shared/worktree-setup-command.ts +190 -0
  72. package/src/runs/shared/worktree.ts +332 -204
  73. package/src/shared/types.ts +81 -60
  74. package/src/shared/utils.ts +7 -2
  75. package/src/shared/workflow-child-permit.ts +18 -13
  76. package/src/tui/fleet.ts +11 -5
  77. package/src/tui/render.ts +23 -5
  78. package/src/workflows/chat-progress.ts +3 -3
  79. package/src/workflows/scripted-workflow.ts +38 -10
  80. package/src/workflows/workflow-checklist.ts +11 -15
  81. package/src/workflows/workflow-child-summary.ts +57 -8
  82. package/src/workflows/workflow-preflight.ts +19 -19
  83. package/src/workflows/workflow-receipt.ts +3 -3
  84. package/src/workflows/workflow-resources.ts +96 -21
  85. package/src/workflows/workflow-settlement.ts +3 -0
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { spawnSync } from "node:child_process";
3
+ import { runSetupCommand, type SetupCommandOptions, type SetupCommandResult } from "./worktree-setup-command.ts";
3
4
  import * as fs from "node:fs";
4
5
  import * as os from "node:os";
5
6
  import * as path from "node:path";
@@ -118,6 +119,10 @@ interface WorktreeSetupHookConfig {
118
119
  }
119
120
 
120
121
  export interface CreateWorktreesOptions {
122
+ signal?: AbortSignal;
123
+ /** Existing absolute run deadline only; setup does not start a child budget. */
124
+ deadlineAt?: number;
125
+ onProgress?: (snapshot: WorktreeSetupProgress) => void;
121
126
  agents?: string[];
122
127
  /** Optional stable labels used to make branch identity readable. */
123
128
  labels?: Array<string | undefined>;
@@ -131,8 +136,6 @@ export interface CreateWorktreesOptions {
131
136
  branchPrefix?: string;
132
137
  setupHook?: WorktreeSetupHookConfig;
133
138
  baseDir?: string;
134
- /** Called with deterministic ownership metadata before setup hooks and child launch; native reports planned paths before allocation, while Worktrunk reports its returned paths after allocation. */
135
- beforeCreate?: (setup: WorktreeSetup) => void;
136
139
  }
137
140
 
138
141
  interface ResolvedWorktreeSetupHook {
@@ -165,12 +168,110 @@ interface GitResult {
165
168
  interface RepoState {
166
169
  toplevel: string;
167
170
  cwdRelative: string;
168
- sourceCheckout: SourceCheckoutSnapshot;
169
171
  baseCommit: string;
170
172
  }
171
173
 
172
174
  const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
173
175
 
176
+ /** In-memory evidence only. Callers project this into existing handoff diagnostics. */
177
+ export interface WorktreeSetupProgress {
178
+ setup: WorktreeSetup;
179
+ attempts: Array<{ index: number; branch: string; path?: string; validated: boolean; command?: WorktreeSetupProgress["command"]; hookCommand?: WorktreeSetupProgress["command"] }>;
180
+ phase: string;
181
+ command?: { command: string; args: string[]; pid?: number; processGroupId?: number; result?: Omit<SetupCommandResult, "stdout" | "stderr"> };
182
+ unknown?: string;
183
+ cleanup?: WorktreeCleanupReport;
184
+ }
185
+
186
+ export class WorktreeSetupError extends Error {
187
+ readonly snapshot: WorktreeSetupProgress;
188
+ constructor(error: unknown, snapshot: WorktreeSetupProgress) {
189
+ super(`${error instanceof Error ? error.message : String(error)}${snapshot.unknown ? `; setup settlement unknown: ${snapshot.unknown}; manual reconciliation required` : ""}`, { cause: error });
190
+ this.name = "WorktreeSetupError";
191
+ this.snapshot = snapshot;
192
+ }
193
+ }
194
+
195
+ let worktreeTurn: Promise<void> = Promise.resolve();
196
+ let setupActive = false;
197
+ let setupPoison: string | undefined;
198
+
199
+ function assertWorktreeMutationAllowed(): void {
200
+ if (setupPoison) throw new Error(`Worktree mutation blocked: ${setupPoison}`);
201
+ if (setupActive) throw new Error("Worktree setup is active; finalization must await withWorktreeTransaction");
202
+ }
203
+
204
+ /** Later async finalization owners wrap their synchronous diff/cleanup as ONE turn. */
205
+ export async function withWorktreeTransaction<T>(action: () => T | Promise<T>): Promise<T> {
206
+ const previous = worktreeTurn;
207
+ let release!: () => void;
208
+ worktreeTurn = new Promise<void>((resolve) => { release = resolve; });
209
+ await previous;
210
+ try {
211
+ if (setupPoison) throw new Error(`Worktree mutation blocked: ${setupPoison}`);
212
+ return await action();
213
+ } finally { release(); }
214
+ }
215
+
216
+ class SetupTransaction {
217
+ readonly progress: WorktreeSetupProgress;
218
+ readonly options: CreateWorktreesOptions;
219
+ constructor(cwd: string, options: CreateWorktreesOptions) {
220
+ this.options = options;
221
+ this.progress = { setup: { cwd, baseCommit: "", worktrees: [] }, attempts: [], phase: "preflight" };
222
+ }
223
+ snapshot(): WorktreeSetupProgress {
224
+ return structuredClone(this.progress);
225
+ }
226
+ publish(): void { this.options.onProgress?.(this.snapshot()); }
227
+ check(): void {
228
+ if (setupPoison) throw new Error(setupPoison);
229
+ if (this.options.signal?.aborted) throw new Error("Worktree setup aborted");
230
+ if (this.options.deadlineAt !== undefined && Date.now() >= this.options.deadlineAt) throw new Error("Worktree setup deadline exceeded");
231
+ }
232
+ unknown(error: unknown): void {
233
+ const reason = error instanceof Error ? error.message : String(error);
234
+ this.progress.unknown ??= reason;
235
+ setupPoison ??= reason;
236
+ }
237
+ async command(command: string, args: string[], options: SetupCommandOptions = {}): Promise<SetupCommandResult> {
238
+ this.check();
239
+ this.progress.command = { command, args };
240
+ if (this.progress.phase === "allocation") this.progress.attempts.at(-1)!.command = this.progress.command;
241
+ if (this.progress.phase === "hook") this.progress.attempts.find((attempt) => attempt.path === options.cwd)!.hookCommand = this.progress.command;
242
+ const result = await runSetupCommand(command, args, {
243
+ ...options, signal: this.options.signal, deadlineAt: this.options.deadlineAt,
244
+ onSpawn: (process) => { Object.assign(this.progress.command!, process); this.publish(); },
245
+ });
246
+ const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
247
+ this.progress.command.result = metadata;
248
+ if (result.processTree?.state === "unknown") this.unknown(result.error ?? "Command tree settlement unverified");
249
+ this.publish();
250
+ if (result.error) throw result.error;
251
+ return result;
252
+ }
253
+ async git(cwd: string, args: string[], acceptedExitCodes?: readonly number[]): Promise<SetupCommandResult> {
254
+ return this.command("git", ["-C", cwd, ...args], { acceptedExitCodes });
255
+ }
256
+ async gitChecked(cwd: string, args: string[]): Promise<string> {
257
+ const result = await this.git(cwd, args);
258
+ return result.stdout;
259
+ }
260
+ attempt(index: number, branch: string, worktreePath?: string): void {
261
+ this.check();
262
+ this.progress.phase = "allocation";
263
+ this.progress.command = undefined;
264
+ this.progress.attempts.push({ index, branch, path: worktreePath, validated: false });
265
+ this.publish();
266
+ }
267
+ validated(worktree: WorktreeInfo): void {
268
+ this.progress.phase = "validated";
269
+ Object.assign(this.progress.attempts.at(-1)!, { path: worktree.path, validated: true });
270
+ this.progress.setup.worktrees.push(worktree);
271
+ this.publish();
272
+ }
273
+ }
274
+
174
275
  function runGit(cwd: string, args: string[], env?: NodeJS.ProcessEnv): GitResult {
175
276
  const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false, ...(env ? { env: { ...process.env, ...env } } : {}) });
176
277
  return {
@@ -198,6 +299,7 @@ export function validateWorktreePatch(worktreePath: string, patchPath: string):
198
299
  }
199
300
 
200
301
  function currentWorktreePatch(worktreePath: string, baseCommit: string): { patch: string } | { error: string } {
302
+ assertWorktreeMutationAllowed();
201
303
  let tempDir: string;
202
304
  try {
203
305
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-index-"));
@@ -237,38 +339,31 @@ export function validateWorktreePatchRepresentsCurrentWorktree(worktreePath: str
237
339
  return undefined;
238
340
  }
239
341
 
240
- function findGitWorktreePath(cwd: string, branch: string): string | undefined {
241
- const targetBranch = `branch refs/heads/${branch}`;
242
- let currentPath: string | undefined;
243
- for (const line of runGitChecked(cwd, ["worktree", "list", "--porcelain"]).split("\n")) {
244
- if (line.startsWith("worktree ")) currentPath = line.slice("worktree ".length).trim();
245
- else if (line.trim() === targetBranch) return currentPath;
246
- }
247
- return undefined;
248
- }
249
-
250
- function resolveRepoState(cwd: string, requestedBaseRef: string | undefined): RepoState {
251
- const cwdRelative = resolveRepoCwdRelative(cwd);
252
- const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
342
+ async function resolveRepoState(tx: SetupTransaction, cwd: string, requestedBaseRef: string | undefined): Promise<RepoState> {
343
+ const repoCheck = await tx.git(cwd, ["rev-parse", "--is-inside-work-tree"], [0, 128]);
344
+ if (repoCheck.status !== 0 || repoCheck.stdout.trim() !== "true") throw new Error("worktree isolation requires a git repository");
345
+ const rawPrefix = (await tx.gitChecked(cwd, ["rev-parse", "--show-prefix"])).trim();
346
+ const cwdRelative = rawPrefix ? path.normalize(rawPrefix.replace(/[\\/]+$/, "")) : "";
347
+ const toplevel = (await tx.gitChecked(cwd, ["rev-parse", "--show-toplevel"])).trim();
253
348
 
254
349
  // pi-subagents writes durable runtime state under .pi/subagents/ by default;
255
350
  // that state must not make managed isolation unusable for later runs.
256
- const status = runGitChecked(toplevel, ["status", "--porcelain", "--", `:!${PROJECT_SUBAGENTS_RELATIVE_DIR}`]);
351
+ const status = await tx.gitChecked(toplevel, ["status", "--porcelain", "--", `:!${PROJECT_SUBAGENTS_RELATIVE_DIR}`]);
257
352
  if (status.trim().length > 0) {
258
353
  throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
259
354
  }
260
355
 
261
- const sourceHead = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim();
262
- const sourceCheckout = snapshotSourceCheckout(toplevel, sourceHead);
263
356
  const baseRef = normalizeWorktreeBaseRef(requestedBaseRef) ?? DEFAULT_WORKTREE_BASE_REF;
264
357
  let baseCommit: string;
265
358
  try {
266
- baseCommit = runGitChecked(toplevel, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`]).trim();
359
+ const base = await tx.git(toplevel, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`], [0, 128]);
360
+ if (base.status !== 0) throw new Error(base.stderr.trim() || "ref not found");
361
+ baseCommit = base.stdout.trim();
267
362
  } catch (error) {
268
363
  throw new Error(`baseRef '${baseRef}' could not be resolved to a commit: ${error instanceof Error ? error.message : String(error)}`, { cause: error instanceof Error ? error : undefined });
269
364
  }
270
365
  if (!baseCommit) throw new Error(`baseRef '${baseRef}' could not be resolved to a commit`);
271
- return { toplevel, cwdRelative, sourceCheckout, baseCommit };
366
+ return { toplevel, cwdRelative, baseCommit };
272
367
  }
273
368
 
274
369
  function normalizeComparableCwd(cwd: string): string {
@@ -357,7 +452,7 @@ function validGitRef(ref: string): boolean {
357
452
  /** Normalize and validate a configured worktree base ref without resolving it. */
358
453
  export function normalizeWorktreeBaseRef(value: unknown): string | undefined {
359
454
  if (value === undefined) return undefined;
360
- if (typeof value !== "string" || !validGitRef(value)) throw new Error("baseRef must be a valid Git ref");
455
+ if (typeof value !== "string" || !validGitRef(value)) throw new Error("baseRef must be a valid Git ref: use HEAD or a supported named ref (for example, refs/heads/main). Full 40/64-character commit IDs and revision expressions are unsupported.");
361
456
  return value;
362
457
  }
363
458
 
@@ -436,11 +531,6 @@ interface WorktrunkCapability {
436
531
  reason?: string;
437
532
  }
438
533
 
439
- interface SourceCheckoutSnapshot {
440
- head: string;
441
- branch?: string;
442
- }
443
-
444
534
  function runWorktrunk(args: string[], cwd?: string): WorktreeCommandResult {
445
535
  try {
446
536
  const result = spawnSync("wt", args, {
@@ -473,18 +563,6 @@ function probeWorktrunk(): WorktrunkCapability {
473
563
  return { available: true };
474
564
  }
475
565
 
476
- function snapshotSourceCheckout(toplevel: string, head: string): SourceCheckoutSnapshot {
477
- const branch = runGit(toplevel, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
478
- const branchName = branch.status === 0 ? branch.stdout.trim() : "";
479
- return branchName ? { head, branch: branchName } : { head };
480
- }
481
-
482
- function restoreSourceCheckoutIfWorktrunkSwitchedIt(toplevel: string, createdBranch: string, sourceCheckout: SourceCheckoutSnapshot): void {
483
- const current = runGit(toplevel, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
484
- if (current.status !== 0 || current.stdout.trim() !== createdBranch) return;
485
- runGitChecked(toplevel, sourceCheckout.branch ? ["checkout", sourceCheckout.branch] : ["checkout", "--detach", sourceCheckout.head]);
486
- }
487
-
488
566
  /** Resolve a requested provider without silently switching after allocation starts. */
489
567
  export function resolveWorktreeProvider(requested: WorktreeProvider | undefined, baseDir?: string): ManagedWorktreeProvider {
490
568
  const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
@@ -500,6 +578,33 @@ export function resolveWorktreeProvider(requested: WorktreeProvider | undefined,
500
578
  return "native";
501
579
  }
502
580
 
581
+ async function resolveSetupProvider(tx: SetupTransaction, requested: WorktreeProvider | undefined, baseDir?: string): Promise<ManagedWorktreeProvider> {
582
+ const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
583
+ if (selection !== "auto" && selection !== "native" && selection !== "worktrunk") throw new Error('worktree provider must be "auto", "native", or "worktrunk"');
584
+ if (selection === "native") return "native";
585
+ if (hasConfiguredWorktreeBaseDir(baseDir)) {
586
+ if (selection === "worktrunk") throw new Error("worktreeProvider='worktrunk' cannot be combined with worktreeBaseDir or PI_SUBAGENTS_WORKTREE_DIR");
587
+ return "native";
588
+ }
589
+ let reason: string | undefined;
590
+ try {
591
+ const probeExitCodes = Array.from({ length: 256 }, (_, code) => code);
592
+ const version = await tx.command("wt", ["--version"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
593
+ if (version.status !== 0 || !/\b(?:wt\s+)?v?(\d+\.\d+(?:\.\d+)?)\b/i.test(version.stdout.trim())) reason = "Worktrunk is unavailable or returned an invalid version";
594
+ else {
595
+ const help = await tx.command("wt", ["switch", "--help"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
596
+ const missing = ["--create", "--base", "--no-cd", "--no-hooks", "--format"].filter((flag) => !`${help.stdout}\n${help.stderr}`.includes(flag));
597
+ if (help.status !== 0 || missing.length) reason = `Worktrunk switch capability unavailable: ${missing.join(", ")}`;
598
+ }
599
+ } catch (error) {
600
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
601
+ reason = error.message;
602
+ }
603
+ if (!reason) return "worktrunk";
604
+ if (selection === "worktrunk") throw new Error(`Worktrunk provider is unavailable: ${reason}`);
605
+ return "native";
606
+ }
607
+
503
608
  /** Whether a launch must bind its worktree-dependent paths after allocation. */
504
609
  export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined, baseDir?: string): boolean {
505
610
  return (requested ?? DEFAULT_WORKTREE_PROVIDER) !== "native" && !hasConfiguredWorktreeBaseDir(baseDir);
@@ -677,11 +782,6 @@ function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
677
782
  return path.normalize(relative);
678
783
  }
679
784
 
680
- function hasTrackedEntries(worktreePath: string, relativePath: string): boolean {
681
- const result = runGit(worktreePath, ["ls-files", "--", relativePath]);
682
- return result.status === 0 && result.stdout.trim().length > 0;
683
- }
684
-
685
785
  function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutput {
686
786
  const trimmed = rawStdout.trim();
687
787
  if (!trimmed) {
@@ -700,53 +800,49 @@ function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutpu
700
800
  return parsed as WorktreeSetupHookOutput;
701
801
  }
702
802
 
703
- function runWorktreeSetupHook(
803
+ async function runWorktreeSetupHook(
804
+ tx: SetupTransaction,
704
805
  hook: ResolvedWorktreeSetupHook,
705
806
  input: WorktreeSetupHookInput,
706
- ): string[] {
707
- const result = spawnSync(hook.hookPath, [], {
708
- windowsHide: true,
709
- cwd: input.worktreePath,
710
- encoding: "utf-8",
711
- input: JSON.stringify(input),
712
- timeout: hook.timeoutMs,
713
- shell: false,
714
- });
715
-
716
- if (result.error) {
717
- const code = "code" in result.error ? result.error.code : undefined;
718
- if (code === "ETIMEDOUT") {
719
- throw new Error(`worktree setup hook timed out after ${hook.timeoutMs}ms`);
720
- }
721
- throw new Error(`worktree setup hook failed: ${result.error.message}`);
722
- }
723
-
724
- if (result.status !== 0) {
725
- const details = result.stderr.trim() || result.stdout.trim() || "no output";
726
- throw new Error(`worktree setup hook failed with exit code ${result.status}: ${details}`);
727
- }
728
-
729
- const output = parseWorktreeSetupHookOutput(result.stdout);
730
- if (output.syntheticPaths === undefined) return [];
731
- if (!Array.isArray(output.syntheticPaths)) {
732
- throw new Error("worktree setup hook output field 'syntheticPaths' must be an array of relative paths");
807
+ ): Promise<string[]> {
808
+ tx.progress.phase = "hook";
809
+ let result: SetupCommandResult;
810
+ try {
811
+ result = await tx.command(hook.hookPath, [], {
812
+ cwd: input.worktreePath,
813
+ input: JSON.stringify(input),
814
+ hookTimeoutMs: hook.timeoutMs,
815
+ });
816
+ } catch (error) {
817
+ throw new Error(`worktree setup hook failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
733
818
  }
819
+ tx.progress.phase = "hook-validation";
734
820
 
735
- const uniquePaths = new Set<string>();
736
- for (const candidate of output.syntheticPaths) {
737
- if (typeof candidate !== "string") {
738
- throw new Error("worktree setup hook output field 'syntheticPaths' must contain only strings");
821
+ try {
822
+ const output = parseWorktreeSetupHookOutput(result.stdout);
823
+ if (output.syntheticPaths === undefined) return [];
824
+ if (!Array.isArray(output.syntheticPaths)) {
825
+ throw new Error("worktree setup hook output field 'syntheticPaths' must be an array of relative paths");
739
826
  }
740
- const normalizedPath = normalizeSyntheticPath(input.worktreePath, candidate);
741
- if (hasTrackedEntries(input.worktreePath, normalizedPath)) {
742
- throw new Error(`worktree setup hook cannot mark tracked paths as synthetic: ${normalizedPath}`);
827
+ const uniquePaths = new Set<string>();
828
+ for (const candidate of output.syntheticPaths) {
829
+ if (typeof candidate !== "string") throw new Error("worktree setup hook output field 'syntheticPaths' must contain only strings");
830
+ const normalizedPath = normalizeSyntheticPath(input.worktreePath, candidate);
831
+ if ((await tx.gitChecked(input.worktreePath, ["ls-files", "--", normalizedPath])).trim()) {
832
+ throw new Error(`worktree setup hook cannot mark tracked paths as synthetic: ${normalizedPath}`);
833
+ }
834
+ uniquePaths.add(normalizedPath);
743
835
  }
744
- uniquePaths.add(normalizedPath);
836
+ return [...uniquePaths];
837
+ } catch (error) {
838
+ // Semantic rejection occurs after exit: retain uncertainty, never signal an old PID.
839
+ tx.unknown(error);
840
+ throw error;
745
841
  }
746
- return [...uniquePaths];
747
842
  }
748
843
 
749
- function finalizeCreatedWorktree(
844
+ async function finalizeCreatedWorktree(
845
+ tx: SetupTransaction,
750
846
  toplevel: string,
751
847
  cwdRelative: string,
752
848
  runId: string,
@@ -754,38 +850,23 @@ function finalizeCreatedWorktree(
754
850
  setupHook: ResolvedWorktreeSetupHook | undefined,
755
851
  agent: string | undefined,
756
852
  worktree: WorktreeInfo,
757
- ): WorktreeInfo {
853
+ ): Promise<WorktreeInfo> {
758
854
  const agentCwd = cwdRelative ? path.join(worktree.path, cwdRelative) : worktree.path;
759
- try {
760
- const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktree.path);
761
- const syntheticPaths = nodeModulesLinked ? ["node_modules"] : [];
762
- if (setupHook) {
763
- const hookSyntheticPaths = runWorktreeSetupHook(setupHook, {
764
- version: 1,
765
- repoRoot: toplevel,
766
- worktreePath: worktree.path,
767
- agentCwd,
768
- branch: worktree.branch,
769
- index: worktree.index,
770
- runId,
771
- baseCommit,
772
- agent,
773
- });
774
- syntheticPaths.push(...hookSyntheticPaths);
775
- }
776
- return { ...worktree, agentCwd, nodeModulesLinked, syntheticPaths };
777
- } catch (error) {
778
- try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktree.path]); } catch {
779
- // Best-effort rollback; preserve the original setup failure.
780
- }
781
- try { runGitChecked(toplevel, ["branch", "-D", worktree.branch]); } catch {
782
- // Best-effort rollback; preserve the original setup failure.
783
- }
784
- throw error;
855
+ tx.check();
856
+ const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktree.path);
857
+ const syntheticPaths = nodeModulesLinked ? ["node_modules"] : [];
858
+ if (setupHook) {
859
+ const hookSyntheticPaths = await runWorktreeSetupHook(tx, setupHook, {
860
+ version: 1, repoRoot: toplevel, worktreePath: worktree.path, agentCwd,
861
+ branch: worktree.branch, index: worktree.index, runId, baseCommit, agent,
862
+ });
863
+ syntheticPaths.push(...hookSyntheticPaths);
785
864
  }
865
+ return { ...worktree, agentCwd, nodeModulesLinked, syntheticPaths };
786
866
  }
787
867
 
788
- function createNativeWorktree(
868
+ async function createNativeWorktree(
869
+ tx: SetupTransaction,
789
870
  toplevel: string,
790
871
  cwdRelative: string,
791
872
  runId: string,
@@ -797,17 +878,20 @@ function createNativeWorktree(
797
878
  labels: Array<string | undefined> | undefined,
798
879
  tasks: Array<string | undefined> | undefined,
799
880
  branchPrefix: string | undefined,
800
- ): WorktreeInfo {
881
+ ): Promise<WorktreeInfo> {
801
882
  const naming = buildWorktreeNaming({ runId, index, agent, label: labels?.[index], task: tasks?.[index], branchPrefix });
802
883
  const worktreePath = buildNativeWorktreePath(dedicatedRoot, toplevel, runId, index);
803
884
  assertSafeWorktreeLocation(worktreePath, toplevel, dedicatedRoot);
885
+ // Absence is required before claiming any partial artifacts from this attempt.
886
+ const branch = await tx.git(toplevel, ["show-ref", "--verify", "--quiet", `refs/heads/${naming.requestedBranch}`], [0, 1]);
887
+ let pathExists = false;
888
+ try { fs.lstatSync(worktreePath); pathExists = true; }
889
+ catch (error) { if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error; }
890
+ if (branch.status !== 1 || pathExists) throw new Error(`worktree path or branch already exists: ${worktreePath}, ${naming.requestedBranch}`);
891
+ tx.attempt(index, naming.requestedBranch, worktreePath);
804
892
  ensureProjectWorktreeDir(dedicatedRoot, toplevel);
805
- const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
806
- if (add.status !== 0) {
807
- const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
808
- throw new Error(message);
809
- }
810
- return finalizeCreatedWorktree(toplevel, cwdRelative, runId, baseCommit, setupHook, agent, {
893
+ await tx.git(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
894
+ const worktree: WorktreeInfo = {
811
895
  path: worktreePath,
812
896
  agentCwd: worktreePath,
813
897
  branch: naming.requestedBranch,
@@ -816,7 +900,9 @@ function createNativeWorktree(
816
900
  syntheticPaths: [],
817
901
  provider: "native",
818
902
  naming,
819
- });
903
+ };
904
+ tx.validated(worktree);
905
+ return finalizeCreatedWorktree(tx, toplevel, cwdRelative, runId, baseCommit, setupHook, agent, worktree);
820
906
  }
821
907
 
822
908
  interface WorktrunkSwitchOutput {
@@ -841,37 +927,30 @@ function parseWorktrunkSwitchOutput(rawStdout: string): WorktrunkSwitchOutput {
841
927
  return parsed as WorktrunkSwitchOutput;
842
928
  }
843
929
 
844
- function createWorktrunkWorktree(
930
+ async function createWorktrunkWorktree(
931
+ tx: SetupTransaction,
845
932
  toplevel: string,
846
933
  cwdRelative: string,
847
934
  runId: string,
848
935
  index: number,
849
936
  baseCommit: string,
850
- sourceCheckout: SourceCheckoutSnapshot,
851
937
  agents: string[] | undefined,
852
938
  labels: Array<string | undefined> | undefined,
853
939
  tasks: Array<string | undefined> | undefined,
854
940
  branchPrefix: string | undefined,
855
- ): WorktreeInfo {
941
+ ): Promise<WorktreeInfo> {
856
942
  const naming = buildWorktreeNaming({ runId, index, agent: agents?.[index], label: labels?.[index], task: tasks?.[index], branchPrefix });
857
943
  const args = ["-C", toplevel, "switch", "--create", naming.requestedBranch, "--base", baseCommit, "--no-cd", "--no-hooks", "--format", "json"];
858
- const result = runWorktrunk(args, toplevel);
859
- if (result.status !== 0) {
860
- const message = result.error?.message || result.stderr.trim() || result.stdout.trim() || "Worktrunk provisioning failed";
861
- throw new Error(`Worktrunk provisioning failed: ${message}`);
862
- }
863
- let createdAllocation = false;
864
- let returnedPath: string | undefined;
944
+ tx.attempt(index, naming.requestedBranch);
945
+ const result = await tx.command("wt", args, { cwd: toplevel, maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES });
946
+ tx.progress.phase = "validation";
865
947
  try {
866
948
  const output = parseWorktrunkSwitchOutput(result.stdout);
867
- createdAllocation = output.action === "created" && output.created_branch === true;
868
- if (typeof output.path === "string" && path.isAbsolute(output.path)) returnedPath = path.resolve(output.path);
869
- if (!createdAllocation || output.branch !== naming.requestedBranch || output.base_branch !== baseCommit) {
949
+ if (output.action !== "created" || output.created_branch !== true || output.branch !== naming.requestedBranch || output.base_branch !== baseCommit) {
870
950
  throw new Error("Worktrunk provisioning returned inconsistent creation metadata");
871
951
  }
872
952
  if (typeof output.path !== "string" || !path.isAbsolute(output.path)) throw new Error("Worktrunk provisioning returned a non-absolute worktree path");
873
- const worktreePathCandidate = returnedPath;
874
- if (!worktreePathCandidate) throw new Error("Worktrunk provisioning returned a non-absolute worktree path");
953
+ const worktreePathCandidate = path.resolve(output.path);
875
954
  let stat: fs.Stats;
876
955
  try {
877
956
  stat = fs.lstatSync(worktreePathCandidate);
@@ -881,16 +960,16 @@ function createWorktrunkWorktree(
881
960
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Worktrunk provisioning returned a path that is not a real directory");
882
961
  const worktreePath = normalizeComparableCwd(worktreePathCandidate);
883
962
  if (worktreePath === normalizeComparableCwd(toplevel)) throw new Error("Worktrunk provisioning returned the source checkout path");
884
- const sourceCommonDirRaw = runGitChecked(toplevel, ["rev-parse", "--git-common-dir"]).trim();
885
- const returnedCommonDirRaw = runGitChecked(worktreePath, ["rev-parse", "--git-common-dir"]).trim();
963
+ const sourceCommonDirRaw = (await tx.gitChecked(toplevel, ["rev-parse", "--git-common-dir"])).trim();
964
+ const returnedCommonDirRaw = (await tx.gitChecked(worktreePath, ["rev-parse", "--git-common-dir"])).trim();
886
965
  const sourceCommonDir = normalizeComparableCwd(path.isAbsolute(sourceCommonDirRaw) ? sourceCommonDirRaw : path.resolve(toplevel, sourceCommonDirRaw));
887
966
  const returnedCommonDir = normalizeComparableCwd(path.isAbsolute(returnedCommonDirRaw) ? returnedCommonDirRaw : path.resolve(worktreePath, returnedCommonDirRaw));
888
967
  if (returnedCommonDir !== sourceCommonDir) throw new Error("Worktrunk provisioning returned a worktree for a different repository");
889
- const returnedBranch = runGitChecked(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).trim();
968
+ const returnedBranch = (await tx.gitChecked(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"])).trim();
890
969
  if (returnedBranch !== naming.requestedBranch) throw new Error("Worktrunk provisioning returned a worktree on a different branch");
891
- const returnedHead = runGitChecked(worktreePath, ["rev-parse", "HEAD"]).trim();
970
+ const returnedHead = (await tx.gitChecked(worktreePath, ["rev-parse", "HEAD"])).trim();
892
971
  if (returnedHead !== baseCommit) throw new Error("Worktrunk provisioning returned a worktree at a different base commit");
893
- return {
972
+ const worktree: WorktreeInfo = {
894
973
  path: worktreePath,
895
974
  agentCwd: cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath,
896
975
  branch: naming.requestedBranch,
@@ -900,35 +979,16 @@ function createWorktrunkWorktree(
900
979
  provider: "worktrunk",
901
980
  naming,
902
981
  };
982
+ tx.validated(worktree);
983
+ return worktree;
903
984
  } catch (error) {
904
- if (createdAllocation) {
905
- try { restoreSourceCheckoutIfWorktrunkSwitchedIt(toplevel, naming.requestedBranch, sourceCheckout); } catch {
906
- // Best-effort rollback; preserve the validation failure.
907
- }
908
- try {
909
- const listedPath = findGitWorktreePath(toplevel, naming.requestedBranch);
910
- const candidates = [listedPath, returnedPath].filter((candidate, candidateIndex, all): candidate is string => Boolean(candidate) && all.indexOf(candidate) === candidateIndex);
911
- for (const candidate of candidates) {
912
- if (normalizeComparableCwd(candidate) === normalizeComparableCwd(toplevel)) continue;
913
- try {
914
- runGitChecked(toplevel, ["worktree", "remove", "--force", candidate]);
915
- break;
916
- } catch {
917
- // Try another provider-reported/listed path before giving up.
918
- }
919
- }
920
- } catch {
921
- // Best-effort rollback; preserve the validation failure.
922
- }
923
- try { runGitChecked(toplevel, ["branch", "-D", naming.requestedBranch]); } catch {
924
- // Best-effort rollback; preserve the validation failure.
925
- }
926
- }
985
+ tx.unknown(error);
927
986
  throw error;
928
987
  }
929
988
  }
930
989
 
931
990
  function removeSyntheticPath(worktree: WorktreeInfo, syntheticPath: string): void {
991
+ assertWorktreeMutationAllowed();
932
992
  const resolved = path.resolve(worktree.path, syntheticPath);
933
993
  const relative = path.relative(worktree.path, resolved);
934
994
  if (!relative || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
@@ -1193,39 +1253,94 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
1193
1253
  return diff.filesChanged > 0 || diff.insertions > 0 || diff.deletions > 0 || diff.diffStat.trim().length > 0;
1194
1254
  }
1195
1255
 
1196
- export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
1256
+ async function compensateSetup(tx: SetupTransaction): Promise<WorktreeCleanupReport> {
1257
+ const { setup, attempts } = tx.progress;
1258
+ const report: WorktreeCleanupReport = { state: "partial", tasks: [], pruned: false, errors: [] };
1259
+ tx.progress.cleanup = report;
1260
+ tx.progress.phase = "rollback";
1261
+ // Cancellation stops allocation, not accounting. Compensation has ONLY the
1262
+ // remaining original absolute deadline; it does not reuse the launch signal.
1263
+ const git = async (args: string[], acceptedExitCodes: readonly number[] = [0]) => {
1264
+ if (setupPoison) throw new Error(setupPoison);
1265
+ tx.progress.command = { command: "git", args: ["-C", setup.cwd, ...args] };
1266
+ const result = await runSetupCommand("git", tx.progress.command.args, {
1267
+ deadlineAt: tx.options.deadlineAt, acceptedExitCodes,
1268
+ onSpawn: (process) => { Object.assign(tx.progress.command!, process); tx.publish(); },
1269
+ });
1270
+ const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
1271
+ tx.progress.command.result = metadata;
1272
+ if (result.processTree?.state === "unknown") tx.unknown(result.error ?? "Rollback command settlement unverified");
1273
+ tx.publish();
1274
+ if (result.error || result.status === null || !acceptedExitCodes.includes(result.status)) throw result.error ?? new Error(result.stderr.trim() || "Rollback command failed");
1275
+ return result;
1276
+ };
1277
+ for (const attempt of [...attempts].reverse()) {
1278
+ if (!attempt.path) {
1279
+ tx.unknown(`Allocation path unconfirmed for branch ${attempt.branch}`);
1280
+ report.errors!.push(tx.progress.unknown!);
1281
+ continue;
1282
+ }
1283
+ const known = setup.worktrees.find((worktree) => worktree.index === attempt.index);
1284
+ const task: WorktreeCleanupTask = { index: attempt.index, path: attempt.path, branch: attempt.branch,
1285
+ provider: known?.provider ?? "native", naming: known?.naming, worktreeRemoved: false, branchRemoved: false };
1286
+ report.tasks.push(task);
1287
+ try {
1288
+ if (normalizeComparableCwd(attempt.path) === normalizeComparableCwd(setup.cwd)) throw new Error("Refusing source-checkout removal");
1289
+ if (attempt.validated) {
1290
+ await git(["worktree", "remove", "--force", attempt.path]);
1291
+ task.worktreeRemoved = true;
1292
+ } else {
1293
+ // Native attempts were absent before mutation. Reconcile only exact
1294
+ // registered path/branch identity; never recursively delete a guessed leaf.
1295
+ const listed = (await git(["worktree", "list", "--porcelain"])).stdout;
1296
+ const entry = listed.split("\n\n").find((block) => block.split("\n").includes(`worktree ${attempt.path}`));
1297
+ if (entry) {
1298
+ if (!entry.split("\n").includes(`branch refs/heads/${attempt.branch}`)) {
1299
+ tx.unknown("Partial allocation ownership mismatch");
1300
+ throw new Error(tx.progress.unknown);
1301
+ }
1302
+ await git(["worktree", "remove", "--force", attempt.path]);
1303
+ } else if (fs.existsSync(attempt.path)) {
1304
+ tx.unknown("Unregistered partial allocation retained");
1305
+ throw new Error(tx.progress.unknown);
1306
+ }
1307
+ task.worktreeRemoved = true;
1308
+ }
1309
+ const branch = await git(["show-ref", "--verify", "--quiet", `refs/heads/${attempt.branch}`], [0, 1]);
1310
+ if (branch.status === 0) await git(["branch", "-D", attempt.branch]);
1311
+ task.branchRemoved = true;
1312
+ } catch (error) {
1313
+ task.preserved = true;
1314
+ task.errors = [error instanceof Error ? error.message : String(error)];
1315
+ }
1316
+ }
1317
+ try {
1318
+ if (attempts.length > 0) { await git(["worktree", "prune"]); report.pruned = true; }
1319
+ } catch (error) { report.errors!.push(error instanceof Error ? error.message : String(error)); }
1320
+ if (tx.progress.unknown) report.errors!.push(tx.progress.unknown);
1321
+ report.tasks.sort((a, b) => a.index - b.index);
1322
+ if (!setupPoison && (report.pruned || attempts.length === 0) && report.tasks.every((task) => task.worktreeRemoved && task.branchRemoved) && !report.errors!.length) report.state = "complete";
1323
+ return report;
1324
+ }
1325
+
1326
+ async function allocateWorktrees(tx: SetupTransaction, cwd: string, runId: string, count: number): Promise<WorktreeSetup> {
1327
+ const options = tx.options;
1328
+ tx.check();
1197
1329
  if (!Number.isSafeInteger(count) || count < 0) throw new Error("worktree count must be a non-negative integer");
1198
- const repo = resolveRepoState(cwd, options?.baseRef);
1330
+ const repo = await resolveRepoState(tx, cwd, options?.baseRef);
1331
+ tx.progress.setup.cwd = repo.toplevel;
1332
+ tx.progress.setup.baseCommit = repo.baseCommit;
1199
1333
  const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
1200
- const provider = resolveWorktreeProvider(options?.provider, options?.baseDir);
1334
+ const provider = await resolveSetupProvider(tx, options?.provider, options?.baseDir);
1201
1335
  const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
1202
1336
  const dedicatedRoot = provider === "native" ? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel) : undefined;
1203
- const worktrees: WorktreeInfo[] = [];
1337
+ const worktrees = tx.progress.setup.worktrees;
1204
1338
 
1205
1339
  try {
1206
1340
  if (provider === "native") {
1207
- const plannedSetup: WorktreeSetup = {
1208
- cwd: repo.toplevel,
1209
- baseCommit: repo.baseCommit,
1210
- worktrees: Array.from({ length: count }, (_, index) => {
1211
- const naming = buildWorktreeNaming({ runId, index, agent: options?.agents?.[index], label: options?.labels?.[index], task: options?.tasks?.[index], branchPrefix });
1212
- const worktreePath = buildNativeWorktreePath(dedicatedRoot!, repo.toplevel, runId, index);
1213
- assertSafeWorktreeLocation(worktreePath, repo.toplevel, dedicatedRoot!);
1214
- return {
1215
- path: worktreePath,
1216
- agentCwd: repo.cwdRelative ? path.join(worktreePath, repo.cwdRelative) : worktreePath,
1217
- branch: naming.requestedBranch,
1218
- index,
1219
- nodeModulesLinked: false,
1220
- syntheticPaths: [],
1221
- provider: "native",
1222
- naming,
1223
- };
1224
- }),
1225
- };
1226
- options?.beforeCreate?.(plannedSetup);
1227
1341
  for (let index = 0; index < count; index++) {
1228
- worktrees.push(createNativeWorktree(
1342
+ worktrees[index] = await createNativeWorktree(
1343
+ tx,
1229
1344
  repo.toplevel,
1230
1345
  repo.cwdRelative,
1231
1346
  runId,
@@ -1237,28 +1352,26 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1237
1352
  options?.labels,
1238
1353
  options?.tasks,
1239
1354
  branchPrefix,
1240
- ));
1355
+ );
1241
1356
  }
1242
1357
  } else {
1243
1358
  for (let index = 0; index < count; index++) {
1244
- worktrees.push(createWorktrunkWorktree(
1359
+ await createWorktrunkWorktree(
1360
+ tx,
1245
1361
  repo.toplevel,
1246
1362
  repo.cwdRelative,
1247
1363
  runId,
1248
1364
  index,
1249
1365
  repo.baseCommit,
1250
- repo.sourceCheckout,
1251
1366
  options?.agents,
1252
1367
  options?.labels,
1253
1368
  options?.tasks,
1254
1369
  branchPrefix,
1255
- ));
1370
+ );
1256
1371
  }
1257
- // Worktrunk determines its path only after creation. Journal the exact
1258
- // returned ownership before Pi runs setup hooks or launches a child.
1259
- options?.beforeCreate?.({ cwd: repo.toplevel, baseCommit: repo.baseCommit, worktrees: [...worktrees] });
1260
1372
  for (let index = 0; index < worktrees.length; index++) {
1261
- worktrees[index] = finalizeCreatedWorktree(
1373
+ worktrees[index] = await finalizeCreatedWorktree(
1374
+ tx,
1262
1375
  repo.toplevel,
1263
1376
  repo.cwdRelative,
1264
1377
  runId,
@@ -1269,23 +1382,37 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1269
1382
  );
1270
1383
  }
1271
1384
  }
1385
+ tx.check();
1386
+ tx.progress.phase = "ready";
1387
+ tx.publish();
1388
+ tx.check();
1389
+ return tx.progress.setup;
1272
1390
  } catch (error) {
1273
- cleanupWorktrees({
1274
- cwd: repo.toplevel,
1275
- worktrees,
1276
- baseCommit: repo.baseCommit,
1277
- }, { kind: "setup-rollback" });
1391
+ await compensateSetup(tx);
1278
1392
  throw error;
1279
1393
  }
1394
+ }
1280
1395
 
1281
- return {
1282
- cwd: repo.toplevel,
1283
- worktrees,
1284
- baseCommit: repo.baseCommit,
1285
- };
1396
+ export async function createWorktrees(cwd: string, runId: string, count: number, options: CreateWorktreesOptions = {}): Promise<WorktreeSetup> {
1397
+ const tx = new SetupTransaction(cwd, options);
1398
+ try {
1399
+ return await withWorktreeTransaction(async () => {
1400
+ setupActive = true;
1401
+ try { return await allocateWorktrees(tx, cwd, runId, count); }
1402
+ finally { setupActive = false; }
1403
+ });
1404
+ } catch (error) {
1405
+ if (setupPoison) tx.progress.unknown ??= setupPoison;
1406
+ tx.progress.cleanup ??= { state: tx.progress.unknown ? "partial" : "complete", tasks: [], pruned: false, errors: tx.progress.unknown ? [tx.progress.unknown] : [] };
1407
+ try { tx.publish(); } catch (publicationError) {
1408
+ tx.progress.cleanup?.errors?.push(`Recovery publication failed: ${String(publicationError)}`);
1409
+ }
1410
+ throw new WorktreeSetupError(error, tx.snapshot());
1411
+ }
1286
1412
  }
1287
1413
 
1288
1414
  export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir: string): WorktreeDiff[] {
1415
+ assertWorktreeMutationAllowed();
1289
1416
  try {
1290
1417
  fs.mkdirSync(diffsDir, { recursive: true });
1291
1418
  } catch {
@@ -1315,6 +1442,7 @@ export function cleanupWorktrees(
1315
1442
  setup: WorktreeSetup,
1316
1443
  intent: WorktreeCleanupIntent = { kind: "preserve", ...(setup.capturedDiffs ? { capturedDiffs: setup.capturedDiffs } : {}) },
1317
1444
  ): WorktreeCleanupReport {
1445
+ assertWorktreeMutationAllowed();
1318
1446
  const tasks: WorktreeCleanupTask[] = [];
1319
1447
  for (let index = setup.worktrees.length - 1; index >= 0; index--) {
1320
1448
  tasks.push(cleanupSingleWorktree(setup, setup.worktrees[index]!, intent));