taskplane 0.28.4 → 0.28.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,2505 +1,2604 @@
1
- /**
2
- * Worktree CRUD, bulk ops, branch protection, preflight
3
- * @module orch/worktree
4
- */
5
- import { existsSync, mkdirSync, readdirSync, realpathSync, rmdirSync, rmSync } from "fs";
6
- import { execSync } from "child_process";
7
- import { join, basename, resolve } from "path";
8
-
9
- import { execLog } from "./execution.ts";
10
- import { runGit } from "./git.ts";
11
- import { resolveOperatorId } from "./naming.ts";
12
- import { DEFAULT_ORCHESTRATOR_CONFIG, WorktreeError } from "./types.ts";
13
- import type { AllocatedLane, BulkWorktreeError, CreateLaneWorktreesResult, CreateWorktreeOptions, LaneTaskOutcome, OrchestratorConfig, PreflightCheck, PreflightResult, RemoveAllWorktreesResult, RemoveWorktreeOutcome, RemoveWorktreeResult, WorktreeInfo } from "./types.ts";
14
-
15
- // ── Worktree Helpers ─────────────────────────────────────────────────
16
-
17
- /**
18
- * Generate branch name per naming convention.
19
- * Format: task/{opId}-lane-{N}-{batchId}
20
- *
21
- * Includes the operator identifier for collision resistance across
22
- * concurrent operators in the same repository.
23
- *
24
- * @param laneNumber - Lane number (1-indexed)
25
- * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
26
- * @param opId - Operator identifier (sanitized, e.g., "henrylach")
27
- */
28
- export function generateBranchName(laneNumber: number, batchId: string, opId: string): string {
29
- return `task/${opId}-lane-${laneNumber}-${batchId}`;
30
- }
31
-
32
- /**
33
- * Resolve the base directory where worktrees are created, based on config.
34
- *
35
- * Two modes (from `worktree_location` config):
36
- * "sibling" → resolve(repoRoot, "..") — worktrees sit next to the repo
37
- * "subdirectory" → resolve(repoRoot, ".worktrees") — worktrees inside the repo (gitignored)
38
- *
39
- * The returned path is the parent directory; individual worktree dirs are
40
- * created as children (e.g., `<base>/{prefix}-1` → `<base>/taskplane-wt-1`).
41
- *
42
- * @param repoRoot - Absolute path to the main repository root
43
- * @param config - Orchestrator config (reads `worktree_location`)
44
- */
45
- export function resolveWorktreeBasePath(
46
- repoRoot: string,
47
- config: OrchestratorConfig,
48
- ): string {
49
- const location = config.orchestrator.worktree_location;
50
- if (location === "sibling") {
51
- return resolve(repoRoot, "..");
52
- }
53
- // Default to subdirectory for any non-"sibling" value (including "subdirectory")
54
- return resolve(repoRoot, ".worktrees");
55
- }
56
-
57
- /**
58
- * Generate the batch container directory name.
59
- *
60
- * Format: `{opId}-{batchId}`
61
- * Example: `henrylach-20260308T111750`
62
- *
63
- * This is the directory that holds all lane worktrees and the merge
64
- * worktree for a single batch.
65
- *
66
- * @param opId - Operator identifier (sanitized, e.g., "henrylach")
67
- * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
68
- */
69
- export function generateBatchContainerName(opId: string, batchId: string): string {
70
- return `${opId}-${batchId}`;
71
- }
72
-
73
- /**
74
- * Generate the absolute path to the batch container directory.
75
- *
76
- * All worktrees for a single batch (lanes + merge) live inside this container.
77
- * Format: `{basePath}/{opId}-{batchId}`
78
- *
79
- * Uses `resolveWorktreeBasePath()` to respect `worktree_location` config
80
- * (sibling vs subdirectory mode). Both `generateWorktreePath()` and
81
- * `generateMergeWorktreePath()` delegate to this function, ensuring
82
- * consistent base-path resolution.
83
- *
84
- * @param opId - Operator identifier (sanitized, e.g., "henrylach")
85
- * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
86
- * @param repoRoot - Absolute path to the main repository root
87
- * @param config - Orchestrator config (optional; defaults to subdirectory mode)
88
- * @returns - Absolute path to the batch container directory
89
- */
90
- export function generateBatchContainerPath(
91
- opId: string,
92
- batchId: string,
93
- repoRoot: string,
94
- config?: OrchestratorConfig,
95
- ): string {
96
- const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
97
- const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
98
- return resolve(basePath, generateBatchContainerName(opId, batchId));
99
- }
100
-
101
- /**
102
- * Generate worktree path based on config's worktree_location setting.
103
- *
104
- * Naming rule: `{basePath}/{opId}-{batchId}/lane-{N}`
105
- * Sibling mode: ../{opId}-{batchId}/lane-{N}
106
- * Subdirectory mode: .worktrees/{opId}-{batchId}/lane-{N}
107
- *
108
- * Each batch gets its own container directory, preventing collisions
109
- * between concurrent batches by the same operator.
110
- *
111
- * Uses `generateBatchContainerPath()` for the container directory,
112
- * preserving `worktree_location` semantics (sibling vs subdirectory).
113
- *
114
- * Uses path.resolve() for Windows path normalization (R002 requirement).
115
- *
116
- * @param prefix - Directory prefix (unused in new scheme, kept for API compat)
117
- * @param laneNumber - Lane number (1-indexed)
118
- * @param repoRoot - Absolute path to the main repository root
119
- * @param opId - Operator identifier (sanitized, e.g., "henrylach")
120
- * @param config - Orchestrator config (optional; defaults to subdirectory mode)
121
- * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
122
- */
123
- export function generateWorktreePath(
124
- prefix: string,
125
- laneNumber: number,
126
- repoRoot: string,
127
- opId: string,
128
- config?: OrchestratorConfig,
129
- batchId?: string,
130
- ): string {
131
- if (batchId) {
132
- // New batch-scoped container layout
133
- const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config);
134
- return resolve(containerPath, `lane-${laneNumber}`);
135
- }
136
-
137
- // Legacy fallback (no batchId) — flat layout for backward compatibility
138
- const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
139
- const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
140
- return resolve(basePath, `${prefix}-${opId}-${laneNumber}`);
141
- }
142
-
143
- /**
144
- * Generate the merge worktree path inside a batch container.
145
- *
146
- * Format: `{basePath}/{opId}-{batchId}/merge`
147
- *
148
- * Uses `generateBatchContainerPath()` for config-aware, base-path-consistent
149
- * path resolution (respects `worktree_location` setting). This ensures
150
- * the merge worktree is co-located with lane worktrees in the same
151
- * batch container for unified cleanup.
152
- *
153
- * @param repoRoot - Absolute path to the main repository root
154
- * @param opId - Operator identifier (sanitized, e.g., "henrylach")
155
- * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
156
- * @param config - Orchestrator config (optional; defaults to subdirectory mode)
157
- */
158
- export function generateMergeWorktreePath(
159
- repoRoot: string,
160
- opId: string,
161
- batchId: string,
162
- config?: OrchestratorConfig,
163
- ): string {
164
- const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config);
165
- return resolve(containerPath, "merge");
166
- }
167
-
168
- /**
169
- * Ensure the batch container directory exists, creating it if necessary.
170
- *
171
- * @param containerPath - Absolute path to the container directory
172
- */
173
- export function ensureBatchContainerDir(containerPath: string): void {
174
- if (!existsSync(containerPath)) {
175
- mkdirSync(containerPath, { recursive: true });
176
- }
177
- }
178
-
179
- /**
180
- * Remove a batch container directory if it exists and is empty.
181
- *
182
- * Safety rules:
183
- * - Only removes the directory if it exists
184
- * - Only removes the directory if it is empty (no files or subdirectories)
185
- * - Never force-removes a non-empty container (partial failure safety)
186
- * - Returns whether the container was removed
187
- *
188
- * Used after per-worktree removals in `removeAllWorktrees()` and
189
- * `forceCleanupWorktree()` to clean up the container directory when
190
- * all worktrees inside it have been removed.
191
- *
192
- * @param containerPath - Absolute path to the batch container directory
193
- * @returns true if the container was removed, false otherwise
194
- */
195
- export function removeBatchContainerIfEmpty(containerPath: string): boolean {
196
- if (!existsSync(containerPath)) {
197
- return false; // Already gone — no-op
198
- }
199
-
200
- try {
201
- const entries = readdirSync(containerPath);
202
- if (entries.length > 0) {
203
- return false; // Non-empty — do not remove (partial failure safety)
204
- }
205
- rmdirSync(containerPath);
206
- return true;
207
- } catch {
208
- // If we can't read or remove — leave it alone (safe default)
209
- return false;
210
- }
211
- }
212
-
213
- /**
214
- * Parse `git worktree list --porcelain` output into structured entries.
215
- *
216
- * Porcelain output format (one block per worktree, separated by blank lines):
217
- * worktree /absolute/path
218
- * HEAD <sha>
219
- * branch refs/heads/<name>
220
- * [detached]
221
- *
222
- * @param cwd - Directory to run git from (must be in a git repo)
223
- */
224
- export interface ParsedWorktreeEntry {
225
- path: string;
226
- head: string;
227
- branch: string | null; // null if detached HEAD
228
- bare: boolean;
229
- }
230
-
231
- export function parseWorktreeList(cwd: string): ParsedWorktreeEntry[] {
232
- const result = runGit(["worktree", "list", "--porcelain"], cwd);
233
- if (!result.ok) return [];
234
-
235
- const entries: ParsedWorktreeEntry[] = [];
236
- const blocks = result.stdout.split(/\n\n+/);
237
-
238
- for (const block of blocks) {
239
- if (!block.trim()) continue;
240
-
241
- const lines = block.trim().split("\n");
242
- let path = "";
243
- let head = "";
244
- let branch: string | null = null;
245
- let bare = false;
246
-
247
- for (const line of lines) {
248
- if (line.startsWith("worktree ")) {
249
- path = line.slice("worktree ".length).trim();
250
- } else if (line.startsWith("HEAD ")) {
251
- head = line.slice("HEAD ".length).trim();
252
- } else if (line.startsWith("branch ")) {
253
- // "branch refs/heads/develop" → "develop"
254
- const ref = line.slice("branch ".length).trim();
255
- branch = ref.replace(/^refs\/heads\//, "");
256
- } else if (line.trim() === "bare") {
257
- bare = true;
258
- }
259
- }
260
-
261
- if (path) {
262
- entries.push({ path, head, branch, bare });
263
- }
264
- }
265
-
266
- return entries;
267
- }
268
-
269
- /**
270
- * Normalize a filesystem path for reliable comparison on Windows.
271
- *
272
- * On Windows, paths may contain 8.3 short names (e.g., `HENRYL~1` instead
273
- * of `HenryLach`). Node's `resolve()` does NOT expand these, but git
274
- * always reports full long names. This causes path comparison failures.
275
- *
276
- * Uses `fs.realpathSync.native()` to expand 8.3 names when the path exists,
277
- * falls back to `resolve()` for non-existent paths (e.g., pre-creation checks).
278
- *
279
- * All comparisons are also lowercased and slash-normalized.
280
- */
281
- export function normalizePath(p: string): string {
282
- let expanded: string;
283
- try {
284
- // realpathSync.native expands 8.3 short names on Windows
285
- expanded = realpathSync.native(resolve(p));
286
- } catch {
287
- // Path doesn't exist yet — fall back to resolve()
288
- expanded = resolve(p);
289
- }
290
- return expanded.replace(/\\/g, "/").toLowerCase();
291
- }
292
-
293
- /**
294
- * Check if a given path is already registered as a git worktree.
295
- * Uses `git worktree list --porcelain` for reliable detection.
296
- *
297
- * Path comparison is case-insensitive, slash-normalized, and expands
298
- * Windows 8.3 short names (e.g., HENRYL~1 → HenryLach) for reliable
299
- * matching against git's long-name output.
300
- */
301
- export function isRegisteredWorktree(targetPath: string, cwd: string): boolean {
302
- const entries = parseWorktreeList(cwd);
303
- const normalized = normalizePath(targetPath);
304
- return entries.some(
305
- (e) => normalizePath(e.path) === normalized,
306
- );
307
- }
308
-
309
-
310
- // ── Worktree CRUD Operations ─────────────────────────────────────────
311
-
312
- /**
313
- * Create a new git worktree for a lane.
314
- *
315
- * Executes `git worktree add -b <branch> <path> <baseBranch>` from the
316
- * main repository root. This creates a new branch based on baseBranch
317
- * and checks it out in the worktree directory.
318
- *
319
- * Pre-checks (R002 requirements):
320
- * 1. Validates baseBranch exists (`git rev-parse --verify`)
321
- * 2. Checks target path is not already a registered worktree
322
- * 3. Checks target path is not a non-empty non-worktree directory
323
- *
324
- * Post-creation verification:
325
- * - Branch points to baseBranch HEAD commit
326
- * - Correct branch is checked out in the worktree
327
- *
328
- * @param opts - Creation options (laneNumber, batchId, baseBranch, prefix)
329
- * @param repoRoot - Absolute path to the main repository root
330
- * @returns - WorktreeInfo on success
331
- * @throws - WorktreeError with stable error code on failure
332
- */
333
- export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): WorktreeInfo {
334
- const { laneNumber, batchId, baseBranch, prefix, opId, config } = opts;
335
-
336
- const branch = generateBranchName(laneNumber, batchId, opId);
337
- const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, opId, config, batchId);
338
-
339
- // ── Pre-check 1: Validate base branch exists ─────────────────
340
- const baseBranchCheck = runGit(
341
- ["rev-parse", "--verify", `refs/heads/${baseBranch}`],
342
- repoRoot,
343
- );
344
- if (!baseBranchCheck.ok) {
345
- throw new WorktreeError(
346
- "WORKTREE_INVALID_BASE",
347
- `Base branch "${baseBranch}" does not exist locally. ` +
348
- `Verify the branch exists: git branch --list ${baseBranch}`,
349
- );
350
- }
351
- const baseBranchHead = baseBranchCheck.stdout.trim();
352
-
353
- // ── Pre-check 2: Check if path is already a registered worktree
354
- if (isRegisteredWorktree(worktreePath, repoRoot)) {
355
- throw new WorktreeError(
356
- "WORKTREE_PATH_IS_WORKTREE",
357
- `Path "${worktreePath}" is already registered as a git worktree. ` +
358
- `Remove it first: git worktree remove "${worktreePath}"`,
359
- );
360
- }
361
-
362
- // ── Pre-check 3: Check if path exists and is non-empty (non-worktree dir)
363
- if (existsSync(worktreePath)) {
364
- try {
365
- const entries = readdirSync(worktreePath);
366
- if (entries.length > 0) {
367
- throw new WorktreeError(
368
- "WORKTREE_PATH_NOT_EMPTY",
369
- `Path "${worktreePath}" exists and is not empty. ` +
370
- `It is not a registered git worktree. Remove or rename it before creating a worktree here.`,
371
- );
372
- }
373
- } catch (err) {
374
- if (err instanceof WorktreeError) throw err;
375
- // If we can't read the path (e.g., it's a file not a directory), error
376
- throw new WorktreeError(
377
- "WORKTREE_PATH_NOT_EMPTY",
378
- `Path "${worktreePath}" exists but cannot be read as a directory.`,
379
- );
380
- }
381
- }
382
-
383
- // ── Pre-check 4: Check if branch already exists ──────────────
384
- const branchCheck = runGit(
385
- ["rev-parse", "--verify", `refs/heads/${branch}`],
386
- repoRoot,
387
- );
388
- if (branchCheck.ok) {
389
- throw new WorktreeError(
390
- "WORKTREE_BRANCH_EXISTS",
391
- `Branch "${branch}" already exists. ` +
392
- `This may indicate a stale worktree from a previous batch. ` +
393
- `Delete it: git branch -D ${branch}`,
394
- );
395
- }
396
-
397
- // ── Ensure batch container directory exists ──────────────────
398
- // Placed after pre-checks so no empty container is left behind on
399
- // validation failure (R004 review feedback).
400
- const containerDir = resolve(worktreePath, "..");
401
- ensureBatchContainerDir(containerDir);
402
-
403
- // ── Create worktree ──────────────────────────────────────────
404
- const createResult = runGit(
405
- ["worktree", "add", "-b", branch, worktreePath, baseBranch],
406
- repoRoot,
407
- );
408
- if (!createResult.ok) {
409
- throw new WorktreeError(
410
- "WORKTREE_GIT_ERROR",
411
- `Failed to create worktree at "${worktreePath}" on branch "${branch}" ` +
412
- `from "${baseBranch}": ${createResult.stderr}`,
413
- );
414
- }
415
-
416
- // ── Post-creation verification (R002 requirements) ───────────
417
- // Verify 1: Correct branch is checked out
418
- const headBranchResult = runGit(
419
- ["rev-parse", "--abbrev-ref", "HEAD"],
420
- worktreePath,
421
- );
422
- if (!headBranchResult.ok || headBranchResult.stdout !== branch) {
423
- throw new WorktreeError(
424
- "WORKTREE_VERIFY_FAILED",
425
- `Verification failed: expected branch "${branch}" checked out ` +
426
- `in worktree, but got "${headBranchResult.stdout || "(unknown)"}".`,
427
- );
428
- }
429
-
430
- // Verify 2: Branch points to baseBranch HEAD commit
431
- const headCommitResult = runGit(["rev-parse", "HEAD"], worktreePath);
432
- if (!headCommitResult.ok || headCommitResult.stdout !== baseBranchHead) {
433
- throw new WorktreeError(
434
- "WORKTREE_VERIFY_FAILED",
435
- `Verification failed: worktree HEAD (${headCommitResult.stdout?.slice(0, 8) || "?"}) ` +
436
- `does not match baseBranch "${baseBranch}" HEAD (${baseBranchHead.slice(0, 8)}).`,
437
- );
438
- }
439
-
440
- return {
441
- path: resolve(worktreePath),
442
- branch,
443
- laneNumber,
444
- };
445
- }
446
-
447
- /**
448
- * Reset an existing worktree to point at a new target branch/commit.
449
- *
450
- * Used after a wave merge to update a lane's worktree to the latest
451
- * develop HEAD, or any other target branch. The existing lane branch
452
- * name is preserved — only its target commit changes.
453
- *
454
- * Strategy: `git checkout -B <laneBranch> <targetBranch>` inside the worktree.
455
- * This repoints the existing lane branch to the target commit and checks it out.
456
- *
457
- * Precondition checks (R003 requirements):
458
- * 1. Worktree path exists on disk
459
- * 2. Path is a registered git worktree (via parseWorktreeList)
460
- * 3. Target branch resolves (git rev-parse --verify)
461
- * 4. Working tree is clean (git status --porcelain returns empty)
462
- *
463
- * Post-reset verification:
464
- * - HEAD equals targetBranch commit
465
- * - Current branch equals worktree.branch (lane branch preserved)
466
- *
467
- * Idempotency: Resetting to the same target commit succeeds (no-op semantically).
468
- *
469
- * @param worktree - WorktreeInfo returned by createWorktree()
470
- * @param targetBranch - Branch name to reset to (e.g. "develop")
471
- * @param repoRoot - Absolute path to the main repository root
472
- * @returns - Updated WorktreeInfo (same branch/laneNumber, same path)
473
- * @throws - WorktreeError with stable error code on failure
474
- */
475
- export function resetWorktree(
476
- worktree: WorktreeInfo,
477
- targetBranch: string,
478
- repoRoot: string,
479
- ): WorktreeInfo {
480
- const { path: worktreePath, branch, laneNumber } = worktree;
481
-
482
- // ── Pre-check 1: Worktree path exists on disk ────────────────
483
- if (!existsSync(worktreePath)) {
484
- throw new WorktreeError(
485
- "WORKTREE_NOT_FOUND",
486
- `Worktree path "${worktreePath}" does not exist on disk. ` +
487
- `It may have been removed externally.`,
488
- );
489
- }
490
-
491
- // ── Pre-check 2: Path is a registered git worktree ───────────
492
- if (!isRegisteredWorktree(worktreePath, repoRoot)) {
493
- throw new WorktreeError(
494
- "WORKTREE_NOT_REGISTERED",
495
- `Path "${worktreePath}" exists but is not a registered git worktree. ` +
496
- `It may have been removed from git tracking. Check: git worktree list`,
497
- );
498
- }
499
-
500
- // ── Pre-check 3: Target branch resolves ──────────────────────
501
- const targetCheck = runGit(
502
- ["rev-parse", "--verify", `refs/heads/${targetBranch}`],
503
- repoRoot,
504
- );
505
- if (!targetCheck.ok) {
506
- throw new WorktreeError(
507
- "WORKTREE_INVALID_BASE",
508
- `Target branch "${targetBranch}" does not exist locally. ` +
509
- `Verify the branch exists: git branch --list ${targetBranch}`,
510
- );
511
- }
512
- const targetCommit = targetCheck.stdout.trim();
513
-
514
- // ── Pre-check 4: Working tree is clean ───────────────────────
515
- const statusCheck = runGit(["status", "--porcelain"], worktreePath);
516
- if (!statusCheck.ok) {
517
- throw new WorktreeError(
518
- "WORKTREE_GIT_ERROR",
519
- `Failed to check working tree status in "${worktreePath}": ${statusCheck.stderr}`,
520
- );
521
- }
522
- if (statusCheck.stdout.length > 0) {
523
- throw new WorktreeError(
524
- "WORKTREE_DIRTY",
525
- `Worktree at "${worktreePath}" has uncommitted changes. ` +
526
- `Workers must commit or discard all changes before a reset can proceed. ` +
527
- `Dirty files:\n${statusCheck.stdout}`,
528
- );
529
- }
530
-
531
- // ── Reset: git checkout -B <laneBranch> <targetBranch> ───────
532
- const resetResult = runGit(
533
- ["checkout", "-B", branch, targetBranch],
534
- worktreePath,
535
- );
536
- if (!resetResult.ok) {
537
- throw new WorktreeError(
538
- "WORKTREE_RESET_FAILED",
539
- `Failed to reset worktree at "${worktreePath}" ` +
540
- `(branch "${branch}" → "${targetBranch}"): ${resetResult.stderr}`,
541
- );
542
- }
543
-
544
- // ── Post-reset verification ──────────────────────────────────
545
- // Verify 1: Current branch equals expected lane branch
546
- const headBranchResult = runGit(
547
- ["rev-parse", "--abbrev-ref", "HEAD"],
548
- worktreePath,
549
- );
550
- if (!headBranchResult.ok || headBranchResult.stdout !== branch) {
551
- throw new WorktreeError(
552
- "WORKTREE_VERIFY_FAILED",
553
- `Post-reset verification failed: expected branch "${branch}" ` +
554
- `checked out, but got "${headBranchResult.stdout || "(unknown)"}".`,
555
- );
556
- }
557
-
558
- // Verify 2: HEAD equals targetBranch commit
559
- const headCommitResult = runGit(["rev-parse", "HEAD"], worktreePath);
560
- if (!headCommitResult.ok || headCommitResult.stdout !== targetCommit) {
561
- throw new WorktreeError(
562
- "WORKTREE_VERIFY_FAILED",
563
- `Post-reset verification failed: worktree HEAD ` +
564
- `(${headCommitResult.stdout?.slice(0, 8) || "?"}) does not match ` +
565
- `target "${targetBranch}" commit (${targetCommit.slice(0, 8)}).`,
566
- );
567
- }
568
-
569
- // Return updated WorktreeInfo (branch and laneNumber preserved)
570
- return {
571
- path: resolve(worktreePath),
572
- branch,
573
- laneNumber,
574
- };
575
- }
576
-
577
- /**
578
- * Sleep for a given number of milliseconds (synchronous busy-wait).
579
- *
580
- * Uses execSync("ping") on Windows / ("sleep") on Unix as a synchronous
581
- * sleep mechanism since this module uses synchronous git operations.
582
- * The busy-wait is acceptable because retry waits are bounded (max 16s)
583
- * and this function is only called during cleanup, not hot paths.
584
- *
585
- * @param ms - Milliseconds to sleep
586
- */
587
- export function sleepSync(ms: number): void {
588
- const seconds = Math.ceil(ms / 1000);
589
- try {
590
- // Cross-platform synchronous sleep
591
- if (process.platform === "win32") {
592
- execSync(`ping -n ${seconds + 1} 127.0.0.1 > nul`, { stdio: "ignore", timeout: ms + 5000 });
593
- } else {
594
- execSync(`sleep ${seconds}`, { stdio: "ignore", timeout: ms + 5000 });
595
- }
596
- } catch {
597
- // Timeout or error — acceptable, we just needed a delay
598
- }
599
- }
600
-
601
- /**
602
- * Async sleep for a given number of milliseconds.
603
- *
604
- * Unlike `sleepSync`, this yields the event loop so that other async work
605
- * (supervisor heartbeats, user input, dashboard updates) can proceed while
606
- * waiting. Use this in async code paths such as merge polling.
607
- *
608
- * @param ms - Milliseconds to sleep
609
- */
610
- export function sleepAsync(ms: number): Promise<void> {
611
- return new Promise((resolve) => setTimeout(resolve, ms));
612
- }
613
-
614
- /**
615
- * Determine if a git worktree remove error is retriable.
616
- *
617
- * Retriable errors are typically filesystem/lock issues on Windows
618
- * where another process (antivirus, IDE, explorer) holds file handles.
619
- *
620
- * Terminal (non-retriable) errors are git usage errors like
621
- * "not a valid worktree" or missing arguments.
622
- *
623
- * @param stderr - Error output from git worktree remove
624
- * @returns true if the error is likely transient and worth retrying
625
- */
626
- export function isRetriableRemoveError(stderr: string): boolean {
627
- const lower = stderr.toLowerCase();
628
- // Windows file locking patterns
629
- if (lower.includes("cannot lock") || lower.includes("unable to access")) return true;
630
- if (lower.includes("permission denied")) return true;
631
- if (lower.includes("device or resource busy")) return true;
632
- if (lower.includes("the process cannot access")) return true;
633
- if (lower.includes("used by another process")) return true;
634
- if (lower.includes("directory not empty")) return true;
635
- if (lower.includes("failed to remove")) return true;
636
- // Generic I/O errors that may be transient
637
- if (lower.includes("i/o error")) return true;
638
- if (lower.includes("input/output error")) return true;
639
- return false;
640
- }
641
-
642
- /**
643
- * Remove a git worktree and clean up its associated branch.
644
- *
645
- * Executes `git worktree remove --force <path>` from the main repository
646
- * root, then handles branch cleanup based on merge status.
647
- *
648
- * Branch protection (when targetBranch is provided):
649
- * - If branch has unmerged commits vs targetBranch → preserves as `saved/<branch>`
650
- * instead of deleting. Returns `{ branchPreserved: true, savedBranch: "saved/..." }`
651
- * - If fully merged or no new commits → deletes normally
652
- * - If targetBranch is missing or git error → skips deletion (safe default)
653
- *
654
- * Idempotent behavior:
655
- * - If path is already missing AND branch is already gone → returns
656
- * `{ removed: false, alreadyRemoved: true, branchDeleted: true }`
657
- * - If path is already missing BUT branch has unmerged commits → preserves branch,
658
- * returns `{ removed: false, alreadyRemoved: true, branchPreserved: true }`
659
- *
660
- * Retry policy (Windows file locking):
661
- * - Up to 5 retries with exponential backoff: 1s, 2s, 4s, 8s, 16s
662
- * - Only retriable errors (filesystem/lock) trigger retries
663
- * - Terminal git errors (invalid worktree, bad args) fail immediately
664
- * - Branch deletion is not retried (single attempt)
665
- *
666
- * Post-removal verification:
667
- * - Path no longer exists on disk
668
- * - Path no longer registered via `git worktree list --porcelain`
669
- *
670
- * @param worktree - WorktreeInfo returned by createWorktree()
671
- * @param repoRoot - Absolute path to the main repository root
672
- * @param targetBranch - Optional target branch for unmerged commit detection (e.g. "develop")
673
- * @returns RemoveWorktreeResult with status flags
674
- * @throws WorktreeError with WORKTREE_REMOVE_RETRY_EXHAUSTED if all retries fail
675
- * @throws WorktreeError with WORKTREE_REMOVE_FAILED for terminal (non-retriable) errors
676
- * @throws WorktreeError with WORKTREE_BRANCH_DELETE_FAILED if branch cleanup fails
677
- */
678
- export function removeWorktree(
679
- worktree: WorktreeInfo,
680
- repoRoot: string,
681
- targetBranch?: string,
682
- ): RemoveWorktreeResult {
683
- const { path: worktreePath, branch } = worktree;
684
-
685
- const pathExists = existsSync(worktreePath);
686
- const isRegistered = isRegisteredWorktree(worktreePath, repoRoot);
687
-
688
- // ── Handle already-removed states ────────────────────────────
689
- if (!pathExists && !isRegistered) {
690
- // Path is gone and not registered. Clean up stale branch if any.
691
- const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
692
- return {
693
- removed: false,
694
- alreadyRemoved: true,
695
- branchDeleted: branchResult.deleted,
696
- branchPreserved: branchResult.preserved,
697
- savedBranch: branchResult.savedBranch,
698
- unmergedCount: branchResult.unmergedCount,
699
- };
700
- }
701
-
702
- // If path is missing but still registered in git, prune first
703
- if (!pathExists && isRegistered) {
704
- // `git worktree prune` removes stale worktree entries
705
- runGit(["worktree", "prune"], repoRoot);
706
- const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
707
- return {
708
- removed: false,
709
- alreadyRemoved: true,
710
- branchDeleted: branchResult.deleted,
711
- branchPreserved: branchResult.preserved,
712
- savedBranch: branchResult.savedBranch,
713
- unmergedCount: branchResult.unmergedCount,
714
- };
715
- }
716
-
717
- // ── Attempt removal with retry/backoff ───────────────────────
718
- const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 16000];
719
- const MAX_ATTEMPTS = RETRY_DELAYS_MS.length + 1; // first attempt + retries
720
-
721
- let lastError = "";
722
-
723
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
724
- const removeResult = runGit(
725
- ["worktree", "remove", "--force", worktreePath],
726
- repoRoot,
727
- );
728
-
729
- if (removeResult.ok) {
730
- // Successful removal — proceed to branch cleanup
731
- break;
732
- }
733
-
734
- lastError = removeResult.stderr;
735
-
736
- // Check if error is terminal (non-retriable)
737
- if (!isRetriableRemoveError(lastError)) {
738
- throw new WorktreeError(
739
- "WORKTREE_REMOVE_FAILED",
740
- `Failed to remove worktree at "${worktreePath}" ` +
741
- `(terminal error, not retried): ${lastError}`,
742
- );
743
- }
744
-
745
- // If we've exhausted all retries, throw
746
- if (attempt >= MAX_ATTEMPTS) {
747
- throw new WorktreeError(
748
- "WORKTREE_REMOVE_RETRY_EXHAUSTED",
749
- `Failed to remove worktree at "${worktreePath}" after ` +
750
- `${MAX_ATTEMPTS} attempts. Last error: ${lastError}. ` +
751
- `This is likely a Windows file locking issue. ` +
752
- `Close any programs accessing "${worktreePath}" and try again.`,
753
- );
754
- }
755
-
756
- // Wait before retrying (exponential backoff)
757
- const delayMs = RETRY_DELAYS_MS[attempt - 1];
758
- sleepSync(delayMs);
759
- }
760
-
761
- // ── Post-removal verification ────────────────────────────────
762
- if (existsSync(worktreePath)) {
763
- throw new WorktreeError(
764
- "WORKTREE_VERIFY_FAILED",
765
- `Post-removal verification failed: path "${worktreePath}" ` +
766
- `still exists on disk after successful git worktree remove.`,
767
- );
768
- }
769
-
770
- if (isRegisteredWorktree(worktreePath, repoRoot)) {
771
- // Try pruning stale entries
772
- runGit(["worktree", "prune"], repoRoot);
773
- if (isRegisteredWorktree(worktreePath, repoRoot)) {
774
- throw new WorktreeError(
775
- "WORKTREE_VERIFY_FAILED",
776
- `Post-removal verification failed: path "${worktreePath}" ` +
777
- `is still registered as a git worktree after removal and prune.`,
778
- );
779
- }
780
- }
781
-
782
- // ── Branch cleanup (single attempt, fail loud if still present) ─
783
- const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
784
-
785
- return {
786
- removed: true,
787
- alreadyRemoved: false,
788
- branchDeleted: branchResult.deleted,
789
- branchPreserved: branchResult.preserved,
790
- savedBranch: branchResult.savedBranch,
791
- unmergedCount: branchResult.unmergedCount,
792
- };
793
- }
794
-
795
- /**
796
- * Result of ensureBranchDeleted — either deleted or preserved.
797
- */
798
- export interface EnsureBranchDeletedResult {
799
- /** Whether the branch was deleted */
800
- deleted: boolean;
801
- /** Whether the branch was preserved (unmerged commits) */
802
- preserved: boolean;
803
- /** Saved branch name (if preserved) */
804
- savedBranch?: string;
805
- /** Number of unmerged commits (if preserved) */
806
- unmergedCount?: number;
807
- }
808
-
809
- /**
810
- * Ensure a lane branch is deleted — or preserved if it has unmerged commits.
811
- *
812
- * When `targetBranch` is provided, checks for unmerged commits first:
813
- * - If unmerged: preserves via `saved/<branch>` ref instead of deleting
814
- * - If fully merged or no unmerged: deletes normally
815
- *
816
- * When `targetBranch` is omitted (backward compat), deletes unconditionally
817
- * using deleteBranchBestEffort() with the original fail-loud semantics.
818
- *
819
- * Upgrades a persistent deletion failure into a hard WorktreeError so
820
- * callers cannot silently proceed with stale lane branches.
821
- */
822
- export function ensureBranchDeleted(
823
- branch: string,
824
- repoRoot: string,
825
- worktreePath: string,
826
- targetBranch?: string,
827
- ): EnsureBranchDeletedResult {
828
- // If targetBranch provided, check for unmerged commits before deleting
829
- if (targetBranch) {
830
- const preserveResult = preserveBranch(branch, targetBranch, repoRoot);
831
-
832
- switch (preserveResult.action) {
833
- case "preserved":
834
- case "already-preserved": {
835
- // Branch had unmerged commits — saved ref exists, now delete the original
836
- // This implements rename semantics: create saved + delete original
837
- const sourceDeleted = deleteBranchBestEffort(branch, repoRoot);
838
- return {
839
- deleted: sourceDeleted,
840
- preserved: true,
841
- savedBranch: preserveResult.savedBranch,
842
- unmergedCount: preserveResult.unmergedCount,
843
- };
844
- }
845
-
846
- case "fully-merged":
847
- case "no-branch":
848
- // Safe to delete — fall through to deletion below
849
- break;
850
-
851
- case "error":
852
- // Preservation check failed — log but still try to preserve by skipping deletion
853
- // This is the safe default: don't delete if we can't verify merge status
854
- return {
855
- deleted: false,
856
- preserved: false,
857
- };
858
- }
859
- }
860
-
861
- // No unmerged commits (or no targetBranch) — delete normally
862
- const branchDeleted = deleteBranchBestEffort(branch, repoRoot);
863
- if (!branchDeleted) {
864
- throw new WorktreeError(
865
- "WORKTREE_BRANCH_DELETE_FAILED",
866
- `Worktree "${worktreePath}" was removed, but failed to delete lane branch ` +
867
- `"${branch}". Delete it manually: git branch -D ${branch}`,
868
- );
869
- }
870
- return { deleted: true, preserved: false };
871
- }
872
-
873
- /**
874
- * Delete a branch with best-effort semantics.
875
- *
876
- * Uses `git branch -D` (force delete) since lane branches are ephemeral
877
- * and may not have been merged anywhere.
878
- *
879
- * "Branch not found" is treated as idempotent success (returns true).
880
- *
881
- * @param branch - Branch name to delete
882
- * @param repoRoot - Repository root directory
883
- * @returns true if branch was deleted or was already absent
884
- */
885
- export function deleteBranchBestEffort(branch: string, repoRoot: string): boolean {
886
- // Check if branch exists first
887
- const branchCheck = runGit(
888
- ["rev-parse", "--verify", `refs/heads/${branch}`],
889
- repoRoot,
890
- );
891
-
892
- if (!branchCheck.ok) {
893
- // Branch doesn't exist — idempotent success
894
- return true;
895
- }
896
-
897
- // Force delete (lane branches are ephemeral, may not be merged)
898
- const deleteResult = runGit(["branch", "-D", branch], repoRoot);
899
-
900
- if (deleteResult.ok) {
901
- return true;
902
- }
903
-
904
- // If delete failed but branch is now gone (race condition), treat as success
905
- const recheckResult = runGit(
906
- ["rev-parse", "--verify", `refs/heads/${branch}`],
907
- repoRoot,
908
- );
909
- if (!recheckResult.ok) {
910
- return true;
911
- }
912
-
913
- // Branch still exists and delete failed — return false
914
- return false;
915
- }
916
-
917
-
918
- // ── Branch Protection Helpers ────────────────────────────────────────
919
-
920
- /** Typed error codes for unmerged commit checks */
921
- export type UnmergedCommitsErrorCode =
922
- | "BRANCH_NOT_FOUND"
923
- | "TARGET_BRANCH_MISSING"
924
- | "UNMERGED_COUNT_FAILED"
925
- | "UNMERGED_COUNT_PARSE_FAILED";
926
-
927
- /**
928
- * Result of checking for unmerged commits on a branch.
929
- */
930
- export interface UnmergedCommitsResult {
931
- /** Whether the check succeeded (git command ran without error) */
932
- ok: boolean;
933
- /** Number of commits on `branch` not reachable from `targetBranch` */
934
- count: number;
935
- /** Typed error code if check failed */
936
- code?: UnmergedCommitsErrorCode;
937
- /** Error message if check failed */
938
- error?: string;
939
- }
940
-
941
- /**
942
- * Check if a branch has commits not reachable from a target branch.
943
- *
944
- * Uses `git rev-list --count <targetBranch>..<branch>` which is
945
- * Windows-safe (no shell pipes). Returns the count of unmerged commits.
946
- *
947
- * Pure logic with git dependency — designed so the git call can be
948
- * tested in integration tests with real repos, while the decision
949
- * logic is tested via the count result.
950
- *
951
- * @param branch - Branch to check for unmerged commits
952
- * @param targetBranch - Target branch to compare against (e.g. "develop")
953
- * @param repoRoot - Repository root directory
954
- * @returns UnmergedCommitsResult with count and status
955
- */
956
- export function hasUnmergedCommits(
957
- branch: string,
958
- targetBranch: string,
959
- repoRoot: string,
960
- ): UnmergedCommitsResult {
961
- // Verify branch exists
962
- const branchCheck = runGit(
963
- ["rev-parse", "--verify", `refs/heads/${branch}`],
964
- repoRoot,
965
- );
966
- if (!branchCheck.ok) {
967
- return { ok: false, count: 0, code: "BRANCH_NOT_FOUND", error: `Branch "${branch}" does not exist` };
968
- }
969
-
970
- // Verify target branch exists
971
- const targetCheck = runGit(
972
- ["rev-parse", "--verify", `refs/heads/${targetBranch}`],
973
- repoRoot,
974
- );
975
- if (!targetCheck.ok) {
976
- return { ok: false, count: 0, code: "TARGET_BRANCH_MISSING", error: `Target branch "${targetBranch}" does not exist` };
977
- }
978
-
979
- // Count commits on branch not reachable from target
980
- const countResult = runGit(
981
- ["rev-list", "--count", `${targetBranch}..${branch}`],
982
- repoRoot,
983
- );
984
- if (!countResult.ok) {
985
- return { ok: false, count: 0, code: "UNMERGED_COUNT_FAILED", error: `Failed to count unmerged commits: ${countResult.stderr}` };
986
- }
987
-
988
- const count = parseInt(countResult.stdout.trim(), 10);
989
- if (isNaN(count)) {
990
- return { ok: false, count: 0, code: "UNMERGED_COUNT_PARSE_FAILED", error: `Failed to parse commit count: "${countResult.stdout}"` };
991
- }
992
-
993
- return { ok: true, count };
994
- }
995
-
996
- /**
997
- * Compute the saved branch name for a given original branch.
998
- *
999
- * Pure function — no side effects. Maps a branch name to its saved
1000
- * counterpart under the `saved/` namespace.
1001
- *
1002
- * Examples:
1003
- * "task/lane-1-20260308T111750" → "saved/task/lane-1-20260308T111750"
1004
- * "feature/my-branch" → "saved/feature/my-branch"
1005
- *
1006
- * @param originalBranch - The branch name to compute a saved name for
1007
- * @returns The saved branch name (always prefixed with "saved/")
1008
- */
1009
- export function computeSavedBranchName(originalBranch: string): string {
1010
- return `saved/${originalBranch}`;
1011
- }
1012
-
1013
- /**
1014
- * Result of saved branch collision resolution.
1015
- */
1016
- export interface SavedBranchResolution {
1017
- /** The action to take */
1018
- action: "create" | "keep-existing" | "create-suffixed";
1019
- /** The final saved branch name to use */
1020
- savedName: string;
1021
- }
1022
-
1023
- /**
1024
- * Resolve a collision when a saved branch name already exists.
1025
- *
1026
- * Decision table:
1027
- * - saved ref absent → action: "create", use savedName
1028
- * - saved ref exists, same SHA → action: "keep-existing", use existing savedName
1029
- * - saved ref exists, different SHA → action: "create-suffixed", append timestamp
1030
- *
1031
- * Pure function — no side effects. All git state is passed in as parameters.
1032
- *
1033
- * @param savedName - The desired saved branch name (e.g. "saved/task/lane-1-...")
1034
- * @param existingSHA - SHA of existing saved branch (empty string if absent)
1035
- * @param newSHA - SHA of the branch being preserved
1036
- * @param timestamp - ISO timestamp for suffix (injectable for testability)
1037
- * @returns SavedBranchResolution with action and final name
1038
- */
1039
- export function resolveSavedBranchCollision(
1040
- savedName: string,
1041
- existingSHA: string,
1042
- newSHA: string,
1043
- timestamp?: string,
1044
- ): SavedBranchResolution {
1045
- // Saved ref doesn't exist — create it
1046
- if (!existingSHA) {
1047
- return { action: "create", savedName };
1048
- }
1049
-
1050
- // Same SHA — no-op, keep existing
1051
- if (existingSHA === newSHA) {
1052
- return { action: "keep-existing", savedName };
1053
- }
1054
-
1055
- // Different SHA — create with timestamp suffix
1056
- const ts = timestamp || new Date().toISOString().replace(/[:.]/g, "-");
1057
- return { action: "create-suffixed", savedName: `${savedName}-${ts}` };
1058
- }
1059
-
1060
- /** Typed error codes for branch preservation */
1061
- export type PreserveBranchErrorCode =
1062
- | "TARGET_BRANCH_MISSING"
1063
- | "UNMERGED_COUNT_FAILED"
1064
- | "SAVED_BRANCH_CREATE_FAILED"
1065
- | "UNKNOWN_RESOLUTION";
1066
-
1067
- /**
1068
- * Result of a branch preservation attempt.
1069
- */
1070
- export interface PreserveBranchResult {
1071
- /** Whether the branch was preserved (or was already preserved / fully merged) */
1072
- ok: boolean;
1073
- /** What action was taken */
1074
- action: "preserved" | "already-preserved" | "fully-merged" | "no-branch" | "error";
1075
- /** The saved branch name (if preserved) */
1076
- savedBranch?: string;
1077
- /** Number of unmerged commits (if checked) */
1078
- unmergedCount?: number;
1079
- /** Typed error code (if action is "error") */
1080
- code?: PreserveBranchErrorCode;
1081
- /** Error message (if action is "error") */
1082
- error?: string;
1083
- }
1084
-
1085
- /**
1086
- * Preserve a branch by creating a saved ref if it has unmerged commits.
1087
- *
1088
- * Orchestrates: hasUnmergedCommits → computeSavedBranchName →
1089
- * resolveSavedBranchCollision → git branch create/rename.
1090
- *
1091
- * Idempotent: if the saved ref already exists at the same SHA, it's a no-op.
1092
- * If the target branch doesn't exist, logs warning and returns gracefully.
1093
- *
1094
- * @param branch - Branch to check and potentially preserve
1095
- * @param targetBranch - Target branch to compare against (e.g. "develop")
1096
- * @param repoRoot - Repository root directory
1097
- * @returns PreserveBranchResult describing what was done
1098
- */
1099
- export function preserveBranch(
1100
- branch: string,
1101
- targetBranch: string,
1102
- repoRoot: string,
1103
- ): PreserveBranchResult {
1104
- // Check if branch exists
1105
- const branchCheck = runGit(
1106
- ["rev-parse", "--verify", `refs/heads/${branch}`],
1107
- repoRoot,
1108
- );
1109
- if (!branchCheck.ok) {
1110
- return { ok: true, action: "no-branch" };
1111
- }
1112
- const branchSHA = branchCheck.stdout.trim();
1113
-
1114
- // Check for unmerged commits
1115
- const unmergedResult = hasUnmergedCommits(branch, targetBranch, repoRoot);
1116
- if (!unmergedResult.ok) {
1117
- // Target branch missing or git error — skip preservation gracefully
1118
- // Map unmerged error codes to preserve error codes
1119
- const preserveCode: PreserveBranchErrorCode =
1120
- unmergedResult.code === "TARGET_BRANCH_MISSING" ? "TARGET_BRANCH_MISSING" : "UNMERGED_COUNT_FAILED";
1121
- return {
1122
- ok: false,
1123
- action: "error",
1124
- code: preserveCode,
1125
- error: unmergedResult.error,
1126
- };
1127
- }
1128
-
1129
- if (unmergedResult.count === 0) {
1130
- return { ok: true, action: "fully-merged", unmergedCount: 0 };
1131
- }
1132
-
1133
- // Branch has unmerged commits — compute saved name
1134
- const savedName = computeSavedBranchName(branch);
1135
-
1136
- // Check for collision
1137
- const existingCheck = runGit(
1138
- ["rev-parse", "--verify", `refs/heads/${savedName}`],
1139
- repoRoot,
1140
- );
1141
- const existingSHA = existingCheck.ok ? existingCheck.stdout.trim() : "";
1142
-
1143
- const resolution = resolveSavedBranchCollision(savedName, existingSHA, branchSHA);
1144
-
1145
- switch (resolution.action) {
1146
- case "keep-existing":
1147
- return {
1148
- ok: true,
1149
- action: "already-preserved",
1150
- savedBranch: resolution.savedName,
1151
- unmergedCount: unmergedResult.count,
1152
- };
1153
-
1154
- case "create":
1155
- case "create-suffixed": {
1156
- // Create saved branch at same SHA
1157
- const createResult = runGit(
1158
- ["branch", resolution.savedName, branchSHA],
1159
- repoRoot,
1160
- );
1161
- if (!createResult.ok) {
1162
- return {
1163
- ok: false,
1164
- action: "error",
1165
- code: "SAVED_BRANCH_CREATE_FAILED",
1166
- error: `Failed to create saved branch "${resolution.savedName}": ${createResult.stderr}`,
1167
- unmergedCount: unmergedResult.count,
1168
- };
1169
- }
1170
- return {
1171
- ok: true,
1172
- action: "preserved",
1173
- savedBranch: resolution.savedName,
1174
- unmergedCount: unmergedResult.count,
1175
- };
1176
- }
1177
-
1178
- default:
1179
- return { ok: false, action: "error", code: "UNKNOWN_RESOLUTION", error: `Unknown resolution action` };
1180
- }
1181
- }
1182
-
1183
-
1184
- // ── Bulk Worktree Operations ─────────────────────────────────────────
1185
-
1186
- /**
1187
- * List all orchestrator worktrees matching a prefix and operator pattern.
1188
- *
1189
- * Parses `git worktree list --porcelain` via parseWorktreeList() and filters
1190
- * entries whose path basename matches `{prefix}-{opId}-{N}` (where N is a number).
1191
- *
1192
- * **Batch-scoped discovery:** When `batchId` is provided, only returns worktrees
1193
- * inside the specific batch container `{opId}-{batchId}/lane-{N}`. This prevents
1194
- * cross-batch interference when the same operator runs concurrent batches.
1195
- *
1196
- * **Operator-scoped discovery:** When `batchId` is omitted, returns ALL worktrees
1197
- * belonging to the operator (across all batches). This supports cleanup scenarios
1198
- * that need to discover all operator worktrees regardless of batch.
1199
- *
1200
- * For backward compatibility, also matches the legacy flat pattern `{prefix}-{opId}-{N}`
1201
- * and (when opId is "op") `{prefix}-{N}`. This supports transition from old naming.
1202
- *
1203
- * Lane number is extracted from the path basename pattern. Entries with
1204
- * malformed/partial data (missing path, unparseable lane number) are
1205
- * silently skipped — they are not orchestrator worktrees.
1206
- *
1207
- * @param prefix - Worktree directory prefix (e.g. "taskplane-wt")
1208
- * @param repoRoot - Absolute path to the main repository root
1209
- * @param opId - Operator identifier for scoping (e.g., "henrylach")
1210
- * @param batchId - Optional batch ID for batch-scoped filtering; when provided,
1211
- * only returns worktrees inside the `{opId}-{batchId}/` container
1212
- * @returns - WorktreeInfo[] sorted by laneNumber (ascending)
1213
- */
1214
- export function listWorktrees(prefix: string, repoRoot: string, opId: string, batchId?: string): WorktreeInfo[] {
1215
- const entries = parseWorktreeList(repoRoot);
1216
- const results: WorktreeInfo[] = [];
1217
-
1218
- // ── Legacy flat patterns ─────────────────────────────────────
1219
- // Primary pattern: {prefix}-{opId}-{N}
1220
- // Example: "taskplane-wt-henrylach-1"
1221
- const primaryPattern = new RegExp(`^${escapeRegex(prefix)}-${escapeRegex(opId)}-(\\d+)$`);
1222
-
1223
- // Legacy pattern: {prefix}-{N} (only matched when opId is the default fallback)
1224
- // This allows cleanup of worktrees from prior batches without operator IDs.
1225
- const legacyPattern = opId === "op"
1226
- ? new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`)
1227
- : null;
1228
-
1229
- // ── New batch-scoped nested pattern ──────────────────────────
1230
- // Basename: lane-{N}
1231
- // Parent directory: {opId}-{batchId} (e.g., "henrylach-20260308T111750")
1232
- // Full: {basePath}/{opId}-{batchId}/lane-{N}
1233
- const nestedLanePattern = /^lane-(\d+)$/;
1234
- // When batchId is provided, match only the exact container for batch isolation.
1235
- // When omitted, match any container belonging to this operator (all batches).
1236
- const containerPattern = batchId
1237
- ? new RegExp(`^${escapeRegex(generateBatchContainerName(opId, batchId))}$`)
1238
- : new RegExp(`^${escapeRegex(opId)}-\\S+$`);
1239
-
1240
- for (const entry of entries) {
1241
- if (!entry.path) continue;
1242
-
1243
- const resolvedPath = resolve(entry.path);
1244
- const entryBasename = basename(resolvedPath);
1245
-
1246
- // ── Try new nested pattern first ─────────────────────────
1247
- const nestedMatch = entryBasename.match(nestedLanePattern);
1248
- if (nestedMatch) {
1249
- // Verify the parent directory matches the container pattern
1250
- const parentDir = basename(resolve(resolvedPath, ".."));
1251
- if (containerPattern.test(parentDir)) {
1252
- const laneNumber = parseInt(nestedMatch[1], 10);
1253
- if (!isNaN(laneNumber) && laneNumber >= 1) {
1254
- results.push({
1255
- path: resolvedPath,
1256
- branch: entry.branch || "",
1257
- laneNumber,
1258
- });
1259
- continue;
1260
- }
1261
- }
1262
- }
1263
-
1264
- // ── Try legacy flat patterns (only when not batch-scoped) ─
1265
- // When batchId is provided, skip legacy matching — the caller
1266
- // explicitly wants only this batch's worktrees.
1267
- if (!batchId) {
1268
- let match = entryBasename.match(primaryPattern);
1269
- if (!match && legacyPattern) {
1270
- match = entryBasename.match(legacyPattern);
1271
- }
1272
- if (match) {
1273
- const laneNumber = parseInt(match[1], 10);
1274
- if (!isNaN(laneNumber) && laneNumber >= 1) {
1275
- results.push({
1276
- path: resolvedPath,
1277
- branch: entry.branch || "",
1278
- laneNumber,
1279
- });
1280
- }
1281
- }
1282
- }
1283
- }
1284
-
1285
- // Sort by laneNumber ascending (deterministic output)
1286
- results.sort((a, b) => a.laneNumber - b.laneNumber);
1287
-
1288
- return results;
1289
- }
1290
-
1291
- /**
1292
- * Escape special regex characters in a string for safe use in RegExp constructor.
1293
- */
1294
- export function escapeRegex(str: string): string {
1295
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1296
- }
1297
-
1298
- /**
1299
- * Create multiple lane worktrees in a single batch.
1300
- *
1301
- * Creates `count` worktrees sequentially (lanes 1..count). Git worktree
1302
- * operations are not safe to parallelize (shared lock file), so sequential
1303
- * creation is the correct approach.
1304
- *
1305
- * Partial failure rollback:
1306
- * - If lane K fails after lanes 1..(K-1) succeeded, ALL previously-created
1307
- * worktrees are rolled back via removeWorktree().
1308
- * - Rollback is best-effort: individual rollback failures are collected in
1309
- * `rollbackErrors` but do not prevent other rollbacks from proceeding.
1310
- * - On successful rollback, `worktrees` is empty (clean slate).
1311
- *
1312
- * @param count - Number of worktrees to create (1-indexed: lane 1..count)
1313
- * @param batchId - Batch ID timestamp for branch naming
1314
- * @param config - Orchestrator config (prefix extracted from it)
1315
- * @param repoRoot - Absolute path to the main repository root
1316
- * @param baseBranch - Branch to base worktrees on (captured at batch start)
1317
- * @param opId - Operator identifier for collision-resistant naming
1318
- * @returns - CreateLaneWorktreesResult with success flag and details
1319
- */
1320
- export function createLaneWorktrees(
1321
- count: number,
1322
- batchId: string,
1323
- config: OrchestratorConfig,
1324
- repoRoot: string,
1325
- baseBranch: string,
1326
- ): CreateLaneWorktreesResult {
1327
- const prefix = config.orchestrator.worktree_prefix;
1328
- const opId = resolveOperatorId(config);
1329
- const created: WorktreeInfo[] = [];
1330
- const errors: BulkWorktreeError[] = [];
1331
-
1332
- for (let lane = 1; lane <= count; lane++) {
1333
- try {
1334
- const wt = createWorktree(
1335
- { laneNumber: lane, batchId, baseBranch, prefix, opId, config },
1336
- repoRoot,
1337
- );
1338
- created.push(wt);
1339
- } catch (err: unknown) {
1340
- const wtErr = err instanceof WorktreeError ? err : null;
1341
- errors.push({
1342
- laneNumber: lane,
1343
- code: wtErr?.code || "UNKNOWN",
1344
- message: wtErr?.message || String(err),
1345
- });
1346
-
1347
- // Rollback all previously-created worktrees
1348
- const rollbackErrors: BulkWorktreeError[] = [];
1349
- for (const wt of created) {
1350
- try {
1351
- removeWorktree(wt, repoRoot);
1352
- } catch (rbErr: unknown) {
1353
- const rbWtErr = rbErr instanceof WorktreeError ? rbErr : null;
1354
- rollbackErrors.push({
1355
- laneNumber: wt.laneNumber,
1356
- code: rbWtErr?.code || "UNKNOWN",
1357
- message: rbWtErr?.message || String(rbErr),
1358
- });
1359
- }
1360
- }
1361
-
1362
- return {
1363
- success: false,
1364
- worktrees: [],
1365
- errors,
1366
- rolledBack: rollbackErrors.length === 0,
1367
- rollbackErrors,
1368
- };
1369
- }
1370
- }
1371
-
1372
- // All created successfully
1373
- // Sort by laneNumber (should already be in order, but enforce)
1374
- created.sort((a, b) => a.laneNumber - b.laneNumber);
1375
-
1376
- return {
1377
- success: true,
1378
- worktrees: created,
1379
- errors: [],
1380
- rolledBack: false,
1381
- rollbackErrors: [],
1382
- };
1383
- }
1384
-
1385
- /**
1386
- * Ensure required lane worktrees exist for the current wave.
1387
- *
1388
- * Reuses existing worktrees when present (multi-wave behavior), resetting
1389
- * them to the base branch HEAD before use, and only creates missing lanes.
1390
- * If creation of a missing lane fails, newly-created lanes in this call are
1391
- * rolled back.
1392
- *
1393
- * This prevents wave 2+ allocation from failing on WORKTREE_PATH_IS_WORKTREE
1394
- * while still supporting wave growth (e.g., 1 lane in wave 1, 3 lanes in wave 2).
1395
- */
1396
- export function ensureLaneWorktrees(
1397
- laneNumbers: number[],
1398
- batchId: string,
1399
- config: OrchestratorConfig,
1400
- repoRoot: string,
1401
- baseBranch: string,
1402
- ): CreateLaneWorktreesResult {
1403
- const prefix = config.orchestrator.worktree_prefix;
1404
- const opId = resolveOperatorId(config);
1405
-
1406
- const existing = listWorktrees(prefix, repoRoot, opId, batchId);
1407
- const existingByLane = new Map<number, WorktreeInfo>();
1408
- for (const wt of existing) {
1409
- existingByLane.set(wt.laneNumber, wt);
1410
- }
1411
-
1412
- const needed = [...new Set(laneNumbers)].sort((a, b) => a - b);
1413
- const selected: WorktreeInfo[] = [];
1414
- const createdNow: WorktreeInfo[] = [];
1415
- const errors: BulkWorktreeError[] = [];
1416
-
1417
- for (const lane of needed) {
1418
- const reused = existingByLane.get(lane);
1419
- if (reused) {
1420
- // Reused worktrees must be reset to base branch HEAD before use.
1421
- // This covers normal multi-wave reuse and stale leftovers from prior batches.
1422
- const resetResult = safeResetWorktree(reused, baseBranch, repoRoot);
1423
- if (resetResult.success) {
1424
- selected.push(reused);
1425
- continue;
1426
- }
1427
-
1428
- // Reset failed: remove and recreate this lane worktree.
1429
- try {
1430
- removeWorktree(reused, repoRoot);
1431
- } catch {
1432
- // Best effort — creation below may still fail with a clear error.
1433
- }
1434
- }
1435
-
1436
- try {
1437
- const wt = createWorktree(
1438
- { laneNumber: lane, batchId, baseBranch, prefix, opId, config },
1439
- repoRoot,
1440
- );
1441
- createdNow.push(wt);
1442
- selected.push(wt);
1443
- } catch (err: unknown) {
1444
- const wtErr = err instanceof WorktreeError ? err : null;
1445
- errors.push({
1446
- laneNumber: lane,
1447
- code: wtErr?.code || "UNKNOWN",
1448
- message: wtErr?.message || String(err),
1449
- });
1450
-
1451
- const rollbackErrors: BulkWorktreeError[] = [];
1452
- for (const wt of createdNow) {
1453
- try {
1454
- removeWorktree(wt, repoRoot);
1455
- } catch (rbErr: unknown) {
1456
- const rbWtErr = rbErr instanceof WorktreeError ? rbErr : null;
1457
- rollbackErrors.push({
1458
- laneNumber: wt.laneNumber,
1459
- code: rbWtErr?.code || "UNKNOWN",
1460
- message: rbWtErr?.message || String(rbErr),
1461
- });
1462
- }
1463
- }
1464
-
1465
- return {
1466
- success: false,
1467
- worktrees: [],
1468
- errors,
1469
- rolledBack: rollbackErrors.length === 0,
1470
- rollbackErrors,
1471
- };
1472
- }
1473
- }
1474
-
1475
- selected.sort((a, b) => a.laneNumber - b.laneNumber);
1476
- return {
1477
- success: true,
1478
- worktrees: selected,
1479
- errors: [],
1480
- rolledBack: false,
1481
- rollbackErrors: [],
1482
- };
1483
- }
1484
-
1485
- /**
1486
- * Remove all orchestrator worktrees matching a prefix and operator scope.
1487
- *
1488
- * Uses listWorktrees() to discover matching worktrees (operator-scoped),
1489
- * then removes each one via removeWorktree(). Best-effort: continues on
1490
- * per-worktree errors (does not fail-fast).
1491
- *
1492
- * When `targetBranch` is provided, branches with unmerged commits are
1493
- * preserved as `saved/<branch>` refs instead of being force-deleted.
1494
- *
1495
- * **Batch-scoped cleanup:** When `batchId` is provided, only removes
1496
- * worktrees inside the specific batch container `{opId}-{batchId}/`.
1497
- * After removing all worktrees, attempts to remove the empty container
1498
- * directory. When `batchId` is omitted, removes all operator worktrees
1499
- * (all batches, including legacy flat-layout).
1500
- *
1501
- * **Container cleanup:** After per-worktree removals, each touched batch
1502
- * container directory is checked and removed if empty. Non-empty containers
1503
- * (from partial failures or active worktrees) are left intact.
1504
- *
1505
- * @param prefix - Worktree directory prefix (e.g. "taskplane-wt")
1506
- * @param repoRoot - Absolute path to the main repository root
1507
- * @param opId - Operator identifier for scoping (e.g., "henrylach")
1508
- * @param targetBranch - Optional target branch for unmerged commit detection (e.g. "develop")
1509
- * @param batchId - Optional batch ID for batch-scoped cleanup
1510
- * @param config - Optional orchestrator config (needed for container path resolution when batchId is provided)
1511
- * @returns - RemoveAllWorktreesResult with per-worktree outcomes
1512
- */
1513
- export function removeAllWorktrees(
1514
- prefix: string,
1515
- repoRoot: string,
1516
- opId: string,
1517
- targetBranch?: string,
1518
- batchId?: string,
1519
- config?: OrchestratorConfig,
1520
- ): RemoveAllWorktreesResult {
1521
- const worktrees = listWorktrees(prefix, repoRoot, opId, batchId);
1522
- const outcomes: RemoveWorktreeOutcome[] = [];
1523
- const removed: WorktreeInfo[] = [];
1524
- const failed: RemoveWorktreeOutcome[] = [];
1525
- const preserved: Array<{ branch: string; savedBranch: string; laneNumber: number; unmergedCount?: number }> = [];
1526
-
1527
- for (const wt of worktrees) {
1528
- try {
1529
- const result = removeWorktree(wt, repoRoot, targetBranch);
1530
- const outcome: RemoveWorktreeOutcome = {
1531
- worktree: wt,
1532
- result,
1533
- error: null,
1534
- };
1535
- outcomes.push(outcome);
1536
- removed.push(wt);
1537
-
1538
- // Track preserved branches for caller logging
1539
- if (result.branchPreserved && result.savedBranch) {
1540
- preserved.push({
1541
- branch: wt.branch,
1542
- savedBranch: result.savedBranch,
1543
- laneNumber: wt.laneNumber,
1544
- unmergedCount: result.unmergedCount,
1545
- });
1546
- }
1547
- } catch (err: unknown) {
1548
- const wtErr = err instanceof WorktreeError ? err : null;
1549
- const bulkErr: BulkWorktreeError = {
1550
- laneNumber: wt.laneNumber,
1551
- code: wtErr?.code || "UNKNOWN",
1552
- message: wtErr?.message || String(err),
1553
- };
1554
- const outcome: RemoveWorktreeOutcome = {
1555
- worktree: wt,
1556
- result: null,
1557
- error: bulkErr,
1558
- };
1559
- outcomes.push(outcome);
1560
- failed.push(outcome);
1561
- }
1562
- }
1563
-
1564
- // ── Container cleanup ────────────────────────────────────────
1565
- // After removing worktrees, attempt to remove empty batch container
1566
- // directories. Collect unique container paths from removed worktrees,
1567
- // then remove each one only if empty (partial failure safety).
1568
- const containerPaths = new Set<string>();
1569
- for (const wt of removed) {
1570
- const parentDir = resolve(wt.path, "..");
1571
- // Only consider directories that look like batch containers
1572
- // (i.e., parent is not the base worktree path itself)
1573
- const parentName = basename(parentDir);
1574
- if (parentName.startsWith(`${opId}-`)) {
1575
- containerPaths.add(parentDir);
1576
- }
1577
- }
1578
- // When batchId is explicitly provided, also add the expected container path
1579
- // even if no worktrees were found (cleanup of empty containers from prior runs)
1580
- if (batchId && config) {
1581
- const expectedContainer = generateBatchContainerPath(opId, batchId, repoRoot, config);
1582
- containerPaths.add(expectedContainer);
1583
- }
1584
- for (const containerPath of containerPaths) {
1585
- removeBatchContainerIfEmpty(containerPath);
1586
- }
1587
-
1588
- // TP-029: Remove empty .worktrees/ base directory in subdirectory mode.
1589
- // In sibling mode the base dir is the repo's parent (e.g., "..") — never remove that.
1590
- // Only attempt removal when empty (same safety as container cleanup).
1591
- if (config && config.orchestrator.worktree_location !== "sibling") {
1592
- const basePath = resolveWorktreeBasePath(repoRoot, config);
1593
- try {
1594
- if (existsSync(basePath)) {
1595
- const entries = readdirSync(basePath);
1596
- if (entries.length === 0) {
1597
- rmdirSync(basePath);
1598
- }
1599
- }
1600
- } catch { /* safe default — leave it alone */ }
1601
- }
1602
-
1603
- return {
1604
- totalAttempted: worktrees.length,
1605
- removed,
1606
- failed,
1607
- outcomes,
1608
- preserved,
1609
- };
1610
- }
1611
-
1612
- /**
1613
- * Execute a command synchronously and return { ok, stdout }.
1614
- * Returns ok=false on any error (non-zero exit, command not found, etc.).
1615
- */
1616
- export function execCheck(command: string, cwd?: string): { ok: boolean; stdout: string } {
1617
- try {
1618
- const stdout = execSync(command, {
1619
- encoding: "utf-8",
1620
- timeout: 10_000,
1621
- stdio: ["pipe", "pipe", "pipe"],
1622
- ...(cwd ? { cwd } : {}),
1623
- }).trim();
1624
- return { ok: true, stdout };
1625
- } catch {
1626
- return { ok: false, stdout: "" };
1627
- }
1628
- }
1629
-
1630
- /**
1631
- * Parse a version string like "git version 2.43.0.windows.1" or "tmux 3.3a"
1632
- * into a comparable [major, minor] tuple. Returns [0, 0] on parse failure.
1633
- */
1634
- export function parseVersion(raw: string): [number, number] {
1635
- const match = raw.match(/(\d+)\.(\d+)/);
1636
- if (!match) return [0, 0];
1637
- return [parseInt(match[1], 10), parseInt(match[2], 10)];
1638
- }
1639
-
1640
- /**
1641
- * Check if actual version meets minimum required version.
1642
- */
1643
- export function meetsMinVersion(actual: [number, number], minimum: [number, number]): boolean {
1644
- if (actual[0] > minimum[0]) return true;
1645
- if (actual[0] === minimum[0] && actual[1] >= minimum[1]) return true;
1646
- return false;
1647
- }
1648
-
1649
- /**
1650
- * Run preflight checks for all orchestrator dependencies.
1651
- *
1652
- * Required checks (fail blocks execution):
1653
- * - git version >= 2.15
1654
- * - git worktree support
1655
- * - pi availability
1656
- *
1657
- * Compatibility checks:
1658
- * - Runtime backend mode visibility (subprocess-only)
1659
- */
1660
- export function runPreflight(config: OrchestratorConfig, repoRoot?: string): PreflightResult {
1661
- const checks: PreflightCheck[] = [];
1662
-
1663
- // ── Git version ──────────────────────────────────────────────
1664
- const gitResult = execCheck("git --version");
1665
- if (gitResult.ok) {
1666
- const version = parseVersion(gitResult.stdout);
1667
- const versionStr = `${version[0]}.${version[1]}`;
1668
- if (meetsMinVersion(version, [2, 15])) {
1669
- checks.push({
1670
- name: "git",
1671
- status: "pass",
1672
- message: `Git ${versionStr} available`,
1673
- });
1674
- } else {
1675
- checks.push({
1676
- name: "git",
1677
- status: "fail",
1678
- message: `Git ${versionStr} found, but 2.15+ required for worktree support`,
1679
- hint: "Upgrade Git: https://git-scm.com/downloads",
1680
- });
1681
- }
1682
- } else {
1683
- checks.push({
1684
- name: "git",
1685
- status: "fail",
1686
- message: "Git not found",
1687
- hint: "Install Git: https://git-scm.com/downloads",
1688
- });
1689
- }
1690
-
1691
- // ── Git worktree support ─────────────────────────────────────
1692
- // In workspace mode, cwd may not be a git repo — run from a repo root
1693
- const worktreeResult = execCheck("git worktree list", repoRoot);
1694
- checks.push({
1695
- name: "git-worktree",
1696
- status: worktreeResult.ok ? "pass" : "fail",
1697
- message: worktreeResult.ok
1698
- ? "Worktree support available"
1699
- : "Git worktree not available",
1700
- hint: worktreeResult.ok
1701
- ? undefined
1702
- : repoRoot
1703
- ? "Upgrade Git to 2.15+"
1704
- : "Workspace root is not a git repo. Check workspace config repo paths.",
1705
- });
1706
-
1707
- // ── Runtime backend contract (Runtime V2) ─────────────────────
1708
- checks.push({
1709
- name: "runtime-backend",
1710
- status: "pass",
1711
- message: `Runtime V2 subprocess backend active (configured spawn_mode: ${config.orchestrator.spawn_mode})`,
1712
- });
1713
-
1714
- // ── Pi availability ──────────────────────────────────────────
1715
- const piResult = execCheck("pi --version");
1716
- if (piResult.ok) {
1717
- checks.push({
1718
- name: "pi",
1719
- status: "pass",
1720
- message: `Pi ${piResult.stdout || "available"}`,
1721
- });
1722
- } else {
1723
- checks.push({
1724
- name: "pi",
1725
- status: "fail",
1726
- message: "Pi not found",
1727
- hint: "Install Pi: npm install -g @mariozechner/pi-coding-agent",
1728
- });
1729
- }
1730
-
1731
- return {
1732
- passed: checks.every((c) => c.status !== "fail"),
1733
- checks,
1734
- };
1735
- }
1736
-
1737
- /**
1738
- * Format preflight results as a readable string for display.
1739
- */
1740
- export function formatPreflightResults(result: PreflightResult): string {
1741
- const lines: string[] = ["Preflight Check:"];
1742
-
1743
- for (const check of result.checks) {
1744
- const icon =
1745
- check.status === "pass" ? "✅" :
1746
- check.status === "warn" ? "⚠️ " :
1747
- "❌";
1748
- const nameCol = check.name.padEnd(18);
1749
- lines.push(` ${icon} ${nameCol} ${check.message}`);
1750
- if (check.hint && check.status !== "pass") {
1751
- // Indent hint lines under the check
1752
- for (const hintLine of check.hint.split("\n")) {
1753
- lines.push(` ${" ".repeat(18)} ${hintLine}`);
1754
- }
1755
- }
1756
- }
1757
-
1758
- lines.push("");
1759
- if (result.passed) {
1760
- lines.push("All required checks passed.");
1761
- } else {
1762
- const failedNames = result.checks
1763
- .filter((c) => c.status === "fail")
1764
- .map((c) => c.name)
1765
- .join(", ");
1766
- lines.push(`❌ Preflight FAILED: ${failedNames}`);
1767
- lines.push("Fix the issues above before running the orchestrator.");
1768
- }
1769
-
1770
- return lines.join("\n");
1771
- }
1772
-
1773
-
1774
- // ── Worktree Reset with Safety ───────────────────────────────────────
1775
-
1776
- /**
1777
- * Reset a worktree with safety handling for dirty trees.
1778
- *
1779
- * For failed/stalled tasks, the worktree may have uncommitted changes.
1780
- * This function first tries a clean reset, and if that fails due to dirty
1781
- * tree, force-cleans it before resetting.
1782
- *
1783
- * @param worktree - WorktreeInfo to reset
1784
- * @param targetBranch - Branch to reset to (e.g., "develop")
1785
- * @param repoRoot - Main repository root
1786
- * @returns { success: boolean, error?: string }
1787
- */
1788
- export function safeResetWorktree(
1789
- worktree: WorktreeInfo,
1790
- targetBranch: string,
1791
- repoRoot: string,
1792
- ): { success: boolean; error?: string } {
1793
- try {
1794
- resetWorktree(worktree, targetBranch, repoRoot);
1795
- return { success: true };
1796
- } catch (err: unknown) {
1797
- // If it's a dirty worktree, force clean and retry
1798
- if (err instanceof WorktreeError && err.code === "WORKTREE_DIRTY") {
1799
- execLog("reset", `lane-${worktree.laneNumber}`, "worktree dirty — force cleaning", {
1800
- path: worktree.path,
1801
- });
1802
-
1803
- // Force discard all changes
1804
- const checkoutResult = runGit(["checkout", "--", "."], worktree.path);
1805
- if (!checkoutResult.ok) {
1806
- return {
1807
- success: false,
1808
- error: `git checkout -- . failed: ${checkoutResult.stderr}`,
1809
- };
1810
- }
1811
-
1812
- // Remove untracked files.
1813
- // git clean may warn about files it can't delete (e.g., Windows reserved
1814
- // names like "nul", "con", "aux") but still clean everything else.
1815
- // We treat this as non-fatal: check porcelain status afterward instead
1816
- // of failing on the exit code.
1817
- const cleanResult = runGit(["clean", "-fd"], worktree.path);
1818
- if (!cleanResult.ok) {
1819
- execLog("reset", `lane-${worktree.laneNumber}`, "git clean -fd returned non-zero (may be partial)", {
1820
- stderr: cleanResult.stderr.slice(0, 200),
1821
- });
1822
- }
1823
-
1824
- // Check if the worktree is clean enough to proceed.
1825
- // If git status --porcelain shows no tracked changes, the reset can work
1826
- // even if some untracked files couldn't be deleted.
1827
- const statusCheck = runGit(["status", "--porcelain"], worktree.path);
1828
- if (statusCheck.ok && statusCheck.stdout.length > 0) {
1829
- // Still dirty after cleaning — check if only untracked files remain
1830
- const lines = statusCheck.stdout.split("\n").filter(l => l.trim());
1831
- const onlyUntracked = lines.every(l => l.startsWith("??"));
1832
- if (!onlyUntracked) {
1833
- return {
1834
- success: false,
1835
- error: `Worktree still dirty after clean: ${statusCheck.stdout.slice(0, 200)}`,
1836
- };
1837
- }
1838
- // Only untracked files remain (e.g., undeletable "nul") — safe to proceed
1839
- execLog("reset", `lane-${worktree.laneNumber}`, "untracked files remain after clean (non-blocking)", {
1840
- files: lines.map(l => l.slice(3)).join(", "),
1841
- });
1842
- }
1843
-
1844
- // Retry reset after cleaning
1845
- try {
1846
- resetWorktree(worktree, targetBranch, repoRoot);
1847
- return { success: true };
1848
- } catch (retryErr: unknown) {
1849
- return {
1850
- success: false,
1851
- error: `Reset failed after clean: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
1852
- };
1853
- }
1854
- }
1855
-
1856
- return {
1857
- success: false,
1858
- error: err instanceof Error ? err.message : String(err),
1859
- };
1860
- }
1861
- }
1862
-
1863
-
1864
- // ── Force Cleanup ────────────────────────────────────────────────────
1865
-
1866
- /**
1867
- * Last-resort worktree cleanup: force-remove the directory and prune git state.
1868
- *
1869
- * Used when both `safeResetWorktree()` and `removeWorktree()` fail — typically
1870
- * because undeletable files (e.g., Windows reserved names like "nul", "con")
1871
- * block `git clean` and `git worktree remove`, leaving git in an inconsistent state.
1872
- *
1873
- * Recovery steps:
1874
- * 1. Force-remove the worktree directory (`rm -rf` equivalent)
1875
- * 2. Prune stale git worktree references (`git worktree prune`)
1876
- * 3. Delete the lane branch if it exists (`git branch -D`)
1877
- *
1878
- * This allows the next wave to recreate the worktree from scratch.
1879
- *
1880
- * @param worktree - WorktreeInfo for the failed worktree
1881
- * @param repoRoot - Main repository root
1882
- * @param batchId - Batch ID for logging context
1883
- */
1884
- export function forceCleanupWorktree(
1885
- worktree: WorktreeInfo,
1886
- repoRoot: string,
1887
- batchId: string,
1888
- ): void {
1889
- const { path: worktreePath, branch, laneNumber } = worktree;
1890
-
1891
- // Step 1: Force-remove the directory
1892
- if (existsSync(worktreePath)) {
1893
- try {
1894
- // On Windows, undeletable reserved-name files (nul, con, aux) need
1895
- // special handling. Try rmSync first, then fall back to OS-specific
1896
- // removal for stubborn files.
1897
- rmSync(worktreePath, { recursive: true, force: true });
1898
- execLog("cleanup", `lane-${laneNumber}`, `force-removed worktree directory`, { path: worktreePath });
1899
- } catch (rmErr: unknown) {
1900
- // If Node's rmSync fails (e.g., Windows reserved names), try platform-specific
1901
- const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr);
1902
- execLog("cleanup", `lane-${laneNumber}`, `rmSync failed, trying OS-level removal`, { error: rmMsg });
1903
-
1904
- try {
1905
- if (process.platform === "win32") {
1906
- // rd /s /q handles Windows reserved names that Node.js cannot delete
1907
- execSync(`rd /s /q "${worktreePath}"`, { stdio: "pipe", timeout: 30_000 });
1908
- } else {
1909
- execSync(`rm -rf "${worktreePath}"`, { stdio: "pipe", timeout: 30_000 });
1910
- }
1911
- execLog("cleanup", `lane-${laneNumber}`, `OS-level removal succeeded`, { path: worktreePath });
1912
- } catch (osErr: unknown) {
1913
- const osMsg = osErr instanceof Error ? osErr.message : String(osErr);
1914
- execLog("cleanup", `lane-${laneNumber}`, `OS-level removal also failed manual cleanup needed`, {
1915
- path: worktreePath,
1916
- error: osMsg,
1917
- });
1918
- }
1919
- }
1920
- }
1921
-
1922
- // Step 2: Prune stale worktree references
1923
- runGit(["worktree", "prune"], repoRoot);
1924
- execLog("cleanup", `lane-${laneNumber}`, `pruned stale worktree references`);
1925
-
1926
- // Step 3: Delete the lane branch if it still exists
1927
- const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
1928
- if (branchCheck.ok) {
1929
- const deleteResult = runGit(["branch", "-D", branch], repoRoot);
1930
- if (deleteResult.ok) {
1931
- execLog("cleanup", `lane-${laneNumber}`, `deleted stale lane branch`, { branch });
1932
- } else {
1933
- execLog("cleanup", `lane-${laneNumber}`, `could not delete lane branch`, {
1934
- branch,
1935
- error: deleteResult.stderr,
1936
- });
1937
- }
1938
- }
1939
-
1940
- // Step 4: Attempt to remove the batch container directory if empty
1941
- // The worktree path is {basePath}/{opId}-{batchId}/lane-{N}, so the
1942
- // container is the parent directory.
1943
- const containerDir = resolve(worktreePath, "..");
1944
- const containerName = basename(containerDir);
1945
- // Only attempt container cleanup if the parent looks like a batch container
1946
- // (contains a hyphen, indicating {opId}-{batchId} naming)
1947
- if (containerName.includes("-")) {
1948
- const containerRemoved = removeBatchContainerIfEmpty(containerDir);
1949
- if (containerRemoved) {
1950
- execLog("cleanup", `lane-${laneNumber}`, `removed empty batch container`, { path: containerDir });
1951
- }
1952
- }
1953
- }
1954
-
1955
-
1956
- // ── Partial Progress Preservation ────────────────────────────────────
1957
-
1958
- /**
1959
- * Result of saving partial progress for a single failed task.
1960
- */
1961
- export interface SavePartialProgressResult {
1962
- /** Whether partial progress was saved (branch created or already existed) */
1963
- saved: boolean;
1964
- /** The saved branch name, if saved */
1965
- savedBranch?: string;
1966
- /** Number of commits ahead of the target branch */
1967
- commitCount: number;
1968
- /** Task ID this progress belongs to */
1969
- taskId: string;
1970
- /** Error message if save failed */
1971
- error?: string;
1972
- }
1973
-
1974
- /**
1975
- * Compute the saved branch name for partial progress from a failed task.
1976
- *
1977
- * Naming convention per roadmap Phase 2 section 2a:
1978
- * - Repo mode: `saved/{opId}-{taskId}-{batchId}`
1979
- * - Workspace mode: `saved/{opId}-{repoId}-{taskId}-{batchId}`
1980
- *
1981
- * Pure function no side effects.
1982
- *
1983
- * @param opId - Operator identifier (sanitized)
1984
- * @param taskId - Task identifier (e.g., "TP-028")
1985
- * @param batchId - Batch ID timestamp (e.g., "20260308T111750")
1986
- * @param repoId - Repo identifier (workspace mode only; omit for repo mode)
1987
- * @returns Saved branch name
1988
- */
1989
- export function computePartialProgressBranchName(
1990
- opId: string,
1991
- taskId: string,
1992
- batchId: string,
1993
- repoId?: string,
1994
- ): string {
1995
- if (repoId) {
1996
- return `saved/${opId}-${repoId}-${taskId}-${batchId}`;
1997
- }
1998
- return `saved/${opId}-${taskId}-${batchId}`;
1999
- }
2000
-
2001
- /**
2002
- * Save partial progress from a failed task's lane branch.
2003
- *
2004
- * Checks if the lane branch has commits ahead of the target branch,
2005
- * and if so, creates a saved branch preserving those commits.
2006
- *
2007
- * Uses `resolveSavedBranchCollision()` for idempotent collision handling:
2008
- * - Same SHA no-op (keep existing)
2009
- * - Different SHA → create with timestamp suffix
2010
- *
2011
- * @param laneBranch - The lane branch that may have partial commits
2012
- * @param targetBranch - The base/target branch to compare against
2013
- * @param opId - Operator identifier
2014
- * @param taskId - Task identifier
2015
- * @param batchId - Batch ID
2016
- * @param repoRoot - Repository root for git operations
2017
- * @param repoId - Repo identifier (workspace mode only)
2018
- * @returns SavePartialProgressResult describing what was done
2019
- */
2020
- export function savePartialProgress(
2021
- laneBranch: string,
2022
- targetBranch: string,
2023
- opId: string,
2024
- taskId: string,
2025
- batchId: string,
2026
- repoRoot: string,
2027
- repoId?: string,
2028
- ): SavePartialProgressResult {
2029
- // Check if lane branch exists
2030
- const branchCheck = runGit(
2031
- ["rev-parse", "--verify", `refs/heads/${laneBranch}`],
2032
- repoRoot,
2033
- );
2034
- if (!branchCheck.ok) {
2035
- return { saved: false, commitCount: 0, taskId, error: `Lane branch "${laneBranch}" not found` };
2036
- }
2037
- const branchSHA = branchCheck.stdout.trim();
2038
-
2039
- // Count commits ahead of target branch
2040
- const unmergedResult = hasUnmergedCommits(laneBranch, targetBranch, repoRoot);
2041
- if (!unmergedResult.ok) {
2042
- return {
2043
- saved: false,
2044
- commitCount: 0,
2045
- taskId,
2046
- error: `Failed to count commits: ${unmergedResult.error}`,
2047
- };
2048
- }
2049
-
2050
- if (unmergedResult.count === 0) {
2051
- // No partial progress — lane branch has no new commits
2052
- return { saved: false, commitCount: 0, taskId };
2053
- }
2054
-
2055
- // Compute saved branch name using task-ID naming convention
2056
- const savedName = computePartialProgressBranchName(opId, taskId, batchId, repoId);
2057
-
2058
- // Check for collision (idempotent re-runs, retries)
2059
- const existingCheck = runGit(
2060
- ["rev-parse", "--verify", `refs/heads/${savedName}`],
2061
- repoRoot,
2062
- );
2063
- const existingSHA = existingCheck.ok ? existingCheck.stdout.trim() : "";
2064
-
2065
- const resolution = resolveSavedBranchCollision(savedName, existingSHA, branchSHA);
2066
-
2067
- switch (resolution.action) {
2068
- case "keep-existing":
2069
- // Already preserved at the same SHA — idempotent success
2070
- return {
2071
- saved: true,
2072
- savedBranch: resolution.savedName,
2073
- commitCount: unmergedResult.count,
2074
- taskId,
2075
- };
2076
-
2077
- case "create":
2078
- case "create-suffixed": {
2079
- const createResult = runGit(
2080
- ["branch", resolution.savedName, branchSHA],
2081
- repoRoot,
2082
- );
2083
- if (!createResult.ok) {
2084
- return {
2085
- saved: false,
2086
- commitCount: unmergedResult.count,
2087
- taskId,
2088
- error: `Failed to create saved branch "${resolution.savedName}": ${createResult.stderr}`,
2089
- };
2090
- }
2091
- return {
2092
- saved: true,
2093
- savedBranch: resolution.savedName,
2094
- commitCount: unmergedResult.count,
2095
- taskId,
2096
- };
2097
- }
2098
-
2099
- default:
2100
- return {
2101
- saved: false,
2102
- commitCount: unmergedResult.count,
2103
- taskId,
2104
- error: `Unknown collision resolution action`,
2105
- };
2106
- }
2107
- }
2108
-
2109
- /**
2110
- * Result of preserving partial progress across all failed tasks.
2111
- */
2112
- export interface PreserveFailedLaneProgressResult {
2113
- /** Per-task results for each failed task that was checked */
2114
- results: SavePartialProgressResult[];
2115
- /**
2116
- * Set of saved branch names that were created (e.g., `saved/{opId}-{taskId}-{batchId}`).
2117
- * These branches independently preserve the commits — lane branches can still be
2118
- * safely deleted during cleanup since the saved refs retain reachability.
2119
- */
2120
- preservedBranches: Set<string>;
2121
- /**
2122
- * Set of lane branch names where preservation FAILED but commits existed.
2123
- * These branches are unsafe to reset/delete — doing so would lose commits
2124
- * that were not successfully saved to a separate branch. Callers should skip
2125
- * worktree reset and branch deletion for these branches to prevent data loss.
2126
- */
2127
- unsafeBranches: Set<string>;
2128
- }
2129
-
2130
- /**
2131
- * Callback for resolving repo root and target branch for a given repoId.
2132
- *
2133
- * Allows callers (engine.ts, resume.ts) to pass workspace-aware resolution
2134
- * logic without creating a circular dependency (worktree.ts waves.ts worktree.ts).
2135
- *
2136
- * @param repoId - Repo identifier (undefined in repo mode)
2137
- * @returns { repoRoot, targetBranch } for the given repo
2138
- */
2139
- export type ResolveRepoContext = (repoId: string | undefined) => {
2140
- repoRoot: string;
2141
- targetBranch: string;
2142
- };
2143
-
2144
- /**
2145
- * Preserve partial progress for all failed tasks before cleanup/reset.
2146
- *
2147
- * Iterates task outcomes to find failed/stalled tasks, maps each to its
2148
- * lane branch via the allocated lanes, and saves any partial commits as
2149
- * task-ID-named saved branches.
2150
- *
2151
- * Returns two branch sets:
2152
- * - `preservedBranches`: saved branch names that were successfully created
2153
- * (lane branches can be safely deleted since these refs retain commits)
2154
- * - `unsafeBranches`: lane branch names where preservation FAILED but commits
2155
- * existed (callers must NOT reset/delete these to prevent data loss)
2156
- *
2157
- * Workspace-aware: uses the provided `resolveRepo` callback to resolve
2158
- * per-repo target branches and repo roots for correct commit counting
2159
- * in workspace mode.
2160
- *
2161
- * @param allocatedLanes - Lanes from the current/last wave (maps tasks to branches)
2162
- * @param taskOutcomes - All task outcomes accumulated so far
2163
- * @param opId - Operator identifier
2164
- * @param batchId - Batch ID
2165
- * @param resolveRepo - Callback to resolve repo root and target branch per repoId
2166
- * @returns PreserveFailedLaneProgressResult with per-task results and preserved branch set
2167
- */
2168
- export function preserveFailedLaneProgress(
2169
- allocatedLanes: AllocatedLane[],
2170
- taskOutcomes: LaneTaskOutcome[],
2171
- opId: string,
2172
- batchId: string,
2173
- resolveRepo: ResolveRepoContext,
2174
- ): PreserveFailedLaneProgressResult {
2175
- const results: SavePartialProgressResult[] = [];
2176
- const preservedBranches = new Set<string>();
2177
- const unsafeBranches = new Set<string>();
2178
-
2179
- // Build a map: taskId → { laneBranch, repoId } from allocated lanes
2180
- const taskToLane = new Map<string, { branch: string; repoId?: string }>();
2181
- for (const lane of allocatedLanes) {
2182
- for (const allocatedTask of lane.tasks) {
2183
- taskToLane.set(allocatedTask.taskId, {
2184
- branch: lane.branch,
2185
- repoId: lane.repoId,
2186
- });
2187
- }
2188
- }
2189
-
2190
- // Find failed/stalled tasks
2191
- const failedTasks = taskOutcomes.filter(
2192
- (to) => to.status === "failed" || to.status === "stalled",
2193
- );
2194
-
2195
- // Track which lane branches we've already processed (a lane may have
2196
- // multiple tasks; only save once per branch since all commits are shared)
2197
- const processedBranches = new Set<string>();
2198
-
2199
- for (const failedTask of failedTasks) {
2200
- const laneInfo = taskToLane.get(failedTask.taskId);
2201
- if (!laneInfo) {
2202
- // Task not found in allocated lanes — skip (shouldn't happen)
2203
- results.push({
2204
- saved: false,
2205
- commitCount: 0,
2206
- taskId: failedTask.taskId,
2207
- error: "Task not found in allocated lanes",
2208
- });
2209
- continue;
2210
- }
2211
-
2212
- // Skip if we've already processed this branch (multiple failed tasks on same lane)
2213
- if (processedBranches.has(laneInfo.branch)) {
2214
- continue;
2215
- }
2216
- processedBranches.add(laneInfo.branch);
2217
-
2218
- // Resolve repo-specific target branch and repo root
2219
- const { repoRoot: perRepoRoot, targetBranch } = resolveRepo(laneInfo.repoId);
2220
-
2221
- const result = savePartialProgress(
2222
- laneInfo.branch,
2223
- targetBranch,
2224
- opId,
2225
- failedTask.taskId,
2226
- batchId,
2227
- perRepoRoot,
2228
- laneInfo.repoId,
2229
- );
2230
-
2231
- results.push(result);
2232
-
2233
- if (result.saved) {
2234
- // Track the saved branch name for caller visibility
2235
- preservedBranches.add(result.savedBranch!);
2236
-
2237
- execLog("partial-progress", failedTask.taskId,
2238
- `Task ${failedTask.taskId} failed but has ${result.commitCount} commit(s) of partial progress on branch ${result.savedBranch}`,
2239
- {
2240
- laneBranch: laneInfo.branch,
2241
- savedBranch: result.savedBranch,
2242
- commitCount: result.commitCount,
2243
- repoId: laneInfo.repoId ?? "(default)",
2244
- },
2245
- );
2246
- } else if (result.commitCount > 0 || result.error) {
2247
- // Preservation FAILED but commits may exist on the lane branch.
2248
- // Mark this branch as unsafe to reset/delete — doing so would
2249
- // irreversibly lose the partial work.
2250
- unsafeBranches.add(laneInfo.branch);
2251
-
2252
- execLog("partial-progress", failedTask.taskId,
2253
- `WARNING: Failed to preserve partial progress for task ${failedTask.taskId} ` +
2254
- `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`,
2255
- {
2256
- laneBranch: laneInfo.branch,
2257
- commitCount: result.commitCount,
2258
- error: result.error ?? "unknown",
2259
- repoId: laneInfo.repoId ?? "(default)",
2260
- },
2261
- );
2262
- }
2263
- }
2264
-
2265
- return { results, preservedBranches, unsafeBranches };
2266
- }
2267
-
2268
-
2269
- /**
2270
- * TP-147: Preserve partial progress for all skipped tasks before cleanup/reset.
2271
- *
2272
- * Skipped tasks may have worker commits (STATUS.md updates, partial code)
2273
- * that would be lost when the worktree is cleaned up. This function saves
2274
- * their lane branches as task-ID-named saved branches, similar to how
2275
- * preserveFailedLaneProgress works for failed tasks.
2276
- *
2277
- * Unlike failed tasks, skipped-task branches are NOT merged (partial work
2278
- * could break verification). Instead they are preserved for manual recovery.
2279
- *
2280
- * @param allocatedLanes - Lanes from the current/last wave
2281
- * @param taskOutcomes - All task outcomes accumulated so far
2282
- * @param opId - Operator identifier
2283
- * @param batchId - Batch ID
2284
- * @param resolveRepo - Callback to resolve repo root and target branch per repoId
2285
- * @returns PreserveFailedLaneProgressResult with per-task results and preserved branch set
2286
- */
2287
- export function preserveSkippedLaneProgress(
2288
- allocatedLanes: AllocatedLane[],
2289
- taskOutcomes: LaneTaskOutcome[],
2290
- opId: string,
2291
- batchId: string,
2292
- resolveRepo: ResolveRepoContext,
2293
- ): PreserveFailedLaneProgressResult {
2294
- const results: SavePartialProgressResult[] = [];
2295
- const preservedBranches = new Set<string>();
2296
- const unsafeBranches = new Set<string>();
2297
-
2298
- // Build a map: taskId { laneBranch, repoId } from allocated lanes
2299
- const taskToLane = new Map<string, { branch: string; repoId?: string }>();
2300
- for (const lane of allocatedLanes) {
2301
- for (const allocatedTask of lane.tasks) {
2302
- taskToLane.set(allocatedTask.taskId, {
2303
- branch: lane.branch,
2304
- repoId: lane.repoId,
2305
- });
2306
- }
2307
- }
2308
-
2309
- // Find skipped tasks
2310
- const skippedTasks = taskOutcomes.filter(
2311
- (to) => to.status === "skipped",
2312
- );
2313
-
2314
- // Track which lane branches we've already processed (a lane may have
2315
- // multiple tasks; only save once per branch since all commits are shared)
2316
- const processedBranches = new Set<string>();
2317
-
2318
- for (const skippedTask of skippedTasks) {
2319
- const laneInfo = taskToLane.get(skippedTask.taskId);
2320
- if (!laneInfo) {
2321
- results.push({
2322
- saved: false,
2323
- commitCount: 0,
2324
- taskId: skippedTask.taskId,
2325
- error: "Task not found in allocated lanes",
2326
- });
2327
- continue;
2328
- }
2329
-
2330
- // Skip if we've already processed this branch
2331
- if (processedBranches.has(laneInfo.branch)) {
2332
- continue;
2333
- }
2334
- processedBranches.add(laneInfo.branch);
2335
-
2336
- // Resolve repo-specific target branch and repo root
2337
- const { repoRoot: perRepoRoot, targetBranch } = resolveRepo(laneInfo.repoId);
2338
-
2339
- const result = savePartialProgress(
2340
- laneInfo.branch,
2341
- targetBranch,
2342
- opId,
2343
- skippedTask.taskId,
2344
- batchId,
2345
- perRepoRoot,
2346
- laneInfo.repoId,
2347
- );
2348
-
2349
- results.push(result);
2350
-
2351
- if (result.saved) {
2352
- preservedBranches.add(result.savedBranch!);
2353
-
2354
- execLog("partial-progress", skippedTask.taskId,
2355
- `Task ${skippedTask.taskId} was skipped but has ${result.commitCount} commit(s) of partial progress preserved on branch ${result.savedBranch}`,
2356
- {
2357
- laneBranch: laneInfo.branch,
2358
- savedBranch: result.savedBranch,
2359
- commitCount: result.commitCount,
2360
- repoId: laneInfo.repoId ?? "(default)",
2361
- },
2362
- );
2363
- } else if (result.commitCount > 0 || result.error) {
2364
- unsafeBranches.add(laneInfo.branch);
2365
-
2366
- execLog("partial-progress", skippedTask.taskId,
2367
- `WARNING: Failed to preserve partial progress for skipped task ${skippedTask.taskId} ` +
2368
- `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`,
2369
- {
2370
- laneBranch: laneInfo.branch,
2371
- commitCount: result.commitCount,
2372
- error: result.error ?? "unknown",
2373
- repoId: laneInfo.repoId ?? "(default)",
2374
- },
2375
- );
2376
- }
2377
- }
2378
-
2379
- return { results, preservedBranches, unsafeBranches };
2380
- }
2381
-
2382
-
2383
- // ── Stale Branch Cleanup (TP-051) ────────────────────────────────────
2384
-
2385
- /**
2386
- * Result of stale branch cleanup after integration.
2387
- */
2388
- export interface StaleBranchCleanupResult {
2389
- /** task/* branches deleted */
2390
- deletedTaskBranches: string[];
2391
- /** saved/task/* branches deleted */
2392
- deletedSavedBranches: string[];
2393
- /** Branches that failed to delete (best-effort) */
2394
- failedDeletes: string[];
2395
- }
2396
-
2397
- /**
2398
- * Delete stale task/* and saved/* branches after integration.
2399
- *
2400
- * After `/orch-integrate` merges or creates a PR, the lane branches
2401
- * (`task/{opId}-lane-{N}-{batchId}`) and their saved counterparts
2402
- * are no longer needed. This function cleans them up.
2403
- *
2404
- * Cleanup scope:
2405
- * 1. **Lane branches:** `task/{opId}-lane-*` (any batch from this operator)
2406
- * 2. **Saved lane branches:** `saved/task/{opId}-lane-*` (preserved lane refs)
2407
- * 3. **Partial-progress branches:** `saved/{opId}-*` (per-task partial progress refs)
2408
- *
2409
- * Targets all branches matching the operator's prefix, not just the current
2410
- * batch this also cleans up orphans from previous batches that were never
2411
- * cleaned.
2412
- *
2413
- * All deletions are best-effort individual failures are logged but don't
2414
- * prevent other branches from being cleaned.
2415
- *
2416
- * @param repoRoot - Repository root directory
2417
- * @param opId - Operator identifier (e.g., "henrylach")
2418
- * @param batchId - Current batch ID (for logging context)
2419
- * @returns Cleanup result with lists of deleted and failed branches
2420
- */
2421
- export function deleteStaleBranches(
2422
- repoRoot: string,
2423
- opId: string,
2424
- batchId: string,
2425
- ): StaleBranchCleanupResult {
2426
- const deletedTaskBranches: string[] = [];
2427
- const deletedSavedBranches: string[] = [];
2428
- const failedDeletes: string[] = [];
2429
-
2430
- // 1. Delete task/{opId}-lane-* branches
2431
- const taskBranchResult = runGit(["branch", "--list", `task/${opId}-lane-*`], repoRoot);
2432
- if (taskBranchResult.ok && taskBranchResult.stdout.trim()) {
2433
- const branches = taskBranchResult.stdout
2434
- .split("\n")
2435
- .map(b => b.replace(/^\*?\s+/, "").trim())
2436
- .filter(Boolean);
2437
-
2438
- for (const branch of branches) {
2439
- const deleted = deleteBranchBestEffort(branch, repoRoot);
2440
- if (deleted) {
2441
- deletedTaskBranches.push(branch);
2442
- } else {
2443
- failedDeletes.push(branch);
2444
- }
2445
- }
2446
- }
2447
-
2448
- // 2. Delete saved/task/{opId}-lane-* branches (preserved lane refs)
2449
- const savedTaskResult = runGit(["branch", "--list", `saved/task/${opId}-lane-*`], repoRoot);
2450
- if (savedTaskResult.ok && savedTaskResult.stdout.trim()) {
2451
- const branches = savedTaskResult.stdout
2452
- .split("\n")
2453
- .map(b => b.replace(/^\*?\s+/, "").trim())
2454
- .filter(Boolean);
2455
-
2456
- for (const branch of branches) {
2457
- const deleted = deleteBranchBestEffort(branch, repoRoot);
2458
- if (deleted) {
2459
- deletedSavedBranches.push(branch);
2460
- } else {
2461
- failedDeletes.push(branch);
2462
- }
2463
- }
2464
- }
2465
-
2466
- // 3. Delete saved/{opId}-*-{batchId} branches (partial-progress refs from this batch)
2467
- // Pattern: saved/{opId}-{taskId}-{batchId} or saved/{opId}-{repoId}-{taskId}-{batchId}
2468
- // Only deletes branches ending with the current batchId to avoid removing
2469
- // partial-progress refs from other batches that the operator may still need.
2470
- const savedProgressResult = runGit(["branch", "--list", `saved/${opId}-*`], repoRoot);
2471
- if (savedProgressResult.ok && savedProgressResult.stdout.trim()) {
2472
- const branches = savedProgressResult.stdout
2473
- .split("\n")
2474
- .map(b => b.replace(/^\*?\s+/, "").trim())
2475
- .filter(Boolean);
2476
-
2477
- const batchSuffix = `-${batchId}`;
2478
- for (const branch of branches) {
2479
- // Avoid double-deleting saved/task/* already handled above
2480
- if (branch.startsWith("saved/task/")) continue;
2481
- // Only delete partial-progress refs from the current batch
2482
- if (!branch.endsWith(batchSuffix)) continue;
2483
- const deleted = deleteBranchBestEffort(branch, repoRoot);
2484
- if (deleted) {
2485
- deletedSavedBranches.push(branch);
2486
- } else {
2487
- failedDeletes.push(branch);
2488
- }
2489
- }
2490
- }
2491
-
2492
- const totalDeleted = deletedTaskBranches.length + deletedSavedBranches.length;
2493
- if (totalDeleted > 0) {
2494
- execLog("cleanup", "branches", `deleted ${totalDeleted} stale branch(es) for batch ${batchId}`, {
2495
- taskBranches: deletedTaskBranches.length,
2496
- savedBranches: deletedSavedBranches.length,
2497
- failed: failedDeletes.length,
2498
- });
2499
- }
2500
-
2501
- return { deletedTaskBranches, deletedSavedBranches, failedDeletes };
2502
- }
2503
-
2504
-
2505
-
1
+ /**
2
+ * Worktree CRUD, bulk ops, branch protection, preflight
3
+ * @module orch/worktree
4
+ */
5
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmdirSync, rmSync } from "fs";
6
+ import { execSync } from "child_process";
7
+ import { join, basename, resolve } from "path";
8
+
9
+ import { execLog } from "./execution.ts";
10
+ import { runGit } from "./git.ts";
11
+ import { resolveOperatorId } from "./naming.ts";
12
+ import { DEFAULT_ORCHESTRATOR_CONFIG, WorktreeError } from "./types.ts";
13
+ import type { AllocatedLane, BulkWorktreeError, CreateLaneWorktreesResult, CreateWorktreeOptions, LaneTaskOutcome, OrchestratorConfig, PreflightCheck, PreflightResult, RemoveAllWorktreesResult, RemoveWorktreeOutcome, RemoveWorktreeResult, WorktreeInfo } from "./types.ts";
14
+
15
+ // ── Worktree Helpers ─────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Generate branch name per naming convention.
19
+ * Format: task/{opId}-lane-{N}-{batchId}
20
+ *
21
+ * Includes the operator identifier for collision resistance across
22
+ * concurrent operators in the same repository.
23
+ *
24
+ * @param laneNumber - Lane number (1-indexed)
25
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
26
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
27
+ */
28
+ export function generateBranchName(laneNumber: number, batchId: string, opId: string): string {
29
+ return `task/${opId}-lane-${laneNumber}-${batchId}`;
30
+ }
31
+
32
+ /**
33
+ * Resolve the base directory where worktrees are created, based on config.
34
+ *
35
+ * Two modes (from `worktree_location` config):
36
+ * "sibling" → resolve(repoRoot, "..") — worktrees sit next to the repo
37
+ * "subdirectory" → resolve(repoRoot, ".worktrees") — worktrees inside the repo (gitignored)
38
+ *
39
+ * The returned path is the parent directory; individual worktree dirs are
40
+ * created as children (e.g., `<base>/{prefix}-1` → `<base>/taskplane-wt-1`).
41
+ *
42
+ * @param repoRoot - Absolute path to the main repository root
43
+ * @param config - Orchestrator config (reads `worktree_location`)
44
+ */
45
+ export function resolveWorktreeBasePath(
46
+ repoRoot: string,
47
+ config: OrchestratorConfig,
48
+ ): string {
49
+ const location = config.orchestrator.worktree_location;
50
+ if (location === "sibling") {
51
+ return resolve(repoRoot, "..");
52
+ }
53
+ // Default to subdirectory for any non-"sibling" value (including "subdirectory")
54
+ return resolve(repoRoot, ".worktrees");
55
+ }
56
+
57
+ /**
58
+ * Generate the batch container directory name.
59
+ *
60
+ * Format: `{opId}-{batchId}`
61
+ * Example: `henrylach-20260308T111750`
62
+ *
63
+ * This is the directory that holds all lane worktrees and the merge
64
+ * worktree for a single batch.
65
+ *
66
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
67
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
68
+ */
69
+ export function generateBatchContainerName(opId: string, batchId: string): string {
70
+ return `${opId}-${batchId}`;
71
+ }
72
+
73
+ /**
74
+ * Generate the absolute path to the batch container directory.
75
+ *
76
+ * All worktrees for a single batch (lanes + merge) live inside this container.
77
+ * Format: `{basePath}/{opId}-{batchId}`
78
+ *
79
+ * Uses `resolveWorktreeBasePath()` to respect `worktree_location` config
80
+ * (sibling vs subdirectory mode). Both `generateWorktreePath()` and
81
+ * `generateMergeWorktreePath()` delegate to this function, ensuring
82
+ * consistent base-path resolution.
83
+ *
84
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
85
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
86
+ * @param repoRoot - Absolute path to the main repository root
87
+ * @param config - Orchestrator config (optional; defaults to subdirectory mode)
88
+ * @returns - Absolute path to the batch container directory
89
+ */
90
+ export function generateBatchContainerPath(
91
+ opId: string,
92
+ batchId: string,
93
+ repoRoot: string,
94
+ config?: OrchestratorConfig,
95
+ ): string {
96
+ const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
97
+ const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
98
+ return resolve(basePath, generateBatchContainerName(opId, batchId));
99
+ }
100
+
101
+ /**
102
+ * Generate worktree path based on config's worktree_location setting.
103
+ *
104
+ * Naming rule: `{basePath}/{opId}-{batchId}/lane-{N}`
105
+ * Sibling mode: ../{opId}-{batchId}/lane-{N}
106
+ * Subdirectory mode: .worktrees/{opId}-{batchId}/lane-{N}
107
+ *
108
+ * Each batch gets its own container directory, preventing collisions
109
+ * between concurrent batches by the same operator.
110
+ *
111
+ * Uses `generateBatchContainerPath()` for the container directory,
112
+ * preserving `worktree_location` semantics (sibling vs subdirectory).
113
+ *
114
+ * Uses path.resolve() for Windows path normalization (R002 requirement).
115
+ *
116
+ * @param prefix - Directory prefix (unused in new scheme, kept for API compat)
117
+ * @param laneNumber - Lane number (1-indexed)
118
+ * @param repoRoot - Absolute path to the main repository root
119
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
120
+ * @param config - Orchestrator config (optional; defaults to subdirectory mode)
121
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
122
+ */
123
+ export function generateWorktreePath(
124
+ prefix: string,
125
+ laneNumber: number,
126
+ repoRoot: string,
127
+ opId: string,
128
+ config?: OrchestratorConfig,
129
+ batchId?: string,
130
+ ): string {
131
+ if (batchId) {
132
+ // New batch-scoped container layout
133
+ const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config);
134
+ return resolve(containerPath, `lane-${laneNumber}`);
135
+ }
136
+
137
+ // Legacy fallback (no batchId) — flat layout for backward compatibility
138
+ const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
139
+ const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
140
+ return resolve(basePath, `${prefix}-${opId}-${laneNumber}`);
141
+ }
142
+
143
+ /**
144
+ * Generate the merge worktree path inside a batch container.
145
+ *
146
+ * Format: `{basePath}/{opId}-{batchId}/merge`
147
+ *
148
+ * Uses `generateBatchContainerPath()` for config-aware, base-path-consistent
149
+ * path resolution (respects `worktree_location` setting). This ensures
150
+ * the merge worktree is co-located with lane worktrees in the same
151
+ * batch container for unified cleanup.
152
+ *
153
+ * @param repoRoot - Absolute path to the main repository root
154
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
155
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
156
+ * @param config - Orchestrator config (optional; defaults to subdirectory mode)
157
+ */
158
+ export function generateMergeWorktreePath(
159
+ repoRoot: string,
160
+ opId: string,
161
+ batchId: string,
162
+ config?: OrchestratorConfig,
163
+ ): string {
164
+ const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config);
165
+ return resolve(containerPath, "merge");
166
+ }
167
+
168
+ /**
169
+ * Ensure the batch container directory exists, creating it if necessary.
170
+ *
171
+ * @param containerPath - Absolute path to the container directory
172
+ */
173
+ export function ensureBatchContainerDir(containerPath: string): void {
174
+ if (!existsSync(containerPath)) {
175
+ mkdirSync(containerPath, { recursive: true });
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Remove a batch container directory if it exists and is empty.
181
+ *
182
+ * Safety rules:
183
+ * - Only removes the directory if it exists
184
+ * - Only removes the directory if it is empty (no files or subdirectories)
185
+ * - Never force-removes a non-empty container (partial failure safety)
186
+ * - Returns whether the container was removed
187
+ *
188
+ * Used after per-worktree removals in `removeAllWorktrees()` and
189
+ * `forceCleanupWorktree()` to clean up the container directory when
190
+ * all worktrees inside it have been removed.
191
+ *
192
+ * @param containerPath - Absolute path to the batch container directory
193
+ * @returns true if the container was removed, false otherwise
194
+ */
195
+ export function removeBatchContainerIfEmpty(containerPath: string): boolean {
196
+ if (!existsSync(containerPath)) {
197
+ return false; // Already gone — no-op
198
+ }
199
+
200
+ try {
201
+ const entries = readdirSync(containerPath);
202
+ if (entries.length > 0) {
203
+ return false; // Non-empty — do not remove (partial failure safety)
204
+ }
205
+ rmdirSync(containerPath);
206
+ return true;
207
+ } catch {
208
+ // If we can't read or remove — leave it alone (safe default)
209
+ return false;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Parse `git worktree list --porcelain` output into structured entries.
215
+ *
216
+ * Porcelain output format (one block per worktree, separated by blank lines):
217
+ * worktree /absolute/path
218
+ * HEAD <sha>
219
+ * branch refs/heads/<name>
220
+ * [detached]
221
+ *
222
+ * @param cwd - Directory to run git from (must be in a git repo)
223
+ */
224
+ export interface ParsedWorktreeEntry {
225
+ path: string;
226
+ head: string;
227
+ branch: string | null; // null if detached HEAD
228
+ bare: boolean;
229
+ }
230
+
231
+ export function parseWorktreeList(cwd: string): ParsedWorktreeEntry[] {
232
+ const result = runGit(["worktree", "list", "--porcelain"], cwd);
233
+ if (!result.ok) return [];
234
+
235
+ const entries: ParsedWorktreeEntry[] = [];
236
+ const blocks = result.stdout.split(/\n\n+/);
237
+
238
+ for (const block of blocks) {
239
+ if (!block.trim()) continue;
240
+
241
+ const lines = block.trim().split("\n");
242
+ let path = "";
243
+ let head = "";
244
+ let branch: string | null = null;
245
+ let bare = false;
246
+
247
+ for (const line of lines) {
248
+ if (line.startsWith("worktree ")) {
249
+ path = line.slice("worktree ".length).trim();
250
+ } else if (line.startsWith("HEAD ")) {
251
+ head = line.slice("HEAD ".length).trim();
252
+ } else if (line.startsWith("branch ")) {
253
+ // "branch refs/heads/develop" → "develop"
254
+ const ref = line.slice("branch ".length).trim();
255
+ branch = ref.replace(/^refs\/heads\//, "");
256
+ } else if (line.trim() === "bare") {
257
+ bare = true;
258
+ }
259
+ }
260
+
261
+ if (path) {
262
+ entries.push({ path, head, branch, bare });
263
+ }
264
+ }
265
+
266
+ return entries;
267
+ }
268
+
269
+ /**
270
+ * Normalize a filesystem path for reliable comparison on Windows.
271
+ *
272
+ * On Windows, paths may contain 8.3 short names (e.g., `HENRYL~1` instead
273
+ * of `HenryLach`). Node's `resolve()` does NOT expand these, but git
274
+ * always reports full long names. This causes path comparison failures.
275
+ *
276
+ * Uses `fs.realpathSync.native()` to expand 8.3 names when the path exists,
277
+ * falls back to `resolve()` for non-existent paths (e.g., pre-creation checks).
278
+ *
279
+ * All comparisons are also lowercased and slash-normalized.
280
+ */
281
+ export function normalizePath(p: string): string {
282
+ let expanded: string;
283
+ try {
284
+ // realpathSync.native expands 8.3 short names on Windows
285
+ expanded = realpathSync.native(resolve(p));
286
+ } catch {
287
+ // Path doesn't exist yet — fall back to resolve()
288
+ expanded = resolve(p);
289
+ }
290
+ return expanded.replace(/\\/g, "/").toLowerCase();
291
+ }
292
+
293
+ /**
294
+ * Check if a given path is already registered as a git worktree.
295
+ * Uses `git worktree list --porcelain` for reliable detection.
296
+ *
297
+ * Path comparison is case-insensitive, slash-normalized, and expands
298
+ * Windows 8.3 short names (e.g., HENRYL~1 → HenryLach) for reliable
299
+ * matching against git's long-name output.
300
+ */
301
+ export function isRegisteredWorktree(targetPath: string, cwd: string): boolean {
302
+ const entries = parseWorktreeList(cwd);
303
+ const normalized = normalizePath(targetPath);
304
+ return entries.some(
305
+ (e) => normalizePath(e.path) === normalized,
306
+ );
307
+ }
308
+
309
+
310
+ // ── Worktree CRUD Operations ─────────────────────────────────────────
311
+
312
+ /**
313
+ * Create a new git worktree for a lane.
314
+ *
315
+ * Executes `git worktree add -b <branch> <path> <baseBranch>` from the
316
+ * main repository root. This creates a new branch based on baseBranch
317
+ * and checks it out in the worktree directory.
318
+ *
319
+ * Pre-checks (R002 requirements):
320
+ * 1. Validates baseBranch exists (`git rev-parse --verify`)
321
+ * 2. Checks target path is not already a registered worktree
322
+ * 3. Checks target path is not a non-empty non-worktree directory
323
+ *
324
+ * Post-creation verification:
325
+ * - Branch points to baseBranch HEAD commit
326
+ * - Correct branch is checked out in the worktree
327
+ *
328
+ * @param opts - Creation options (laneNumber, batchId, baseBranch, prefix)
329
+ * @param repoRoot - Absolute path to the main repository root
330
+ * @returns - WorktreeInfo on success
331
+ * @throws - WorktreeError with stable error code on failure
332
+ */
333
+ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): WorktreeInfo {
334
+ const { laneNumber, batchId, baseBranch, prefix, opId, config } = opts;
335
+
336
+ const branch = generateBranchName(laneNumber, batchId, opId);
337
+ const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, opId, config, batchId);
338
+
339
+ // ── Pre-check 1: Validate base branch exists ─────────────────
340
+ const baseBranchCheck = runGit(
341
+ ["rev-parse", "--verify", `refs/heads/${baseBranch}`],
342
+ repoRoot,
343
+ );
344
+ if (!baseBranchCheck.ok) {
345
+ throw new WorktreeError(
346
+ "WORKTREE_INVALID_BASE",
347
+ `Base branch "${baseBranch}" does not exist locally. ` +
348
+ `Verify the branch exists: git branch --list ${baseBranch}`,
349
+ );
350
+ }
351
+ const baseBranchHead = baseBranchCheck.stdout.trim();
352
+
353
+ // ── Pre-check 2: Check if path is already a registered worktree
354
+ if (isRegisteredWorktree(worktreePath, repoRoot)) {
355
+ throw new WorktreeError(
356
+ "WORKTREE_PATH_IS_WORKTREE",
357
+ `Path "${worktreePath}" is already registered as a git worktree. ` +
358
+ `Remove it first: git worktree remove "${worktreePath}"`,
359
+ );
360
+ }
361
+
362
+ // ── Pre-check 3: Check if path exists and is non-empty (non-worktree dir)
363
+ if (existsSync(worktreePath)) {
364
+ try {
365
+ const entries = readdirSync(worktreePath);
366
+ if (entries.length > 0) {
367
+ throw new WorktreeError(
368
+ "WORKTREE_PATH_NOT_EMPTY",
369
+ `Path "${worktreePath}" exists and is not empty. ` +
370
+ `It is not a registered git worktree. Remove or rename it before creating a worktree here.`,
371
+ );
372
+ }
373
+ } catch (err) {
374
+ if (err instanceof WorktreeError) throw err;
375
+ // If we can't read the path (e.g., it's a file not a directory), error
376
+ throw new WorktreeError(
377
+ "WORKTREE_PATH_NOT_EMPTY",
378
+ `Path "${worktreePath}" exists but cannot be read as a directory.`,
379
+ );
380
+ }
381
+ }
382
+
383
+ // ── Pre-check 4: Check if branch already exists ──────────────
384
+ const branchCheck = runGit(
385
+ ["rev-parse", "--verify", `refs/heads/${branch}`],
386
+ repoRoot,
387
+ );
388
+ if (branchCheck.ok) {
389
+ throw new WorktreeError(
390
+ "WORKTREE_BRANCH_EXISTS",
391
+ `Branch "${branch}" already exists. ` +
392
+ `This may indicate a stale worktree from a previous batch. ` +
393
+ `Delete it: git branch -D ${branch}`,
394
+ );
395
+ }
396
+
397
+ // ── Ensure batch container directory exists ──────────────────
398
+ // Placed after pre-checks so no empty container is left behind on
399
+ // validation failure (R004 review feedback).
400
+ const containerDir = resolve(worktreePath, "..");
401
+ ensureBatchContainerDir(containerDir);
402
+
403
+ // ── Create worktree ──────────────────────────────────────────
404
+ const createResult = runGit(
405
+ ["worktree", "add", "-b", branch, worktreePath, baseBranch],
406
+ repoRoot,
407
+ );
408
+ if (!createResult.ok) {
409
+ throw new WorktreeError(
410
+ "WORKTREE_GIT_ERROR",
411
+ `Failed to create worktree at "${worktreePath}" on branch "${branch}" ` +
412
+ `from "${baseBranch}": ${createResult.stderr}`,
413
+ );
414
+ }
415
+
416
+ // ── Post-creation verification (R002 requirements) ───────────
417
+ // Verify 1: Correct branch is checked out
418
+ const headBranchResult = runGit(
419
+ ["rev-parse", "--abbrev-ref", "HEAD"],
420
+ worktreePath,
421
+ );
422
+ if (!headBranchResult.ok || headBranchResult.stdout !== branch) {
423
+ throw new WorktreeError(
424
+ "WORKTREE_VERIFY_FAILED",
425
+ `Verification failed: expected branch "${branch}" checked out ` +
426
+ `in worktree, but got "${headBranchResult.stdout || "(unknown)"}".`,
427
+ );
428
+ }
429
+
430
+ // Verify 2: Branch points to baseBranch HEAD commit
431
+ const headCommitResult = runGit(["rev-parse", "HEAD"], worktreePath);
432
+ if (!headCommitResult.ok || headCommitResult.stdout !== baseBranchHead) {
433
+ throw new WorktreeError(
434
+ "WORKTREE_VERIFY_FAILED",
435
+ `Verification failed: worktree HEAD (${headCommitResult.stdout?.slice(0, 8) || "?"}) ` +
436
+ `does not match baseBranch "${baseBranch}" HEAD (${baseBranchHead.slice(0, 8)}).`,
437
+ );
438
+ }
439
+
440
+ return {
441
+ path: resolve(worktreePath),
442
+ branch,
443
+ laneNumber,
444
+ };
445
+ }
446
+
447
+ /**
448
+ * Reset an existing worktree to point at a new target branch/commit.
449
+ *
450
+ * Used after a wave merge to update a lane's worktree to the latest
451
+ * develop HEAD, or any other target branch. The existing lane branch
452
+ * name is preserved — only its target commit changes.
453
+ *
454
+ * Strategy: `git checkout -B <laneBranch> <targetBranch>` inside the worktree.
455
+ * This repoints the existing lane branch to the target commit and checks it out.
456
+ *
457
+ * Precondition checks (R003 requirements):
458
+ * 1. Worktree path exists on disk
459
+ * 2. Path is a registered git worktree (via parseWorktreeList)
460
+ * 3. Target branch resolves (git rev-parse --verify)
461
+ * 4. Working tree is clean (git status --porcelain returns empty)
462
+ *
463
+ * Post-reset verification:
464
+ * - HEAD equals targetBranch commit
465
+ * - Current branch equals worktree.branch (lane branch preserved)
466
+ *
467
+ * Idempotency: Resetting to the same target commit succeeds (no-op semantically).
468
+ *
469
+ * @param worktree - WorktreeInfo returned by createWorktree()
470
+ * @param targetBranch - Branch name to reset to (e.g. "develop")
471
+ * @param repoRoot - Absolute path to the main repository root
472
+ * @returns - Updated WorktreeInfo (same branch/laneNumber, same path)
473
+ * @throws - WorktreeError with stable error code on failure
474
+ */
475
+ export function resetWorktree(
476
+ worktree: WorktreeInfo,
477
+ targetBranch: string,
478
+ repoRoot: string,
479
+ ): WorktreeInfo {
480
+ const { path: worktreePath, branch, laneNumber } = worktree;
481
+
482
+ // ── Pre-check 1: Worktree path exists on disk ────────────────
483
+ if (!existsSync(worktreePath)) {
484
+ throw new WorktreeError(
485
+ "WORKTREE_NOT_FOUND",
486
+ `Worktree path "${worktreePath}" does not exist on disk. ` +
487
+ `It may have been removed externally.`,
488
+ );
489
+ }
490
+
491
+ // ── Pre-check 2: Path is a registered git worktree ───────────
492
+ if (!isRegisteredWorktree(worktreePath, repoRoot)) {
493
+ throw new WorktreeError(
494
+ "WORKTREE_NOT_REGISTERED",
495
+ `Path "${worktreePath}" exists but is not a registered git worktree. ` +
496
+ `It may have been removed from git tracking. Check: git worktree list`,
497
+ );
498
+ }
499
+
500
+ // ── Pre-check 3: Target branch resolves ──────────────────────
501
+ const targetCheck = runGit(
502
+ ["rev-parse", "--verify", `refs/heads/${targetBranch}`],
503
+ repoRoot,
504
+ );
505
+ if (!targetCheck.ok) {
506
+ throw new WorktreeError(
507
+ "WORKTREE_INVALID_BASE",
508
+ `Target branch "${targetBranch}" does not exist locally. ` +
509
+ `Verify the branch exists: git branch --list ${targetBranch}`,
510
+ );
511
+ }
512
+ const targetCommit = targetCheck.stdout.trim();
513
+
514
+ // ── Pre-check 4: Working tree is clean ───────────────────────
515
+ const statusCheck = runGit(["status", "--porcelain"], worktreePath);
516
+ if (!statusCheck.ok) {
517
+ throw new WorktreeError(
518
+ "WORKTREE_GIT_ERROR",
519
+ `Failed to check working tree status in "${worktreePath}": ${statusCheck.stderr}`,
520
+ );
521
+ }
522
+ if (statusCheck.stdout.length > 0) {
523
+ throw new WorktreeError(
524
+ "WORKTREE_DIRTY",
525
+ `Worktree at "${worktreePath}" has uncommitted changes. ` +
526
+ `Workers must commit or discard all changes before a reset can proceed. ` +
527
+ `Dirty files:\n${statusCheck.stdout}`,
528
+ );
529
+ }
530
+
531
+ // ── Reset: git checkout -B <laneBranch> <targetBranch> ───────
532
+ const resetResult = runGit(
533
+ ["checkout", "-B", branch, targetBranch],
534
+ worktreePath,
535
+ );
536
+ if (!resetResult.ok) {
537
+ throw new WorktreeError(
538
+ "WORKTREE_RESET_FAILED",
539
+ `Failed to reset worktree at "${worktreePath}" ` +
540
+ `(branch "${branch}" → "${targetBranch}"): ${resetResult.stderr}`,
541
+ );
542
+ }
543
+
544
+ // ── Post-reset verification ──────────────────────────────────
545
+ // Verify 1: Current branch equals expected lane branch
546
+ const headBranchResult = runGit(
547
+ ["rev-parse", "--abbrev-ref", "HEAD"],
548
+ worktreePath,
549
+ );
550
+ if (!headBranchResult.ok || headBranchResult.stdout !== branch) {
551
+ throw new WorktreeError(
552
+ "WORKTREE_VERIFY_FAILED",
553
+ `Post-reset verification failed: expected branch "${branch}" ` +
554
+ `checked out, but got "${headBranchResult.stdout || "(unknown)"}".`,
555
+ );
556
+ }
557
+
558
+ // Verify 2: HEAD equals targetBranch commit
559
+ const headCommitResult = runGit(["rev-parse", "HEAD"], worktreePath);
560
+ if (!headCommitResult.ok || headCommitResult.stdout !== targetCommit) {
561
+ throw new WorktreeError(
562
+ "WORKTREE_VERIFY_FAILED",
563
+ `Post-reset verification failed: worktree HEAD ` +
564
+ `(${headCommitResult.stdout?.slice(0, 8) || "?"}) does not match ` +
565
+ `target "${targetBranch}" commit (${targetCommit.slice(0, 8)}).`,
566
+ );
567
+ }
568
+
569
+ // Return updated WorktreeInfo (branch and laneNumber preserved)
570
+ return {
571
+ path: resolve(worktreePath),
572
+ branch,
573
+ laneNumber,
574
+ };
575
+ }
576
+
577
+ /**
578
+ * Sleep for a given number of milliseconds (synchronous busy-wait).
579
+ *
580
+ * Uses execSync("ping") on Windows / ("sleep") on Unix as a synchronous
581
+ * sleep mechanism since this module uses synchronous git operations.
582
+ * The busy-wait is acceptable because retry waits are bounded (max 16s)
583
+ * and this function is only called during cleanup, not hot paths.
584
+ *
585
+ * @param ms - Milliseconds to sleep
586
+ */
587
+ export function sleepSync(ms: number): void {
588
+ const seconds = Math.ceil(ms / 1000);
589
+ try {
590
+ // Cross-platform synchronous sleep
591
+ if (process.platform === "win32") {
592
+ execSync(`ping -n ${seconds + 1} 127.0.0.1 > nul`, { stdio: "ignore", timeout: ms + 5000 });
593
+ } else {
594
+ execSync(`sleep ${seconds}`, { stdio: "ignore", timeout: ms + 5000 });
595
+ }
596
+ } catch {
597
+ // Timeout or error — acceptable, we just needed a delay
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Async sleep for a given number of milliseconds.
603
+ *
604
+ * Unlike `sleepSync`, this yields the event loop so that other async work
605
+ * (supervisor heartbeats, user input, dashboard updates) can proceed while
606
+ * waiting. Use this in async code paths such as merge polling.
607
+ *
608
+ * @param ms - Milliseconds to sleep
609
+ */
610
+ export function sleepAsync(ms: number): Promise<void> {
611
+ return new Promise((resolve) => setTimeout(resolve, ms));
612
+ }
613
+
614
+ /**
615
+ * Determine if a git worktree remove error is retriable.
616
+ *
617
+ * Retriable errors are typically filesystem/lock issues on Windows
618
+ * where another process (antivirus, IDE, explorer) holds file handles.
619
+ *
620
+ * Terminal (non-retriable) errors are git usage errors like
621
+ * "not a valid worktree" or missing arguments.
622
+ *
623
+ * @param stderr - Error output from git worktree remove
624
+ * @returns true if the error is likely transient and worth retrying
625
+ */
626
+ export function isRetriableRemoveError(stderr: string): boolean {
627
+ const lower = stderr.toLowerCase();
628
+ // Windows file locking patterns
629
+ if (lower.includes("cannot lock") || lower.includes("unable to access")) return true;
630
+ if (lower.includes("permission denied")) return true;
631
+ if (lower.includes("device or resource busy")) return true;
632
+ if (lower.includes("the process cannot access")) return true;
633
+ if (lower.includes("used by another process")) return true;
634
+ if (lower.includes("directory not empty")) return true;
635
+ if (lower.includes("failed to remove")) return true;
636
+ // Generic I/O errors that may be transient
637
+ if (lower.includes("i/o error")) return true;
638
+ if (lower.includes("input/output error")) return true;
639
+ return false;
640
+ }
641
+
642
+ /**
643
+ * Remove a git worktree and clean up its associated branch.
644
+ *
645
+ * Executes `git worktree remove --force <path>` from the main repository
646
+ * root, then handles branch cleanup based on merge status.
647
+ *
648
+ * Branch protection (when targetBranch is provided):
649
+ * - If branch has unmerged commits vs targetBranch → preserves as `saved/<branch>`
650
+ * instead of deleting. Returns `{ branchPreserved: true, savedBranch: "saved/..." }`
651
+ * - If fully merged or no new commits → deletes normally
652
+ * - If targetBranch is missing or git error → skips deletion (safe default)
653
+ *
654
+ * Idempotent behavior:
655
+ * - If path is already missing AND branch is already gone → returns
656
+ * `{ removed: false, alreadyRemoved: true, branchDeleted: true }`
657
+ * - If path is already missing BUT branch has unmerged commits → preserves branch,
658
+ * returns `{ removed: false, alreadyRemoved: true, branchPreserved: true }`
659
+ *
660
+ * Retry policy (Windows file locking):
661
+ * - Up to 5 retries with exponential backoff: 1s, 2s, 4s, 8s, 16s
662
+ * - Only retriable errors (filesystem/lock) trigger retries
663
+ * - Terminal git errors (invalid worktree, bad args) fail immediately
664
+ * - Branch deletion is not retried (single attempt)
665
+ *
666
+ * Post-removal verification:
667
+ * - Path no longer exists on disk
668
+ * - Path no longer registered via `git worktree list --porcelain`
669
+ *
670
+ * @param worktree - WorktreeInfo returned by createWorktree()
671
+ * @param repoRoot - Absolute path to the main repository root
672
+ * @param targetBranch - Optional target branch for unmerged commit detection (e.g. "develop")
673
+ * @returns RemoveWorktreeResult with status flags
674
+ * @throws WorktreeError with WORKTREE_REMOVE_RETRY_EXHAUSTED if all retries fail
675
+ * @throws WorktreeError with WORKTREE_REMOVE_FAILED for terminal (non-retriable) errors
676
+ * @throws WorktreeError with WORKTREE_BRANCH_DELETE_FAILED if branch cleanup fails
677
+ */
678
+ export function removeWorktree(
679
+ worktree: WorktreeInfo,
680
+ repoRoot: string,
681
+ targetBranch?: string,
682
+ ): RemoveWorktreeResult {
683
+ const { path: worktreePath, branch } = worktree;
684
+
685
+ const pathExists = existsSync(worktreePath);
686
+ const isRegistered = isRegisteredWorktree(worktreePath, repoRoot);
687
+
688
+ // ── Handle already-removed states ────────────────────────────
689
+ if (!pathExists && !isRegistered) {
690
+ // Path is gone and not registered. Clean up stale branch if any.
691
+ const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
692
+ return {
693
+ removed: false,
694
+ alreadyRemoved: true,
695
+ branchDeleted: branchResult.deleted,
696
+ branchPreserved: branchResult.preserved,
697
+ savedBranch: branchResult.savedBranch,
698
+ unmergedCount: branchResult.unmergedCount,
699
+ };
700
+ }
701
+
702
+ // If path is missing but still registered in git, prune first
703
+ if (!pathExists && isRegistered) {
704
+ // `git worktree prune` removes stale worktree entries
705
+ runGit(["worktree", "prune"], repoRoot);
706
+ const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
707
+ return {
708
+ removed: false,
709
+ alreadyRemoved: true,
710
+ branchDeleted: branchResult.deleted,
711
+ branchPreserved: branchResult.preserved,
712
+ savedBranch: branchResult.savedBranch,
713
+ unmergedCount: branchResult.unmergedCount,
714
+ };
715
+ }
716
+
717
+ // ── Attempt removal with retry/backoff ───────────────────────
718
+ const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 16000];
719
+ const MAX_ATTEMPTS = RETRY_DELAYS_MS.length + 1; // first attempt + retries
720
+
721
+ let lastError = "";
722
+
723
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
724
+ const removeResult = runGit(
725
+ ["worktree", "remove", "--force", worktreePath],
726
+ repoRoot,
727
+ );
728
+
729
+ if (removeResult.ok) {
730
+ // Successful removal — proceed to branch cleanup
731
+ break;
732
+ }
733
+
734
+ lastError = removeResult.stderr;
735
+
736
+ // Check if error is terminal (non-retriable)
737
+ if (!isRetriableRemoveError(lastError)) {
738
+ throw new WorktreeError(
739
+ "WORKTREE_REMOVE_FAILED",
740
+ `Failed to remove worktree at "${worktreePath}" ` +
741
+ `(terminal error, not retried): ${lastError}`,
742
+ );
743
+ }
744
+
745
+ // If we've exhausted all retries, throw
746
+ if (attempt >= MAX_ATTEMPTS) {
747
+ throw new WorktreeError(
748
+ "WORKTREE_REMOVE_RETRY_EXHAUSTED",
749
+ `Failed to remove worktree at "${worktreePath}" after ` +
750
+ `${MAX_ATTEMPTS} attempts. Last error: ${lastError}. ` +
751
+ `This is likely a Windows file locking issue. ` +
752
+ `Close any programs accessing "${worktreePath}" and try again.`,
753
+ );
754
+ }
755
+
756
+ // Wait before retrying (exponential backoff)
757
+ const delayMs = RETRY_DELAYS_MS[attempt - 1];
758
+ sleepSync(delayMs);
759
+ }
760
+
761
+ // ── Post-removal verification ────────────────────────────────
762
+ if (existsSync(worktreePath)) {
763
+ throw new WorktreeError(
764
+ "WORKTREE_VERIFY_FAILED",
765
+ `Post-removal verification failed: path "${worktreePath}" ` +
766
+ `still exists on disk after successful git worktree remove.`,
767
+ );
768
+ }
769
+
770
+ if (isRegisteredWorktree(worktreePath, repoRoot)) {
771
+ // Try pruning stale entries
772
+ runGit(["worktree", "prune"], repoRoot);
773
+ if (isRegisteredWorktree(worktreePath, repoRoot)) {
774
+ throw new WorktreeError(
775
+ "WORKTREE_VERIFY_FAILED",
776
+ `Post-removal verification failed: path "${worktreePath}" ` +
777
+ `is still registered as a git worktree after removal and prune.`,
778
+ );
779
+ }
780
+ }
781
+
782
+ // ── Branch cleanup (single attempt, fail loud if still present) ─
783
+ const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
784
+
785
+ return {
786
+ removed: true,
787
+ alreadyRemoved: false,
788
+ branchDeleted: branchResult.deleted,
789
+ branchPreserved: branchResult.preserved,
790
+ savedBranch: branchResult.savedBranch,
791
+ unmergedCount: branchResult.unmergedCount,
792
+ };
793
+ }
794
+
795
+ /**
796
+ * Result of ensureBranchDeleted — either deleted or preserved.
797
+ */
798
+ export interface EnsureBranchDeletedResult {
799
+ /** Whether the branch was deleted */
800
+ deleted: boolean;
801
+ /** Whether the branch was preserved (unmerged commits) */
802
+ preserved: boolean;
803
+ /** Saved branch name (if preserved) */
804
+ savedBranch?: string;
805
+ /** Number of unmerged commits (if preserved) */
806
+ unmergedCount?: number;
807
+ }
808
+
809
+ /**
810
+ * Ensure a lane branch is deleted — or preserved if it has unmerged commits.
811
+ *
812
+ * When `targetBranch` is provided, checks for unmerged commits first:
813
+ * - If unmerged: preserves via `saved/<branch>` ref instead of deleting
814
+ * - If fully merged or no unmerged: deletes normally
815
+ *
816
+ * When `targetBranch` is omitted (backward compat), deletes unconditionally
817
+ * using deleteBranchBestEffort() with the original fail-loud semantics.
818
+ *
819
+ * Upgrades a persistent deletion failure into a hard WorktreeError so
820
+ * callers cannot silently proceed with stale lane branches.
821
+ */
822
+ export function ensureBranchDeleted(
823
+ branch: string,
824
+ repoRoot: string,
825
+ worktreePath: string,
826
+ targetBranch?: string,
827
+ ): EnsureBranchDeletedResult {
828
+ // If targetBranch provided, check for unmerged commits before deleting
829
+ if (targetBranch) {
830
+ const preserveResult = preserveBranch(branch, targetBranch, repoRoot);
831
+
832
+ switch (preserveResult.action) {
833
+ case "preserved":
834
+ case "already-preserved": {
835
+ // Branch had unmerged commits — saved ref exists, now delete the original
836
+ // This implements rename semantics: create saved + delete original
837
+ const sourceDeleted = deleteBranchBestEffort(branch, repoRoot);
838
+ return {
839
+ deleted: sourceDeleted,
840
+ preserved: true,
841
+ savedBranch: preserveResult.savedBranch,
842
+ unmergedCount: preserveResult.unmergedCount,
843
+ };
844
+ }
845
+
846
+ case "fully-merged":
847
+ case "no-branch":
848
+ // Safe to delete — fall through to deletion below
849
+ break;
850
+
851
+ case "error":
852
+ // Preservation check failed — log but still try to preserve by skipping deletion
853
+ // This is the safe default: don't delete if we can't verify merge status
854
+ return {
855
+ deleted: false,
856
+ preserved: false,
857
+ };
858
+ }
859
+ }
860
+
861
+ // No unmerged commits (or no targetBranch) — delete normally
862
+ const branchDeleted = deleteBranchBestEffort(branch, repoRoot);
863
+ if (!branchDeleted) {
864
+ throw new WorktreeError(
865
+ "WORKTREE_BRANCH_DELETE_FAILED",
866
+ `Worktree "${worktreePath}" was removed, but failed to delete lane branch ` +
867
+ `"${branch}". Delete it manually: git branch -D ${branch}`,
868
+ );
869
+ }
870
+ return { deleted: true, preserved: false };
871
+ }
872
+
873
+ /**
874
+ * Delete a branch with best-effort semantics.
875
+ *
876
+ * Uses `git branch -D` (force delete) since lane branches are ephemeral
877
+ * and may not have been merged anywhere.
878
+ *
879
+ * "Branch not found" is treated as idempotent success (returns true).
880
+ *
881
+ * @param branch - Branch name to delete
882
+ * @param repoRoot - Repository root directory
883
+ * @returns true if branch was deleted or was already absent
884
+ */
885
+ export function deleteBranchBestEffort(branch: string, repoRoot: string): boolean {
886
+ // Check if branch exists first
887
+ const branchCheck = runGit(
888
+ ["rev-parse", "--verify", `refs/heads/${branch}`],
889
+ repoRoot,
890
+ );
891
+
892
+ if (!branchCheck.ok) {
893
+ // Branch doesn't exist — idempotent success
894
+ return true;
895
+ }
896
+
897
+ // Force delete (lane branches are ephemeral, may not be merged)
898
+ const deleteResult = runGit(["branch", "-D", branch], repoRoot);
899
+
900
+ if (deleteResult.ok) {
901
+ return true;
902
+ }
903
+
904
+ // If delete failed but branch is now gone (race condition), treat as success
905
+ const recheckResult = runGit(
906
+ ["rev-parse", "--verify", `refs/heads/${branch}`],
907
+ repoRoot,
908
+ );
909
+ if (!recheckResult.ok) {
910
+ return true;
911
+ }
912
+
913
+ // Branch still exists and delete failed — return false
914
+ return false;
915
+ }
916
+
917
+
918
+ // ── Branch Protection Helpers ────────────────────────────────────────
919
+
920
+ /** Typed error codes for unmerged commit checks */
921
+ export type UnmergedCommitsErrorCode =
922
+ | "BRANCH_NOT_FOUND"
923
+ | "TARGET_BRANCH_MISSING"
924
+ | "UNMERGED_COUNT_FAILED"
925
+ | "UNMERGED_COUNT_PARSE_FAILED";
926
+
927
+ /**
928
+ * Result of checking for unmerged commits on a branch.
929
+ */
930
+ export interface UnmergedCommitsResult {
931
+ /** Whether the check succeeded (git command ran without error) */
932
+ ok: boolean;
933
+ /** Number of commits on `branch` not reachable from `targetBranch` */
934
+ count: number;
935
+ /** Typed error code if check failed */
936
+ code?: UnmergedCommitsErrorCode;
937
+ /** Error message if check failed */
938
+ error?: string;
939
+ }
940
+
941
+ /**
942
+ * Check if a branch has commits not reachable from a target branch.
943
+ *
944
+ * Uses `git rev-list --count <targetBranch>..<branch>` which is
945
+ * Windows-safe (no shell pipes). Returns the count of unmerged commits.
946
+ *
947
+ * Pure logic with git dependency — designed so the git call can be
948
+ * tested in integration tests with real repos, while the decision
949
+ * logic is tested via the count result.
950
+ *
951
+ * @param branch - Branch to check for unmerged commits
952
+ * @param targetBranch - Target branch to compare against (e.g. "develop")
953
+ * @param repoRoot - Repository root directory
954
+ * @returns UnmergedCommitsResult with count and status
955
+ */
956
+ export function hasUnmergedCommits(
957
+ branch: string,
958
+ targetBranch: string,
959
+ repoRoot: string,
960
+ ): UnmergedCommitsResult {
961
+ // Verify branch exists
962
+ const branchCheck = runGit(
963
+ ["rev-parse", "--verify", `refs/heads/${branch}`],
964
+ repoRoot,
965
+ );
966
+ if (!branchCheck.ok) {
967
+ return { ok: false, count: 0, code: "BRANCH_NOT_FOUND", error: `Branch "${branch}" does not exist` };
968
+ }
969
+
970
+ // Verify target branch exists
971
+ const targetCheck = runGit(
972
+ ["rev-parse", "--verify", `refs/heads/${targetBranch}`],
973
+ repoRoot,
974
+ );
975
+ if (!targetCheck.ok) {
976
+ return { ok: false, count: 0, code: "TARGET_BRANCH_MISSING", error: `Target branch "${targetBranch}" does not exist` };
977
+ }
978
+
979
+ // Count commits on branch not reachable from target
980
+ const countResult = runGit(
981
+ ["rev-list", "--count", `${targetBranch}..${branch}`],
982
+ repoRoot,
983
+ );
984
+ if (!countResult.ok) {
985
+ return { ok: false, count: 0, code: "UNMERGED_COUNT_FAILED", error: `Failed to count unmerged commits: ${countResult.stderr}` };
986
+ }
987
+
988
+ const count = parseInt(countResult.stdout.trim(), 10);
989
+ if (isNaN(count)) {
990
+ return { ok: false, count: 0, code: "UNMERGED_COUNT_PARSE_FAILED", error: `Failed to parse commit count: "${countResult.stdout}"` };
991
+ }
992
+
993
+ return { ok: true, count };
994
+ }
995
+
996
+ /**
997
+ * Compute the saved branch name for a given original branch.
998
+ *
999
+ * Pure function — no side effects. Maps a branch name to its saved
1000
+ * counterpart under the `saved/` namespace.
1001
+ *
1002
+ * Examples:
1003
+ * "task/lane-1-20260308T111750" → "saved/task/lane-1-20260308T111750"
1004
+ * "feature/my-branch" → "saved/feature/my-branch"
1005
+ *
1006
+ * @param originalBranch - The branch name to compute a saved name for
1007
+ * @returns The saved branch name (always prefixed with "saved/")
1008
+ */
1009
+ export function computeSavedBranchName(originalBranch: string): string {
1010
+ return `saved/${originalBranch}`;
1011
+ }
1012
+
1013
+ /**
1014
+ * Result of saved branch collision resolution.
1015
+ */
1016
+ export interface SavedBranchResolution {
1017
+ /** The action to take */
1018
+ action: "create" | "keep-existing" | "create-suffixed";
1019
+ /** The final saved branch name to use */
1020
+ savedName: string;
1021
+ }
1022
+
1023
+ /**
1024
+ * Resolve a collision when a saved branch name already exists.
1025
+ *
1026
+ * Decision table:
1027
+ * - saved ref absent → action: "create", use savedName
1028
+ * - saved ref exists, same SHA → action: "keep-existing", use existing savedName
1029
+ * - saved ref exists, different SHA → action: "create-suffixed", append timestamp
1030
+ *
1031
+ * Pure function — no side effects. All git state is passed in as parameters.
1032
+ *
1033
+ * @param savedName - The desired saved branch name (e.g. "saved/task/lane-1-...")
1034
+ * @param existingSHA - SHA of existing saved branch (empty string if absent)
1035
+ * @param newSHA - SHA of the branch being preserved
1036
+ * @param timestamp - ISO timestamp for suffix (injectable for testability)
1037
+ * @returns SavedBranchResolution with action and final name
1038
+ */
1039
+ export function resolveSavedBranchCollision(
1040
+ savedName: string,
1041
+ existingSHA: string,
1042
+ newSHA: string,
1043
+ timestamp?: string,
1044
+ ): SavedBranchResolution {
1045
+ // Saved ref doesn't exist — create it
1046
+ if (!existingSHA) {
1047
+ return { action: "create", savedName };
1048
+ }
1049
+
1050
+ // Same SHA — no-op, keep existing
1051
+ if (existingSHA === newSHA) {
1052
+ return { action: "keep-existing", savedName };
1053
+ }
1054
+
1055
+ // Different SHA — create with timestamp suffix
1056
+ const ts = timestamp || new Date().toISOString().replace(/[:.]/g, "-");
1057
+ return { action: "create-suffixed", savedName: `${savedName}-${ts}` };
1058
+ }
1059
+
1060
+ /** Typed error codes for branch preservation */
1061
+ export type PreserveBranchErrorCode =
1062
+ | "TARGET_BRANCH_MISSING"
1063
+ | "UNMERGED_COUNT_FAILED"
1064
+ | "SAVED_BRANCH_CREATE_FAILED"
1065
+ | "UNKNOWN_RESOLUTION";
1066
+
1067
+ /**
1068
+ * Result of a branch preservation attempt.
1069
+ */
1070
+ export interface PreserveBranchResult {
1071
+ /** Whether the branch was preserved (or was already preserved / fully merged) */
1072
+ ok: boolean;
1073
+ /** What action was taken */
1074
+ action: "preserved" | "already-preserved" | "fully-merged" | "no-branch" | "error";
1075
+ /** The saved branch name (if preserved) */
1076
+ savedBranch?: string;
1077
+ /** Number of unmerged commits (if checked) */
1078
+ unmergedCount?: number;
1079
+ /** Typed error code (if action is "error") */
1080
+ code?: PreserveBranchErrorCode;
1081
+ /** Error message (if action is "error") */
1082
+ error?: string;
1083
+ }
1084
+
1085
+ /**
1086
+ * Preserve a branch by creating a saved ref if it has unmerged commits.
1087
+ *
1088
+ * Orchestrates: hasUnmergedCommits → computeSavedBranchName →
1089
+ * resolveSavedBranchCollision → git branch create/rename.
1090
+ *
1091
+ * Idempotent: if the saved ref already exists at the same SHA, it's a no-op.
1092
+ * If the target branch doesn't exist, logs warning and returns gracefully.
1093
+ *
1094
+ * @param branch - Branch to check and potentially preserve
1095
+ * @param targetBranch - Target branch to compare against (e.g. "develop")
1096
+ * @param repoRoot - Repository root directory
1097
+ * @returns PreserveBranchResult describing what was done
1098
+ */
1099
+ export function preserveBranch(
1100
+ branch: string,
1101
+ targetBranch: string,
1102
+ repoRoot: string,
1103
+ ): PreserveBranchResult {
1104
+ // Check if branch exists
1105
+ const branchCheck = runGit(
1106
+ ["rev-parse", "--verify", `refs/heads/${branch}`],
1107
+ repoRoot,
1108
+ );
1109
+ if (!branchCheck.ok) {
1110
+ return { ok: true, action: "no-branch" };
1111
+ }
1112
+ const branchSHA = branchCheck.stdout.trim();
1113
+
1114
+ // Check for unmerged commits
1115
+ const unmergedResult = hasUnmergedCommits(branch, targetBranch, repoRoot);
1116
+ if (!unmergedResult.ok) {
1117
+ // Target branch missing or git error — skip preservation gracefully
1118
+ // Map unmerged error codes to preserve error codes
1119
+ const preserveCode: PreserveBranchErrorCode =
1120
+ unmergedResult.code === "TARGET_BRANCH_MISSING" ? "TARGET_BRANCH_MISSING" : "UNMERGED_COUNT_FAILED";
1121
+ return {
1122
+ ok: false,
1123
+ action: "error",
1124
+ code: preserveCode,
1125
+ error: unmergedResult.error,
1126
+ };
1127
+ }
1128
+
1129
+ if (unmergedResult.count === 0) {
1130
+ return { ok: true, action: "fully-merged", unmergedCount: 0 };
1131
+ }
1132
+
1133
+ // Branch has unmerged commits — compute saved name
1134
+ const savedName = computeSavedBranchName(branch);
1135
+
1136
+ // Check for collision
1137
+ const existingCheck = runGit(
1138
+ ["rev-parse", "--verify", `refs/heads/${savedName}`],
1139
+ repoRoot,
1140
+ );
1141
+ const existingSHA = existingCheck.ok ? existingCheck.stdout.trim() : "";
1142
+
1143
+ const resolution = resolveSavedBranchCollision(savedName, existingSHA, branchSHA);
1144
+
1145
+ switch (resolution.action) {
1146
+ case "keep-existing":
1147
+ return {
1148
+ ok: true,
1149
+ action: "already-preserved",
1150
+ savedBranch: resolution.savedName,
1151
+ unmergedCount: unmergedResult.count,
1152
+ };
1153
+
1154
+ case "create":
1155
+ case "create-suffixed": {
1156
+ // Create saved branch at same SHA
1157
+ const createResult = runGit(
1158
+ ["branch", resolution.savedName, branchSHA],
1159
+ repoRoot,
1160
+ );
1161
+ if (!createResult.ok) {
1162
+ return {
1163
+ ok: false,
1164
+ action: "error",
1165
+ code: "SAVED_BRANCH_CREATE_FAILED",
1166
+ error: `Failed to create saved branch "${resolution.savedName}": ${createResult.stderr}`,
1167
+ unmergedCount: unmergedResult.count,
1168
+ };
1169
+ }
1170
+ return {
1171
+ ok: true,
1172
+ action: "preserved",
1173
+ savedBranch: resolution.savedName,
1174
+ unmergedCount: unmergedResult.count,
1175
+ };
1176
+ }
1177
+
1178
+ default:
1179
+ return { ok: false, action: "error", code: "UNKNOWN_RESOLUTION", error: `Unknown resolution action` };
1180
+ }
1181
+ }
1182
+
1183
+
1184
+ // ── Bulk Worktree Operations ─────────────────────────────────────────
1185
+
1186
+ /**
1187
+ * List all orchestrator worktrees matching a prefix and operator pattern.
1188
+ *
1189
+ * Parses `git worktree list --porcelain` via parseWorktreeList() and filters
1190
+ * entries whose path basename matches `{prefix}-{opId}-{N}` (where N is a number).
1191
+ *
1192
+ * **Batch-scoped discovery:** When `batchId` is provided, only returns worktrees
1193
+ * inside the specific batch container `{opId}-{batchId}/lane-{N}`. This prevents
1194
+ * cross-batch interference when the same operator runs concurrent batches.
1195
+ *
1196
+ * **Operator-scoped discovery:** When `batchId` is omitted, returns ALL worktrees
1197
+ * belonging to the operator (across all batches). This supports cleanup scenarios
1198
+ * that need to discover all operator worktrees regardless of batch.
1199
+ *
1200
+ * For backward compatibility, also matches the legacy flat pattern `{prefix}-{opId}-{N}`
1201
+ * and (when opId is "op") `{prefix}-{N}`. This supports transition from old naming.
1202
+ *
1203
+ * Lane number is extracted from the path basename pattern. Entries with
1204
+ * malformed/partial data (missing path, unparseable lane number) are
1205
+ * silently skipped — they are not orchestrator worktrees.
1206
+ *
1207
+ * @param prefix - Worktree directory prefix (e.g. "taskplane-wt")
1208
+ * @param repoRoot - Absolute path to the main repository root
1209
+ * @param opId - Operator identifier for scoping (e.g., "henrylach")
1210
+ * @param batchId - Optional batch ID for batch-scoped filtering; when provided,
1211
+ * only returns worktrees inside the `{opId}-{batchId}/` container
1212
+ * @returns - WorktreeInfo[] sorted by laneNumber (ascending)
1213
+ */
1214
+ export function listWorktrees(prefix: string, repoRoot: string, opId: string, batchId?: string): WorktreeInfo[] {
1215
+ const entries = parseWorktreeList(repoRoot);
1216
+ const results: WorktreeInfo[] = [];
1217
+
1218
+ // ── Legacy flat patterns ─────────────────────────────────────
1219
+ // Primary pattern: {prefix}-{opId}-{N}
1220
+ // Example: "taskplane-wt-henrylach-1"
1221
+ const primaryPattern = new RegExp(`^${escapeRegex(prefix)}-${escapeRegex(opId)}-(\\d+)$`);
1222
+
1223
+ // Legacy pattern: {prefix}-{N} (only matched when opId is the default fallback)
1224
+ // This allows cleanup of worktrees from prior batches without operator IDs.
1225
+ const legacyPattern = opId === "op"
1226
+ ? new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`)
1227
+ : null;
1228
+
1229
+ // ── New batch-scoped nested pattern ──────────────────────────
1230
+ // Basename: lane-{N}
1231
+ // Parent directory: {opId}-{batchId} (e.g., "henrylach-20260308T111750")
1232
+ // Full: {basePath}/{opId}-{batchId}/lane-{N}
1233
+ const nestedLanePattern = /^lane-(\d+)$/;
1234
+ // When batchId is provided, match only the exact container for batch isolation.
1235
+ // When omitted, match any container belonging to this operator (all batches).
1236
+ const containerPattern = batchId
1237
+ ? new RegExp(`^${escapeRegex(generateBatchContainerName(opId, batchId))}$`)
1238
+ : new RegExp(`^${escapeRegex(opId)}-\\S+$`);
1239
+
1240
+ for (const entry of entries) {
1241
+ if (!entry.path) continue;
1242
+
1243
+ const resolvedPath = resolve(entry.path);
1244
+ const entryBasename = basename(resolvedPath);
1245
+
1246
+ // ── Try new nested pattern first ─────────────────────────
1247
+ const nestedMatch = entryBasename.match(nestedLanePattern);
1248
+ if (nestedMatch) {
1249
+ // Verify the parent directory matches the container pattern
1250
+ const parentDir = basename(resolve(resolvedPath, ".."));
1251
+ if (containerPattern.test(parentDir)) {
1252
+ const laneNumber = parseInt(nestedMatch[1], 10);
1253
+ if (!isNaN(laneNumber) && laneNumber >= 1) {
1254
+ results.push({
1255
+ path: resolvedPath,
1256
+ branch: entry.branch || "",
1257
+ laneNumber,
1258
+ });
1259
+ continue;
1260
+ }
1261
+ }
1262
+ }
1263
+
1264
+ // ── Try legacy flat patterns (only when not batch-scoped) ─
1265
+ // When batchId is provided, skip legacy matching — the caller
1266
+ // explicitly wants only this batch's worktrees.
1267
+ if (!batchId) {
1268
+ let match = entryBasename.match(primaryPattern);
1269
+ if (!match && legacyPattern) {
1270
+ match = entryBasename.match(legacyPattern);
1271
+ }
1272
+ if (match) {
1273
+ const laneNumber = parseInt(match[1], 10);
1274
+ if (!isNaN(laneNumber) && laneNumber >= 1) {
1275
+ results.push({
1276
+ path: resolvedPath,
1277
+ branch: entry.branch || "",
1278
+ laneNumber,
1279
+ });
1280
+ }
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ // Sort by laneNumber ascending (deterministic output)
1286
+ results.sort((a, b) => a.laneNumber - b.laneNumber);
1287
+
1288
+ return results;
1289
+ }
1290
+
1291
+ /**
1292
+ * Escape special regex characters in a string for safe use in RegExp constructor.
1293
+ */
1294
+ export function escapeRegex(str: string): string {
1295
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1296
+ }
1297
+
1298
+ /**
1299
+ * Create multiple lane worktrees in a single batch.
1300
+ *
1301
+ * Creates `count` worktrees sequentially (lanes 1..count). Git worktree
1302
+ * operations are not safe to parallelize (shared lock file), so sequential
1303
+ * creation is the correct approach.
1304
+ *
1305
+ * Partial failure rollback:
1306
+ * - If lane K fails after lanes 1..(K-1) succeeded, ALL previously-created
1307
+ * worktrees are rolled back via removeWorktree().
1308
+ * - Rollback is best-effort: individual rollback failures are collected in
1309
+ * `rollbackErrors` but do not prevent other rollbacks from proceeding.
1310
+ * - On successful rollback, `worktrees` is empty (clean slate).
1311
+ *
1312
+ * @param count - Number of worktrees to create (1-indexed: lane 1..count)
1313
+ * @param batchId - Batch ID timestamp for branch naming
1314
+ * @param config - Orchestrator config (prefix extracted from it)
1315
+ * @param repoRoot - Absolute path to the main repository root
1316
+ * @param baseBranch - Branch to base worktrees on (captured at batch start)
1317
+ * @param opId - Operator identifier for collision-resistant naming
1318
+ * @returns - CreateLaneWorktreesResult with success flag and details
1319
+ */
1320
+ export function createLaneWorktrees(
1321
+ count: number,
1322
+ batchId: string,
1323
+ config: OrchestratorConfig,
1324
+ repoRoot: string,
1325
+ baseBranch: string,
1326
+ ): CreateLaneWorktreesResult {
1327
+ const prefix = config.orchestrator.worktree_prefix;
1328
+ const opId = resolveOperatorId(config);
1329
+ const created: WorktreeInfo[] = [];
1330
+ const errors: BulkWorktreeError[] = [];
1331
+
1332
+ for (let lane = 1; lane <= count; lane++) {
1333
+ try {
1334
+ const wt = createWorktree(
1335
+ { laneNumber: lane, batchId, baseBranch, prefix, opId, config },
1336
+ repoRoot,
1337
+ );
1338
+ created.push(wt);
1339
+ } catch (err: unknown) {
1340
+ const wtErr = err instanceof WorktreeError ? err : null;
1341
+ errors.push({
1342
+ laneNumber: lane,
1343
+ code: wtErr?.code || "UNKNOWN",
1344
+ message: wtErr?.message || String(err),
1345
+ });
1346
+
1347
+ // Rollback all previously-created worktrees
1348
+ const rollbackErrors: BulkWorktreeError[] = [];
1349
+ for (const wt of created) {
1350
+ try {
1351
+ removeWorktree(wt, repoRoot);
1352
+ } catch (rbErr: unknown) {
1353
+ const rbWtErr = rbErr instanceof WorktreeError ? rbErr : null;
1354
+ rollbackErrors.push({
1355
+ laneNumber: wt.laneNumber,
1356
+ code: rbWtErr?.code || "UNKNOWN",
1357
+ message: rbWtErr?.message || String(rbErr),
1358
+ });
1359
+ }
1360
+ }
1361
+
1362
+ return {
1363
+ success: false,
1364
+ worktrees: [],
1365
+ errors,
1366
+ rolledBack: rollbackErrors.length === 0,
1367
+ rollbackErrors,
1368
+ };
1369
+ }
1370
+ }
1371
+
1372
+ // All created successfully
1373
+ // Sort by laneNumber (should already be in order, but enforce)
1374
+ created.sort((a, b) => a.laneNumber - b.laneNumber);
1375
+
1376
+ return {
1377
+ success: true,
1378
+ worktrees: created,
1379
+ errors: [],
1380
+ rolledBack: false,
1381
+ rollbackErrors: [],
1382
+ };
1383
+ }
1384
+
1385
+ /**
1386
+ * Ensure required lane worktrees exist for the current wave.
1387
+ *
1388
+ * Reuses existing worktrees when present (multi-wave behavior), resetting
1389
+ * them to the base branch HEAD before use, and only creates missing lanes.
1390
+ * If creation of a missing lane fails, newly-created lanes in this call are
1391
+ * rolled back.
1392
+ *
1393
+ * This prevents wave 2+ allocation from failing on WORKTREE_PATH_IS_WORKTREE
1394
+ * while still supporting wave growth (e.g., 1 lane in wave 1, 3 lanes in wave 2).
1395
+ */
1396
+ export function ensureLaneWorktrees(
1397
+ laneNumbers: number[],
1398
+ batchId: string,
1399
+ config: OrchestratorConfig,
1400
+ repoRoot: string,
1401
+ baseBranch: string,
1402
+ ): CreateLaneWorktreesResult {
1403
+ const prefix = config.orchestrator.worktree_prefix;
1404
+ const opId = resolveOperatorId(config);
1405
+
1406
+ const existing = listWorktrees(prefix, repoRoot, opId, batchId);
1407
+ const existingByLane = new Map<number, WorktreeInfo>();
1408
+ for (const wt of existing) {
1409
+ existingByLane.set(wt.laneNumber, wt);
1410
+ }
1411
+
1412
+ const needed = [...new Set(laneNumbers)].sort((a, b) => a - b);
1413
+ const selected: WorktreeInfo[] = [];
1414
+ const createdNow: WorktreeInfo[] = [];
1415
+ const errors: BulkWorktreeError[] = [];
1416
+
1417
+ for (const lane of needed) {
1418
+ const reused = existingByLane.get(lane);
1419
+ if (reused) {
1420
+ // Reused worktrees must be reset to base branch HEAD before use.
1421
+ // This covers normal multi-wave reuse and stale leftovers from prior batches.
1422
+ const resetResult = safeResetWorktree(reused, baseBranch, repoRoot);
1423
+ if (resetResult.success) {
1424
+ selected.push(reused);
1425
+ continue;
1426
+ }
1427
+
1428
+ // Reset failed: remove and recreate this lane worktree.
1429
+ try {
1430
+ removeWorktree(reused, repoRoot);
1431
+ } catch {
1432
+ // Best effort — creation below may still fail with a clear error.
1433
+ }
1434
+ }
1435
+
1436
+ try {
1437
+ const wt = createWorktree(
1438
+ { laneNumber: lane, batchId, baseBranch, prefix, opId, config },
1439
+ repoRoot,
1440
+ );
1441
+ createdNow.push(wt);
1442
+ selected.push(wt);
1443
+ } catch (err: unknown) {
1444
+ const wtErr = err instanceof WorktreeError ? err : null;
1445
+ errors.push({
1446
+ laneNumber: lane,
1447
+ code: wtErr?.code || "UNKNOWN",
1448
+ message: wtErr?.message || String(err),
1449
+ });
1450
+
1451
+ const rollbackErrors: BulkWorktreeError[] = [];
1452
+ for (const wt of createdNow) {
1453
+ try {
1454
+ removeWorktree(wt, repoRoot);
1455
+ } catch (rbErr: unknown) {
1456
+ const rbWtErr = rbErr instanceof WorktreeError ? rbErr : null;
1457
+ rollbackErrors.push({
1458
+ laneNumber: wt.laneNumber,
1459
+ code: rbWtErr?.code || "UNKNOWN",
1460
+ message: rbWtErr?.message || String(rbErr),
1461
+ });
1462
+ }
1463
+ }
1464
+
1465
+ return {
1466
+ success: false,
1467
+ worktrees: [],
1468
+ errors,
1469
+ rolledBack: rollbackErrors.length === 0,
1470
+ rollbackErrors,
1471
+ };
1472
+ }
1473
+ }
1474
+
1475
+ selected.sort((a, b) => a.laneNumber - b.laneNumber);
1476
+ return {
1477
+ success: true,
1478
+ worktrees: selected,
1479
+ errors: [],
1480
+ rolledBack: false,
1481
+ rollbackErrors: [],
1482
+ };
1483
+ }
1484
+
1485
+ /**
1486
+ * Remove all orchestrator worktrees matching a prefix and operator scope.
1487
+ *
1488
+ * Uses listWorktrees() to discover matching worktrees (operator-scoped),
1489
+ * then removes each one via removeWorktree(). Best-effort: continues on
1490
+ * per-worktree errors (does not fail-fast).
1491
+ *
1492
+ * When `targetBranch` is provided, branches with unmerged commits are
1493
+ * preserved as `saved/<branch>` refs instead of being force-deleted.
1494
+ *
1495
+ * **Batch-scoped cleanup:** When `batchId` is provided, only removes
1496
+ * worktrees inside the specific batch container `{opId}-{batchId}/`.
1497
+ * After removing all worktrees, attempts to remove the empty container
1498
+ * directory. When `batchId` is omitted, removes all operator worktrees
1499
+ * (all batches, including legacy flat-layout).
1500
+ *
1501
+ * **Container cleanup:** After per-worktree removals, each touched batch
1502
+ * container directory is checked and removed if empty. Non-empty containers
1503
+ * (from partial failures or active worktrees) are left intact.
1504
+ *
1505
+ * @param prefix - Worktree directory prefix (e.g. "taskplane-wt")
1506
+ * @param repoRoot - Absolute path to the main repository root
1507
+ * @param opId - Operator identifier for scoping (e.g., "henrylach")
1508
+ * @param targetBranch - Optional target branch for unmerged commit detection (e.g. "develop")
1509
+ * @param batchId - Optional batch ID for batch-scoped cleanup
1510
+ * @param config - Optional orchestrator config (needed for container path resolution when batchId is provided)
1511
+ * @returns - RemoveAllWorktreesResult with per-worktree outcomes
1512
+ */
1513
+ export function removeAllWorktrees(
1514
+ prefix: string,
1515
+ repoRoot: string,
1516
+ opId: string,
1517
+ targetBranch?: string,
1518
+ batchId?: string,
1519
+ config?: OrchestratorConfig,
1520
+ ): RemoveAllWorktreesResult {
1521
+ const worktrees = listWorktrees(prefix, repoRoot, opId, batchId);
1522
+ const outcomes: RemoveWorktreeOutcome[] = [];
1523
+ const removed: WorktreeInfo[] = [];
1524
+ const failed: RemoveWorktreeOutcome[] = [];
1525
+ const preserved: Array<{ branch: string; savedBranch: string; laneNumber: number; unmergedCount?: number }> = [];
1526
+
1527
+ for (const wt of worktrees) {
1528
+ try {
1529
+ const result = removeWorktree(wt, repoRoot, targetBranch);
1530
+ const outcome: RemoveWorktreeOutcome = {
1531
+ worktree: wt,
1532
+ result,
1533
+ error: null,
1534
+ };
1535
+ outcomes.push(outcome);
1536
+ removed.push(wt);
1537
+
1538
+ // Track preserved branches for caller logging
1539
+ if (result.branchPreserved && result.savedBranch) {
1540
+ preserved.push({
1541
+ branch: wt.branch,
1542
+ savedBranch: result.savedBranch,
1543
+ laneNumber: wt.laneNumber,
1544
+ unmergedCount: result.unmergedCount,
1545
+ });
1546
+ }
1547
+ } catch (err: unknown) {
1548
+ const wtErr = err instanceof WorktreeError ? err : null;
1549
+ const bulkErr: BulkWorktreeError = {
1550
+ laneNumber: wt.laneNumber,
1551
+ code: wtErr?.code || "UNKNOWN",
1552
+ message: wtErr?.message || String(err),
1553
+ };
1554
+ const outcome: RemoveWorktreeOutcome = {
1555
+ worktree: wt,
1556
+ result: null,
1557
+ error: bulkErr,
1558
+ };
1559
+ outcomes.push(outcome);
1560
+ failed.push(outcome);
1561
+ }
1562
+ }
1563
+
1564
+ // ── Container cleanup ────────────────────────────────────────
1565
+ // After removing worktrees, attempt to remove empty batch container
1566
+ // directories. Collect unique container paths from removed worktrees,
1567
+ // then remove each one only if empty (partial failure safety).
1568
+ const containerPaths = new Set<string>();
1569
+ for (const wt of removed) {
1570
+ const parentDir = resolve(wt.path, "..");
1571
+ // Only consider directories that look like batch containers
1572
+ // (i.e., parent is not the base worktree path itself)
1573
+ const parentName = basename(parentDir);
1574
+ if (parentName.startsWith(`${opId}-`)) {
1575
+ containerPaths.add(parentDir);
1576
+ }
1577
+ }
1578
+ // When batchId is explicitly provided, also add the expected container path
1579
+ // even if no worktrees were found (cleanup of empty containers from prior runs)
1580
+ if (batchId && config) {
1581
+ const expectedContainer = generateBatchContainerPath(opId, batchId, repoRoot, config);
1582
+ containerPaths.add(expectedContainer);
1583
+ }
1584
+ for (const containerPath of containerPaths) {
1585
+ removeBatchContainerIfEmpty(containerPath);
1586
+ }
1587
+
1588
+ // TP-029: Remove empty .worktrees/ base directory in subdirectory mode.
1589
+ // In sibling mode the base dir is the repo's parent (e.g., "..") — never remove that.
1590
+ // Only attempt removal when empty (same safety as container cleanup).
1591
+ if (config && config.orchestrator.worktree_location !== "sibling") {
1592
+ const basePath = resolveWorktreeBasePath(repoRoot, config);
1593
+ try {
1594
+ if (existsSync(basePath)) {
1595
+ const entries = readdirSync(basePath);
1596
+ if (entries.length === 0) {
1597
+ rmdirSync(basePath);
1598
+ }
1599
+ }
1600
+ } catch { /* safe default — leave it alone */ }
1601
+ }
1602
+
1603
+ return {
1604
+ totalAttempted: worktrees.length,
1605
+ removed,
1606
+ failed,
1607
+ outcomes,
1608
+ preserved,
1609
+ };
1610
+ }
1611
+
1612
+ /**
1613
+ * Execute a command synchronously and return { ok, stdout }.
1614
+ * Returns ok=false on any error (non-zero exit, command not found, etc.).
1615
+ */
1616
+ /**
1617
+ * Result of an `execCheck` invocation. When `ok === false`, `errorKind`
1618
+ * classifies the failure so callers can surface accurate diagnostics instead
1619
+ * of the historical "binary not found" catch-all.
1620
+ *
1621
+ * @since TP-185
1622
+ */
1623
+ export type ExecCheckResult = {
1624
+ ok: boolean;
1625
+ stdout: string;
1626
+ errorKind?: "not-found" | "timeout" | "exit-code" | "signal" | "unknown";
1627
+ errorDetail?: string;
1628
+ };
1629
+
1630
+ /**
1631
+ * Run a shell command and report whether it succeeded. Used by the orchestrator
1632
+ * preflight to probe `git`, `git worktree`, and `pi`.
1633
+ *
1634
+ * @param command - Full command line (passed to `execSync`).
1635
+ * @param cwd - Optional working directory.
1636
+ * @param timeoutMs - Per-invocation timeout in milliseconds. Defaults to 10s,
1637
+ * which is fine for warm tools but can be tight for cold-start scenarios on
1638
+ * Windows (mise shim + Node bootstrap + AV scan + tool startup). Pass a
1639
+ * larger value (e.g. 30_000) for tools that may pay a cold-start tax.
1640
+ */
1641
+ export function execCheck(command: string, cwd?: string, timeoutMs = 10_000): ExecCheckResult {
1642
+ try {
1643
+ const stdout = execSync(command, {
1644
+ encoding: "utf-8",
1645
+ timeout: timeoutMs,
1646
+ stdio: ["pipe", "pipe", "pipe"],
1647
+ ...(cwd ? { cwd } : {}),
1648
+ }).trim();
1649
+ return { ok: true, stdout };
1650
+ } catch (err: unknown) {
1651
+ // Classify the failure mode so the caller can produce a useful hint.
1652
+ // Node's `execSync` reports failures via:
1653
+ // - `code === 'ENOENT'` → binary not found on PATH (POSIX direct spawn)
1654
+ // - `status === 127` → POSIX shell reported "command not found"
1655
+ // - cmd.exe stderr "not recognized" → Windows shell missing-binary indicator (exit 1)
1656
+ // - `signal === 'SIGTERM'` → timeout fired (Node killed the child)
1657
+ // - `status` is a number != 0 → child exited non-zero on its own
1658
+ // - `signal` is set otherwise → child killed externally
1659
+ // Note: when `execSync`'s `timeout` option fires, the resulting error has
1660
+ // `signal: 'SIGTERM'` AND `errno` populated (the signal-kill errno on the
1661
+ // platform). We attribute SIGTERM to the timeout because `execCheck` is
1662
+ // the one setting the timeout option — there's no other realistic source
1663
+ // of SIGTERM for a short-lived diagnostic command we just spawned.
1664
+ const e = err as { code?: string | number; status?: number | null; signal?: NodeJS.Signals | null; errno?: number; message?: string; path?: string; stderr?: string | Buffer };
1665
+ const stderrText = typeof e?.stderr === "string"
1666
+ ? e.stderr
1667
+ : e?.stderr instanceof Buffer
1668
+ ? e.stderr.toString("utf-8")
1669
+ : "";
1670
+ const commandName = command.split(/\s+/)[0];
1671
+ if (e?.code === "ENOENT") {
1672
+ return { ok: false, stdout: "", errorKind: "not-found", errorDetail: e.path ?? commandName };
1673
+ }
1674
+ if (e?.status === 127) {
1675
+ return { ok: false, stdout: "", errorKind: "not-found", errorDetail: commandName };
1676
+ }
1677
+ // Windows cmd.exe pattern: exit 1 + "is not recognized" in stderr.
1678
+ if (e?.signal !== "SIGTERM" && /is not recognized as an internal or external command|command not found/i.test(stderrText)) {
1679
+ return { ok: false, stdout: "", errorKind: "not-found", errorDetail: commandName };
1680
+ }
1681
+ if (e?.signal === "SIGTERM") {
1682
+ return { ok: false, stdout: "", errorKind: "timeout", errorDetail: `exceeded ${timeoutMs}ms timeout` };
1683
+ }
1684
+ if (typeof e?.status === "number") {
1685
+ return { ok: false, stdout: "", errorKind: "exit-code", errorDetail: `exit ${e.status}` };
1686
+ }
1687
+ if (e?.signal) {
1688
+ return { ok: false, stdout: "", errorKind: "signal", errorDetail: String(e.signal) };
1689
+ }
1690
+ return { ok: false, stdout: "", errorKind: "unknown", errorDetail: e?.message ?? "unknown error" };
1691
+ }
1692
+ }
1693
+
1694
+ /**
1695
+ * Parse a version string like "git version 2.43.0.windows.1" or "tmux 3.3a"
1696
+ * into a comparable [major, minor] tuple. Returns [0, 0] on parse failure.
1697
+ */
1698
+ export function parseVersion(raw: string): [number, number] {
1699
+ const match = raw.match(/(\d+)\.(\d+)/);
1700
+ if (!match) return [0, 0];
1701
+ return [parseInt(match[1], 10), parseInt(match[2], 10)];
1702
+ }
1703
+
1704
+ /**
1705
+ * Check if actual version meets minimum required version.
1706
+ */
1707
+ export function meetsMinVersion(actual: [number, number], minimum: [number, number]): boolean {
1708
+ if (actual[0] > minimum[0]) return true;
1709
+ if (actual[0] === minimum[0] && actual[1] >= minimum[1]) return true;
1710
+ return false;
1711
+ }
1712
+
1713
+ /**
1714
+ * Run preflight checks for all orchestrator dependencies.
1715
+ *
1716
+ * Required checks (fail blocks execution):
1717
+ * - git version >= 2.15
1718
+ * - git worktree support
1719
+ * - pi availability
1720
+ *
1721
+ * Compatibility checks:
1722
+ * - Runtime backend mode visibility (subprocess-only)
1723
+ */
1724
+ export function runPreflight(config: OrchestratorConfig, repoRoot?: string): PreflightResult {
1725
+ const checks: PreflightCheck[] = [];
1726
+
1727
+ // ── Git version ──────────────────────────────────────────────
1728
+ const gitResult = execCheck("git --version");
1729
+ if (gitResult.ok) {
1730
+ const version = parseVersion(gitResult.stdout);
1731
+ const versionStr = `${version[0]}.${version[1]}`;
1732
+ if (meetsMinVersion(version, [2, 15])) {
1733
+ checks.push({
1734
+ name: "git",
1735
+ status: "pass",
1736
+ message: `Git ${versionStr} available`,
1737
+ });
1738
+ } else {
1739
+ checks.push({
1740
+ name: "git",
1741
+ status: "fail",
1742
+ message: `Git ${versionStr} found, but 2.15+ required for worktree support`,
1743
+ hint: "Upgrade Git: https://git-scm.com/downloads",
1744
+ });
1745
+ }
1746
+ } else {
1747
+ checks.push({
1748
+ name: "git",
1749
+ status: "fail",
1750
+ message: "Git not found",
1751
+ hint: "Install Git: https://git-scm.com/downloads",
1752
+ });
1753
+ }
1754
+
1755
+ // ── Git worktree support ─────────────────────────────────────
1756
+ // In workspace mode, cwd may not be a git repo — run from a repo root
1757
+ const worktreeResult = execCheck("git worktree list", repoRoot);
1758
+ checks.push({
1759
+ name: "git-worktree",
1760
+ status: worktreeResult.ok ? "pass" : "fail",
1761
+ message: worktreeResult.ok
1762
+ ? "Worktree support available"
1763
+ : "Git worktree not available",
1764
+ hint: worktreeResult.ok
1765
+ ? undefined
1766
+ : repoRoot
1767
+ ? "Upgrade Git to 2.15+"
1768
+ : "Workspace root is not a git repo. Check workspace config repo paths.",
1769
+ });
1770
+
1771
+ // ── Runtime backend contract (Runtime V2) ─────────────────────
1772
+ checks.push({
1773
+ name: "runtime-backend",
1774
+ status: "pass",
1775
+ message: `Runtime V2 subprocess backend active (configured spawn_mode: ${config.orchestrator.spawn_mode})`,
1776
+ });
1777
+
1778
+ // ── Pi availability ──────────────────────────────────────────
1779
+ // Use a 30s timeout (vs default 10s) and retry once on timeout to absorb
1780
+ // cold-start variance. Each `pi --version` invocation is a fresh Node
1781
+ // process: mise shim resolution + Node bootstrap + Windows Defender
1782
+ // process-launch scan + pi's own startup can comfortably exceed 10s on
1783
+ // the first invocation after sleep/wake, even when pi is correctly
1784
+ // installed and on PATH. (#TP-185)
1785
+ const PI_PREFLIGHT_TIMEOUT_MS = 30_000;
1786
+ let piResult = execCheck("pi --version", undefined, PI_PREFLIGHT_TIMEOUT_MS);
1787
+ if (!piResult.ok && piResult.errorKind === "timeout") {
1788
+ // Single retry: the first call typically warms the OS file cache and
1789
+ // satisfies AV pre-scan, so a follow-up usually completes in <1s.
1790
+ piResult = execCheck("pi --version", undefined, PI_PREFLIGHT_TIMEOUT_MS);
1791
+ }
1792
+
1793
+ if (piResult.ok) {
1794
+ checks.push({
1795
+ name: "pi",
1796
+ status: "pass",
1797
+ message: `Pi ${piResult.stdout || "available"}`,
1798
+ });
1799
+ } else {
1800
+ // Tailor the failure message and hint to the actual error mode.
1801
+ // The legacy code reported every failure as "Pi not found" with an
1802
+ // `npm install -g` hint, which is misleading when the real cause is
1803
+ // a timeout or non-zero exit from a correctly-installed pi.
1804
+ let message: string;
1805
+ let hint: string;
1806
+ switch (piResult.errorKind) {
1807
+ case "not-found":
1808
+ message = "Pi not found on PATH";
1809
+ hint = "Install Pi: npm install -g @mariozechner/pi-coding-agent";
1810
+ break;
1811
+ case "timeout":
1812
+ message = `Pi did not respond within ${PI_PREFLIGHT_TIMEOUT_MS / 1000}s (retried once)`;
1813
+ hint = "Pi appears installed but is responding slowly. Common causes: antivirus scanning the Node binary on first launch, slow disk, a zombie pi process holding a lock, or a stale mise shim. Try running `pi --version` directly to see how long it takes.";
1814
+ break;
1815
+ case "exit-code":
1816
+ message = `Pi exited with error (${piResult.errorDetail ?? "non-zero status"})`;
1817
+ hint = "Run `pi --version` directly to see the error output.";
1818
+ break;
1819
+ case "signal":
1820
+ message = `Pi was killed by signal (${piResult.errorDetail ?? "unknown"})`;
1821
+ hint = "The pi process was killed externally. Check for OOM, antivirus quarantine, or interrupted shell.";
1822
+ break;
1823
+ default:
1824
+ message = `Pi check failed (${piResult.errorDetail ?? "unknown error"})`;
1825
+ hint = "Run `pi --version` manually to diagnose.";
1826
+ }
1827
+ checks.push({ name: "pi", status: "fail", message, hint });
1828
+ }
1829
+
1830
+ return {
1831
+ passed: checks.every((c) => c.status !== "fail"),
1832
+ checks,
1833
+ };
1834
+ }
1835
+
1836
+ /**
1837
+ * Format preflight results as a readable string for display.
1838
+ */
1839
+ export function formatPreflightResults(result: PreflightResult): string {
1840
+ const lines: string[] = ["Preflight Check:"];
1841
+
1842
+ for (const check of result.checks) {
1843
+ const icon =
1844
+ check.status === "pass" ? "✅" :
1845
+ check.status === "warn" ? "⚠️ " :
1846
+ "❌";
1847
+ const nameCol = check.name.padEnd(18);
1848
+ lines.push(` ${icon} ${nameCol} ${check.message}`);
1849
+ if (check.hint && check.status !== "pass") {
1850
+ // Indent hint lines under the check
1851
+ for (const hintLine of check.hint.split("\n")) {
1852
+ lines.push(` ${" ".repeat(18)} ${hintLine}`);
1853
+ }
1854
+ }
1855
+ }
1856
+
1857
+ lines.push("");
1858
+ if (result.passed) {
1859
+ lines.push("All required checks passed.");
1860
+ } else {
1861
+ const failedNames = result.checks
1862
+ .filter((c) => c.status === "fail")
1863
+ .map((c) => c.name)
1864
+ .join(", ");
1865
+ lines.push(`❌ Preflight FAILED: ${failedNames}`);
1866
+ lines.push("Fix the issues above before running the orchestrator.");
1867
+ }
1868
+
1869
+ return lines.join("\n");
1870
+ }
1871
+
1872
+
1873
+ // ── Worktree Reset with Safety ───────────────────────────────────────
1874
+
1875
+ /**
1876
+ * Reset a worktree with safety handling for dirty trees.
1877
+ *
1878
+ * For failed/stalled tasks, the worktree may have uncommitted changes.
1879
+ * This function first tries a clean reset, and if that fails due to dirty
1880
+ * tree, force-cleans it before resetting.
1881
+ *
1882
+ * @param worktree - WorktreeInfo to reset
1883
+ * @param targetBranch - Branch to reset to (e.g., "develop")
1884
+ * @param repoRoot - Main repository root
1885
+ * @returns { success: boolean, error?: string }
1886
+ */
1887
+ export function safeResetWorktree(
1888
+ worktree: WorktreeInfo,
1889
+ targetBranch: string,
1890
+ repoRoot: string,
1891
+ ): { success: boolean; error?: string } {
1892
+ try {
1893
+ resetWorktree(worktree, targetBranch, repoRoot);
1894
+ return { success: true };
1895
+ } catch (err: unknown) {
1896
+ // If it's a dirty worktree, force clean and retry
1897
+ if (err instanceof WorktreeError && err.code === "WORKTREE_DIRTY") {
1898
+ execLog("reset", `lane-${worktree.laneNumber}`, "worktree dirty force cleaning", {
1899
+ path: worktree.path,
1900
+ });
1901
+
1902
+ // Force discard all changes
1903
+ const checkoutResult = runGit(["checkout", "--", "."], worktree.path);
1904
+ if (!checkoutResult.ok) {
1905
+ return {
1906
+ success: false,
1907
+ error: `git checkout -- . failed: ${checkoutResult.stderr}`,
1908
+ };
1909
+ }
1910
+
1911
+ // Remove untracked files.
1912
+ // git clean may warn about files it can't delete (e.g., Windows reserved
1913
+ // names like "nul", "con", "aux") but still clean everything else.
1914
+ // We treat this as non-fatal: check porcelain status afterward instead
1915
+ // of failing on the exit code.
1916
+ const cleanResult = runGit(["clean", "-fd"], worktree.path);
1917
+ if (!cleanResult.ok) {
1918
+ execLog("reset", `lane-${worktree.laneNumber}`, "git clean -fd returned non-zero (may be partial)", {
1919
+ stderr: cleanResult.stderr.slice(0, 200),
1920
+ });
1921
+ }
1922
+
1923
+ // Check if the worktree is clean enough to proceed.
1924
+ // If git status --porcelain shows no tracked changes, the reset can work
1925
+ // even if some untracked files couldn't be deleted.
1926
+ const statusCheck = runGit(["status", "--porcelain"], worktree.path);
1927
+ if (statusCheck.ok && statusCheck.stdout.length > 0) {
1928
+ // Still dirty after cleaning — check if only untracked files remain
1929
+ const lines = statusCheck.stdout.split("\n").filter(l => l.trim());
1930
+ const onlyUntracked = lines.every(l => l.startsWith("??"));
1931
+ if (!onlyUntracked) {
1932
+ return {
1933
+ success: false,
1934
+ error: `Worktree still dirty after clean: ${statusCheck.stdout.slice(0, 200)}`,
1935
+ };
1936
+ }
1937
+ // Only untracked files remain (e.g., undeletable "nul") — safe to proceed
1938
+ execLog("reset", `lane-${worktree.laneNumber}`, "untracked files remain after clean (non-blocking)", {
1939
+ files: lines.map(l => l.slice(3)).join(", "),
1940
+ });
1941
+ }
1942
+
1943
+ // Retry reset after cleaning
1944
+ try {
1945
+ resetWorktree(worktree, targetBranch, repoRoot);
1946
+ return { success: true };
1947
+ } catch (retryErr: unknown) {
1948
+ return {
1949
+ success: false,
1950
+ error: `Reset failed after clean: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
1951
+ };
1952
+ }
1953
+ }
1954
+
1955
+ return {
1956
+ success: false,
1957
+ error: err instanceof Error ? err.message : String(err),
1958
+ };
1959
+ }
1960
+ }
1961
+
1962
+
1963
+ // ── Force Cleanup ────────────────────────────────────────────────────
1964
+
1965
+ /**
1966
+ * Last-resort worktree cleanup: force-remove the directory and prune git state.
1967
+ *
1968
+ * Used when both `safeResetWorktree()` and `removeWorktree()` fail — typically
1969
+ * because undeletable files (e.g., Windows reserved names like "nul", "con")
1970
+ * block `git clean` and `git worktree remove`, leaving git in an inconsistent state.
1971
+ *
1972
+ * Recovery steps:
1973
+ * 1. Force-remove the worktree directory (`rm -rf` equivalent)
1974
+ * 2. Prune stale git worktree references (`git worktree prune`)
1975
+ * 3. Delete the lane branch if it exists (`git branch -D`)
1976
+ *
1977
+ * This allows the next wave to recreate the worktree from scratch.
1978
+ *
1979
+ * @param worktree - WorktreeInfo for the failed worktree
1980
+ * @param repoRoot - Main repository root
1981
+ * @param batchId - Batch ID for logging context
1982
+ */
1983
+ export function forceCleanupWorktree(
1984
+ worktree: WorktreeInfo,
1985
+ repoRoot: string,
1986
+ batchId: string,
1987
+ ): void {
1988
+ const { path: worktreePath, branch, laneNumber } = worktree;
1989
+
1990
+ // Step 1: Force-remove the directory
1991
+ if (existsSync(worktreePath)) {
1992
+ try {
1993
+ // On Windows, undeletable reserved-name files (nul, con, aux) need
1994
+ // special handling. Try rmSync first, then fall back to OS-specific
1995
+ // removal for stubborn files.
1996
+ rmSync(worktreePath, { recursive: true, force: true });
1997
+ execLog("cleanup", `lane-${laneNumber}`, `force-removed worktree directory`, { path: worktreePath });
1998
+ } catch (rmErr: unknown) {
1999
+ // If Node's rmSync fails (e.g., Windows reserved names), try platform-specific
2000
+ const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr);
2001
+ execLog("cleanup", `lane-${laneNumber}`, `rmSync failed, trying OS-level removal`, { error: rmMsg });
2002
+
2003
+ try {
2004
+ if (process.platform === "win32") {
2005
+ // rd /s /q handles Windows reserved names that Node.js cannot delete
2006
+ execSync(`rd /s /q "${worktreePath}"`, { stdio: "pipe", timeout: 30_000 });
2007
+ } else {
2008
+ execSync(`rm -rf "${worktreePath}"`, { stdio: "pipe", timeout: 30_000 });
2009
+ }
2010
+ execLog("cleanup", `lane-${laneNumber}`, `OS-level removal succeeded`, { path: worktreePath });
2011
+ } catch (osErr: unknown) {
2012
+ const osMsg = osErr instanceof Error ? osErr.message : String(osErr);
2013
+ execLog("cleanup", `lane-${laneNumber}`, `OS-level removal also failed — manual cleanup needed`, {
2014
+ path: worktreePath,
2015
+ error: osMsg,
2016
+ });
2017
+ }
2018
+ }
2019
+ }
2020
+
2021
+ // Step 2: Prune stale worktree references
2022
+ runGit(["worktree", "prune"], repoRoot);
2023
+ execLog("cleanup", `lane-${laneNumber}`, `pruned stale worktree references`);
2024
+
2025
+ // Step 3: Delete the lane branch if it still exists
2026
+ const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
2027
+ if (branchCheck.ok) {
2028
+ const deleteResult = runGit(["branch", "-D", branch], repoRoot);
2029
+ if (deleteResult.ok) {
2030
+ execLog("cleanup", `lane-${laneNumber}`, `deleted stale lane branch`, { branch });
2031
+ } else {
2032
+ execLog("cleanup", `lane-${laneNumber}`, `could not delete lane branch`, {
2033
+ branch,
2034
+ error: deleteResult.stderr,
2035
+ });
2036
+ }
2037
+ }
2038
+
2039
+ // Step 4: Attempt to remove the batch container directory if empty
2040
+ // The worktree path is {basePath}/{opId}-{batchId}/lane-{N}, so the
2041
+ // container is the parent directory.
2042
+ const containerDir = resolve(worktreePath, "..");
2043
+ const containerName = basename(containerDir);
2044
+ // Only attempt container cleanup if the parent looks like a batch container
2045
+ // (contains a hyphen, indicating {opId}-{batchId} naming)
2046
+ if (containerName.includes("-")) {
2047
+ const containerRemoved = removeBatchContainerIfEmpty(containerDir);
2048
+ if (containerRemoved) {
2049
+ execLog("cleanup", `lane-${laneNumber}`, `removed empty batch container`, { path: containerDir });
2050
+ }
2051
+ }
2052
+ }
2053
+
2054
+
2055
+ // ── Partial Progress Preservation ────────────────────────────────────
2056
+
2057
+ /**
2058
+ * Result of saving partial progress for a single failed task.
2059
+ */
2060
+ export interface SavePartialProgressResult {
2061
+ /** Whether partial progress was saved (branch created or already existed) */
2062
+ saved: boolean;
2063
+ /** The saved branch name, if saved */
2064
+ savedBranch?: string;
2065
+ /** Number of commits ahead of the target branch */
2066
+ commitCount: number;
2067
+ /** Task ID this progress belongs to */
2068
+ taskId: string;
2069
+ /** Error message if save failed */
2070
+ error?: string;
2071
+ }
2072
+
2073
+ /**
2074
+ * Compute the saved branch name for partial progress from a failed task.
2075
+ *
2076
+ * Naming convention per roadmap Phase 2 section 2a:
2077
+ * - Repo mode: `saved/{opId}-{taskId}-{batchId}`
2078
+ * - Workspace mode: `saved/{opId}-{repoId}-{taskId}-{batchId}`
2079
+ *
2080
+ * Pure function — no side effects.
2081
+ *
2082
+ * @param opId - Operator identifier (sanitized)
2083
+ * @param taskId - Task identifier (e.g., "TP-028")
2084
+ * @param batchId - Batch ID timestamp (e.g., "20260308T111750")
2085
+ * @param repoId - Repo identifier (workspace mode only; omit for repo mode)
2086
+ * @returns Saved branch name
2087
+ */
2088
+ export function computePartialProgressBranchName(
2089
+ opId: string,
2090
+ taskId: string,
2091
+ batchId: string,
2092
+ repoId?: string,
2093
+ ): string {
2094
+ if (repoId) {
2095
+ return `saved/${opId}-${repoId}-${taskId}-${batchId}`;
2096
+ }
2097
+ return `saved/${opId}-${taskId}-${batchId}`;
2098
+ }
2099
+
2100
+ /**
2101
+ * Save partial progress from a failed task's lane branch.
2102
+ *
2103
+ * Checks if the lane branch has commits ahead of the target branch,
2104
+ * and if so, creates a saved branch preserving those commits.
2105
+ *
2106
+ * Uses `resolveSavedBranchCollision()` for idempotent collision handling:
2107
+ * - Same SHA → no-op (keep existing)
2108
+ * - Different SHA → create with timestamp suffix
2109
+ *
2110
+ * @param laneBranch - The lane branch that may have partial commits
2111
+ * @param targetBranch - The base/target branch to compare against
2112
+ * @param opId - Operator identifier
2113
+ * @param taskId - Task identifier
2114
+ * @param batchId - Batch ID
2115
+ * @param repoRoot - Repository root for git operations
2116
+ * @param repoId - Repo identifier (workspace mode only)
2117
+ * @returns SavePartialProgressResult describing what was done
2118
+ */
2119
+ export function savePartialProgress(
2120
+ laneBranch: string,
2121
+ targetBranch: string,
2122
+ opId: string,
2123
+ taskId: string,
2124
+ batchId: string,
2125
+ repoRoot: string,
2126
+ repoId?: string,
2127
+ ): SavePartialProgressResult {
2128
+ // Check if lane branch exists
2129
+ const branchCheck = runGit(
2130
+ ["rev-parse", "--verify", `refs/heads/${laneBranch}`],
2131
+ repoRoot,
2132
+ );
2133
+ if (!branchCheck.ok) {
2134
+ return { saved: false, commitCount: 0, taskId, error: `Lane branch "${laneBranch}" not found` };
2135
+ }
2136
+ const branchSHA = branchCheck.stdout.trim();
2137
+
2138
+ // Count commits ahead of target branch
2139
+ const unmergedResult = hasUnmergedCommits(laneBranch, targetBranch, repoRoot);
2140
+ if (!unmergedResult.ok) {
2141
+ return {
2142
+ saved: false,
2143
+ commitCount: 0,
2144
+ taskId,
2145
+ error: `Failed to count commits: ${unmergedResult.error}`,
2146
+ };
2147
+ }
2148
+
2149
+ if (unmergedResult.count === 0) {
2150
+ // No partial progress — lane branch has no new commits
2151
+ return { saved: false, commitCount: 0, taskId };
2152
+ }
2153
+
2154
+ // Compute saved branch name using task-ID naming convention
2155
+ const savedName = computePartialProgressBranchName(opId, taskId, batchId, repoId);
2156
+
2157
+ // Check for collision (idempotent re-runs, retries)
2158
+ const existingCheck = runGit(
2159
+ ["rev-parse", "--verify", `refs/heads/${savedName}`],
2160
+ repoRoot,
2161
+ );
2162
+ const existingSHA = existingCheck.ok ? existingCheck.stdout.trim() : "";
2163
+
2164
+ const resolution = resolveSavedBranchCollision(savedName, existingSHA, branchSHA);
2165
+
2166
+ switch (resolution.action) {
2167
+ case "keep-existing":
2168
+ // Already preserved at the same SHA — idempotent success
2169
+ return {
2170
+ saved: true,
2171
+ savedBranch: resolution.savedName,
2172
+ commitCount: unmergedResult.count,
2173
+ taskId,
2174
+ };
2175
+
2176
+ case "create":
2177
+ case "create-suffixed": {
2178
+ const createResult = runGit(
2179
+ ["branch", resolution.savedName, branchSHA],
2180
+ repoRoot,
2181
+ );
2182
+ if (!createResult.ok) {
2183
+ return {
2184
+ saved: false,
2185
+ commitCount: unmergedResult.count,
2186
+ taskId,
2187
+ error: `Failed to create saved branch "${resolution.savedName}": ${createResult.stderr}`,
2188
+ };
2189
+ }
2190
+ return {
2191
+ saved: true,
2192
+ savedBranch: resolution.savedName,
2193
+ commitCount: unmergedResult.count,
2194
+ taskId,
2195
+ };
2196
+ }
2197
+
2198
+ default:
2199
+ return {
2200
+ saved: false,
2201
+ commitCount: unmergedResult.count,
2202
+ taskId,
2203
+ error: `Unknown collision resolution action`,
2204
+ };
2205
+ }
2206
+ }
2207
+
2208
+ /**
2209
+ * Result of preserving partial progress across all failed tasks.
2210
+ */
2211
+ export interface PreserveFailedLaneProgressResult {
2212
+ /** Per-task results for each failed task that was checked */
2213
+ results: SavePartialProgressResult[];
2214
+ /**
2215
+ * Set of saved branch names that were created (e.g., `saved/{opId}-{taskId}-{batchId}`).
2216
+ * These branches independently preserve the commits — lane branches can still be
2217
+ * safely deleted during cleanup since the saved refs retain reachability.
2218
+ */
2219
+ preservedBranches: Set<string>;
2220
+ /**
2221
+ * Set of lane branch names where preservation FAILED but commits existed.
2222
+ * These branches are unsafe to reset/delete — doing so would lose commits
2223
+ * that were not successfully saved to a separate branch. Callers should skip
2224
+ * worktree reset and branch deletion for these branches to prevent data loss.
2225
+ */
2226
+ unsafeBranches: Set<string>;
2227
+ }
2228
+
2229
+ /**
2230
+ * Callback for resolving repo root and target branch for a given repoId.
2231
+ *
2232
+ * Allows callers (engine.ts, resume.ts) to pass workspace-aware resolution
2233
+ * logic without creating a circular dependency (worktree.ts → waves.ts → worktree.ts).
2234
+ *
2235
+ * @param repoId - Repo identifier (undefined in repo mode)
2236
+ * @returns { repoRoot, targetBranch } for the given repo
2237
+ */
2238
+ export type ResolveRepoContext = (repoId: string | undefined) => {
2239
+ repoRoot: string;
2240
+ targetBranch: string;
2241
+ };
2242
+
2243
+ /**
2244
+ * Preserve partial progress for all failed tasks before cleanup/reset.
2245
+ *
2246
+ * Iterates task outcomes to find failed/stalled tasks, maps each to its
2247
+ * lane branch via the allocated lanes, and saves any partial commits as
2248
+ * task-ID-named saved branches.
2249
+ *
2250
+ * Returns two branch sets:
2251
+ * - `preservedBranches`: saved branch names that were successfully created
2252
+ * (lane branches can be safely deleted since these refs retain commits)
2253
+ * - `unsafeBranches`: lane branch names where preservation FAILED but commits
2254
+ * existed (callers must NOT reset/delete these to prevent data loss)
2255
+ *
2256
+ * Workspace-aware: uses the provided `resolveRepo` callback to resolve
2257
+ * per-repo target branches and repo roots for correct commit counting
2258
+ * in workspace mode.
2259
+ *
2260
+ * @param allocatedLanes - Lanes from the current/last wave (maps tasks to branches)
2261
+ * @param taskOutcomes - All task outcomes accumulated so far
2262
+ * @param opId - Operator identifier
2263
+ * @param batchId - Batch ID
2264
+ * @param resolveRepo - Callback to resolve repo root and target branch per repoId
2265
+ * @returns PreserveFailedLaneProgressResult with per-task results and preserved branch set
2266
+ */
2267
+ export function preserveFailedLaneProgress(
2268
+ allocatedLanes: AllocatedLane[],
2269
+ taskOutcomes: LaneTaskOutcome[],
2270
+ opId: string,
2271
+ batchId: string,
2272
+ resolveRepo: ResolveRepoContext,
2273
+ ): PreserveFailedLaneProgressResult {
2274
+ const results: SavePartialProgressResult[] = [];
2275
+ const preservedBranches = new Set<string>();
2276
+ const unsafeBranches = new Set<string>();
2277
+
2278
+ // Build a map: taskId { laneBranch, repoId } from allocated lanes
2279
+ const taskToLane = new Map<string, { branch: string; repoId?: string }>();
2280
+ for (const lane of allocatedLanes) {
2281
+ for (const allocatedTask of lane.tasks) {
2282
+ taskToLane.set(allocatedTask.taskId, {
2283
+ branch: lane.branch,
2284
+ repoId: lane.repoId,
2285
+ });
2286
+ }
2287
+ }
2288
+
2289
+ // Find failed/stalled tasks
2290
+ const failedTasks = taskOutcomes.filter(
2291
+ (to) => to.status === "failed" || to.status === "stalled",
2292
+ );
2293
+
2294
+ // Track which lane branches we've already processed (a lane may have
2295
+ // multiple tasks; only save once per branch since all commits are shared)
2296
+ const processedBranches = new Set<string>();
2297
+
2298
+ for (const failedTask of failedTasks) {
2299
+ const laneInfo = taskToLane.get(failedTask.taskId);
2300
+ if (!laneInfo) {
2301
+ // Task not found in allocated lanes — skip (shouldn't happen)
2302
+ results.push({
2303
+ saved: false,
2304
+ commitCount: 0,
2305
+ taskId: failedTask.taskId,
2306
+ error: "Task not found in allocated lanes",
2307
+ });
2308
+ continue;
2309
+ }
2310
+
2311
+ // Skip if we've already processed this branch (multiple failed tasks on same lane)
2312
+ if (processedBranches.has(laneInfo.branch)) {
2313
+ continue;
2314
+ }
2315
+ processedBranches.add(laneInfo.branch);
2316
+
2317
+ // Resolve repo-specific target branch and repo root
2318
+ const { repoRoot: perRepoRoot, targetBranch } = resolveRepo(laneInfo.repoId);
2319
+
2320
+ const result = savePartialProgress(
2321
+ laneInfo.branch,
2322
+ targetBranch,
2323
+ opId,
2324
+ failedTask.taskId,
2325
+ batchId,
2326
+ perRepoRoot,
2327
+ laneInfo.repoId,
2328
+ );
2329
+
2330
+ results.push(result);
2331
+
2332
+ if (result.saved) {
2333
+ // Track the saved branch name for caller visibility
2334
+ preservedBranches.add(result.savedBranch!);
2335
+
2336
+ execLog("partial-progress", failedTask.taskId,
2337
+ `Task ${failedTask.taskId} failed but has ${result.commitCount} commit(s) of partial progress on branch ${result.savedBranch}`,
2338
+ {
2339
+ laneBranch: laneInfo.branch,
2340
+ savedBranch: result.savedBranch,
2341
+ commitCount: result.commitCount,
2342
+ repoId: laneInfo.repoId ?? "(default)",
2343
+ },
2344
+ );
2345
+ } else if (result.commitCount > 0 || result.error) {
2346
+ // Preservation FAILED but commits may exist on the lane branch.
2347
+ // Mark this branch as unsafe to reset/delete — doing so would
2348
+ // irreversibly lose the partial work.
2349
+ unsafeBranches.add(laneInfo.branch);
2350
+
2351
+ execLog("partial-progress", failedTask.taskId,
2352
+ `WARNING: Failed to preserve partial progress for task ${failedTask.taskId} ` +
2353
+ `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`,
2354
+ {
2355
+ laneBranch: laneInfo.branch,
2356
+ commitCount: result.commitCount,
2357
+ error: result.error ?? "unknown",
2358
+ repoId: laneInfo.repoId ?? "(default)",
2359
+ },
2360
+ );
2361
+ }
2362
+ }
2363
+
2364
+ return { results, preservedBranches, unsafeBranches };
2365
+ }
2366
+
2367
+
2368
+ /**
2369
+ * TP-147: Preserve partial progress for all skipped tasks before cleanup/reset.
2370
+ *
2371
+ * Skipped tasks may have worker commits (STATUS.md updates, partial code)
2372
+ * that would be lost when the worktree is cleaned up. This function saves
2373
+ * their lane branches as task-ID-named saved branches, similar to how
2374
+ * preserveFailedLaneProgress works for failed tasks.
2375
+ *
2376
+ * Unlike failed tasks, skipped-task branches are NOT merged (partial work
2377
+ * could break verification). Instead they are preserved for manual recovery.
2378
+ *
2379
+ * @param allocatedLanes - Lanes from the current/last wave
2380
+ * @param taskOutcomes - All task outcomes accumulated so far
2381
+ * @param opId - Operator identifier
2382
+ * @param batchId - Batch ID
2383
+ * @param resolveRepo - Callback to resolve repo root and target branch per repoId
2384
+ * @returns PreserveFailedLaneProgressResult with per-task results and preserved branch set
2385
+ */
2386
+ export function preserveSkippedLaneProgress(
2387
+ allocatedLanes: AllocatedLane[],
2388
+ taskOutcomes: LaneTaskOutcome[],
2389
+ opId: string,
2390
+ batchId: string,
2391
+ resolveRepo: ResolveRepoContext,
2392
+ ): PreserveFailedLaneProgressResult {
2393
+ const results: SavePartialProgressResult[] = [];
2394
+ const preservedBranches = new Set<string>();
2395
+ const unsafeBranches = new Set<string>();
2396
+
2397
+ // Build a map: taskId → { laneBranch, repoId } from allocated lanes
2398
+ const taskToLane = new Map<string, { branch: string; repoId?: string }>();
2399
+ for (const lane of allocatedLanes) {
2400
+ for (const allocatedTask of lane.tasks) {
2401
+ taskToLane.set(allocatedTask.taskId, {
2402
+ branch: lane.branch,
2403
+ repoId: lane.repoId,
2404
+ });
2405
+ }
2406
+ }
2407
+
2408
+ // Find skipped tasks
2409
+ const skippedTasks = taskOutcomes.filter(
2410
+ (to) => to.status === "skipped",
2411
+ );
2412
+
2413
+ // Track which lane branches we've already processed (a lane may have
2414
+ // multiple tasks; only save once per branch since all commits are shared)
2415
+ const processedBranches = new Set<string>();
2416
+
2417
+ for (const skippedTask of skippedTasks) {
2418
+ const laneInfo = taskToLane.get(skippedTask.taskId);
2419
+ if (!laneInfo) {
2420
+ results.push({
2421
+ saved: false,
2422
+ commitCount: 0,
2423
+ taskId: skippedTask.taskId,
2424
+ error: "Task not found in allocated lanes",
2425
+ });
2426
+ continue;
2427
+ }
2428
+
2429
+ // Skip if we've already processed this branch
2430
+ if (processedBranches.has(laneInfo.branch)) {
2431
+ continue;
2432
+ }
2433
+ processedBranches.add(laneInfo.branch);
2434
+
2435
+ // Resolve repo-specific target branch and repo root
2436
+ const { repoRoot: perRepoRoot, targetBranch } = resolveRepo(laneInfo.repoId);
2437
+
2438
+ const result = savePartialProgress(
2439
+ laneInfo.branch,
2440
+ targetBranch,
2441
+ opId,
2442
+ skippedTask.taskId,
2443
+ batchId,
2444
+ perRepoRoot,
2445
+ laneInfo.repoId,
2446
+ );
2447
+
2448
+ results.push(result);
2449
+
2450
+ if (result.saved) {
2451
+ preservedBranches.add(result.savedBranch!);
2452
+
2453
+ execLog("partial-progress", skippedTask.taskId,
2454
+ `Task ${skippedTask.taskId} was skipped but has ${result.commitCount} commit(s) of partial progress preserved on branch ${result.savedBranch}`,
2455
+ {
2456
+ laneBranch: laneInfo.branch,
2457
+ savedBranch: result.savedBranch,
2458
+ commitCount: result.commitCount,
2459
+ repoId: laneInfo.repoId ?? "(default)",
2460
+ },
2461
+ );
2462
+ } else if (result.commitCount > 0 || result.error) {
2463
+ unsafeBranches.add(laneInfo.branch);
2464
+
2465
+ execLog("partial-progress", skippedTask.taskId,
2466
+ `WARNING: Failed to preserve partial progress for skipped task ${skippedTask.taskId} ` +
2467
+ `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`,
2468
+ {
2469
+ laneBranch: laneInfo.branch,
2470
+ commitCount: result.commitCount,
2471
+ error: result.error ?? "unknown",
2472
+ repoId: laneInfo.repoId ?? "(default)",
2473
+ },
2474
+ );
2475
+ }
2476
+ }
2477
+
2478
+ return { results, preservedBranches, unsafeBranches };
2479
+ }
2480
+
2481
+
2482
+ // ── Stale Branch Cleanup (TP-051) ────────────────────────────────────
2483
+
2484
+ /**
2485
+ * Result of stale branch cleanup after integration.
2486
+ */
2487
+ export interface StaleBranchCleanupResult {
2488
+ /** task/* branches deleted */
2489
+ deletedTaskBranches: string[];
2490
+ /** saved/task/* branches deleted */
2491
+ deletedSavedBranches: string[];
2492
+ /** Branches that failed to delete (best-effort) */
2493
+ failedDeletes: string[];
2494
+ }
2495
+
2496
+ /**
2497
+ * Delete stale task/* and saved/* branches after integration.
2498
+ *
2499
+ * After `/orch-integrate` merges or creates a PR, the lane branches
2500
+ * (`task/{opId}-lane-{N}-{batchId}`) and their saved counterparts
2501
+ * are no longer needed. This function cleans them up.
2502
+ *
2503
+ * Cleanup scope:
2504
+ * 1. **Lane branches:** `task/{opId}-lane-*` (any batch from this operator)
2505
+ * 2. **Saved lane branches:** `saved/task/{opId}-lane-*` (preserved lane refs)
2506
+ * 3. **Partial-progress branches:** `saved/{opId}-*` (per-task partial progress refs)
2507
+ *
2508
+ * Targets all branches matching the operator's prefix, not just the current
2509
+ * batch — this also cleans up orphans from previous batches that were never
2510
+ * cleaned.
2511
+ *
2512
+ * All deletions are best-effort — individual failures are logged but don't
2513
+ * prevent other branches from being cleaned.
2514
+ *
2515
+ * @param repoRoot - Repository root directory
2516
+ * @param opId - Operator identifier (e.g., "henrylach")
2517
+ * @param batchId - Current batch ID (for logging context)
2518
+ * @returns Cleanup result with lists of deleted and failed branches
2519
+ */
2520
+ export function deleteStaleBranches(
2521
+ repoRoot: string,
2522
+ opId: string,
2523
+ batchId: string,
2524
+ ): StaleBranchCleanupResult {
2525
+ const deletedTaskBranches: string[] = [];
2526
+ const deletedSavedBranches: string[] = [];
2527
+ const failedDeletes: string[] = [];
2528
+
2529
+ // 1. Delete task/{opId}-lane-* branches
2530
+ const taskBranchResult = runGit(["branch", "--list", `task/${opId}-lane-*`], repoRoot);
2531
+ if (taskBranchResult.ok && taskBranchResult.stdout.trim()) {
2532
+ const branches = taskBranchResult.stdout
2533
+ .split("\n")
2534
+ .map(b => b.replace(/^\*?\s+/, "").trim())
2535
+ .filter(Boolean);
2536
+
2537
+ for (const branch of branches) {
2538
+ const deleted = deleteBranchBestEffort(branch, repoRoot);
2539
+ if (deleted) {
2540
+ deletedTaskBranches.push(branch);
2541
+ } else {
2542
+ failedDeletes.push(branch);
2543
+ }
2544
+ }
2545
+ }
2546
+
2547
+ // 2. Delete saved/task/{opId}-lane-* branches (preserved lane refs)
2548
+ const savedTaskResult = runGit(["branch", "--list", `saved/task/${opId}-lane-*`], repoRoot);
2549
+ if (savedTaskResult.ok && savedTaskResult.stdout.trim()) {
2550
+ const branches = savedTaskResult.stdout
2551
+ .split("\n")
2552
+ .map(b => b.replace(/^\*?\s+/, "").trim())
2553
+ .filter(Boolean);
2554
+
2555
+ for (const branch of branches) {
2556
+ const deleted = deleteBranchBestEffort(branch, repoRoot);
2557
+ if (deleted) {
2558
+ deletedSavedBranches.push(branch);
2559
+ } else {
2560
+ failedDeletes.push(branch);
2561
+ }
2562
+ }
2563
+ }
2564
+
2565
+ // 3. Delete saved/{opId}-*-{batchId} branches (partial-progress refs from this batch)
2566
+ // Pattern: saved/{opId}-{taskId}-{batchId} or saved/{opId}-{repoId}-{taskId}-{batchId}
2567
+ // Only deletes branches ending with the current batchId to avoid removing
2568
+ // partial-progress refs from other batches that the operator may still need.
2569
+ const savedProgressResult = runGit(["branch", "--list", `saved/${opId}-*`], repoRoot);
2570
+ if (savedProgressResult.ok && savedProgressResult.stdout.trim()) {
2571
+ const branches = savedProgressResult.stdout
2572
+ .split("\n")
2573
+ .map(b => b.replace(/^\*?\s+/, "").trim())
2574
+ .filter(Boolean);
2575
+
2576
+ const batchSuffix = `-${batchId}`;
2577
+ for (const branch of branches) {
2578
+ // Avoid double-deleting saved/task/* already handled above
2579
+ if (branch.startsWith("saved/task/")) continue;
2580
+ // Only delete partial-progress refs from the current batch
2581
+ if (!branch.endsWith(batchSuffix)) continue;
2582
+ const deleted = deleteBranchBestEffort(branch, repoRoot);
2583
+ if (deleted) {
2584
+ deletedSavedBranches.push(branch);
2585
+ } else {
2586
+ failedDeletes.push(branch);
2587
+ }
2588
+ }
2589
+ }
2590
+
2591
+ const totalDeleted = deletedTaskBranches.length + deletedSavedBranches.length;
2592
+ if (totalDeleted > 0) {
2593
+ execLog("cleanup", "branches", `deleted ${totalDeleted} stale branch(es) for batch ${batchId}`, {
2594
+ taskBranches: deletedTaskBranches.length,
2595
+ savedBranches: deletedSavedBranches.length,
2596
+ failed: failedDeletes.length,
2597
+ });
2598
+ }
2599
+
2600
+ return { deletedTaskBranches, deletedSavedBranches, failedDeletes };
2601
+ }
2602
+
2603
+
2604
+