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