pi-subagents 0.65.1 → 0.67.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 +123 -0
- package/README.md +5 -4
- package/agents/evidence-auditor.md +34 -0
- package/agents/researcher.md +23 -13
- package/agents/reviewer.md +3 -2
- package/docs/agents.md +20 -3
- package/docs/configuration.md +25 -5
- package/docs/extension-api.md +124 -18
- package/docs/missions.md +8 -0
- package/docs/models.md +59 -2
- package/docs/observability.md +46 -6
- package/docs/standalone-background.md +49 -0
- package/docs/tool-reference.md +20 -10
- package/docs/watchdog.md +35 -4
- package/docs/workflows.md +40 -19
- package/inspector-runner.mjs +2 -2
- package/package.json +2 -1
- package/prompts/parallel-review.md +1 -1
- package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
- package/skills/pi-subagents/SKILL.md +14 -0
- package/skills/pi-subagents/references/execution-controls.md +20 -5
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
- package/src/agents/advertised-agent-prompt.ts +94 -0
- package/src/agents/agent-management.ts +14 -1
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +14 -0
- package/src/agents/builtin-names.ts +1 -0
- package/src/api/delegation.ts +4 -0
- package/src/api/preflight.ts +76 -45
- package/src/api/shared-types.ts +3 -1
- package/src/api/workflow-resources.ts +6 -0
- package/src/extension/fanout-child.ts +63 -4
- package/src/extension/index.ts +58 -8
- package/src/extension/public-execution.ts +4 -3
- package/src/extension/rpc.ts +8 -21
- package/src/extension/schemas.ts +71 -80
- package/src/extension/tool-description.ts +29 -81
- package/src/inspectors/actions.ts +148 -0
- package/src/inspectors/ghostty/actions.ts +74 -0
- package/src/inspectors/ghostty/plugin.ts +17 -0
- package/src/inspectors/herdr/actions.ts +99 -179
- package/src/inspectors/herdr/plugin.ts +20 -0
- package/src/inspectors/herdr/project-panes.ts +1 -1
- package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
- package/src/inspectors/plugins.ts +8 -0
- package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
- package/src/inspectors/types.ts +51 -0
- package/src/intercom/intercom-bridge.ts +50 -8
- package/src/intercom/native-supervisor-channel.ts +104 -67
- package/src/runs/background/active-async-capacity.ts +22 -18
- package/src/runs/background/async-execution.ts +45 -56
- package/src/runs/background/async-job-tracker.ts +35 -3
- package/src/runs/background/async-resume.ts +5 -9
- 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 +44 -30
- package/src/runs/background/binary-bootstrap.ts +33 -0
- package/src/runs/background/chain-root-attachment.ts +8 -0
- package/src/runs/background/control-channel.ts +78 -44
- package/src/runs/background/fleet-view.ts +30 -2
- package/src/runs/background/notify.ts +117 -13
- package/src/runs/background/owned-process-tree.ts +35 -8
- package/src/runs/background/process-terminal.ts +23 -23
- package/src/runs/background/run-child-session.ts +121 -36
- package/src/runs/background/run-status.ts +78 -5
- package/src/runs/background/runner-aliases.ts +28 -9
- package/src/runs/background/runner-child-launch.ts +88 -0
- package/src/runs/background/runner-child-sessions.ts +5 -4
- package/src/runs/background/scheduled-runs.ts +40 -13
- package/src/runs/background/stale-run-reconciler.ts +3 -1
- package/src/runs/background/steering.ts +20 -2
- package/src/runs/background/subagent-runner.ts +458 -239
- package/src/runs/background/subagent-wait.ts +54 -8
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/background/wait-tool.ts +1 -1
- package/src/runs/foreground/async-steering-action.ts +37 -7
- package/src/runs/foreground/execution.ts +145 -56
- package/src/runs/foreground/prompt-audit.ts +3 -1
- package/src/runs/foreground/subagent-executor.ts +584 -297
- package/src/runs/foreground/workflow-detach-reconcile.ts +10 -5
- package/src/runs/foreground/workflow-foreground-steering.ts +57 -2
- package/src/runs/shared/acceptance.ts +7 -4
- package/src/runs/shared/agent-contract.ts +1 -1
- package/src/runs/shared/async-status-projection.ts +51 -47
- package/src/runs/shared/capability-ceiling.ts +2 -0
- package/src/runs/shared/child-hooks.ts +167 -3
- package/src/runs/shared/child-launch.ts +28 -13
- package/src/runs/shared/child-lifecycle.ts +6 -3
- package/src/runs/shared/child-runtime-config.ts +3 -1
- package/src/runs/shared/child-session.ts +75 -8
- package/src/runs/shared/child-tool-plan.ts +124 -5
- package/src/runs/shared/completion-evidence.ts +2 -2
- package/src/runs/shared/completion-guard.ts +6 -3
- package/src/runs/shared/effective-system-prompt.ts +33 -0
- package/src/runs/shared/external-cli-runner.ts +9 -7
- package/src/runs/shared/host-step-status.ts +11 -11
- package/src/runs/shared/llm-intent-arbiter.ts +21 -11
- package/src/runs/shared/model-fallback.ts +12 -6
- package/src/runs/shared/nested-events.ts +5 -5
- package/src/runs/shared/orca-progress-tabs.ts +7 -1
- package/src/runs/shared/parallel-handoff.ts +57 -12
- package/src/runs/shared/parallel-utils.ts +2 -2
- package/src/runs/shared/pi-spawn.ts +10 -0
- 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 +20 -4
- package/src/runs/shared/task-intent.ts +46 -13
- package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
- package/src/runs/shared/worktree-setup-command.ts +190 -0
- package/src/runs/shared/worktree.ts +366 -208
- package/src/shared/fork-context.ts +15 -72
- package/src/shared/launch-contract.ts +65 -2
- package/src/shared/opencode-session-headers.ts +30 -0
- package/src/shared/types.ts +85 -61
- package/src/shared/utils.ts +7 -2
- package/src/shared/workflow-child-permit.ts +18 -13
- package/src/slash/delegation-adapters.ts +3 -1
- package/src/slash/delegation-request.ts +14 -0
- package/src/slash/slash-commands.ts +2 -1
- package/src/slash/subagents-admin.ts +11 -4
- package/src/tui/fleet-status.ts +164 -19
- package/src/tui/fleet.ts +27 -19
- package/src/tui/render.ts +172 -33
- package/src/watchdog/child-status.ts +8 -0
- package/src/watchdog/model-selection.ts +20 -0
- package/src/watchdog/permission-arbiter.ts +3 -1
- package/src/watchdog/register-child.ts +1 -0
- package/src/watchdog/register-main.ts +31 -27
- package/src/watchdog/review.ts +132 -67
- package/src/watchdog/runtime.ts +82 -20
- package/src/watchdog/scope.ts +1 -1
- package/src/watchdog/settings.ts +9 -3
- package/src/watchdog/tool-actions.ts +13 -12
- package/src/watchdog/turn-delta.ts +23 -0
- package/src/watchdog/types.ts +4 -0
- package/src/workflows/chat-progress.ts +3 -3
- package/src/workflows/scripted-workflow.ts +275 -17
- package/src/workflows/workflow-checklist.ts +13 -17
- 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
- /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -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,8 @@ 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
|
+
const WORKTRUNK_COMMAND = process.platform === "win32" ? "git" : "wt";
|
|
22
|
+
const WORKTRUNK_ARG_PREFIX = process.platform === "win32" ? ["wt"] : [];
|
|
20
23
|
export const MACHINE_DIFF_OPTIONS = ["--no-color", "--no-ext-diff", "--no-textconv", "--default-prefix", "--line-prefix=", "--no-relative"] as const;
|
|
21
24
|
const MACHINE_PATCH_OPTIONS = [...MACHINE_DIFF_OPTIONS, "--binary"] as const;
|
|
22
25
|
const PATCH_VALIDATION_OPTIONS = ["apply", "--check", "--cached", "--reverse", "--binary", "--whitespace=nowarn"] as const;
|
|
@@ -118,6 +121,10 @@ interface WorktreeSetupHookConfig {
|
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
export interface CreateWorktreesOptions {
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
/** Existing absolute run deadline only; setup does not start a child budget. */
|
|
126
|
+
deadlineAt?: number;
|
|
127
|
+
onProgress?: (snapshot: WorktreeSetupProgress) => void;
|
|
121
128
|
agents?: string[];
|
|
122
129
|
/** Optional stable labels used to make branch identity readable. */
|
|
123
130
|
labels?: Array<string | undefined>;
|
|
@@ -131,8 +138,6 @@ export interface CreateWorktreesOptions {
|
|
|
131
138
|
branchPrefix?: string;
|
|
132
139
|
setupHook?: WorktreeSetupHookConfig;
|
|
133
140
|
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
141
|
}
|
|
137
142
|
|
|
138
143
|
interface ResolvedWorktreeSetupHook {
|
|
@@ -165,12 +170,110 @@ interface GitResult {
|
|
|
165
170
|
interface RepoState {
|
|
166
171
|
toplevel: string;
|
|
167
172
|
cwdRelative: string;
|
|
168
|
-
sourceCheckout: SourceCheckoutSnapshot;
|
|
169
173
|
baseCommit: string;
|
|
170
174
|
}
|
|
171
175
|
|
|
172
176
|
const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
|
|
173
177
|
|
|
178
|
+
/** In-memory evidence only. Callers project this into existing handoff diagnostics. */
|
|
179
|
+
export interface WorktreeSetupProgress {
|
|
180
|
+
setup: WorktreeSetup;
|
|
181
|
+
attempts: Array<{ index: number; branch: string; path?: string; validated: boolean; command?: WorktreeSetupProgress["command"]; hookCommand?: WorktreeSetupProgress["command"] }>;
|
|
182
|
+
phase: string;
|
|
183
|
+
command?: { command: string; args: string[]; pid?: number; processGroupId?: number; result?: Omit<SetupCommandResult, "stdout" | "stderr"> };
|
|
184
|
+
unknown?: string;
|
|
185
|
+
cleanup?: WorktreeCleanupReport;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export class WorktreeSetupError extends Error {
|
|
189
|
+
readonly snapshot: WorktreeSetupProgress;
|
|
190
|
+
constructor(error: unknown, snapshot: WorktreeSetupProgress) {
|
|
191
|
+
super(`${error instanceof Error ? error.message : String(error)}${snapshot.unknown ? `; setup settlement unknown: ${snapshot.unknown}; manual reconciliation required` : ""}`, { cause: error });
|
|
192
|
+
this.name = "WorktreeSetupError";
|
|
193
|
+
this.snapshot = snapshot;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let worktreeTurn: Promise<void> = Promise.resolve();
|
|
198
|
+
let setupActive = false;
|
|
199
|
+
let setupPoison: string | undefined;
|
|
200
|
+
|
|
201
|
+
function assertWorktreeMutationAllowed(): void {
|
|
202
|
+
if (setupPoison) throw new Error(`Worktree mutation blocked: ${setupPoison}`);
|
|
203
|
+
if (setupActive) throw new Error("Worktree setup is active; finalization must await withWorktreeTransaction");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Later async finalization owners wrap their synchronous diff/cleanup as ONE turn. */
|
|
207
|
+
export async function withWorktreeTransaction<T>(action: () => T | Promise<T>): Promise<T> {
|
|
208
|
+
const previous = worktreeTurn;
|
|
209
|
+
let release!: () => void;
|
|
210
|
+
worktreeTurn = new Promise<void>((resolve) => { release = resolve; });
|
|
211
|
+
await previous;
|
|
212
|
+
try {
|
|
213
|
+
if (setupPoison) throw new Error(`Worktree mutation blocked: ${setupPoison}`);
|
|
214
|
+
return await action();
|
|
215
|
+
} finally { release(); }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
class SetupTransaction {
|
|
219
|
+
readonly progress: WorktreeSetupProgress;
|
|
220
|
+
readonly options: CreateWorktreesOptions;
|
|
221
|
+
constructor(cwd: string, options: CreateWorktreesOptions) {
|
|
222
|
+
this.options = options;
|
|
223
|
+
this.progress = { setup: { cwd, baseCommit: "", worktrees: [] }, attempts: [], phase: "preflight" };
|
|
224
|
+
}
|
|
225
|
+
snapshot(): WorktreeSetupProgress {
|
|
226
|
+
return structuredClone(this.progress);
|
|
227
|
+
}
|
|
228
|
+
publish(): void { this.options.onProgress?.(this.snapshot()); }
|
|
229
|
+
check(): void {
|
|
230
|
+
if (setupPoison) throw new Error(setupPoison);
|
|
231
|
+
if (this.options.signal?.aborted) throw new Error("Worktree setup aborted");
|
|
232
|
+
if (this.options.deadlineAt !== undefined && Date.now() >= this.options.deadlineAt) throw new Error("Worktree setup deadline exceeded");
|
|
233
|
+
}
|
|
234
|
+
unknown(error: unknown): void {
|
|
235
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
236
|
+
this.progress.unknown ??= reason;
|
|
237
|
+
setupPoison ??= reason;
|
|
238
|
+
}
|
|
239
|
+
async command(command: string, args: string[], options: SetupCommandOptions = {}): Promise<SetupCommandResult> {
|
|
240
|
+
this.check();
|
|
241
|
+
this.progress.command = { command, args };
|
|
242
|
+
if (this.progress.phase === "allocation") this.progress.attempts.at(-1)!.command = this.progress.command;
|
|
243
|
+
if (this.progress.phase === "hook") this.progress.attempts.find((attempt) => attempt.path === options.cwd)!.hookCommand = this.progress.command;
|
|
244
|
+
const result = await runSetupCommand(command, args, {
|
|
245
|
+
...options, signal: this.options.signal, deadlineAt: this.options.deadlineAt,
|
|
246
|
+
onSpawn: (process) => { Object.assign(this.progress.command!, process); this.publish(); },
|
|
247
|
+
});
|
|
248
|
+
const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
|
|
249
|
+
this.progress.command.result = metadata;
|
|
250
|
+
if (result.processTree?.state === "unknown") this.unknown(result.error ?? "Command tree settlement unverified");
|
|
251
|
+
this.publish();
|
|
252
|
+
if (result.error) throw result.error;
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
async git(cwd: string, args: string[], acceptedExitCodes?: readonly number[]): Promise<SetupCommandResult> {
|
|
256
|
+
return this.command("git", ["-C", cwd, ...args], { acceptedExitCodes });
|
|
257
|
+
}
|
|
258
|
+
async gitChecked(cwd: string, args: string[]): Promise<string> {
|
|
259
|
+
const result = await this.git(cwd, args);
|
|
260
|
+
return result.stdout;
|
|
261
|
+
}
|
|
262
|
+
attempt(index: number, branch: string, worktreePath?: string): void {
|
|
263
|
+
this.check();
|
|
264
|
+
this.progress.phase = "allocation";
|
|
265
|
+
this.progress.command = undefined;
|
|
266
|
+
this.progress.attempts.push({ index, branch, path: worktreePath, validated: false });
|
|
267
|
+
this.publish();
|
|
268
|
+
}
|
|
269
|
+
validated(worktree: WorktreeInfo): void {
|
|
270
|
+
this.progress.phase = "validated";
|
|
271
|
+
Object.assign(this.progress.attempts.at(-1)!, { path: worktree.path, validated: true });
|
|
272
|
+
this.progress.setup.worktrees.push(worktree);
|
|
273
|
+
this.publish();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
174
277
|
function runGit(cwd: string, args: string[], env?: NodeJS.ProcessEnv): GitResult {
|
|
175
278
|
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false, ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
176
279
|
return {
|
|
@@ -198,6 +301,7 @@ export function validateWorktreePatch(worktreePath: string, patchPath: string):
|
|
|
198
301
|
}
|
|
199
302
|
|
|
200
303
|
function currentWorktreePatch(worktreePath: string, baseCommit: string): { patch: string } | { error: string } {
|
|
304
|
+
assertWorktreeMutationAllowed();
|
|
201
305
|
let tempDir: string;
|
|
202
306
|
try {
|
|
203
307
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-index-"));
|
|
@@ -237,38 +341,49 @@ export function validateWorktreePatchRepresentsCurrentWorktree(worktreePath: str
|
|
|
237
341
|
return undefined;
|
|
238
342
|
}
|
|
239
343
|
|
|
240
|
-
function
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (line.startsWith("worktree ")) currentPath = line.slice("worktree ".length).trim();
|
|
245
|
-
else if (line.trim() === targetBranch) return currentPath;
|
|
246
|
-
}
|
|
247
|
-
return undefined;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function resolveRepoState(cwd: string, requestedBaseRef: string | undefined): RepoState {
|
|
251
|
-
const cwdRelative = resolveRepoCwdRelative(cwd);
|
|
252
|
-
const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
|
344
|
+
async function probeWorktreeSource(tx: Pick<SetupTransaction, "git" | "gitChecked">, cwd: string): Promise<string> {
|
|
345
|
+
const repoCheck = await tx.git(cwd, ["rev-parse", "--is-inside-work-tree"], [0, 128]);
|
|
346
|
+
if (repoCheck.status !== 0 || repoCheck.stdout.trim() !== "true") throw new Error("worktree isolation requires a git repository");
|
|
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
|
}
|
|
355
|
+
return toplevel;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Read-only admission check; allocation repeats it because source state can change. */
|
|
359
|
+
export async function preflightWorktreeSource(cwd: string, options: Pick<SetupCommandOptions, "signal" | "deadlineAt"> = {}): Promise<void> {
|
|
360
|
+
const git = async (cwd: string, args: string[], acceptedExitCodes: readonly number[] = [0]): Promise<SetupCommandResult> => {
|
|
361
|
+
const result = await runSetupCommand("git", ["-C", cwd, ...args], { ...options, acceptedExitCodes });
|
|
362
|
+
if (result.error) throw result.error;
|
|
363
|
+
if (result.outputIncomplete || result.status === null || !acceptedExitCodes.includes(result.status)) {
|
|
364
|
+
throw new Error(result.stderr.trim().slice(0, 2000) || "Worktree source probe failed");
|
|
365
|
+
}
|
|
366
|
+
return result;
|
|
367
|
+
};
|
|
368
|
+
await probeWorktreeSource({ git, gitChecked: async (cwd, args) => (await git(cwd, args)).stdout }, cwd);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function resolveRepoState(tx: SetupTransaction, cwd: string, requestedBaseRef: string | undefined): Promise<RepoState> {
|
|
372
|
+
const toplevel = await probeWorktreeSource(tx, cwd);
|
|
373
|
+
const rawPrefix = (await tx.gitChecked(cwd, ["rev-parse", "--show-prefix"])).trim();
|
|
374
|
+
const cwdRelative = rawPrefix ? path.normalize(rawPrefix.replace(/[\\/]+$/, "")) : "";
|
|
260
375
|
|
|
261
|
-
const sourceHead = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim();
|
|
262
|
-
const sourceCheckout = snapshotSourceCheckout(toplevel, sourceHead);
|
|
263
376
|
const baseRef = normalizeWorktreeBaseRef(requestedBaseRef) ?? DEFAULT_WORKTREE_BASE_REF;
|
|
264
377
|
let baseCommit: string;
|
|
265
378
|
try {
|
|
266
|
-
|
|
379
|
+
const base = await tx.git(toplevel, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`], [0, 128]);
|
|
380
|
+
if (base.status !== 0) throw new Error(base.stderr.trim() || "ref not found");
|
|
381
|
+
baseCommit = base.stdout.trim();
|
|
267
382
|
} catch (error) {
|
|
268
383
|
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
384
|
}
|
|
270
385
|
if (!baseCommit) throw new Error(`baseRef '${baseRef}' could not be resolved to a commit`);
|
|
271
|
-
return { toplevel, cwdRelative,
|
|
386
|
+
return { toplevel, cwdRelative, baseCommit };
|
|
272
387
|
}
|
|
273
388
|
|
|
274
389
|
function normalizeComparableCwd(cwd: string): string {
|
|
@@ -357,7 +472,7 @@ function validGitRef(ref: string): boolean {
|
|
|
357
472
|
/** Normalize and validate a configured worktree base ref without resolving it. */
|
|
358
473
|
export function normalizeWorktreeBaseRef(value: unknown): string | undefined {
|
|
359
474
|
if (value === undefined) return undefined;
|
|
360
|
-
if (typeof value !== "string" || !validGitRef(value)) throw new Error("baseRef must be a valid Git ref");
|
|
475
|
+
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
476
|
return value;
|
|
362
477
|
}
|
|
363
478
|
|
|
@@ -431,19 +546,19 @@ function hasConfiguredWorktreeBaseDir(baseDir: string | undefined): boolean {
|
|
|
431
546
|
: (process.env.PI_SUBAGENTS_WORKTREE_DIR?.trim().length ?? 0) > 0;
|
|
432
547
|
}
|
|
433
548
|
|
|
549
|
+
function isInsidePiExtensionsDirectory(targetPath: string): boolean {
|
|
550
|
+
const extensionsDir = normalizeComparableCwd(path.join(getAgentDir(), "extensions"));
|
|
551
|
+
return isPathInside(extensionsDir, normalizeComparableCwd(targetPath));
|
|
552
|
+
}
|
|
553
|
+
|
|
434
554
|
interface WorktrunkCapability {
|
|
435
555
|
available: boolean;
|
|
436
556
|
reason?: string;
|
|
437
557
|
}
|
|
438
558
|
|
|
439
|
-
interface SourceCheckoutSnapshot {
|
|
440
|
-
head: string;
|
|
441
|
-
branch?: string;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
559
|
function runWorktrunk(args: string[], cwd?: string): WorktreeCommandResult {
|
|
445
560
|
try {
|
|
446
|
-
const result = spawnSync(
|
|
561
|
+
const result = spawnSync(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, ...args], {
|
|
447
562
|
cwd,
|
|
448
563
|
encoding: "utf-8",
|
|
449
564
|
windowsHide: true,
|
|
@@ -473,18 +588,6 @@ function probeWorktrunk(): WorktrunkCapability {
|
|
|
473
588
|
return { available: true };
|
|
474
589
|
}
|
|
475
590
|
|
|
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
591
|
/** Resolve a requested provider without silently switching after allocation starts. */
|
|
489
592
|
export function resolveWorktreeProvider(requested: WorktreeProvider | undefined, baseDir?: string): ManagedWorktreeProvider {
|
|
490
593
|
const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
|
|
@@ -500,6 +603,34 @@ export function resolveWorktreeProvider(requested: WorktreeProvider | undefined,
|
|
|
500
603
|
return "native";
|
|
501
604
|
}
|
|
502
605
|
|
|
606
|
+
async function resolveSetupProvider(tx: SetupTransaction, requested: WorktreeProvider | undefined, baseDir: string | undefined, repoRoot: string): Promise<ManagedWorktreeProvider> {
|
|
607
|
+
const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
|
|
608
|
+
if (selection !== "auto" && selection !== "native" && selection !== "worktrunk") throw new Error('worktree provider must be "auto", "native", or "worktrunk"');
|
|
609
|
+
if (selection === "native") return "native";
|
|
610
|
+
if (hasConfiguredWorktreeBaseDir(baseDir)) {
|
|
611
|
+
if (selection === "worktrunk") throw new Error("worktreeProvider='worktrunk' cannot be combined with worktreeBaseDir or PI_SUBAGENTS_WORKTREE_DIR");
|
|
612
|
+
return "native";
|
|
613
|
+
}
|
|
614
|
+
if (selection === "auto" && isInsidePiExtensionsDirectory(repoRoot)) return "native";
|
|
615
|
+
let reason: string | undefined;
|
|
616
|
+
try {
|
|
617
|
+
const probeExitCodes = Array.from({ length: 256 }, (_, code) => code);
|
|
618
|
+
const version = await tx.command(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, "--version"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
|
|
619
|
+
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";
|
|
620
|
+
else {
|
|
621
|
+
const help = await tx.command(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, "switch", "--help"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
|
|
622
|
+
const missing = ["--create", "--base", "--no-cd", "--no-hooks", "--format"].filter((flag) => !`${help.stdout}\n${help.stderr}`.includes(flag));
|
|
623
|
+
if (help.status !== 0 || missing.length) reason = `Worktrunk switch capability unavailable: ${missing.join(", ")}`;
|
|
624
|
+
}
|
|
625
|
+
} catch (error) {
|
|
626
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
|
627
|
+
reason = error.message;
|
|
628
|
+
}
|
|
629
|
+
if (!reason) return "worktrunk";
|
|
630
|
+
if (selection === "worktrunk") throw new Error(`Worktrunk provider is unavailable: ${reason}`);
|
|
631
|
+
return "native";
|
|
632
|
+
}
|
|
633
|
+
|
|
503
634
|
/** Whether a launch must bind its worktree-dependent paths after allocation. */
|
|
504
635
|
export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined, baseDir?: string): boolean {
|
|
505
636
|
return (requested ?? DEFAULT_WORKTREE_PROVIDER) !== "native" && !hasConfiguredWorktreeBaseDir(baseDir);
|
|
@@ -511,11 +642,13 @@ export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined,
|
|
|
511
642
|
* to the repository. Managed leaves always nest one level deeper under the
|
|
512
643
|
* project folder (`basename(repoRoot)`).
|
|
513
644
|
*/
|
|
514
|
-
function resolveWorktreeDedicatedRoot(configuredBaseDir: string | undefined, repoRoot: string): string {
|
|
645
|
+
function resolveWorktreeDedicatedRoot(configuredBaseDir: string | undefined, repoRoot: string, relocateExtensionRepo = true): string {
|
|
515
646
|
const rawBaseDir = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
|
|
516
647
|
let expanded: string;
|
|
517
648
|
if (rawBaseDir === undefined || (configuredBaseDir === undefined && !rawBaseDir.trim())) {
|
|
518
|
-
expanded =
|
|
649
|
+
expanded = relocateExtensionRepo && isInsidePiExtensionsDirectory(repoRoot)
|
|
650
|
+
? path.join(getAgentDir(), "worktrees")
|
|
651
|
+
: path.join(path.dirname(repoRoot), "worktrees");
|
|
519
652
|
} else {
|
|
520
653
|
const trimmed = rawBaseDir.trim();
|
|
521
654
|
if (!trimmed) throw new Error("worktree base directory cannot be empty");
|
|
@@ -677,11 +810,6 @@ function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
|
|
|
677
810
|
return path.normalize(relative);
|
|
678
811
|
}
|
|
679
812
|
|
|
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
813
|
function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutput {
|
|
686
814
|
const trimmed = rawStdout.trim();
|
|
687
815
|
if (!trimmed) {
|
|
@@ -700,53 +828,49 @@ function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutpu
|
|
|
700
828
|
return parsed as WorktreeSetupHookOutput;
|
|
701
829
|
}
|
|
702
830
|
|
|
703
|
-
function runWorktreeSetupHook(
|
|
831
|
+
async function runWorktreeSetupHook(
|
|
832
|
+
tx: SetupTransaction,
|
|
704
833
|
hook: ResolvedWorktreeSetupHook,
|
|
705
834
|
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");
|
|
835
|
+
): Promise<string[]> {
|
|
836
|
+
tx.progress.phase = "hook";
|
|
837
|
+
let result: SetupCommandResult;
|
|
838
|
+
try {
|
|
839
|
+
result = await tx.command(hook.hookPath, [], {
|
|
840
|
+
cwd: input.worktreePath,
|
|
841
|
+
input: JSON.stringify(input),
|
|
842
|
+
hookTimeoutMs: hook.timeoutMs,
|
|
843
|
+
});
|
|
844
|
+
} catch (error) {
|
|
845
|
+
throw new Error(`worktree setup hook failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
733
846
|
}
|
|
847
|
+
tx.progress.phase = "hook-validation";
|
|
734
848
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
if (
|
|
738
|
-
|
|
849
|
+
try {
|
|
850
|
+
const output = parseWorktreeSetupHookOutput(result.stdout);
|
|
851
|
+
if (output.syntheticPaths === undefined) return [];
|
|
852
|
+
if (!Array.isArray(output.syntheticPaths)) {
|
|
853
|
+
throw new Error("worktree setup hook output field 'syntheticPaths' must be an array of relative paths");
|
|
739
854
|
}
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
throw new Error(
|
|
855
|
+
const uniquePaths = new Set<string>();
|
|
856
|
+
for (const candidate of output.syntheticPaths) {
|
|
857
|
+
if (typeof candidate !== "string") throw new Error("worktree setup hook output field 'syntheticPaths' must contain only strings");
|
|
858
|
+
const normalizedPath = normalizeSyntheticPath(input.worktreePath, candidate);
|
|
859
|
+
if ((await tx.gitChecked(input.worktreePath, ["ls-files", "--", normalizedPath])).trim()) {
|
|
860
|
+
throw new Error(`worktree setup hook cannot mark tracked paths as synthetic: ${normalizedPath}`);
|
|
861
|
+
}
|
|
862
|
+
uniquePaths.add(normalizedPath);
|
|
743
863
|
}
|
|
744
|
-
uniquePaths
|
|
864
|
+
return [...uniquePaths];
|
|
865
|
+
} catch (error) {
|
|
866
|
+
// Semantic rejection occurs after exit: retain uncertainty, never signal an old PID.
|
|
867
|
+
tx.unknown(error);
|
|
868
|
+
throw error;
|
|
745
869
|
}
|
|
746
|
-
return [...uniquePaths];
|
|
747
870
|
}
|
|
748
871
|
|
|
749
|
-
function finalizeCreatedWorktree(
|
|
872
|
+
async function finalizeCreatedWorktree(
|
|
873
|
+
tx: SetupTransaction,
|
|
750
874
|
toplevel: string,
|
|
751
875
|
cwdRelative: string,
|
|
752
876
|
runId: string,
|
|
@@ -754,38 +878,23 @@ function finalizeCreatedWorktree(
|
|
|
754
878
|
setupHook: ResolvedWorktreeSetupHook | undefined,
|
|
755
879
|
agent: string | undefined,
|
|
756
880
|
worktree: WorktreeInfo,
|
|
757
|
-
): WorktreeInfo {
|
|
881
|
+
): Promise<WorktreeInfo> {
|
|
758
882
|
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;
|
|
883
|
+
tx.check();
|
|
884
|
+
const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktree.path);
|
|
885
|
+
const syntheticPaths = nodeModulesLinked ? ["node_modules"] : [];
|
|
886
|
+
if (setupHook) {
|
|
887
|
+
const hookSyntheticPaths = await runWorktreeSetupHook(tx, setupHook, {
|
|
888
|
+
version: 1, repoRoot: toplevel, worktreePath: worktree.path, agentCwd,
|
|
889
|
+
branch: worktree.branch, index: worktree.index, runId, baseCommit, agent,
|
|
890
|
+
});
|
|
891
|
+
syntheticPaths.push(...hookSyntheticPaths);
|
|
785
892
|
}
|
|
893
|
+
return { ...worktree, agentCwd, nodeModulesLinked, syntheticPaths };
|
|
786
894
|
}
|
|
787
895
|
|
|
788
|
-
function createNativeWorktree(
|
|
896
|
+
async function createNativeWorktree(
|
|
897
|
+
tx: SetupTransaction,
|
|
789
898
|
toplevel: string,
|
|
790
899
|
cwdRelative: string,
|
|
791
900
|
runId: string,
|
|
@@ -797,17 +906,20 @@ function createNativeWorktree(
|
|
|
797
906
|
labels: Array<string | undefined> | undefined,
|
|
798
907
|
tasks: Array<string | undefined> | undefined,
|
|
799
908
|
branchPrefix: string | undefined,
|
|
800
|
-
): WorktreeInfo {
|
|
909
|
+
): Promise<WorktreeInfo> {
|
|
801
910
|
const naming = buildWorktreeNaming({ runId, index, agent, label: labels?.[index], task: tasks?.[index], branchPrefix });
|
|
802
911
|
const worktreePath = buildNativeWorktreePath(dedicatedRoot, toplevel, runId, index);
|
|
803
912
|
assertSafeWorktreeLocation(worktreePath, toplevel, dedicatedRoot);
|
|
913
|
+
// Absence is required before claiming any partial artifacts from this attempt.
|
|
914
|
+
const branch = await tx.git(toplevel, ["show-ref", "--verify", "--quiet", `refs/heads/${naming.requestedBranch}`], [0, 1]);
|
|
915
|
+
let pathExists = false;
|
|
916
|
+
try { fs.lstatSync(worktreePath); pathExists = true; }
|
|
917
|
+
catch (error) { if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error; }
|
|
918
|
+
if (branch.status !== 1 || pathExists) throw new Error(`worktree path or branch already exists: ${worktreePath}, ${naming.requestedBranch}`);
|
|
919
|
+
tx.attempt(index, naming.requestedBranch, worktreePath);
|
|
804
920
|
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, {
|
|
921
|
+
await tx.git(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
|
|
922
|
+
const worktree: WorktreeInfo = {
|
|
811
923
|
path: worktreePath,
|
|
812
924
|
agentCwd: worktreePath,
|
|
813
925
|
branch: naming.requestedBranch,
|
|
@@ -816,7 +928,9 @@ function createNativeWorktree(
|
|
|
816
928
|
syntheticPaths: [],
|
|
817
929
|
provider: "native",
|
|
818
930
|
naming,
|
|
819
|
-
}
|
|
931
|
+
};
|
|
932
|
+
tx.validated(worktree);
|
|
933
|
+
return finalizeCreatedWorktree(tx, toplevel, cwdRelative, runId, baseCommit, setupHook, agent, worktree);
|
|
820
934
|
}
|
|
821
935
|
|
|
822
936
|
interface WorktrunkSwitchOutput {
|
|
@@ -841,37 +955,30 @@ function parseWorktrunkSwitchOutput(rawStdout: string): WorktrunkSwitchOutput {
|
|
|
841
955
|
return parsed as WorktrunkSwitchOutput;
|
|
842
956
|
}
|
|
843
957
|
|
|
844
|
-
function createWorktrunkWorktree(
|
|
958
|
+
async function createWorktrunkWorktree(
|
|
959
|
+
tx: SetupTransaction,
|
|
845
960
|
toplevel: string,
|
|
846
961
|
cwdRelative: string,
|
|
847
962
|
runId: string,
|
|
848
963
|
index: number,
|
|
849
964
|
baseCommit: string,
|
|
850
|
-
sourceCheckout: SourceCheckoutSnapshot,
|
|
851
965
|
agents: string[] | undefined,
|
|
852
966
|
labels: Array<string | undefined> | undefined,
|
|
853
967
|
tasks: Array<string | undefined> | undefined,
|
|
854
968
|
branchPrefix: string | undefined,
|
|
855
|
-
): WorktreeInfo {
|
|
969
|
+
): Promise<WorktreeInfo> {
|
|
856
970
|
const naming = buildWorktreeNaming({ runId, index, agent: agents?.[index], label: labels?.[index], task: tasks?.[index], branchPrefix });
|
|
857
971
|
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;
|
|
972
|
+
tx.attempt(index, naming.requestedBranch);
|
|
973
|
+
const result = await tx.command(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, ...args], { cwd: toplevel, maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES });
|
|
974
|
+
tx.progress.phase = "validation";
|
|
865
975
|
try {
|
|
866
976
|
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) {
|
|
977
|
+
if (output.action !== "created" || output.created_branch !== true || output.branch !== naming.requestedBranch || output.base_branch !== baseCommit) {
|
|
870
978
|
throw new Error("Worktrunk provisioning returned inconsistent creation metadata");
|
|
871
979
|
}
|
|
872
980
|
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");
|
|
981
|
+
const worktreePathCandidate = path.resolve(output.path);
|
|
875
982
|
let stat: fs.Stats;
|
|
876
983
|
try {
|
|
877
984
|
stat = fs.lstatSync(worktreePathCandidate);
|
|
@@ -881,16 +988,16 @@ function createWorktrunkWorktree(
|
|
|
881
988
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Worktrunk provisioning returned a path that is not a real directory");
|
|
882
989
|
const worktreePath = normalizeComparableCwd(worktreePathCandidate);
|
|
883
990
|
if (worktreePath === normalizeComparableCwd(toplevel)) throw new Error("Worktrunk provisioning returned the source checkout path");
|
|
884
|
-
const sourceCommonDirRaw =
|
|
885
|
-
const returnedCommonDirRaw =
|
|
991
|
+
const sourceCommonDirRaw = (await tx.gitChecked(toplevel, ["rev-parse", "--git-common-dir"])).trim();
|
|
992
|
+
const returnedCommonDirRaw = (await tx.gitChecked(worktreePath, ["rev-parse", "--git-common-dir"])).trim();
|
|
886
993
|
const sourceCommonDir = normalizeComparableCwd(path.isAbsolute(sourceCommonDirRaw) ? sourceCommonDirRaw : path.resolve(toplevel, sourceCommonDirRaw));
|
|
887
994
|
const returnedCommonDir = normalizeComparableCwd(path.isAbsolute(returnedCommonDirRaw) ? returnedCommonDirRaw : path.resolve(worktreePath, returnedCommonDirRaw));
|
|
888
995
|
if (returnedCommonDir !== sourceCommonDir) throw new Error("Worktrunk provisioning returned a worktree for a different repository");
|
|
889
|
-
const returnedBranch =
|
|
996
|
+
const returnedBranch = (await tx.gitChecked(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"])).trim();
|
|
890
997
|
if (returnedBranch !== naming.requestedBranch) throw new Error("Worktrunk provisioning returned a worktree on a different branch");
|
|
891
|
-
const returnedHead =
|
|
998
|
+
const returnedHead = (await tx.gitChecked(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
892
999
|
if (returnedHead !== baseCommit) throw new Error("Worktrunk provisioning returned a worktree at a different base commit");
|
|
893
|
-
|
|
1000
|
+
const worktree: WorktreeInfo = {
|
|
894
1001
|
path: worktreePath,
|
|
895
1002
|
agentCwd: cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath,
|
|
896
1003
|
branch: naming.requestedBranch,
|
|
@@ -900,35 +1007,16 @@ function createWorktrunkWorktree(
|
|
|
900
1007
|
provider: "worktrunk",
|
|
901
1008
|
naming,
|
|
902
1009
|
};
|
|
1010
|
+
tx.validated(worktree);
|
|
1011
|
+
return worktree;
|
|
903
1012
|
} 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
|
-
}
|
|
1013
|
+
tx.unknown(error);
|
|
927
1014
|
throw error;
|
|
928
1015
|
}
|
|
929
1016
|
}
|
|
930
1017
|
|
|
931
1018
|
function removeSyntheticPath(worktree: WorktreeInfo, syntheticPath: string): void {
|
|
1019
|
+
assertWorktreeMutationAllowed();
|
|
932
1020
|
const resolved = path.resolve(worktree.path, syntheticPath);
|
|
933
1021
|
const relative = path.relative(worktree.path, resolved);
|
|
934
1022
|
if (!relative || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
@@ -1193,39 +1281,96 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
|
|
|
1193
1281
|
return diff.filesChanged > 0 || diff.insertions > 0 || diff.deletions > 0 || diff.diffStat.trim().length > 0;
|
|
1194
1282
|
}
|
|
1195
1283
|
|
|
1196
|
-
|
|
1284
|
+
async function compensateSetup(tx: SetupTransaction): Promise<WorktreeCleanupReport> {
|
|
1285
|
+
const { setup, attempts } = tx.progress;
|
|
1286
|
+
const report: WorktreeCleanupReport = { state: "partial", tasks: [], pruned: false, errors: [] };
|
|
1287
|
+
tx.progress.cleanup = report;
|
|
1288
|
+
tx.progress.phase = "rollback";
|
|
1289
|
+
// Cancellation stops allocation, not accounting. Compensation has ONLY the
|
|
1290
|
+
// remaining original absolute deadline; it does not reuse the launch signal.
|
|
1291
|
+
const git = async (args: string[], acceptedExitCodes: readonly number[] = [0]) => {
|
|
1292
|
+
if (setupPoison) throw new Error(setupPoison);
|
|
1293
|
+
tx.progress.command = { command: "git", args: ["-C", setup.cwd, ...args] };
|
|
1294
|
+
const result = await runSetupCommand("git", tx.progress.command.args, {
|
|
1295
|
+
deadlineAt: tx.options.deadlineAt, acceptedExitCodes,
|
|
1296
|
+
onSpawn: (process) => { Object.assign(tx.progress.command!, process); tx.publish(); },
|
|
1297
|
+
});
|
|
1298
|
+
const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
|
|
1299
|
+
tx.progress.command.result = metadata;
|
|
1300
|
+
if (result.processTree?.state === "unknown") tx.unknown(result.error ?? "Rollback command settlement unverified");
|
|
1301
|
+
tx.publish();
|
|
1302
|
+
if (result.error || result.status === null || !acceptedExitCodes.includes(result.status)) throw result.error ?? new Error(result.stderr.trim() || "Rollback command failed");
|
|
1303
|
+
return result;
|
|
1304
|
+
};
|
|
1305
|
+
for (const attempt of [...attempts].reverse()) {
|
|
1306
|
+
if (!attempt.path) {
|
|
1307
|
+
tx.unknown(`Allocation path unconfirmed for branch ${attempt.branch}`);
|
|
1308
|
+
report.errors!.push(tx.progress.unknown!);
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
const known = setup.worktrees.find((worktree) => worktree.index === attempt.index);
|
|
1312
|
+
const task: WorktreeCleanupTask = { index: attempt.index, path: attempt.path, branch: attempt.branch,
|
|
1313
|
+
provider: known?.provider ?? "native", naming: known?.naming, worktreeRemoved: false, branchRemoved: false };
|
|
1314
|
+
report.tasks.push(task);
|
|
1315
|
+
try {
|
|
1316
|
+
if (normalizeComparableCwd(attempt.path) === normalizeComparableCwd(setup.cwd)) throw new Error("Refusing source-checkout removal");
|
|
1317
|
+
if (attempt.validated) {
|
|
1318
|
+
await git(["worktree", "remove", "--force", attempt.path]);
|
|
1319
|
+
task.worktreeRemoved = true;
|
|
1320
|
+
} else {
|
|
1321
|
+
// Native attempts were absent before mutation. Reconcile only exact
|
|
1322
|
+
// registered path/branch identity; never recursively delete a guessed leaf.
|
|
1323
|
+
const listed = (await git(["worktree", "list", "--porcelain"])).stdout;
|
|
1324
|
+
const entry = listed.split("\n\n").find((block) => block.split("\n").includes(`worktree ${attempt.path}`));
|
|
1325
|
+
if (entry) {
|
|
1326
|
+
if (!entry.split("\n").includes(`branch refs/heads/${attempt.branch}`)) {
|
|
1327
|
+
tx.unknown("Partial allocation ownership mismatch");
|
|
1328
|
+
throw new Error(tx.progress.unknown);
|
|
1329
|
+
}
|
|
1330
|
+
await git(["worktree", "remove", "--force", attempt.path]);
|
|
1331
|
+
} else if (fs.existsSync(attempt.path)) {
|
|
1332
|
+
tx.unknown("Unregistered partial allocation retained");
|
|
1333
|
+
throw new Error(tx.progress.unknown);
|
|
1334
|
+
}
|
|
1335
|
+
task.worktreeRemoved = true;
|
|
1336
|
+
}
|
|
1337
|
+
const branch = await git(["show-ref", "--verify", "--quiet", `refs/heads/${attempt.branch}`], [0, 1]);
|
|
1338
|
+
if (branch.status === 0) await git(["branch", "-D", attempt.branch]);
|
|
1339
|
+
task.branchRemoved = true;
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
task.preserved = true;
|
|
1342
|
+
task.errors = [error instanceof Error ? error.message : String(error)];
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
try {
|
|
1346
|
+
if (attempts.length > 0) { await git(["worktree", "prune"]); report.pruned = true; }
|
|
1347
|
+
} catch (error) { report.errors!.push(error instanceof Error ? error.message : String(error)); }
|
|
1348
|
+
if (tx.progress.unknown) report.errors!.push(tx.progress.unknown);
|
|
1349
|
+
report.tasks.sort((a, b) => a.index - b.index);
|
|
1350
|
+
if (!setupPoison && (report.pruned || attempts.length === 0) && report.tasks.every((task) => task.worktreeRemoved && task.branchRemoved) && !report.errors!.length) report.state = "complete";
|
|
1351
|
+
return report;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
async function allocateWorktrees(tx: SetupTransaction, cwd: string, runId: string, count: number): Promise<WorktreeSetup> {
|
|
1355
|
+
const options = tx.options;
|
|
1356
|
+
tx.check();
|
|
1197
1357
|
if (!Number.isSafeInteger(count) || count < 0) throw new Error("worktree count must be a non-negative integer");
|
|
1198
|
-
const repo = resolveRepoState(cwd, options?.baseRef);
|
|
1358
|
+
const repo = await resolveRepoState(tx, cwd, options?.baseRef);
|
|
1359
|
+
tx.progress.setup.cwd = repo.toplevel;
|
|
1360
|
+
tx.progress.setup.baseCommit = repo.baseCommit;
|
|
1199
1361
|
const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
|
|
1200
|
-
const provider =
|
|
1362
|
+
const provider = await resolveSetupProvider(tx, options?.provider, options?.baseDir, repo.toplevel);
|
|
1201
1363
|
const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
|
|
1202
|
-
const dedicatedRoot = provider === "native"
|
|
1203
|
-
|
|
1364
|
+
const dedicatedRoot = provider === "native"
|
|
1365
|
+
? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel, (options?.provider ?? DEFAULT_WORKTREE_PROVIDER) === "auto")
|
|
1366
|
+
: undefined;
|
|
1367
|
+
const worktrees = tx.progress.setup.worktrees;
|
|
1204
1368
|
|
|
1205
1369
|
try {
|
|
1206
1370
|
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
1371
|
for (let index = 0; index < count; index++) {
|
|
1228
|
-
worktrees
|
|
1372
|
+
worktrees[index] = await createNativeWorktree(
|
|
1373
|
+
tx,
|
|
1229
1374
|
repo.toplevel,
|
|
1230
1375
|
repo.cwdRelative,
|
|
1231
1376
|
runId,
|
|
@@ -1237,28 +1382,26 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
|
|
|
1237
1382
|
options?.labels,
|
|
1238
1383
|
options?.tasks,
|
|
1239
1384
|
branchPrefix,
|
|
1240
|
-
)
|
|
1385
|
+
);
|
|
1241
1386
|
}
|
|
1242
1387
|
} else {
|
|
1243
1388
|
for (let index = 0; index < count; index++) {
|
|
1244
|
-
|
|
1389
|
+
await createWorktrunkWorktree(
|
|
1390
|
+
tx,
|
|
1245
1391
|
repo.toplevel,
|
|
1246
1392
|
repo.cwdRelative,
|
|
1247
1393
|
runId,
|
|
1248
1394
|
index,
|
|
1249
1395
|
repo.baseCommit,
|
|
1250
|
-
repo.sourceCheckout,
|
|
1251
1396
|
options?.agents,
|
|
1252
1397
|
options?.labels,
|
|
1253
1398
|
options?.tasks,
|
|
1254
1399
|
branchPrefix,
|
|
1255
|
-
)
|
|
1400
|
+
);
|
|
1256
1401
|
}
|
|
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
1402
|
for (let index = 0; index < worktrees.length; index++) {
|
|
1261
|
-
worktrees[index] = finalizeCreatedWorktree(
|
|
1403
|
+
worktrees[index] = await finalizeCreatedWorktree(
|
|
1404
|
+
tx,
|
|
1262
1405
|
repo.toplevel,
|
|
1263
1406
|
repo.cwdRelative,
|
|
1264
1407
|
runId,
|
|
@@ -1269,23 +1412,37 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
|
|
|
1269
1412
|
);
|
|
1270
1413
|
}
|
|
1271
1414
|
}
|
|
1415
|
+
tx.check();
|
|
1416
|
+
tx.progress.phase = "ready";
|
|
1417
|
+
tx.publish();
|
|
1418
|
+
tx.check();
|
|
1419
|
+
return tx.progress.setup;
|
|
1272
1420
|
} catch (error) {
|
|
1273
|
-
|
|
1274
|
-
cwd: repo.toplevel,
|
|
1275
|
-
worktrees,
|
|
1276
|
-
baseCommit: repo.baseCommit,
|
|
1277
|
-
}, { kind: "setup-rollback" });
|
|
1421
|
+
await compensateSetup(tx);
|
|
1278
1422
|
throw error;
|
|
1279
1423
|
}
|
|
1424
|
+
}
|
|
1280
1425
|
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1426
|
+
export async function createWorktrees(cwd: string, runId: string, count: number, options: CreateWorktreesOptions = {}): Promise<WorktreeSetup> {
|
|
1427
|
+
const tx = new SetupTransaction(cwd, options);
|
|
1428
|
+
try {
|
|
1429
|
+
return await withWorktreeTransaction(async () => {
|
|
1430
|
+
setupActive = true;
|
|
1431
|
+
try { return await allocateWorktrees(tx, cwd, runId, count); }
|
|
1432
|
+
finally { setupActive = false; }
|
|
1433
|
+
});
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
if (setupPoison) tx.progress.unknown ??= setupPoison;
|
|
1436
|
+
tx.progress.cleanup ??= { state: tx.progress.unknown ? "partial" : "complete", tasks: [], pruned: false, errors: tx.progress.unknown ? [tx.progress.unknown] : [] };
|
|
1437
|
+
try { tx.publish(); } catch (publicationError) {
|
|
1438
|
+
tx.progress.cleanup?.errors?.push(`Recovery publication failed: ${String(publicationError)}`);
|
|
1439
|
+
}
|
|
1440
|
+
throw new WorktreeSetupError(error, tx.snapshot());
|
|
1441
|
+
}
|
|
1286
1442
|
}
|
|
1287
1443
|
|
|
1288
1444
|
export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir: string): WorktreeDiff[] {
|
|
1445
|
+
assertWorktreeMutationAllowed();
|
|
1289
1446
|
try {
|
|
1290
1447
|
fs.mkdirSync(diffsDir, { recursive: true });
|
|
1291
1448
|
} catch {
|
|
@@ -1315,6 +1472,7 @@ export function cleanupWorktrees(
|
|
|
1315
1472
|
setup: WorktreeSetup,
|
|
1316
1473
|
intent: WorktreeCleanupIntent = { kind: "preserve", ...(setup.capturedDiffs ? { capturedDiffs: setup.capturedDiffs } : {}) },
|
|
1317
1474
|
): WorktreeCleanupReport {
|
|
1475
|
+
assertWorktreeMutationAllowed();
|
|
1318
1476
|
const tasks: WorktreeCleanupTask[] = [];
|
|
1319
1477
|
for (let index = setup.worktrees.length - 1; index >= 0; index--) {
|
|
1320
1478
|
tasks.push(cleanupSingleWorktree(setup, setup.worktrees[index]!, intent));
|