pi-subagents 0.65.0 → 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 +87 -0
- package/README.md +1 -1
- package/agents/researcher.md +23 -13
- package/docs/agents.md +17 -3
- package/docs/configuration.md +34 -0
- package/docs/extension-api.md +94 -0
- package/docs/models.md +58 -1
- package/docs/observability.md +42 -2
- package/docs/tool-reference.md +11 -5
- package/docs/workflows.md +22 -7
- package/package.json +4 -1
- package/runner-server-preload.mjs +13 -0
- package/skills/pi-subagents/SKILL.md +2 -1
- package/skills/pi-subagents/references/execution-controls.md +19 -2
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
- 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/preflight.ts +5 -1
- package/src/api/shared-types.ts +1 -1
- package/src/api/workflow-resources.ts +6 -0
- package/src/extension/config.ts +4 -2
- package/src/extension/index.ts +71 -4
- package/src/extension/public-execution.ts +0 -1
- package/src/extension/rpc.ts +4 -21
- package/src/extension/schemas.ts +9 -7
- package/src/extension/tool-description.ts +10 -5
- package/src/integrations/pi-web-session-liveness.ts +73 -0
- package/src/intercom/native-supervisor-channel.ts +102 -88
- package/src/intercom/supervisor-ui.ts +3 -2
- package/src/missions/workflow-state.ts +37 -16
- package/src/runs/background/active-async-capacity.ts +18 -18
- package/src/runs/background/async-execution.ts +8 -1
- package/src/runs/background/async-job-tracker.ts +35 -3
- package/src/runs/background/async-resume.ts +3 -1
- package/src/runs/background/async-retention.ts +9 -0
- 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 +88 -12
- package/src/runs/background/owned-process-tree.ts +6 -6
- package/src/runs/background/process-terminal.ts +23 -23
- package/src/runs/background/retained-nested-route-tracker.ts +96 -0
- package/src/runs/background/run-child-session.ts +62 -33
- package/src/runs/background/run-status.ts +75 -5
- package/src/runs/background/runner-aliases.ts +46 -8
- 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 +430 -208
- 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 +116 -26
- package/src/runs/foreground/foreground-history.ts +3 -1
- package/src/runs/foreground/prompt-audit.ts +9 -5
- package/src/runs/foreground/subagent-executor.ts +531 -227
- 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 +16 -3
- 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 +55 -24
- 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 +30 -20
- package/src/runs/shared/model-exclusions.ts +2 -1
- package/src/runs/shared/model-fallback.ts +41 -8
- package/src/runs/shared/nested-events.ts +8 -8
- 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 +3 -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-cleanup-plan.ts +6 -3
- package/src/runs/shared/worktree-setup-command.ts +190 -0
- package/src/runs/shared/worktree.ts +403 -210
- package/src/shared/model-response-aliases.ts +13 -0
- package/src/shared/types.ts +89 -60
- package/src/shared/utils.ts +10 -2
- package/src/shared/watch-strategy.ts +2 -0
- package/src/shared/workflow-child-permit.ts +18 -13
- package/src/tui/fleet-status.ts +1 -1
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render.ts +44 -15
- package/src/workflows/chat-progress.ts +3 -3
- package/src/workflows/scripted-workflow.ts +70 -16
- package/src/workflows/workflow-checklist.ts +15 -18
- 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";
|
|
@@ -17,6 +18,9 @@ const WORKTREE_NAMING_COMPONENT_MAX_BYTES = 96;
|
|
|
17
18
|
const WORKTREE_NAMING_LABEL_MAX_BYTES = 256;
|
|
18
19
|
const WORKTREE_NAMING_BRANCH_MAX_BYTES = 256;
|
|
19
20
|
const WORKTREE_COMMAND_OUTPUT_MAX_BYTES = 128 * 1024;
|
|
21
|
+
export const MACHINE_DIFF_OPTIONS = ["--no-color", "--no-ext-diff", "--no-textconv", "--default-prefix", "--line-prefix=", "--no-relative"] as const;
|
|
22
|
+
const MACHINE_PATCH_OPTIONS = [...MACHINE_DIFF_OPTIONS, "--binary"] as const;
|
|
23
|
+
const PATCH_VALIDATION_OPTIONS = ["apply", "--check", "--cached", "--reverse", "--binary", "--whitespace=nowarn"] as const;
|
|
20
24
|
|
|
21
25
|
export interface WorktreeNamingInput {
|
|
22
26
|
runId: string;
|
|
@@ -115,6 +119,10 @@ interface WorktreeSetupHookConfig {
|
|
|
115
119
|
}
|
|
116
120
|
|
|
117
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;
|
|
118
126
|
agents?: string[];
|
|
119
127
|
/** Optional stable labels used to make branch identity readable. */
|
|
120
128
|
labels?: Array<string | undefined>;
|
|
@@ -128,8 +136,6 @@ export interface CreateWorktreesOptions {
|
|
|
128
136
|
branchPrefix?: string;
|
|
129
137
|
setupHook?: WorktreeSetupHookConfig;
|
|
130
138
|
baseDir?: string;
|
|
131
|
-
/** 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. */
|
|
132
|
-
beforeCreate?: (setup: WorktreeSetup) => void;
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
interface ResolvedWorktreeSetupHook {
|
|
@@ -162,14 +168,112 @@ interface GitResult {
|
|
|
162
168
|
interface RepoState {
|
|
163
169
|
toplevel: string;
|
|
164
170
|
cwdRelative: string;
|
|
165
|
-
sourceCheckout: SourceCheckoutSnapshot;
|
|
166
171
|
baseCommit: string;
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
|
|
170
175
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
|
|
275
|
+
function runGit(cwd: string, args: string[], env?: NodeJS.ProcessEnv): GitResult {
|
|
276
|
+
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false, ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
173
277
|
return {
|
|
174
278
|
stdout: result.stdout ?? "",
|
|
175
279
|
stderr: result.stderr ?? "",
|
|
@@ -187,38 +291,79 @@ function runGitChecked(cwd: string, args: string[]): string {
|
|
|
187
291
|
return result.stdout;
|
|
188
292
|
}
|
|
189
293
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
294
|
+
/** Validate a captured patch against the worktree index without changing either. */
|
|
295
|
+
export function validateWorktreePatch(worktreePath: string, patchPath: string): string | undefined {
|
|
296
|
+
const result = runGit(worktreePath, [...PATCH_VALIDATION_OPTIONS, patchPath]);
|
|
297
|
+
if (result.status === 0) return undefined;
|
|
298
|
+
return result.stderr.trim() || result.stdout.trim() || `git -C ${worktreePath} apply --check failed`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function currentWorktreePatch(worktreePath: string, baseCommit: string): { patch: string } | { error: string } {
|
|
302
|
+
assertWorktreeMutationAllowed();
|
|
303
|
+
let tempDir: string;
|
|
304
|
+
try {
|
|
305
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-index-"));
|
|
306
|
+
} catch (error) {
|
|
307
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
196
308
|
}
|
|
309
|
+
const env = { GIT_INDEX_FILE: path.join(tempDir, "index") };
|
|
310
|
+
try {
|
|
311
|
+
const readTree = runGit(worktreePath, ["read-tree", "HEAD"], env);
|
|
312
|
+
if (readTree.status !== 0) return { error: readTree.stderr.trim() || readTree.stdout.trim() || "git read-tree failed" };
|
|
313
|
+
const add = runGit(worktreePath, ["add", "-A"], env);
|
|
314
|
+
if (add.status !== 0) return { error: add.stderr.trim() || add.stdout.trim() || "git add failed" };
|
|
315
|
+
const diff = runGit(worktreePath, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, baseCommit], env);
|
|
316
|
+
if (diff.status !== 0) return { error: diff.stderr.trim() || diff.stdout.trim() || "git diff failed" };
|
|
317
|
+
return { patch: diff.stdout };
|
|
318
|
+
} finally {
|
|
319
|
+
try {
|
|
320
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
321
|
+
} catch {
|
|
322
|
+
// Cleanup safety depends on preserving the worktree, not on best-effort temp-index deletion.
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function validateWorktreePatchRepresentsCurrentWorktree(worktreePath: string, baseCommit: string, patchPath: string): string | undefined {
|
|
328
|
+
const validationError = validateWorktreePatch(worktreePath, patchPath);
|
|
329
|
+
if (validationError) return validationError;
|
|
330
|
+
let capturedPatch: string;
|
|
331
|
+
try {
|
|
332
|
+
capturedPatch = fs.readFileSync(patchPath, "utf-8");
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return error instanceof Error ? error.message : String(error);
|
|
335
|
+
}
|
|
336
|
+
const current = currentWorktreePatch(worktreePath, baseCommit);
|
|
337
|
+
if ("error" in current) return current.error || "failed to capture current worktree patch";
|
|
338
|
+
if (capturedPatch !== current.patch) return "captured handoff patch does not match current worktree changes";
|
|
197
339
|
return undefined;
|
|
198
340
|
}
|
|
199
341
|
|
|
200
|
-
function resolveRepoState(cwd: string, requestedBaseRef: string | undefined): RepoState {
|
|
201
|
-
const
|
|
202
|
-
|
|
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();
|
|
203
348
|
|
|
204
349
|
// pi-subagents writes durable runtime state under .pi/subagents/ by default;
|
|
205
350
|
// that state must not make managed isolation unusable for later runs.
|
|
206
|
-
const status =
|
|
351
|
+
const status = await tx.gitChecked(toplevel, ["status", "--porcelain", "--", `:!${PROJECT_SUBAGENTS_RELATIVE_DIR}`]);
|
|
207
352
|
if (status.trim().length > 0) {
|
|
208
353
|
throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
|
|
209
354
|
}
|
|
210
355
|
|
|
211
|
-
const sourceHead = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim();
|
|
212
|
-
const sourceCheckout = snapshotSourceCheckout(toplevel, sourceHead);
|
|
213
356
|
const baseRef = normalizeWorktreeBaseRef(requestedBaseRef) ?? DEFAULT_WORKTREE_BASE_REF;
|
|
214
357
|
let baseCommit: string;
|
|
215
358
|
try {
|
|
216
|
-
|
|
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();
|
|
217
362
|
} catch (error) {
|
|
218
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 });
|
|
219
364
|
}
|
|
220
365
|
if (!baseCommit) throw new Error(`baseRef '${baseRef}' could not be resolved to a commit`);
|
|
221
|
-
return { toplevel, cwdRelative,
|
|
366
|
+
return { toplevel, cwdRelative, baseCommit };
|
|
222
367
|
}
|
|
223
368
|
|
|
224
369
|
function normalizeComparableCwd(cwd: string): string {
|
|
@@ -307,7 +452,7 @@ function validGitRef(ref: string): boolean {
|
|
|
307
452
|
/** Normalize and validate a configured worktree base ref without resolving it. */
|
|
308
453
|
export function normalizeWorktreeBaseRef(value: unknown): string | undefined {
|
|
309
454
|
if (value === undefined) return undefined;
|
|
310
|
-
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.");
|
|
311
456
|
return value;
|
|
312
457
|
}
|
|
313
458
|
|
|
@@ -386,11 +531,6 @@ interface WorktrunkCapability {
|
|
|
386
531
|
reason?: string;
|
|
387
532
|
}
|
|
388
533
|
|
|
389
|
-
interface SourceCheckoutSnapshot {
|
|
390
|
-
head: string;
|
|
391
|
-
branch?: string;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
534
|
function runWorktrunk(args: string[], cwd?: string): WorktreeCommandResult {
|
|
395
535
|
try {
|
|
396
536
|
const result = spawnSync("wt", args, {
|
|
@@ -423,18 +563,6 @@ function probeWorktrunk(): WorktrunkCapability {
|
|
|
423
563
|
return { available: true };
|
|
424
564
|
}
|
|
425
565
|
|
|
426
|
-
function snapshotSourceCheckout(toplevel: string, head: string): SourceCheckoutSnapshot {
|
|
427
|
-
const branch = runGit(toplevel, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
428
|
-
const branchName = branch.status === 0 ? branch.stdout.trim() : "";
|
|
429
|
-
return branchName ? { head, branch: branchName } : { head };
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function restoreSourceCheckoutIfWorktrunkSwitchedIt(toplevel: string, createdBranch: string, sourceCheckout: SourceCheckoutSnapshot): void {
|
|
433
|
-
const current = runGit(toplevel, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
434
|
-
if (current.status !== 0 || current.stdout.trim() !== createdBranch) return;
|
|
435
|
-
runGitChecked(toplevel, sourceCheckout.branch ? ["checkout", sourceCheckout.branch] : ["checkout", "--detach", sourceCheckout.head]);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
566
|
/** Resolve a requested provider without silently switching after allocation starts. */
|
|
439
567
|
export function resolveWorktreeProvider(requested: WorktreeProvider | undefined, baseDir?: string): ManagedWorktreeProvider {
|
|
440
568
|
const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
|
|
@@ -450,6 +578,33 @@ export function resolveWorktreeProvider(requested: WorktreeProvider | undefined,
|
|
|
450
578
|
return "native";
|
|
451
579
|
}
|
|
452
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
|
+
|
|
453
608
|
/** Whether a launch must bind its worktree-dependent paths after allocation. */
|
|
454
609
|
export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined, baseDir?: string): boolean {
|
|
455
610
|
return (requested ?? DEFAULT_WORKTREE_PROVIDER) !== "native" && !hasConfiguredWorktreeBaseDir(baseDir);
|
|
@@ -627,11 +782,6 @@ function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
|
|
|
627
782
|
return path.normalize(relative);
|
|
628
783
|
}
|
|
629
784
|
|
|
630
|
-
function hasTrackedEntries(worktreePath: string, relativePath: string): boolean {
|
|
631
|
-
const result = runGit(worktreePath, ["ls-files", "--", relativePath]);
|
|
632
|
-
return result.status === 0 && result.stdout.trim().length > 0;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
785
|
function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutput {
|
|
636
786
|
const trimmed = rawStdout.trim();
|
|
637
787
|
if (!trimmed) {
|
|
@@ -650,53 +800,49 @@ function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutpu
|
|
|
650
800
|
return parsed as WorktreeSetupHookOutput;
|
|
651
801
|
}
|
|
652
802
|
|
|
653
|
-
function runWorktreeSetupHook(
|
|
803
|
+
async function runWorktreeSetupHook(
|
|
804
|
+
tx: SetupTransaction,
|
|
654
805
|
hook: ResolvedWorktreeSetupHook,
|
|
655
806
|
input: WorktreeSetupHookInput,
|
|
656
|
-
): string[] {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const code = "code" in result.error ? result.error.code : undefined;
|
|
668
|
-
if (code === "ETIMEDOUT") {
|
|
669
|
-
throw new Error(`worktree setup hook timed out after ${hook.timeoutMs}ms`);
|
|
670
|
-
}
|
|
671
|
-
throw new Error(`worktree setup hook failed: ${result.error.message}`);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
if (result.status !== 0) {
|
|
675
|
-
const details = result.stderr.trim() || result.stdout.trim() || "no output";
|
|
676
|
-
throw new Error(`worktree setup hook failed with exit code ${result.status}: ${details}`);
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
const output = parseWorktreeSetupHookOutput(result.stdout);
|
|
680
|
-
if (output.syntheticPaths === undefined) return [];
|
|
681
|
-
if (!Array.isArray(output.syntheticPaths)) {
|
|
682
|
-
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 });
|
|
683
818
|
}
|
|
819
|
+
tx.progress.phase = "hook-validation";
|
|
684
820
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
if (
|
|
688
|
-
|
|
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");
|
|
689
826
|
}
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
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);
|
|
693
835
|
}
|
|
694
|
-
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;
|
|
695
841
|
}
|
|
696
|
-
return [...uniquePaths];
|
|
697
842
|
}
|
|
698
843
|
|
|
699
|
-
function finalizeCreatedWorktree(
|
|
844
|
+
async function finalizeCreatedWorktree(
|
|
845
|
+
tx: SetupTransaction,
|
|
700
846
|
toplevel: string,
|
|
701
847
|
cwdRelative: string,
|
|
702
848
|
runId: string,
|
|
@@ -704,38 +850,23 @@ function finalizeCreatedWorktree(
|
|
|
704
850
|
setupHook: ResolvedWorktreeSetupHook | undefined,
|
|
705
851
|
agent: string | undefined,
|
|
706
852
|
worktree: WorktreeInfo,
|
|
707
|
-
): WorktreeInfo {
|
|
853
|
+
): Promise<WorktreeInfo> {
|
|
708
854
|
const agentCwd = cwdRelative ? path.join(worktree.path, cwdRelative) : worktree.path;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
branch: worktree.branch,
|
|
719
|
-
index: worktree.index,
|
|
720
|
-
runId,
|
|
721
|
-
baseCommit,
|
|
722
|
-
agent,
|
|
723
|
-
});
|
|
724
|
-
syntheticPaths.push(...hookSyntheticPaths);
|
|
725
|
-
}
|
|
726
|
-
return { ...worktree, agentCwd, nodeModulesLinked, syntheticPaths };
|
|
727
|
-
} catch (error) {
|
|
728
|
-
try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktree.path]); } catch {
|
|
729
|
-
// Best-effort rollback; preserve the original setup failure.
|
|
730
|
-
}
|
|
731
|
-
try { runGitChecked(toplevel, ["branch", "-D", worktree.branch]); } catch {
|
|
732
|
-
// Best-effort rollback; preserve the original setup failure.
|
|
733
|
-
}
|
|
734
|
-
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);
|
|
735
864
|
}
|
|
865
|
+
return { ...worktree, agentCwd, nodeModulesLinked, syntheticPaths };
|
|
736
866
|
}
|
|
737
867
|
|
|
738
|
-
function createNativeWorktree(
|
|
868
|
+
async function createNativeWorktree(
|
|
869
|
+
tx: SetupTransaction,
|
|
739
870
|
toplevel: string,
|
|
740
871
|
cwdRelative: string,
|
|
741
872
|
runId: string,
|
|
@@ -747,17 +878,20 @@ function createNativeWorktree(
|
|
|
747
878
|
labels: Array<string | undefined> | undefined,
|
|
748
879
|
tasks: Array<string | undefined> | undefined,
|
|
749
880
|
branchPrefix: string | undefined,
|
|
750
|
-
): WorktreeInfo {
|
|
881
|
+
): Promise<WorktreeInfo> {
|
|
751
882
|
const naming = buildWorktreeNaming({ runId, index, agent, label: labels?.[index], task: tasks?.[index], branchPrefix });
|
|
752
883
|
const worktreePath = buildNativeWorktreePath(dedicatedRoot, toplevel, runId, index);
|
|
753
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);
|
|
754
892
|
ensureProjectWorktreeDir(dedicatedRoot, toplevel);
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
|
|
758
|
-
throw new Error(message);
|
|
759
|
-
}
|
|
760
|
-
return finalizeCreatedWorktree(toplevel, cwdRelative, runId, baseCommit, setupHook, agent, {
|
|
893
|
+
await tx.git(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
|
|
894
|
+
const worktree: WorktreeInfo = {
|
|
761
895
|
path: worktreePath,
|
|
762
896
|
agentCwd: worktreePath,
|
|
763
897
|
branch: naming.requestedBranch,
|
|
@@ -766,7 +900,9 @@ function createNativeWorktree(
|
|
|
766
900
|
syntheticPaths: [],
|
|
767
901
|
provider: "native",
|
|
768
902
|
naming,
|
|
769
|
-
}
|
|
903
|
+
};
|
|
904
|
+
tx.validated(worktree);
|
|
905
|
+
return finalizeCreatedWorktree(tx, toplevel, cwdRelative, runId, baseCommit, setupHook, agent, worktree);
|
|
770
906
|
}
|
|
771
907
|
|
|
772
908
|
interface WorktrunkSwitchOutput {
|
|
@@ -791,37 +927,30 @@ function parseWorktrunkSwitchOutput(rawStdout: string): WorktrunkSwitchOutput {
|
|
|
791
927
|
return parsed as WorktrunkSwitchOutput;
|
|
792
928
|
}
|
|
793
929
|
|
|
794
|
-
function createWorktrunkWorktree(
|
|
930
|
+
async function createWorktrunkWorktree(
|
|
931
|
+
tx: SetupTransaction,
|
|
795
932
|
toplevel: string,
|
|
796
933
|
cwdRelative: string,
|
|
797
934
|
runId: string,
|
|
798
935
|
index: number,
|
|
799
936
|
baseCommit: string,
|
|
800
|
-
sourceCheckout: SourceCheckoutSnapshot,
|
|
801
937
|
agents: string[] | undefined,
|
|
802
938
|
labels: Array<string | undefined> | undefined,
|
|
803
939
|
tasks: Array<string | undefined> | undefined,
|
|
804
940
|
branchPrefix: string | undefined,
|
|
805
|
-
): WorktreeInfo {
|
|
941
|
+
): Promise<WorktreeInfo> {
|
|
806
942
|
const naming = buildWorktreeNaming({ runId, index, agent: agents?.[index], label: labels?.[index], task: tasks?.[index], branchPrefix });
|
|
807
943
|
const args = ["-C", toplevel, "switch", "--create", naming.requestedBranch, "--base", baseCommit, "--no-cd", "--no-hooks", "--format", "json"];
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
throw new Error(`Worktrunk provisioning failed: ${message}`);
|
|
812
|
-
}
|
|
813
|
-
let createdAllocation = false;
|
|
814
|
-
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";
|
|
815
947
|
try {
|
|
816
948
|
const output = parseWorktrunkSwitchOutput(result.stdout);
|
|
817
|
-
|
|
818
|
-
if (typeof output.path === "string" && path.isAbsolute(output.path)) returnedPath = path.resolve(output.path);
|
|
819
|
-
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) {
|
|
820
950
|
throw new Error("Worktrunk provisioning returned inconsistent creation metadata");
|
|
821
951
|
}
|
|
822
952
|
if (typeof output.path !== "string" || !path.isAbsolute(output.path)) throw new Error("Worktrunk provisioning returned a non-absolute worktree path");
|
|
823
|
-
const worktreePathCandidate =
|
|
824
|
-
if (!worktreePathCandidate) throw new Error("Worktrunk provisioning returned a non-absolute worktree path");
|
|
953
|
+
const worktreePathCandidate = path.resolve(output.path);
|
|
825
954
|
let stat: fs.Stats;
|
|
826
955
|
try {
|
|
827
956
|
stat = fs.lstatSync(worktreePathCandidate);
|
|
@@ -831,16 +960,16 @@ function createWorktrunkWorktree(
|
|
|
831
960
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Worktrunk provisioning returned a path that is not a real directory");
|
|
832
961
|
const worktreePath = normalizeComparableCwd(worktreePathCandidate);
|
|
833
962
|
if (worktreePath === normalizeComparableCwd(toplevel)) throw new Error("Worktrunk provisioning returned the source checkout path");
|
|
834
|
-
const sourceCommonDirRaw =
|
|
835
|
-
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();
|
|
836
965
|
const sourceCommonDir = normalizeComparableCwd(path.isAbsolute(sourceCommonDirRaw) ? sourceCommonDirRaw : path.resolve(toplevel, sourceCommonDirRaw));
|
|
837
966
|
const returnedCommonDir = normalizeComparableCwd(path.isAbsolute(returnedCommonDirRaw) ? returnedCommonDirRaw : path.resolve(worktreePath, returnedCommonDirRaw));
|
|
838
967
|
if (returnedCommonDir !== sourceCommonDir) throw new Error("Worktrunk provisioning returned a worktree for a different repository");
|
|
839
|
-
const returnedBranch =
|
|
968
|
+
const returnedBranch = (await tx.gitChecked(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"])).trim();
|
|
840
969
|
if (returnedBranch !== naming.requestedBranch) throw new Error("Worktrunk provisioning returned a worktree on a different branch");
|
|
841
|
-
const returnedHead =
|
|
970
|
+
const returnedHead = (await tx.gitChecked(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
842
971
|
if (returnedHead !== baseCommit) throw new Error("Worktrunk provisioning returned a worktree at a different base commit");
|
|
843
|
-
|
|
972
|
+
const worktree: WorktreeInfo = {
|
|
844
973
|
path: worktreePath,
|
|
845
974
|
agentCwd: cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath,
|
|
846
975
|
branch: naming.requestedBranch,
|
|
@@ -850,35 +979,16 @@ function createWorktrunkWorktree(
|
|
|
850
979
|
provider: "worktrunk",
|
|
851
980
|
naming,
|
|
852
981
|
};
|
|
982
|
+
tx.validated(worktree);
|
|
983
|
+
return worktree;
|
|
853
984
|
} catch (error) {
|
|
854
|
-
|
|
855
|
-
try { restoreSourceCheckoutIfWorktrunkSwitchedIt(toplevel, naming.requestedBranch, sourceCheckout); } catch {
|
|
856
|
-
// Best-effort rollback; preserve the validation failure.
|
|
857
|
-
}
|
|
858
|
-
try {
|
|
859
|
-
const listedPath = findGitWorktreePath(toplevel, naming.requestedBranch);
|
|
860
|
-
const candidates = [listedPath, returnedPath].filter((candidate, candidateIndex, all): candidate is string => Boolean(candidate) && all.indexOf(candidate) === candidateIndex);
|
|
861
|
-
for (const candidate of candidates) {
|
|
862
|
-
if (normalizeComparableCwd(candidate) === normalizeComparableCwd(toplevel)) continue;
|
|
863
|
-
try {
|
|
864
|
-
runGitChecked(toplevel, ["worktree", "remove", "--force", candidate]);
|
|
865
|
-
break;
|
|
866
|
-
} catch {
|
|
867
|
-
// Try another provider-reported/listed path before giving up.
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
} catch {
|
|
871
|
-
// Best-effort rollback; preserve the validation failure.
|
|
872
|
-
}
|
|
873
|
-
try { runGitChecked(toplevel, ["branch", "-D", naming.requestedBranch]); } catch {
|
|
874
|
-
// Best-effort rollback; preserve the validation failure.
|
|
875
|
-
}
|
|
876
|
-
}
|
|
985
|
+
tx.unknown(error);
|
|
877
986
|
throw error;
|
|
878
987
|
}
|
|
879
988
|
}
|
|
880
989
|
|
|
881
990
|
function removeSyntheticPath(worktree: WorktreeInfo, syntheticPath: string): void {
|
|
991
|
+
assertWorktreeMutationAllowed();
|
|
882
992
|
const resolved = path.resolve(worktree.path, syntheticPath);
|
|
883
993
|
const relative = path.relative(worktree.path, resolved);
|
|
884
994
|
if (!relative || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
@@ -957,15 +1067,18 @@ function captureWorktreeDiff(
|
|
|
957
1067
|
): WorktreeDiff {
|
|
958
1068
|
removeSyntheticPathsBeforeDiff(worktree);
|
|
959
1069
|
runGitChecked(worktree.path, ["add", "-A"]);
|
|
960
|
-
const diffStat = runGitChecked(worktree.path, ["diff", "--cached", "--stat", setup.baseCommit]).trim();
|
|
961
|
-
const patch = runGitChecked(worktree.path, ["diff", "--cached", setup.baseCommit]);
|
|
962
|
-
const numstat = runGitChecked(worktree.path, ["diff", "--cached", "--numstat", setup.baseCommit]);
|
|
1070
|
+
const diffStat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--stat", setup.baseCommit]).trim();
|
|
1071
|
+
const patch = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, setup.baseCommit]);
|
|
1072
|
+
const numstat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--numstat", setup.baseCommit]);
|
|
963
1073
|
fs.writeFileSync(patchPath, patch, "utf-8");
|
|
964
1074
|
|
|
965
1075
|
if (!patch.trim()) {
|
|
966
1076
|
return emptyDiff(worktree.index, agent, worktree.branch, patchPath);
|
|
967
1077
|
}
|
|
968
1078
|
|
|
1079
|
+
const validationError = validateWorktreePatch(worktree.path, patchPath);
|
|
1080
|
+
if (validationError) throw new Error(`captured worktree patch is not machine-applyable: ${validationError}`);
|
|
1081
|
+
|
|
969
1082
|
const parsed = parseNumstat(numstat);
|
|
970
1083
|
return {
|
|
971
1084
|
index: worktree.index,
|
|
@@ -1034,7 +1147,7 @@ function cleanupSingleWorktree(
|
|
|
1034
1147
|
errors.push(`synthetic path cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1035
1148
|
}
|
|
1036
1149
|
const status = runGit(worktree.path, ["status", "--porcelain"]);
|
|
1037
|
-
const baseDiff = runGit(worktree.path, ["diff", "--quiet", setup.baseCommit, "--"]);
|
|
1150
|
+
const baseDiff = runGit(worktree.path, ["diff", "--quiet", ...MACHINE_DIFF_OPTIONS, setup.baseCommit, "--"]);
|
|
1038
1151
|
if (status.status !== 0 || (baseDiff.status !== 0 && baseDiff.status !== 1)) {
|
|
1039
1152
|
const reason = status.status !== 0
|
|
1040
1153
|
? status.stderr.trim() || status.stdout.trim() || "git status failed"
|
|
@@ -1055,13 +1168,25 @@ function cleanupSingleWorktree(
|
|
|
1055
1168
|
const hasWork = status.stdout.trim().length > 0 || baseDiff.status === 1;
|
|
1056
1169
|
if (hasWork && intent.kind === "preserve") {
|
|
1057
1170
|
const captured = (intent.capturedDiffs ?? setup.capturedDiffs)?.find((diff) => diff.index === worktree.index);
|
|
1058
|
-
|
|
1171
|
+
let patchValidationError: string | undefined;
|
|
1172
|
+
let patchCaptured = false;
|
|
1173
|
+
if (captured !== undefined
|
|
1059
1174
|
&& captured.error === undefined
|
|
1060
1175
|
&& fs.existsSync(captured.patchPath)
|
|
1061
|
-
&&
|
|
1062
|
-
|
|
1176
|
+
&& handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath)) {
|
|
1177
|
+
try {
|
|
1178
|
+
if (fs.statSync(captured.patchPath).size > 0) {
|
|
1179
|
+
patchValidationError = validateWorktreePatchRepresentsCurrentWorktree(worktree.path, setup.baseCommit, captured.patchPath);
|
|
1180
|
+
patchCaptured = patchValidationError === undefined;
|
|
1181
|
+
}
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
patchValidationError = error instanceof Error ? error.message : String(error);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1063
1186
|
if (!patchCaptured) {
|
|
1064
|
-
const reason =
|
|
1187
|
+
const reason = patchValidationError
|
|
1188
|
+
? `captured handoff patch failed validation: ${patchValidationError}`
|
|
1189
|
+
: "worktree contains changes that are not represented by a captured handoff patch";
|
|
1065
1190
|
return {
|
|
1066
1191
|
index: worktree.index,
|
|
1067
1192
|
path: worktree.path,
|
|
@@ -1128,39 +1253,94 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
|
|
|
1128
1253
|
return diff.filesChanged > 0 || diff.insertions > 0 || diff.deletions > 0 || diff.diffStat.trim().length > 0;
|
|
1129
1254
|
}
|
|
1130
1255
|
|
|
1131
|
-
|
|
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();
|
|
1132
1329
|
if (!Number.isSafeInteger(count) || count < 0) throw new Error("worktree count must be a non-negative integer");
|
|
1133
|
-
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;
|
|
1134
1333
|
const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
|
|
1135
|
-
const provider =
|
|
1334
|
+
const provider = await resolveSetupProvider(tx, options?.provider, options?.baseDir);
|
|
1136
1335
|
const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
|
|
1137
1336
|
const dedicatedRoot = provider === "native" ? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel) : undefined;
|
|
1138
|
-
const worktrees
|
|
1337
|
+
const worktrees = tx.progress.setup.worktrees;
|
|
1139
1338
|
|
|
1140
1339
|
try {
|
|
1141
1340
|
if (provider === "native") {
|
|
1142
|
-
const plannedSetup: WorktreeSetup = {
|
|
1143
|
-
cwd: repo.toplevel,
|
|
1144
|
-
baseCommit: repo.baseCommit,
|
|
1145
|
-
worktrees: Array.from({ length: count }, (_, index) => {
|
|
1146
|
-
const naming = buildWorktreeNaming({ runId, index, agent: options?.agents?.[index], label: options?.labels?.[index], task: options?.tasks?.[index], branchPrefix });
|
|
1147
|
-
const worktreePath = buildNativeWorktreePath(dedicatedRoot!, repo.toplevel, runId, index);
|
|
1148
|
-
assertSafeWorktreeLocation(worktreePath, repo.toplevel, dedicatedRoot!);
|
|
1149
|
-
return {
|
|
1150
|
-
path: worktreePath,
|
|
1151
|
-
agentCwd: repo.cwdRelative ? path.join(worktreePath, repo.cwdRelative) : worktreePath,
|
|
1152
|
-
branch: naming.requestedBranch,
|
|
1153
|
-
index,
|
|
1154
|
-
nodeModulesLinked: false,
|
|
1155
|
-
syntheticPaths: [],
|
|
1156
|
-
provider: "native",
|
|
1157
|
-
naming,
|
|
1158
|
-
};
|
|
1159
|
-
}),
|
|
1160
|
-
};
|
|
1161
|
-
options?.beforeCreate?.(plannedSetup);
|
|
1162
1341
|
for (let index = 0; index < count; index++) {
|
|
1163
|
-
worktrees
|
|
1342
|
+
worktrees[index] = await createNativeWorktree(
|
|
1343
|
+
tx,
|
|
1164
1344
|
repo.toplevel,
|
|
1165
1345
|
repo.cwdRelative,
|
|
1166
1346
|
runId,
|
|
@@ -1172,28 +1352,26 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
|
|
|
1172
1352
|
options?.labels,
|
|
1173
1353
|
options?.tasks,
|
|
1174
1354
|
branchPrefix,
|
|
1175
|
-
)
|
|
1355
|
+
);
|
|
1176
1356
|
}
|
|
1177
1357
|
} else {
|
|
1178
1358
|
for (let index = 0; index < count; index++) {
|
|
1179
|
-
|
|
1359
|
+
await createWorktrunkWorktree(
|
|
1360
|
+
tx,
|
|
1180
1361
|
repo.toplevel,
|
|
1181
1362
|
repo.cwdRelative,
|
|
1182
1363
|
runId,
|
|
1183
1364
|
index,
|
|
1184
1365
|
repo.baseCommit,
|
|
1185
|
-
repo.sourceCheckout,
|
|
1186
1366
|
options?.agents,
|
|
1187
1367
|
options?.labels,
|
|
1188
1368
|
options?.tasks,
|
|
1189
1369
|
branchPrefix,
|
|
1190
|
-
)
|
|
1370
|
+
);
|
|
1191
1371
|
}
|
|
1192
|
-
// Worktrunk determines its path only after creation. Journal the exact
|
|
1193
|
-
// returned ownership before Pi runs setup hooks or launches a child.
|
|
1194
|
-
options?.beforeCreate?.({ cwd: repo.toplevel, baseCommit: repo.baseCommit, worktrees: [...worktrees] });
|
|
1195
1372
|
for (let index = 0; index < worktrees.length; index++) {
|
|
1196
|
-
worktrees[index] = finalizeCreatedWorktree(
|
|
1373
|
+
worktrees[index] = await finalizeCreatedWorktree(
|
|
1374
|
+
tx,
|
|
1197
1375
|
repo.toplevel,
|
|
1198
1376
|
repo.cwdRelative,
|
|
1199
1377
|
runId,
|
|
@@ -1204,23 +1382,37 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
|
|
|
1204
1382
|
);
|
|
1205
1383
|
}
|
|
1206
1384
|
}
|
|
1385
|
+
tx.check();
|
|
1386
|
+
tx.progress.phase = "ready";
|
|
1387
|
+
tx.publish();
|
|
1388
|
+
tx.check();
|
|
1389
|
+
return tx.progress.setup;
|
|
1207
1390
|
} catch (error) {
|
|
1208
|
-
|
|
1209
|
-
cwd: repo.toplevel,
|
|
1210
|
-
worktrees,
|
|
1211
|
-
baseCommit: repo.baseCommit,
|
|
1212
|
-
}, { kind: "setup-rollback" });
|
|
1391
|
+
await compensateSetup(tx);
|
|
1213
1392
|
throw error;
|
|
1214
1393
|
}
|
|
1394
|
+
}
|
|
1215
1395
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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
|
+
}
|
|
1221
1412
|
}
|
|
1222
1413
|
|
|
1223
1414
|
export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir: string): WorktreeDiff[] {
|
|
1415
|
+
assertWorktreeMutationAllowed();
|
|
1224
1416
|
try {
|
|
1225
1417
|
fs.mkdirSync(diffsDir, { recursive: true });
|
|
1226
1418
|
} catch {
|
|
@@ -1250,6 +1442,7 @@ export function cleanupWorktrees(
|
|
|
1250
1442
|
setup: WorktreeSetup,
|
|
1251
1443
|
intent: WorktreeCleanupIntent = { kind: "preserve", ...(setup.capturedDiffs ? { capturedDiffs: setup.capturedDiffs } : {}) },
|
|
1252
1444
|
): WorktreeCleanupReport {
|
|
1445
|
+
assertWorktreeMutationAllowed();
|
|
1253
1446
|
const tasks: WorktreeCleanupTask[] = [];
|
|
1254
1447
|
for (let index = setup.worktrees.length - 1; index >= 0; index--) {
|
|
1255
1448
|
tasks.push(cleanupSingleWorktree(setup, setup.worktrees[index]!, intent));
|