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.
- package/CHANGELOG.md +55 -0
- package/README.md +1 -1
- package/agents/researcher.md +23 -13
- package/docs/agents.md +16 -2
- package/docs/configuration.md +18 -0
- package/docs/extension-api.md +91 -0
- package/docs/models.md +58 -1
- package/docs/observability.md +42 -2
- package/docs/tool-reference.md +3 -3
- package/docs/workflows.md +14 -7
- package/package.json +2 -1
- package/skills/pi-subagents/references/execution-controls.md +14 -1
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/src/agents/advertised-agent-prompt.ts +63 -0
- package/src/agents/agent-management.ts +14 -1
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +8 -0
- package/src/api/shared-types.ts +1 -1
- package/src/api/workflow-resources.ts +6 -0
- package/src/extension/index.ts +40 -2
- package/src/extension/public-execution.ts +0 -1
- package/src/extension/rpc.ts +4 -21
- package/src/extension/schemas.ts +8 -6
- package/src/extension/tool-description.ts +6 -5
- package/src/intercom/native-supervisor-channel.ts +82 -54
- package/src/runs/background/active-async-capacity.ts +18 -18
- package/src/runs/background/async-job-tracker.ts +35 -3
- package/src/runs/background/async-status-snapshot.ts +10 -12
- package/src/runs/background/async-status.ts +17 -9
- package/src/runs/background/auto-drain.ts +40 -29
- package/src/runs/background/chain-root-attachment.ts +8 -0
- package/src/runs/background/control-channel.ts +78 -44
- package/src/runs/background/notify.ts +86 -12
- package/src/runs/background/owned-process-tree.ts +6 -6
- package/src/runs/background/process-terminal.ts +23 -23
- package/src/runs/background/run-child-session.ts +60 -32
- package/src/runs/background/run-status.ts +75 -5
- package/src/runs/background/runner-aliases.ts +18 -7
- package/src/runs/background/runner-child-launch.ts +86 -0
- package/src/runs/background/stale-run-reconciler.ts +3 -1
- package/src/runs/background/subagent-runner.ts +412 -209
- package/src/runs/background/subagent-wait.ts +3 -0
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/foreground/async-steering-action.ts +19 -0
- package/src/runs/foreground/execution.ts +101 -25
- package/src/runs/foreground/subagent-executor.ts +474 -197
- package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
- package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
- package/src/runs/shared/acceptance.ts +2 -2
- package/src/runs/shared/agent-contract.ts +1 -1
- package/src/runs/shared/async-status-projection.ts +47 -47
- package/src/runs/shared/child-hooks.ts +151 -2
- package/src/runs/shared/child-launch.ts +18 -13
- package/src/runs/shared/child-session.ts +42 -6
- package/src/runs/shared/child-tool-plan.ts +2 -2
- package/src/runs/shared/completion-evidence.ts +2 -2
- package/src/runs/shared/completion-guard.ts +1 -0
- package/src/runs/shared/host-step-status.ts +11 -11
- package/src/runs/shared/llm-intent-arbiter.ts +10 -9
- package/src/runs/shared/model-fallback.ts +10 -6
- package/src/runs/shared/nested-events.ts +5 -5
- package/src/runs/shared/orca-progress-tabs.ts +6 -0
- package/src/runs/shared/parallel-handoff.ts +57 -12
- package/src/runs/shared/parallel-utils.ts +2 -2
- package/src/runs/shared/readonly-drain-observation.ts +42 -0
- package/src/runs/shared/readonly-model-continuation.ts +69 -0
- package/src/runs/shared/readonly-session-evidence.ts +307 -0
- package/src/runs/shared/run-fanout-budget.ts +8 -8
- package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
- package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
- package/src/runs/shared/worktree-setup-command.ts +190 -0
- package/src/runs/shared/worktree.ts +332 -204
- package/src/shared/types.ts +81 -60
- package/src/shared/utils.ts +7 -2
- package/src/shared/workflow-child-permit.ts +18 -13
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render.ts +23 -5
- package/src/workflows/chat-progress.ts +3 -3
- package/src/workflows/scripted-workflow.ts +38 -10
- package/src/workflows/workflow-checklist.ts +11 -15
- package/src/workflows/workflow-child-summary.ts +57 -8
- package/src/workflows/workflow-preflight.ts +19 -19
- package/src/workflows/workflow-receipt.ts +3 -3
- package/src/workflows/workflow-resources.ts +96 -21
- 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
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
if (
|
|
738
|
-
|
|
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
|
|
741
|
-
|
|
742
|
-
throw new Error(
|
|
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
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
806
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
885
|
-
const returnedCommonDirRaw =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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));
|