taskplane 0.4.1 → 0.4.3
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/extensions/taskplane/config-loader.ts +1 -0
- package/extensions/taskplane/config-schema.ts +3 -0
- package/extensions/taskplane/persistence.ts +13 -0
- package/extensions/taskplane/resume.ts +1 -0
- package/extensions/taskplane/settings-tui.ts +33 -6
- package/extensions/taskplane/types.ts +8 -0
- package/extensions/taskplane/worktree.ts +256 -30
- package/package.json +1 -1
|
@@ -726,6 +726,7 @@ export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.t
|
|
|
726
726
|
spawn_mode: o.orchestrator.spawnMode,
|
|
727
727
|
tmux_prefix: o.orchestrator.tmuxPrefix,
|
|
728
728
|
operator_id: o.orchestrator.operatorId,
|
|
729
|
+
integration: o.orchestrator.integration,
|
|
729
730
|
},
|
|
730
731
|
dependencies: {
|
|
731
732
|
source: o.dependencies.source,
|
|
@@ -216,6 +216,8 @@ export interface OrchestratorCoreConfig {
|
|
|
216
216
|
tmuxPrefix: string;
|
|
217
217
|
/** Operator identifier. Auto-detected from OS username if empty */
|
|
218
218
|
operatorId: string;
|
|
219
|
+
/** How completed batches are integrated. manual = user runs /orch-integrate. auto = fast-forward on completion. */
|
|
220
|
+
integration: "manual" | "auto";
|
|
219
221
|
}
|
|
220
222
|
|
|
221
223
|
/** Dependency resolution settings */
|
|
@@ -427,6 +429,7 @@ export const DEFAULT_ORCHESTRATOR_SECTION: OrchestratorSection = {
|
|
|
427
429
|
spawnMode: "subprocess",
|
|
428
430
|
tmuxPrefix: "orch",
|
|
429
431
|
operatorId: "",
|
|
432
|
+
integration: "manual",
|
|
430
433
|
},
|
|
431
434
|
dependencies: {
|
|
432
435
|
source: "prompt",
|
|
@@ -366,6 +366,18 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
|
|
|
366
366
|
);
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
// ── Optional string fields: orchBranch ───────────────────────
|
|
370
|
+
// orchBranch was added after schema v2 shipped; default to "" if missing.
|
|
371
|
+
if (obj.orchBranch !== undefined && typeof obj.orchBranch !== "string") {
|
|
372
|
+
throw new StateFileError(
|
|
373
|
+
"STATE_SCHEMA_INVALID",
|
|
374
|
+
`Invalid "orchBranch" field (expected string, got ${typeof obj.orchBranch})`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (obj.orchBranch === undefined) {
|
|
378
|
+
obj.orchBranch = "";
|
|
379
|
+
}
|
|
380
|
+
|
|
369
381
|
// ── v2: mode field ───────────────────────────────────────────
|
|
370
382
|
// mode is required in v2, absent in v1 (defaults to "repo" via upconvert).
|
|
371
383
|
if (!isV1 && obj.mode === undefined) {
|
|
@@ -776,6 +788,7 @@ export function serializeBatchState(
|
|
|
776
788
|
phase: state.phase,
|
|
777
789
|
batchId: state.batchId,
|
|
778
790
|
baseBranch: state.baseBranch,
|
|
791
|
+
orchBranch: state.orchBranch ?? "",
|
|
779
792
|
mode: state.mode ?? "repo",
|
|
780
793
|
startedAt: state.startedAt,
|
|
781
794
|
updatedAt: now,
|
|
@@ -612,6 +612,7 @@ export async function resumeOrchBatch(
|
|
|
612
612
|
batchState.phase = "executing";
|
|
613
613
|
batchState.batchId = persistedState.batchId;
|
|
614
614
|
batchState.baseBranch = persistedState.baseBranch || "";
|
|
615
|
+
batchState.orchBranch = persistedState.orchBranch || "";
|
|
615
616
|
batchState.mode = persistedState.mode;
|
|
616
617
|
batchState.startedAt = persistedState.startedAt;
|
|
617
618
|
batchState.pauseSignal = { paused: false };
|
|
@@ -98,9 +98,11 @@ export const SECTIONS: SectionDef[] = [
|
|
|
98
98
|
{ configPath: "orchestrator.orchestrator.worktreeLocation", label: "Worktree Location", control: "toggle", layer: "L1", fieldType: "enum", values: ["sibling", "subdirectory"], description: "Where lane worktree directories are created" },
|
|
99
99
|
{ configPath: "orchestrator.orchestrator.worktreePrefix", label: "Worktree Prefix", control: "input", layer: "L1", fieldType: "string", description: "Prefix for worktree directory names" },
|
|
100
100
|
{ configPath: "orchestrator.orchestrator.batchIdFormat", label: "Batch ID Format", control: "toggle", layer: "L1", fieldType: "enum", values: ["timestamp", "sequential"], description: "Batch ID format for logs/branch naming" },
|
|
101
|
-
|
|
101
|
+
// spawn_mode removed from Orchestrator section — /orch always requires tmux.
|
|
102
|
+
// The user-facing spawn mode setting is under Worker (controls /task behavior).
|
|
102
103
|
{ configPath: "orchestrator.orchestrator.tmuxPrefix", label: "Tmux Prefix", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "tmuxPrefix", description: "Prefix for orchestrator tmux sessions" },
|
|
103
104
|
{ configPath: "orchestrator.orchestrator.operatorId", label: "Operator ID", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "operatorId", description: "Operator identifier (empty = auto-detect)" },
|
|
105
|
+
{ configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "toggle", layer: "L1", fieldType: "enum", values: ["manual", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. auto = fast-forward on completion." },
|
|
104
106
|
],
|
|
105
107
|
},
|
|
106
108
|
{
|
|
@@ -153,7 +155,7 @@ export const SECTIONS: SectionDef[] = [
|
|
|
153
155
|
{ configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (empty = inherit session)" },
|
|
154
156
|
{ configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
|
|
155
157
|
{ configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "input", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
|
|
156
|
-
{ configPath: "taskRunner.worker.spawnMode", label: "
|
|
158
|
+
{ configPath: "taskRunner.worker.spawnMode", label: "Spawn Mode", control: "toggle", layer: "L1", fieldType: "enum", values: ["subprocess", "tmux"], description: "How /task spawns workers and reviewers. subprocess = child process (simpler), tmux = named sessions (attachable for debugging)" },
|
|
157
159
|
],
|
|
158
160
|
},
|
|
159
161
|
{
|
|
@@ -1109,6 +1111,30 @@ async function showSectionSettingsLoop(
|
|
|
1109
1111
|
const field = section.fields.find((f) => f.configPath === result.fieldId);
|
|
1110
1112
|
if (!field) continue; // Safety: field not found
|
|
1111
1113
|
|
|
1114
|
+
// Input fields: the submenu returned a sentinel — use ctx.ui.input() for actual editing
|
|
1115
|
+
if (result.rawValue === "__EDIT_REQUESTED__" && field.control === "input") {
|
|
1116
|
+
const state = loadConfigState(configRoot, pointerConfigRoot);
|
|
1117
|
+
const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
|
|
1118
|
+
const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
|
|
1119
|
+
const placeholder = currentClean === "(not set)" || currentClean === "(inherit)" ? "" : currentClean;
|
|
1120
|
+
|
|
1121
|
+
const newValue = await ctx.ui.input(
|
|
1122
|
+
`${field.label}${field.description ? ` — ${field.description}` : ""}`,
|
|
1123
|
+
placeholder,
|
|
1124
|
+
);
|
|
1125
|
+
|
|
1126
|
+
if (newValue === null || newValue === undefined) continue; // Cancelled
|
|
1127
|
+
|
|
1128
|
+
// Validate
|
|
1129
|
+
const validation = validateFieldInput(field, newValue);
|
|
1130
|
+
if (!validation.valid) {
|
|
1131
|
+
ctx.ui.notify(`❌ Invalid value: ${validation.error}`, "error");
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
result.rawValue = newValue;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1112
1138
|
const typedValue = coerceValueForWrite(field, result.rawValue);
|
|
1113
1139
|
|
|
1114
1140
|
// Collect UI answers for the write-decision contract
|
|
@@ -1199,11 +1225,12 @@ async function showSectionSettingsOnce(
|
|
|
1199
1225
|
item.values = field.values.map((v) => `${v} ${sourceBadge}`);
|
|
1200
1226
|
}
|
|
1201
1227
|
|
|
1202
|
-
// Input fields
|
|
1228
|
+
// Input fields: use a single-value cycling pattern instead of a submenu.
|
|
1229
|
+
// The inline submenu approach freezes on Windows/tmux (issue #57).
|
|
1230
|
+
// We set a single sentinel value so pressing Enter/Space triggers onChange,
|
|
1231
|
+
// which exits the TUI. The caller then uses ctx.ui.input() for actual editing.
|
|
1203
1232
|
if (field.control === "input") {
|
|
1204
|
-
item.
|
|
1205
|
-
return createInputSubmenu(field, currentValue, submenuDone);
|
|
1206
|
-
};
|
|
1233
|
+
item.values = [`__EDIT_REQUESTED__`];
|
|
1207
1234
|
}
|
|
1208
1235
|
|
|
1209
1236
|
return item;
|
|
@@ -17,6 +17,8 @@ export interface OrchestratorConfig {
|
|
|
17
17
|
tmux_prefix: string;
|
|
18
18
|
/** Optional operator identifier. Auto-detected from OS username if empty. */
|
|
19
19
|
operator_id: string;
|
|
20
|
+
/** How completed batches are integrated. manual = user runs /orch-integrate. auto = fast-forward on completion. */
|
|
21
|
+
integration: "manual" | "auto";
|
|
20
22
|
};
|
|
21
23
|
dependencies: {
|
|
22
24
|
source: "prompt" | "agent";
|
|
@@ -151,6 +153,7 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
|
|
|
151
153
|
spawn_mode: "subprocess",
|
|
152
154
|
tmux_prefix: "orch",
|
|
153
155
|
operator_id: "",
|
|
156
|
+
integration: "manual",
|
|
154
157
|
},
|
|
155
158
|
dependencies: {
|
|
156
159
|
source: "prompt",
|
|
@@ -829,6 +832,8 @@ export interface OrchBatchRuntimeState {
|
|
|
829
832
|
batchId: string;
|
|
830
833
|
/** Branch that was active when /orch started — used as base for worktrees and merge target */
|
|
831
834
|
baseBranch: string;
|
|
835
|
+
/** Orchestrator-managed branch name (e.g., 'orch/henry-20260318T140000'). Empty = legacy mode (merge into baseBranch directly). */
|
|
836
|
+
orchBranch: string;
|
|
832
837
|
/** Workspace execution mode (v2). Defaults to "repo" for backward compatibility. */
|
|
833
838
|
mode: WorkspaceMode;
|
|
834
839
|
/** Shared pause signal — set by /orch-pause, read by executeLane/executeWave */
|
|
@@ -908,6 +913,7 @@ export function freshOrchBatchState(): OrchBatchRuntimeState {
|
|
|
908
913
|
phase: "idle",
|
|
909
914
|
batchId: "",
|
|
910
915
|
baseBranch: "",
|
|
916
|
+
orchBranch: "",
|
|
911
917
|
mode: "repo",
|
|
912
918
|
pauseSignal: { paused: false },
|
|
913
919
|
waveResults: [],
|
|
@@ -1367,6 +1373,8 @@ export interface PersistedBatchState {
|
|
|
1367
1373
|
batchId: string;
|
|
1368
1374
|
/** Branch that was active when /orch started — used as base for worktrees and merge target */
|
|
1369
1375
|
baseBranch: string;
|
|
1376
|
+
/** Orchestrator-managed branch name (e.g., 'orch/henry-20260318T140000'). Empty = legacy mode (merge into baseBranch directly). */
|
|
1377
|
+
orchBranch: string;
|
|
1370
1378
|
/**
|
|
1371
1379
|
* Workspace execution mode at batch start (v2).
|
|
1372
1380
|
* - "repo": Single-repo mode (default, backward-compatible).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Worktree CRUD, bulk ops, branch protection, preflight
|
|
3
3
|
* @module orch/worktree
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, readdirSync, realpathSync, rmSync } from "fs";
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from "fs";
|
|
6
6
|
import { execSync } from "child_process";
|
|
7
7
|
import { join, basename, resolve } from "path";
|
|
8
8
|
|
|
@@ -54,20 +54,71 @@ export function resolveWorktreeBasePath(
|
|
|
54
54
|
return resolve(repoRoot, ".worktrees");
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Generate the batch container directory name.
|
|
59
|
+
*
|
|
60
|
+
* Format: `{opId}-{batchId}`
|
|
61
|
+
* Example: `henrylach-20260308T111750`
|
|
62
|
+
*
|
|
63
|
+
* This is the directory that holds all lane worktrees and the merge
|
|
64
|
+
* worktree for a single batch.
|
|
65
|
+
*
|
|
66
|
+
* @param opId - Operator identifier (sanitized, e.g., "henrylach")
|
|
67
|
+
* @param batchId - Batch ID timestamp (e.g. "20260308T111750")
|
|
68
|
+
*/
|
|
69
|
+
export function generateBatchContainerName(opId: string, batchId: string): string {
|
|
70
|
+
return `${opId}-${batchId}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Generate the absolute path to the batch container directory.
|
|
75
|
+
*
|
|
76
|
+
* All worktrees for a single batch (lanes + merge) live inside this container.
|
|
77
|
+
* Format: `{basePath}/{opId}-{batchId}`
|
|
78
|
+
*
|
|
79
|
+
* Uses `resolveWorktreeBasePath()` to respect `worktree_location` config
|
|
80
|
+
* (sibling vs subdirectory mode). Both `generateWorktreePath()` and
|
|
81
|
+
* `generateMergeWorktreePath()` delegate to this function, ensuring
|
|
82
|
+
* consistent base-path resolution.
|
|
83
|
+
*
|
|
84
|
+
* @param opId - Operator identifier (sanitized, e.g., "henrylach")
|
|
85
|
+
* @param batchId - Batch ID timestamp (e.g. "20260308T111750")
|
|
86
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
87
|
+
* @param config - Orchestrator config (optional; defaults to subdirectory mode)
|
|
88
|
+
* @returns - Absolute path to the batch container directory
|
|
89
|
+
*/
|
|
90
|
+
export function generateBatchContainerPath(
|
|
91
|
+
opId: string,
|
|
92
|
+
batchId: string,
|
|
93
|
+
repoRoot: string,
|
|
94
|
+
config?: OrchestratorConfig,
|
|
95
|
+
): string {
|
|
96
|
+
const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
|
|
97
|
+
const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
|
|
98
|
+
return resolve(basePath, generateBatchContainerName(opId, batchId));
|
|
99
|
+
}
|
|
100
|
+
|
|
57
101
|
/**
|
|
58
102
|
* Generate worktree path based on config's worktree_location setting.
|
|
59
103
|
*
|
|
60
|
-
* Naming rule:
|
|
61
|
-
* Sibling mode: ../{
|
|
62
|
-
* Subdirectory mode: .worktrees/{
|
|
104
|
+
* Naming rule: `{basePath}/{opId}-{batchId}/lane-{N}`
|
|
105
|
+
* Sibling mode: ../{opId}-{batchId}/lane-{N}
|
|
106
|
+
* Subdirectory mode: .worktrees/{opId}-{batchId}/lane-{N}
|
|
107
|
+
*
|
|
108
|
+
* Each batch gets its own container directory, preventing collisions
|
|
109
|
+
* between concurrent batches by the same operator.
|
|
110
|
+
*
|
|
111
|
+
* Uses `generateBatchContainerPath()` for the container directory,
|
|
112
|
+
* preserving `worktree_location` semantics (sibling vs subdirectory).
|
|
63
113
|
*
|
|
64
114
|
* Uses path.resolve() for Windows path normalization (R002 requirement).
|
|
65
115
|
*
|
|
66
|
-
* @param prefix - Directory prefix (
|
|
116
|
+
* @param prefix - Directory prefix (unused in new scheme, kept for API compat)
|
|
67
117
|
* @param laneNumber - Lane number (1-indexed)
|
|
68
118
|
* @param repoRoot - Absolute path to the main repository root
|
|
69
119
|
* @param opId - Operator identifier (sanitized, e.g., "henrylach")
|
|
70
120
|
* @param config - Orchestrator config (optional; defaults to subdirectory mode)
|
|
121
|
+
* @param batchId - Batch ID timestamp (e.g. "20260308T111750")
|
|
71
122
|
*/
|
|
72
123
|
export function generateWorktreePath(
|
|
73
124
|
prefix: string,
|
|
@@ -75,12 +126,90 @@ export function generateWorktreePath(
|
|
|
75
126
|
repoRoot: string,
|
|
76
127
|
opId: string,
|
|
77
128
|
config?: OrchestratorConfig,
|
|
129
|
+
batchId?: string,
|
|
78
130
|
): string {
|
|
131
|
+
if (batchId) {
|
|
132
|
+
// New batch-scoped container layout
|
|
133
|
+
const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config);
|
|
134
|
+
return resolve(containerPath, `lane-${laneNumber}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Legacy fallback (no batchId) — flat layout for backward compatibility
|
|
79
138
|
const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
|
|
80
139
|
const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
|
|
81
140
|
return resolve(basePath, `${prefix}-${opId}-${laneNumber}`);
|
|
82
141
|
}
|
|
83
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Generate the merge worktree path inside a batch container.
|
|
145
|
+
*
|
|
146
|
+
* Format: `{basePath}/{opId}-{batchId}/merge`
|
|
147
|
+
*
|
|
148
|
+
* Uses `generateBatchContainerPath()` for config-aware, base-path-consistent
|
|
149
|
+
* path resolution (respects `worktree_location` setting). This ensures
|
|
150
|
+
* the merge worktree is co-located with lane worktrees in the same
|
|
151
|
+
* batch container for unified cleanup.
|
|
152
|
+
*
|
|
153
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
154
|
+
* @param opId - Operator identifier (sanitized, e.g., "henrylach")
|
|
155
|
+
* @param batchId - Batch ID timestamp (e.g. "20260308T111750")
|
|
156
|
+
* @param config - Orchestrator config (optional; defaults to subdirectory mode)
|
|
157
|
+
*/
|
|
158
|
+
export function generateMergeWorktreePath(
|
|
159
|
+
repoRoot: string,
|
|
160
|
+
opId: string,
|
|
161
|
+
batchId: string,
|
|
162
|
+
config?: OrchestratorConfig,
|
|
163
|
+
): string {
|
|
164
|
+
const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config);
|
|
165
|
+
return resolve(containerPath, "merge");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Ensure the batch container directory exists, creating it if necessary.
|
|
170
|
+
*
|
|
171
|
+
* @param containerPath - Absolute path to the container directory
|
|
172
|
+
*/
|
|
173
|
+
export function ensureBatchContainerDir(containerPath: string): void {
|
|
174
|
+
if (!existsSync(containerPath)) {
|
|
175
|
+
mkdirSync(containerPath, { recursive: true });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Remove a batch container directory if it exists and is empty.
|
|
181
|
+
*
|
|
182
|
+
* Safety rules:
|
|
183
|
+
* - Only removes the directory if it exists
|
|
184
|
+
* - Only removes the directory if it is empty (no files or subdirectories)
|
|
185
|
+
* - Never force-removes a non-empty container (partial failure safety)
|
|
186
|
+
* - Returns whether the container was removed
|
|
187
|
+
*
|
|
188
|
+
* Used after per-worktree removals in `removeAllWorktrees()` and
|
|
189
|
+
* `forceCleanupWorktree()` to clean up the container directory when
|
|
190
|
+
* all worktrees inside it have been removed.
|
|
191
|
+
*
|
|
192
|
+
* @param containerPath - Absolute path to the batch container directory
|
|
193
|
+
* @returns true if the container was removed, false otherwise
|
|
194
|
+
*/
|
|
195
|
+
export function removeBatchContainerIfEmpty(containerPath: string): boolean {
|
|
196
|
+
if (!existsSync(containerPath)) {
|
|
197
|
+
return false; // Already gone — no-op
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const entries = readdirSync(containerPath);
|
|
202
|
+
if (entries.length > 0) {
|
|
203
|
+
return false; // Non-empty — do not remove (partial failure safety)
|
|
204
|
+
}
|
|
205
|
+
rmSync(containerPath, { recursive: false });
|
|
206
|
+
return true;
|
|
207
|
+
} catch {
|
|
208
|
+
// If we can't read or remove — leave it alone (safe default)
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
84
213
|
/**
|
|
85
214
|
* Parse `git worktree list --porcelain` output into structured entries.
|
|
86
215
|
*
|
|
@@ -205,7 +334,7 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W
|
|
|
205
334
|
const { laneNumber, batchId, baseBranch, prefix, opId, config } = opts;
|
|
206
335
|
|
|
207
336
|
const branch = generateBranchName(laneNumber, batchId, opId);
|
|
208
|
-
const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, opId, config);
|
|
337
|
+
const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, opId, config, batchId);
|
|
209
338
|
|
|
210
339
|
// ── Pre-check 1: Validate base branch exists ─────────────────
|
|
211
340
|
const baseBranchCheck = runGit(
|
|
@@ -265,6 +394,12 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W
|
|
|
265
394
|
);
|
|
266
395
|
}
|
|
267
396
|
|
|
397
|
+
// ── Ensure batch container directory exists ──────────────────
|
|
398
|
+
// Placed after pre-checks so no empty container is left behind on
|
|
399
|
+
// validation failure (R004 review feedback).
|
|
400
|
+
const containerDir = resolve(worktreePath, "..");
|
|
401
|
+
ensureBatchContainerDir(containerDir);
|
|
402
|
+
|
|
268
403
|
// ── Create worktree ──────────────────────────────────────────
|
|
269
404
|
const createResult = runGit(
|
|
270
405
|
["worktree", "add", "-b", branch, worktreePath, baseBranch],
|
|
@@ -1041,13 +1176,16 @@ export function preserveBranch(
|
|
|
1041
1176
|
* Parses `git worktree list --porcelain` via parseWorktreeList() and filters
|
|
1042
1177
|
* entries whose path basename matches `{prefix}-{opId}-{N}` (where N is a number).
|
|
1043
1178
|
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1179
|
+
* **Batch-scoped discovery:** When `batchId` is provided, only returns worktrees
|
|
1180
|
+
* inside the specific batch container `{opId}-{batchId}/lane-{N}`. This prevents
|
|
1181
|
+
* cross-batch interference when the same operator runs concurrent batches.
|
|
1182
|
+
*
|
|
1183
|
+
* **Operator-scoped discovery:** When `batchId` is omitted, returns ALL worktrees
|
|
1184
|
+
* belonging to the operator (across all batches). This supports cleanup scenarios
|
|
1185
|
+
* that need to discover all operator worktrees regardless of batch.
|
|
1047
1186
|
*
|
|
1048
|
-
* For backward compatibility, also matches the legacy pattern `{prefix}-{N}`
|
|
1049
|
-
* (
|
|
1050
|
-
* is `"op"` (the default fallback), to avoid capturing other operators' resources.
|
|
1187
|
+
* For backward compatibility, also matches the legacy flat pattern `{prefix}-{opId}-{N}`
|
|
1188
|
+
* and (when opId is "op") `{prefix}-{N}`. This supports transition from old naming.
|
|
1051
1189
|
*
|
|
1052
1190
|
* Lane number is extracted from the path basename pattern. Entries with
|
|
1053
1191
|
* malformed/partial data (missing path, unparseable lane number) are
|
|
@@ -1056,12 +1194,15 @@ export function preserveBranch(
|
|
|
1056
1194
|
* @param prefix - Worktree directory prefix (e.g. "taskplane-wt")
|
|
1057
1195
|
* @param repoRoot - Absolute path to the main repository root
|
|
1058
1196
|
* @param opId - Operator identifier for scoping (e.g., "henrylach")
|
|
1197
|
+
* @param batchId - Optional batch ID for batch-scoped filtering; when provided,
|
|
1198
|
+
* only returns worktrees inside the `{opId}-{batchId}/` container
|
|
1059
1199
|
* @returns - WorktreeInfo[] sorted by laneNumber (ascending)
|
|
1060
1200
|
*/
|
|
1061
|
-
export function listWorktrees(prefix: string, repoRoot: string, opId: string): WorktreeInfo[] {
|
|
1201
|
+
export function listWorktrees(prefix: string, repoRoot: string, opId: string, batchId?: string): WorktreeInfo[] {
|
|
1062
1202
|
const entries = parseWorktreeList(repoRoot);
|
|
1063
1203
|
const results: WorktreeInfo[] = [];
|
|
1064
1204
|
|
|
1205
|
+
// ── Legacy flat patterns ─────────────────────────────────────
|
|
1065
1206
|
// Primary pattern: {prefix}-{opId}-{N}
|
|
1066
1207
|
// Example: "taskplane-wt-henrylach-1"
|
|
1067
1208
|
const primaryPattern = new RegExp(`^${escapeRegex(prefix)}-${escapeRegex(opId)}-(\\d+)$`);
|
|
@@ -1072,27 +1213,60 @@ export function listWorktrees(prefix: string, repoRoot: string, opId: string): W
|
|
|
1072
1213
|
? new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`)
|
|
1073
1214
|
: null;
|
|
1074
1215
|
|
|
1216
|
+
// ── New batch-scoped nested pattern ──────────────────────────
|
|
1217
|
+
// Basename: lane-{N}
|
|
1218
|
+
// Parent directory: {opId}-{batchId} (e.g., "henrylach-20260308T111750")
|
|
1219
|
+
// Full: {basePath}/{opId}-{batchId}/lane-{N}
|
|
1220
|
+
const nestedLanePattern = /^lane-(\d+)$/;
|
|
1221
|
+
// When batchId is provided, match only the exact container for batch isolation.
|
|
1222
|
+
// When omitted, match any container belonging to this operator (all batches).
|
|
1223
|
+
const containerPattern = batchId
|
|
1224
|
+
? new RegExp(`^${escapeRegex(generateBatchContainerName(opId, batchId))}$`)
|
|
1225
|
+
: new RegExp(`^${escapeRegex(opId)}-\\S+$`);
|
|
1226
|
+
|
|
1075
1227
|
for (const entry of entries) {
|
|
1076
1228
|
if (!entry.path) continue;
|
|
1077
1229
|
|
|
1078
|
-
|
|
1079
|
-
const entryBasename = basename(
|
|
1080
|
-
|
|
1081
|
-
// Try
|
|
1082
|
-
|
|
1083
|
-
if (
|
|
1084
|
-
|
|
1230
|
+
const resolvedPath = resolve(entry.path);
|
|
1231
|
+
const entryBasename = basename(resolvedPath);
|
|
1232
|
+
|
|
1233
|
+
// ── Try new nested pattern first ─────────────────────────
|
|
1234
|
+
const nestedMatch = entryBasename.match(nestedLanePattern);
|
|
1235
|
+
if (nestedMatch) {
|
|
1236
|
+
// Verify the parent directory matches the container pattern
|
|
1237
|
+
const parentDir = basename(resolve(resolvedPath, ".."));
|
|
1238
|
+
if (containerPattern.test(parentDir)) {
|
|
1239
|
+
const laneNumber = parseInt(nestedMatch[1], 10);
|
|
1240
|
+
if (!isNaN(laneNumber) && laneNumber >= 1) {
|
|
1241
|
+
results.push({
|
|
1242
|
+
path: resolvedPath,
|
|
1243
|
+
branch: entry.branch || "",
|
|
1244
|
+
laneNumber,
|
|
1245
|
+
});
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1085
1249
|
}
|
|
1086
|
-
if (!match) continue;
|
|
1087
1250
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1251
|
+
// ── Try legacy flat patterns (only when not batch-scoped) ─
|
|
1252
|
+
// When batchId is provided, skip legacy matching — the caller
|
|
1253
|
+
// explicitly wants only this batch's worktrees.
|
|
1254
|
+
if (!batchId) {
|
|
1255
|
+
let match = entryBasename.match(primaryPattern);
|
|
1256
|
+
if (!match && legacyPattern) {
|
|
1257
|
+
match = entryBasename.match(legacyPattern);
|
|
1258
|
+
}
|
|
1259
|
+
if (match) {
|
|
1260
|
+
const laneNumber = parseInt(match[1], 10);
|
|
1261
|
+
if (!isNaN(laneNumber) && laneNumber >= 1) {
|
|
1262
|
+
results.push({
|
|
1263
|
+
path: resolvedPath,
|
|
1264
|
+
branch: entry.branch || "",
|
|
1265
|
+
laneNumber,
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1096
1270
|
}
|
|
1097
1271
|
|
|
1098
1272
|
// Sort by laneNumber ascending (deterministic output)
|
|
@@ -1305,10 +1479,22 @@ export function ensureLaneWorktrees(
|
|
|
1305
1479
|
* When `targetBranch` is provided, branches with unmerged commits are
|
|
1306
1480
|
* preserved as `saved/<branch>` refs instead of being force-deleted.
|
|
1307
1481
|
*
|
|
1482
|
+
* **Batch-scoped cleanup:** When `batchId` is provided, only removes
|
|
1483
|
+
* worktrees inside the specific batch container `{opId}-{batchId}/`.
|
|
1484
|
+
* After removing all worktrees, attempts to remove the empty container
|
|
1485
|
+
* directory. When `batchId` is omitted, removes all operator worktrees
|
|
1486
|
+
* (all batches, including legacy flat-layout).
|
|
1487
|
+
*
|
|
1488
|
+
* **Container cleanup:** After per-worktree removals, each touched batch
|
|
1489
|
+
* container directory is checked and removed if empty. Non-empty containers
|
|
1490
|
+
* (from partial failures or active worktrees) are left intact.
|
|
1491
|
+
*
|
|
1308
1492
|
* @param prefix - Worktree directory prefix (e.g. "taskplane-wt")
|
|
1309
1493
|
* @param repoRoot - Absolute path to the main repository root
|
|
1310
1494
|
* @param opId - Operator identifier for scoping (e.g., "henrylach")
|
|
1311
1495
|
* @param targetBranch - Optional target branch for unmerged commit detection (e.g. "develop")
|
|
1496
|
+
* @param batchId - Optional batch ID for batch-scoped cleanup
|
|
1497
|
+
* @param config - Optional orchestrator config (needed for container path resolution when batchId is provided)
|
|
1312
1498
|
* @returns - RemoveAllWorktreesResult with per-worktree outcomes
|
|
1313
1499
|
*/
|
|
1314
1500
|
export function removeAllWorktrees(
|
|
@@ -1316,8 +1502,10 @@ export function removeAllWorktrees(
|
|
|
1316
1502
|
repoRoot: string,
|
|
1317
1503
|
opId: string,
|
|
1318
1504
|
targetBranch?: string,
|
|
1505
|
+
batchId?: string,
|
|
1506
|
+
config?: OrchestratorConfig,
|
|
1319
1507
|
): RemoveAllWorktreesResult {
|
|
1320
|
-
const worktrees = listWorktrees(prefix, repoRoot, opId);
|
|
1508
|
+
const worktrees = listWorktrees(prefix, repoRoot, opId, batchId);
|
|
1321
1509
|
const outcomes: RemoveWorktreeOutcome[] = [];
|
|
1322
1510
|
const removed: WorktreeInfo[] = [];
|
|
1323
1511
|
const failed: RemoveWorktreeOutcome[] = [];
|
|
@@ -1360,6 +1548,30 @@ export function removeAllWorktrees(
|
|
|
1360
1548
|
}
|
|
1361
1549
|
}
|
|
1362
1550
|
|
|
1551
|
+
// ── Container cleanup ────────────────────────────────────────
|
|
1552
|
+
// After removing worktrees, attempt to remove empty batch container
|
|
1553
|
+
// directories. Collect unique container paths from removed worktrees,
|
|
1554
|
+
// then remove each one only if empty (partial failure safety).
|
|
1555
|
+
const containerPaths = new Set<string>();
|
|
1556
|
+
for (const wt of removed) {
|
|
1557
|
+
const parentDir = resolve(wt.path, "..");
|
|
1558
|
+
// Only consider directories that look like batch containers
|
|
1559
|
+
// (i.e., parent is not the base worktree path itself)
|
|
1560
|
+
const parentName = basename(parentDir);
|
|
1561
|
+
if (parentName.startsWith(`${opId}-`)) {
|
|
1562
|
+
containerPaths.add(parentDir);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
// When batchId is explicitly provided, also add the expected container path
|
|
1566
|
+
// even if no worktrees were found (cleanup of empty containers from prior runs)
|
|
1567
|
+
if (batchId && config) {
|
|
1568
|
+
const expectedContainer = generateBatchContainerPath(opId, batchId, repoRoot, config);
|
|
1569
|
+
containerPaths.add(expectedContainer);
|
|
1570
|
+
}
|
|
1571
|
+
for (const containerPath of containerPaths) {
|
|
1572
|
+
removeBatchContainerIfEmpty(containerPath);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1363
1575
|
return {
|
|
1364
1576
|
totalAttempted: worktrees.length,
|
|
1365
1577
|
removed,
|
|
@@ -1754,5 +1966,19 @@ export function forceCleanupWorktree(
|
|
|
1754
1966
|
});
|
|
1755
1967
|
}
|
|
1756
1968
|
}
|
|
1969
|
+
|
|
1970
|
+
// Step 4: Attempt to remove the batch container directory if empty
|
|
1971
|
+
// The worktree path is {basePath}/{opId}-{batchId}/lane-{N}, so the
|
|
1972
|
+
// container is the parent directory.
|
|
1973
|
+
const containerDir = resolve(worktreePath, "..");
|
|
1974
|
+
const containerName = basename(containerDir);
|
|
1975
|
+
// Only attempt container cleanup if the parent looks like a batch container
|
|
1976
|
+
// (contains a hyphen, indicating {opId}-{batchId} naming)
|
|
1977
|
+
if (containerName.includes("-")) {
|
|
1978
|
+
const containerRemoved = removeBatchContainerIfEmpty(containerDir);
|
|
1979
|
+
if (containerRemoved) {
|
|
1980
|
+
execLog("cleanup", `lane-${laneNumber}`, `removed empty batch container`, { path: containerDir });
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1757
1983
|
}
|
|
1758
1984
|
|