taskplane 0.28.4 → 0.28.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,1548 +1,1548 @@
1
- /**
2
- * Wave computation, graph validation, lane assignment/allocation
3
- * @module orch/waves
4
- */
5
- import { join } from "path";
6
-
7
- import { parseDependencyReference } from "./discovery.ts";
8
- import { resolveOperatorId } from "./naming.ts";
9
- import { AllocationError, buildSegmentId, getTaskDurationMinutes } from "./types.ts";
10
- import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, TaskSegmentPlan, TaskSegmentPlanMap, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts";
11
- import { getCurrentBranch, runGit } from "./git.ts";
12
- import { ensureLaneWorktrees, removeAllWorktrees, removeWorktree } from "./worktree.ts";
13
-
14
- // ── Dependency Graph Construction ────────────────────────────────────
15
-
16
- /**
17
- * Build a dependency graph from the task registry.
18
- *
19
- * Source of truth: `ParsedTask.dependencies` from discovery phase (Step 4).
20
- * No re-parsing of PROMPT.md. The graph only contains pending tasks as nodes.
21
- * Completed tasks are NOT added as nodes — they are treated as pre-satisfied
22
- * in-degree contributors during wave computation.
23
- */
24
- export function buildDependencyGraph(
25
- pending: Map<string, ParsedTask>,
26
- completed: Set<string>,
27
- ): DependencyGraph {
28
- const dependencies = new Map<string, string[]>();
29
- const dependents = new Map<string, string[]>();
30
- const nodes = new Set<string>();
31
-
32
- // Initialize all pending tasks as graph nodes
33
- for (const taskId of pending.keys()) {
34
- nodes.add(taskId);
35
- dependencies.set(taskId, []);
36
- dependents.set(taskId, []);
37
- }
38
-
39
- // Build adjacency lists from parsed dependencies
40
- for (const [taskId, task] of pending) {
41
- const edgeSet = new Set<string>();
42
- for (const depRaw of task.dependencies) {
43
- const depId = parseDependencyReference(depRaw).taskId;
44
- if (edgeSet.has(depId)) continue;
45
- edgeSet.add(depId);
46
- // Only add edges to other pending tasks (completed = already satisfied)
47
- if (pending.has(depId)) {
48
- dependencies.get(taskId)!.push(depId);
49
- dependents.get(depId)!.push(taskId);
50
- }
51
- // If depId is completed, it's pre-satisfied — no edge needed
52
- // If depId is unknown, that's a validation error caught by validateGraph()
53
- }
54
- }
55
-
56
- return { dependencies, dependents, nodes };
57
- }
58
-
59
-
60
- // ── Graph Validation ─────────────────────────────────────────────────
61
-
62
- /**
63
- * Validate the dependency graph for correctness.
64
- *
65
- * Checks performed (in order):
66
- * 1. Self-edges: task depends on itself (A → A)
67
- * 2. Duplicate dependencies: same dep listed twice
68
- * 3. Missing targets: dependency on unknown task (not pending, not completed)
69
- * 4. Circular dependencies: DFS cycle detection with full cycle path
70
- *
71
- * Returns all errors found (does not stop at first error).
72
- */
73
- export function validateGraph(
74
- graph: DependencyGraph,
75
- pending: Map<string, ParsedTask>,
76
- completed: Set<string>,
77
- ): GraphValidationResult {
78
- const errors: DiscoveryError[] = [];
79
-
80
- // 1. Self-edge check
81
- for (const [taskId, task] of pending) {
82
- for (const depRaw of task.dependencies) {
83
- const depId = parseDependencyReference(depRaw).taskId;
84
- if (depId === taskId) {
85
- errors.push({
86
- code: "DEP_UNRESOLVED",
87
- message: `${taskId} has a self-dependency (depends on itself)`,
88
- taskId,
89
- taskPath: task.promptPath,
90
- });
91
- }
92
- }
93
- }
94
-
95
- // 2. Duplicate dependency check (same target task referenced multiple times)
96
- for (const [taskId, task] of pending) {
97
- const seenTargets = new Set<string>();
98
- for (const depRaw of task.dependencies) {
99
- const depId = parseDependencyReference(depRaw).taskId;
100
- if (seenTargets.has(depId)) {
101
- errors.push({
102
- code: "DEP_UNRESOLVED",
103
- message: `${taskId} lists duplicate dependency targeting ${depId}`,
104
- taskId,
105
- taskPath: task.promptPath,
106
- });
107
- }
108
- seenTargets.add(depId);
109
- }
110
- }
111
-
112
- // 3. Missing target check (not in pending AND not in completed)
113
- for (const [taskId, task] of pending) {
114
- for (const depRaw of task.dependencies) {
115
- const depId = parseDependencyReference(depRaw).taskId;
116
- if (!pending.has(depId) && !completed.has(depId)) {
117
- errors.push({
118
- code: "DEP_UNRESOLVED",
119
- message: `${taskId} depends on ${depRaw} which is neither pending nor completed`,
120
- taskId,
121
- taskPath: task.promptPath,
122
- });
123
- }
124
- }
125
- }
126
-
127
- // 4. Circular dependency detection (DFS with cycle path extraction)
128
- const visited = new Set<string>();
129
- const inStack = new Set<string>();
130
-
131
- function dfs(node: string): string[] | null {
132
- if (inStack.has(node)) {
133
- // Found a cycle — reconstruct path
134
- return [node];
135
- }
136
- if (visited.has(node)) return null;
137
-
138
- visited.add(node);
139
- inStack.add(node);
140
-
141
- const deps = graph.dependencies.get(node) || [];
142
- // Deterministic order: sort dependencies alphabetically
143
- const sortedDeps = [...deps].sort();
144
-
145
- for (const dep of sortedDeps) {
146
- const cyclePath = dfs(dep);
147
- if (cyclePath) {
148
- // If we haven't closed the cycle yet, keep adding nodes
149
- if (cyclePath.length === 1 || cyclePath[0] !== cyclePath[cyclePath.length - 1]) {
150
- cyclePath.push(node);
151
- }
152
- return cyclePath;
153
- }
154
- }
155
-
156
- inStack.delete(node);
157
- return null;
158
- }
159
-
160
- // Process nodes in deterministic (sorted) order
161
- const sortedNodes = [...graph.nodes].sort();
162
- for (const node of sortedNodes) {
163
- if (!visited.has(node)) {
164
- const cyclePath = dfs(node);
165
- if (cyclePath) {
166
- // Reverse so the path reads naturally: A → B → C → A
167
- cyclePath.reverse();
168
- const cycleStr = cyclePath.join(" → ");
169
- errors.push({
170
- code: "DEP_UNRESOLVED",
171
- message: `Circular dependency detected: ${cycleStr}`,
172
- });
173
- // Only report first cycle to avoid noisy output
174
- break;
175
- }
176
- }
177
- }
178
-
179
- return {
180
- valid: errors.length === 0,
181
- errors,
182
- };
183
- }
184
-
185
-
186
- // ── Wave Computation (Topological Sort) ──────────────────────────────
187
-
188
- /**
189
- * Compute execution waves via Kahn's algorithm (topological sort).
190
- *
191
- * Algorithm contract:
192
- * - Completed tasks are pre-satisfied: they contribute 0 in-degree but are
193
- * excluded from the scheduled output.
194
- * - Wave 1: all pending tasks with 0 unmet dependencies (deps are either
195
- * completed or have no deps).
196
- * - Wave N+1: tasks whose deps are all in waves 1..N or completed.
197
- * - Deterministic ordering: within each wave, tasks are sorted by task ID
198
- * alphabetically. Queue initialization and zero in-degree pops both use
199
- * sorted order.
200
- * - If not all tasks are placed (cycle exists), returns an error.
201
- */
202
- export function computeWaves(
203
- graph: DependencyGraph,
204
- completed: Set<string>,
205
- pending: Map<string, ParsedTask>,
206
- ): { waves: string[][]; errors: DiscoveryError[] } {
207
- const errors: DiscoveryError[] = [];
208
- const waves: string[][] = [];
209
-
210
- // Calculate in-degree for each node (only counting edges from other pending tasks)
211
- const inDegree = new Map<string, number>();
212
- for (const node of graph.nodes) {
213
- const deps = graph.dependencies.get(node) || [];
214
- // Only count deps that are in the pending set (completed are pre-satisfied)
215
- const pendingDeps = deps.filter((d) => graph.nodes.has(d));
216
- inDegree.set(node, pendingDeps.length);
217
- }
218
-
219
- const placed = new Set<string>();
220
- const remaining = new Set(graph.nodes);
221
-
222
- while (remaining.size > 0) {
223
- // Collect all nodes with in-degree 0 (all deps satisfied)
224
- const waveNodes: string[] = [];
225
- for (const node of remaining) {
226
- if ((inDegree.get(node) || 0) === 0) {
227
- waveNodes.push(node);
228
- }
229
- }
230
-
231
- // Deterministic ordering: sort alphabetically by task ID
232
- waveNodes.sort();
233
-
234
- if (waveNodes.length === 0) {
235
- // Remaining nodes all have unsatisfied deps — cycle exists
236
- const stuckNodes = [...remaining].sort().join(", ");
237
- errors.push({
238
- code: "DEP_UNRESOLVED",
239
- message: `Cannot schedule remaining tasks (possible cycle): ${stuckNodes}`,
240
- });
241
- break;
242
- }
243
-
244
- waves.push(waveNodes);
245
-
246
- // Remove placed nodes and reduce in-degree for dependents
247
- for (const node of waveNodes) {
248
- placed.add(node);
249
- remaining.delete(node);
250
-
251
- const deps = graph.dependents.get(node) || [];
252
- for (const dependent of deps) {
253
- const current = inDegree.get(dependent) || 0;
254
- inDegree.set(dependent, current - 1);
255
- }
256
- }
257
- }
258
-
259
- return { waves, errors };
260
- }
261
-
262
-
263
- // ── File Scope Affinity ──────────────────────────────────────────────
264
-
265
- /**
266
- * Group tasks with overlapping file scopes into affinity groups.
267
- *
268
- * Uses connected components over a file-scope overlap graph:
269
- * - Nodes are task IDs within the wave
270
- * - Edges connect tasks that share at least one file scope entry
271
- * - Connected components form affinity groups
272
- *
273
- * Affinity groups should be assigned to the same lane for serial execution
274
- * to avoid file-writing conflicts.
275
- *
276
- * Edge cases:
277
- * - Tasks with empty file scope: no affinity edges (independent)
278
- * - Partial overlaps: if A overlaps B and B overlaps C, all three
279
- * are in the same affinity group (transitive closure)
280
- * - Oversized groups (> maxLanes): group stays together on one lane
281
- * (serial fallback — correctness over parallelism)
282
- */
283
- export function normalizeScope(scope: string): string {
284
- return scope.replace(/\\/g, "/").trim().replace(/\/+/g, "/").replace(/\/$/, "");
285
- }
286
-
287
- export function isGlobScope(scope: string): boolean {
288
- return scope.includes("*");
289
- }
290
-
291
- export function prefixOfGlob(scope: string): string {
292
- const idx = scope.indexOf("*");
293
- if (idx < 0) return scope;
294
- return scope.slice(0, idx).replace(/\/$/, "");
295
- }
296
-
297
- export function pathStartsWithSegment(pathValue: string, prefix: string): boolean {
298
- if (!prefix) return true;
299
- return pathValue === prefix || pathValue.startsWith(`${prefix}/`);
300
- }
301
-
302
- export function scopesOverlap(aRaw: string, bRaw: string): boolean {
303
- const a = normalizeScope(aRaw);
304
- const b = normalizeScope(bRaw);
305
- if (!a || !b) return false;
306
- if (a === b) return true;
307
-
308
- const aGlob = isGlobScope(a);
309
- const bGlob = isGlobScope(b);
310
-
311
- // file vs file (no wildcards): overlap only on exact match
312
- if (!aGlob && !bGlob) return false;
313
-
314
- if (aGlob && !bGlob) {
315
- return pathStartsWithSegment(b, prefixOfGlob(a));
316
- }
317
- if (!aGlob && bGlob) {
318
- return pathStartsWithSegment(a, prefixOfGlob(b));
319
- }
320
-
321
- // glob vs glob: overlap if either prefix contains the other
322
- const aPrefix = prefixOfGlob(a);
323
- const bPrefix = prefixOfGlob(b);
324
- return pathStartsWithSegment(aPrefix, bPrefix) || pathStartsWithSegment(bPrefix, aPrefix);
325
- }
326
-
327
- export function taskScopesOverlap(taskA: ParsedTask, taskB: ParsedTask): boolean {
328
- if (taskA.fileScope.length === 0 || taskB.fileScope.length === 0) return false;
329
- for (const scopeA of taskA.fileScope) {
330
- for (const scopeB of taskB.fileScope) {
331
- if (scopesOverlap(scopeA, scopeB)) return true;
332
- }
333
- }
334
- return false;
335
- }
336
-
337
- export function applyFileScopeAffinity(
338
- waveTasks: string[],
339
- pending: Map<string, ParsedTask>,
340
- ): string[][] {
341
- if (waveTasks.length === 0) return [];
342
-
343
- // Build overlap graph using Union-Find
344
- const parent = new Map<string, string>();
345
- const rank = new Map<string, number>();
346
-
347
- for (const taskId of waveTasks) {
348
- parent.set(taskId, taskId);
349
- rank.set(taskId, 0);
350
- }
351
-
352
- function find(x: string): string {
353
- while (parent.get(x) !== x) {
354
- parent.set(x, parent.get(parent.get(x)!)!);
355
- x = parent.get(x)!;
356
- }
357
- return x;
358
- }
359
-
360
- function union(a: string, b: string): void {
361
- const ra = find(a);
362
- const rb = find(b);
363
- if (ra === rb) return;
364
- const rankA = rank.get(ra) || 0;
365
- const rankB = rank.get(rb) || 0;
366
- if (rankA < rankB) {
367
- parent.set(ra, rb);
368
- } else if (rankA > rankB) {
369
- parent.set(rb, ra);
370
- } else {
371
- parent.set(rb, ra);
372
- rank.set(ra, rankA + 1);
373
- }
374
- }
375
-
376
- // Pairwise overlap check (handles exact + wildcard overlaps)
377
- for (let i = 0; i < waveTasks.length; i++) {
378
- for (let j = i + 1; j < waveTasks.length; j++) {
379
- const taskA = pending.get(waveTasks[i]);
380
- const taskB = pending.get(waveTasks[j]);
381
- if (!taskA || !taskB) continue;
382
- if (taskScopesOverlap(taskA, taskB)) {
383
- union(taskA.taskId, taskB.taskId);
384
- }
385
- }
386
- }
387
-
388
- const groups = new Map<string, string[]>();
389
- for (const taskId of waveTasks) {
390
- const root = find(taskId);
391
- const group = groups.get(root) || [];
392
- group.push(taskId);
393
- groups.set(root, group);
394
- }
395
-
396
- const result: string[][] = [];
397
- for (const group of groups.values()) {
398
- group.sort();
399
- result.push(group);
400
- }
401
- result.sort((a, b) => a[0].localeCompare(b[0]));
402
-
403
- return result;
404
- }
405
-
406
-
407
- // ── Repo-Scoped Lane Helpers ─────────────────────────────────────────
408
-
409
- /**
410
- * A group of tasks targeting the same repository.
411
- *
412
- * In repo mode: all tasks are in one group with `repoId` undefined.
413
- * In workspace mode: tasks are grouped by `resolvedRepoId`.
414
- */
415
- export interface RepoTaskGroup {
416
- /** Repo ID (undefined for repo mode / tasks without resolvedRepoId) */
417
- repoId: string | undefined;
418
- /** Task IDs in this group (sorted alphabetically) */
419
- taskIds: string[];
420
- }
421
-
422
- /**
423
- * Group wave tasks by their resolved repo ID.
424
- *
425
- * In workspace mode, tasks carry `resolvedRepoId` from the discovery/routing
426
- * phase. This function groups them so each repo gets independent lane
427
- * allocation (own affinity groups, own max_lanes budget).
428
- *
429
- * In repo mode, all tasks have `resolvedRepoId === undefined`, so they all
430
- * land in a single group keyed by `""` (empty string). This preserves
431
- * existing single-repo behavior exactly.
432
- *
433
- * Deterministic ordering guarantees:
434
- * 1. Groups are sorted by repoId (undefined sorts first as empty string)
435
- * 2. Task IDs within each group are sorted alphabetically
436
- *
437
- * @param waveTasks - Task IDs in this wave
438
- * @param pending - Full pending task map (from discovery)
439
- * @returns RepoTaskGroup[] sorted by repoId then by task IDs within group
440
- */
441
- export function groupTasksByRepo(
442
- waveTasks: string[],
443
- pending: Map<string, ParsedTask>,
444
- ): RepoTaskGroup[] {
445
- const groupMap = new Map<string, string[]>();
446
-
447
- for (const taskId of waveTasks) {
448
- const task = pending.get(taskId);
449
- // Use resolvedRepoId or empty string as group key (undefined → "" for Map key)
450
- const key = task?.resolvedRepoId ?? "";
451
- const existing = groupMap.get(key) || [];
452
- existing.push(taskId);
453
- groupMap.set(key, existing);
454
- }
455
-
456
- // Build sorted groups
457
- const groups: RepoTaskGroup[] = [];
458
- const sortedKeys = [...groupMap.keys()].sort();
459
- for (const key of sortedKeys) {
460
- const taskIds = groupMap.get(key)!;
461
- taskIds.sort(); // Deterministic task order within group
462
- groups.push({
463
- repoId: key || undefined, // Convert "" back to undefined for repo mode
464
- taskIds,
465
- });
466
- }
467
-
468
- return groups;
469
- }
470
-
471
- /**
472
- * Generate a lane identifier string.
473
- *
474
- * - Repo mode (repoId undefined): `"lane-{N}"` — preserves legacy format
475
- * - Workspace mode (repoId set): `"{repoId}/lane-{N}"` — collision-safe across repos
476
- *
477
- * The `laneLocalNumber` is the 1-indexed lane number within the repo group
478
- * (NOT the global lane number). This gives operators clear per-repo context.
479
- *
480
- * @param laneLocalNumber - Lane number within the repo group (1-indexed)
481
- * @param repoId - Repo identifier (undefined in repo mode)
482
- */
483
- export function generateLaneId(laneLocalNumber: number, repoId?: string): string {
484
- if (repoId) {
485
- return `${repoId}/lane-${laneLocalNumber}`;
486
- }
487
- return `lane-${laneLocalNumber}`;
488
- }
489
-
490
- /**
491
- * Generate a lane session identifier for a lane.
492
- *
493
- * Includes the operator identifier (`opId`) for collision resistance
494
- * across concurrent operators on the same machine.
495
- *
496
- * - Repo mode: `"{prefix}-{opId}-lane-{N}"` — operator-scoped
497
- * - Workspace mode: `"{prefix}-{opId}-{repoId}-lane-{N}"` — operator + repo scoped
498
- *
499
- * Session identifiers must not contain periods or colons. Both `opId`
500
- * and `repoId` are assumed to be sanitized identifiers (alphanumeric
501
- * + hyphens only).
502
- *
503
- * @param sessionPrefix - Session prefix from config (e.g., "orch")
504
- * @param laneLocalNumber - Lane number within the repo group (1-indexed)
505
- * @param opId - Operator identifier (sanitized, e.g., "henrylach")
506
- * @param repoId - Repo identifier (undefined in repo mode)
507
- */
508
- export function generateLaneSessionId(sessionPrefix: string, laneLocalNumber: number, opId: string, repoId?: string): string {
509
- if (repoId) {
510
- return `${sessionPrefix}-${opId}-${repoId}-lane-${laneLocalNumber}`;
511
- }
512
- return `${sessionPrefix}-${opId}-lane-${laneLocalNumber}`;
513
- }
514
-
515
-
516
- // ── Repo-Scoped Worktree Resolution ─────────────────────────────────
517
-
518
- /**
519
- * Resolve the repo root path for a given repo group.
520
- *
521
- * - Repo mode (repoId undefined): returns the passed `defaultRepoRoot`.
522
- * - Workspace mode (repoId set): looks up `workspaceConfig.repos.get(repoId).path`.
523
- * Falls back to `defaultRepoRoot` if repoId is not found in config (defensive).
524
- *
525
- * @param repoId - Repo identifier (undefined in repo mode)
526
- * @param defaultRepoRoot - Default repo root (the single repoRoot in repo mode)
527
- * @param workspaceConfig - Workspace configuration (null in repo mode)
528
- * @returns Absolute path to the repo root for this group
529
- */
530
- export function resolveRepoRoot(
531
- repoId: string | undefined,
532
- defaultRepoRoot: string,
533
- workspaceConfig?: WorkspaceConfig | null,
534
- ): string {
535
- if (!repoId || !workspaceConfig) {
536
- return defaultRepoRoot;
537
- }
538
- const repoConfig = workspaceConfig.repos.get(repoId);
539
- if (!repoConfig) {
540
- // Defensive fallback — discovery/routing should have caught this
541
- return defaultRepoRoot;
542
- }
543
- return repoConfig.path;
544
- }
545
-
546
- /**
547
- * Resolve the base branch for worktree creation in a given repo.
548
- *
549
- * Fallback chain (first non-empty wins):
550
- * 1. `WorkspaceRepoConfig.defaultBranch` — explicit per-repo override from workspace config
551
- * 2. Detected current branch via `getCurrentBranch(repoRoot)` — runtime detection
552
- * 3. `batchBaseBranch` — the branch captured at batch start (ultimate fallback)
553
- *
554
- * In repo mode (repoId undefined), step 1 is skipped and step 2 uses
555
- * the same repo root as the batch, so the result is equivalent to
556
- * `batchBaseBranch` (which was itself detected from that repo).
557
- *
558
- * @param repoId - Repo identifier (undefined in repo mode)
559
- * @param repoRoot - Absolute path to this repo's root
560
- * @param batchBaseBranch - The base branch captured at batch start
561
- * @param workspaceConfig - Workspace configuration (null in repo mode)
562
- * @returns Branch name to base worktrees on for this repo
563
- */
564
- export function resolveBaseBranch(
565
- repoId: string | undefined,
566
- repoRoot: string,
567
- batchBaseBranch: string,
568
- workspaceConfig?: WorkspaceConfig | null,
569
- ): string {
570
- // Step 0: If the batch base branch is an orch branch (wave 2+), check if
571
- // it exists in this repo. The orch branch has merged work from previous
572
- // waves — worktrees MUST branch from it so workers see prior wave output.
573
- // Without this, wave 2 worktrees branch from the repo's HEAD (e.g. develop)
574
- // which lacks wave 1's code, causing dependency satisfaction failures.
575
- if (batchBaseBranch.startsWith("orch/") && repoId) {
576
- try {
577
- const check = runGit(["rev-parse", "--verify", `refs/heads/${batchBaseBranch}`], repoRoot);
578
- if (check.ok) {
579
- return batchBaseBranch;
580
- }
581
- // TP-146: Orch branch exists as batch base but not in this repo.
582
- // This means worktrees will branch from the repo's current HEAD
583
- // instead of the orch branch, bypassing batch isolation.
584
- console.error(
585
- `[taskplane] resolveBaseBranch WARNING: orch branch "${batchBaseBranch}" not found in repo "${repoId}" at ${repoRoot} — falling back to repo HEAD. ` +
586
- `This bypasses orch branch isolation. Ensure the orch branch was created in all workspace repos.`,
587
- );
588
- } catch (err) {
589
- console.error(
590
- `[taskplane] resolveBaseBranch WARNING: orch branch check failed for repo "${repoId}" at ${repoRoot}: ${err}`,
591
- );
592
- }
593
- }
594
-
595
- // Step 1: Detect current branch of this specific repo.
596
- // This is the branch the developer is working on — worktrees should
597
- // branch from here so task files committed on this branch are visible.
598
- // In repo mode this equals batchBaseBranch. In workspace mode this
599
- // detects each repo's actual HEAD independently.
600
- if (repoId) {
601
- const detected = getCurrentBranch(repoRoot);
602
- if (detected) {
603
- return detected;
604
- }
605
- }
606
-
607
- // Step 2: Per-repo default branch from workspace config.
608
- // Used when repo HEAD is detached or undetectable.
609
- if (repoId && workspaceConfig) {
610
- const repoConfig = workspaceConfig.repos.get(repoId);
611
- if (repoConfig?.defaultBranch) {
612
- return repoConfig.defaultBranch;
613
- }
614
- }
615
-
616
- // Step 3: Ultimate fallback — batch-level base branch.
617
- // In workspace mode the batch base branch is the orch branch (e.g.
618
- // "orch/op-batch123"), which only exists in the primary repo. Using it
619
- // for a secondary repo would cause worktree creation failure because the
620
- // ref doesn't exist there. Fail fast with an actionable message instead.
621
- if (repoId && batchBaseBranch.startsWith("orch/")) {
622
- throw new Error(
623
- `Cannot resolve base branch for repo "${repoId}" at ${repoRoot}: ` +
624
- `HEAD is detached and no defaultBranch is configured. ` +
625
- `The batch base branch "${batchBaseBranch}" is an orch branch that does not exist in this repo. ` +
626
- `Configure a defaultBranch for this repo in task-orchestrator.yaml workspace settings.`,
627
- );
628
- }
629
-
630
- return batchBaseBranch;
631
- }
632
-
633
-
634
- // ── Segment Planning (TP-080) ───────────────────────────────────────
635
-
636
- const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
637
- const INFERRED_LINEAR_REASON = "inferred:first-appearance-linear-chain";
638
-
639
- function normalizeRepoIdCandidate(raw: string): string | null {
640
- const candidate = raw.trim().toLowerCase();
641
- if (!SEGMENT_REPO_ID_PATTERN.test(candidate)) return null;
642
- return candidate;
643
- }
644
-
645
- interface SegmentPlanBuildOptions {
646
- /** Optional workspace repo IDs used to validate file-scope repo prefixes. */
647
- workspaceRepoIds?: Iterable<string>;
648
- }
649
-
650
- function collectKnownRepoIds(
651
- pending: Map<string, ParsedTask>,
652
- workspaceRepoIds?: Iterable<string>,
653
- ): Set<string> {
654
- const known = new Set<string>();
655
-
656
- if (workspaceRepoIds) {
657
- for (const repoIdRaw of workspaceRepoIds) {
658
- const repoId = normalizeRepoIdCandidate(String(repoIdRaw));
659
- if (repoId) known.add(repoId);
660
- }
661
- }
662
-
663
- for (const task of pending.values()) {
664
- if (task.resolvedRepoId) {
665
- const repoId = normalizeRepoIdCandidate(task.resolvedRepoId);
666
- if (repoId) known.add(repoId);
667
- }
668
- if (task.explicitSegmentDag) {
669
- for (const repoIdRaw of task.explicitSegmentDag.repoIds) {
670
- const repoId = normalizeRepoIdCandidate(repoIdRaw);
671
- if (repoId) known.add(repoId);
672
- }
673
- }
674
- }
675
- return known;
676
- }
677
-
678
- function extractRepoPrefixFromFileScope(fileScopeEntry: string): string | null {
679
- const normalized = fileScopeEntry.replace(/\\/g, "/").trim();
680
- if (!normalized) return null;
681
- const firstSegment = normalized.split("/")[0]?.trim();
682
- if (!firstSegment) return null;
683
- return normalizeRepoIdCandidate(firstSegment);
684
- }
685
-
686
- interface InferredRepoOrder {
687
- repoIds: string[];
688
- usedFallback: boolean;
689
- }
690
-
691
- /**
692
- * Build deterministic repo ordering for inferred segment plans.
693
- *
694
- * Signal precedence:
695
- * 1) file scope repo prefixes (first appearance)
696
- * 2) dependency task repos (first appearance)
697
- * 3) fallback to `resolvedRepoId`, then synthetic `default`
698
- */
699
- export function inferTaskRepoOrder(
700
- task: ParsedTask,
701
- pending: Map<string, ParsedTask>,
702
- knownRepoIds: Set<string>,
703
- ): InferredRepoOrder {
704
- const firstAppearance = new Map<string, number>();
705
- let cursor = 0;
706
-
707
- function record(repoIdRaw: string, requireKnown = false): string | null {
708
- const repoId = normalizeRepoIdCandidate(repoIdRaw);
709
- if (!repoId) return null;
710
- if (requireKnown && knownRepoIds.size > 0 && !knownRepoIds.has(repoId)) return null;
711
- if (!firstAppearance.has(repoId)) {
712
- firstAppearance.set(repoId, cursor++);
713
- }
714
- return repoId;
715
- }
716
-
717
- let hasPrimarySignal = false;
718
-
719
- for (const scopeEntry of task.fileScope) {
720
- if (knownRepoIds.size === 0) {
721
- // Repo-mode guard: without known workspace repo IDs, fileScope prefixes like
722
- // "src/" or "lib/" are ambiguous and should not create synthetic segments.
723
- continue;
724
- }
725
- const repoId = extractRepoPrefixFromFileScope(scopeEntry);
726
- if (!repoId) continue;
727
- if (record(repoId, true) !== null) {
728
- hasPrimarySignal = true;
729
- }
730
- }
731
-
732
- for (const depRaw of task.dependencies) {
733
- const depId = parseDependencyReference(depRaw).taskId;
734
- const depTask = pending.get(depId);
735
- if (depTask?.resolvedRepoId && record(depTask.resolvedRepoId, true) !== null) {
736
- hasPrimarySignal = true;
737
- }
738
- }
739
-
740
- if (!hasPrimarySignal) {
741
- const fallback = normalizeRepoIdCandidate(task.resolvedRepoId ?? "") || "default";
742
- return {
743
- repoIds: [fallback],
744
- usedFallback: true,
745
- };
746
- }
747
-
748
- if (task.resolvedRepoId) {
749
- record(task.resolvedRepoId, true);
750
- }
751
-
752
- const repoIds = [...firstAppearance.entries()]
753
- .sort((a, b) => {
754
- if (a[1] !== b[1]) return a[1] - b[1];
755
- return a[0].localeCompare(b[0]);
756
- })
757
- .map(([repoId]) => repoId);
758
-
759
- return {
760
- repoIds,
761
- usedFallback: false,
762
- };
763
- }
764
-
765
- function sortSegmentEdges<T extends { fromSegmentId: string; toSegmentId: string }>(
766
- edges: T[],
767
- ): T[] {
768
- return [...edges].sort((a, b) => {
769
- if (a.fromSegmentId !== b.fromSegmentId) return a.fromSegmentId.localeCompare(b.fromSegmentId);
770
- return a.toSegmentId.localeCompare(b.toSegmentId);
771
- });
772
- }
773
-
774
- function buildSegmentNodes(taskId: string, repoIds: string[]) {
775
- const nodes = repoIds.map((repoId, order) => ({
776
- segmentId: buildSegmentId(taskId, repoId),
777
- taskId,
778
- repoId,
779
- order,
780
- }));
781
- return nodes.sort((a, b) => (a.order - b.order) || a.repoId.localeCompare(b.repoId));
782
- }
783
-
784
- export function buildSegmentPlanForTask(
785
- task: ParsedTask,
786
- pending: Map<string, ParsedTask>,
787
- knownRepoIds: Set<string>,
788
- ): TaskSegmentPlan {
789
- if (task.explicitSegmentDag) {
790
- const repoIds = [...task.explicitSegmentDag.repoIds];
791
- const segments = buildSegmentNodes(task.taskId, repoIds);
792
- const edges = sortSegmentEdges(
793
- task.explicitSegmentDag.edges.map((edge) => ({
794
- fromSegmentId: buildSegmentId(task.taskId, edge.fromRepoId),
795
- toSegmentId: buildSegmentId(task.taskId, edge.toRepoId),
796
- provenance: "explicit" as const,
797
- reason: "prompt:segment-dag",
798
- })),
799
- );
800
- return {
801
- taskId: task.taskId,
802
- segments,
803
- edges,
804
- mode: "explicit-dag",
805
- };
806
- }
807
-
808
- const inferred = inferTaskRepoOrder(task, pending, knownRepoIds);
809
- const segments = buildSegmentNodes(task.taskId, inferred.repoIds);
810
- const edges = sortSegmentEdges(
811
- segments.slice(0, -1).map((segment, idx) => ({
812
- fromSegmentId: segment.segmentId,
813
- toSegmentId: segments[idx + 1].segmentId,
814
- provenance: "inferred" as const,
815
- reason: INFERRED_LINEAR_REASON,
816
- })),
817
- );
818
-
819
- return {
820
- taskId: task.taskId,
821
- segments,
822
- edges,
823
- mode: inferred.usedFallback ? "repo-singleton" : "inferred-sequential",
824
- };
825
- }
826
-
827
- /** Build a deterministic taskId→segmentPlan map for the whole pending set. */
828
- export function buildTaskSegmentPlans(
829
- pending: Map<string, ParsedTask>,
830
- options: SegmentPlanBuildOptions = {},
831
- ): TaskSegmentPlanMap {
832
- const knownRepoIds = collectKnownRepoIds(pending, options.workspaceRepoIds);
833
- const plans: TaskSegmentPlanMap = new Map();
834
- for (const taskId of [...pending.keys()].sort()) {
835
- const task = pending.get(taskId);
836
- if (!task) continue;
837
- plans.set(taskId, buildSegmentPlanForTask(task, pending, knownRepoIds));
838
- }
839
- return plans;
840
- }
841
-
842
-
843
- // ── Lane Assignment ──────────────────────────────────────────────────
844
-
845
- /**
846
- * Assign tasks within a wave to lanes.
847
- *
848
- * Algorithm (affinity-first strategy):
849
- * 1. Compute affinity groups via file scope overlap
850
- * 2. Each affinity group goes to one lane (serial within lane)
851
- * 3. Remaining single-task "groups" are distributed via round-robin
852
- * or load-balanced fill
853
- * 4. Lane count: min(number of groups, maxLanes)
854
- *
855
- * Deterministic tie-breaking: groups are sorted by first task ID,
856
- * then assigned in order. Round-robin assignment is deterministic
857
- * given deterministic group ordering.
858
- *
859
- * For "round-robin" strategy: simple sequential assignment.
860
- * For "load-balanced" strategy: assign to lane with lowest total weight.
861
- * For "affinity-first": affinity groups first, then load-balanced fill.
862
- */
863
- export function assignTasksToLanes(
864
- waveTasks: string[],
865
- pending: Map<string, ParsedTask>,
866
- maxLanes: number,
867
- strategy: string,
868
- sizeWeights: Record<string, number>,
869
- ): LaneAssignment[] {
870
- if (waveTasks.length === 0) return [];
871
-
872
- // Step 1: Compute affinity groups
873
- const affinityGroups = applyFileScopeAffinity(waveTasks, pending);
874
-
875
- // Step 2: Determine lane count
876
- const laneCount = Math.min(affinityGroups.length, maxLanes);
877
-
878
- // Step 3: Initialize lane weights (for load-balanced assignment)
879
- const laneWeights: number[] = new Array(laneCount).fill(0);
880
- const laneAssignments: LaneAssignment[][] = new Array(laneCount)
881
- .fill(null)
882
- .map(() => []);
883
-
884
- function getWeight(taskId: string): number {
885
- const task = pending.get(taskId);
886
- if (!task) return sizeWeights["M"] || 2;
887
- return sizeWeights[task.size] || sizeWeights["M"] || 2;
888
- }
889
-
890
- function assignGroupToLane(group: string[], laneIndex: number): void {
891
- for (const taskId of group) {
892
- const task = pending.get(taskId);
893
- if (!task) continue;
894
- laneAssignments[laneIndex].push({
895
- taskId,
896
- lane: laneIndex + 1, // 1-indexed lanes
897
- task,
898
- });
899
- laneWeights[laneIndex] += getWeight(taskId);
900
- }
901
- }
902
-
903
- function findLightestLane(): number {
904
- let minIdx = 0;
905
- let minWeight = laneWeights[0];
906
- for (let i = 1; i < laneCount; i++) {
907
- if (laneWeights[i] < minWeight) {
908
- minWeight = laneWeights[i];
909
- minIdx = i;
910
- }
911
- }
912
- return minIdx;
913
- }
914
-
915
- // Step 4: Assign groups to lanes based on strategy
916
- if (strategy === "round-robin") {
917
- for (let i = 0; i < affinityGroups.length; i++) {
918
- const laneIdx = i % laneCount;
919
- assignGroupToLane(affinityGroups[i], laneIdx);
920
- }
921
- } else if (strategy === "load-balanced") {
922
- // Sort groups by weight (heaviest first for better balance)
923
- const sortedGroups = [...affinityGroups].sort((a, b) => {
924
- const weightA = a.reduce((sum, id) => sum + getWeight(id), 0);
925
- const weightB = b.reduce((sum, id) => sum + getWeight(id), 0);
926
- if (weightB !== weightA) return weightB - weightA;
927
- // Deterministic tie-break: alphabetical by first task ID
928
- return a[0].localeCompare(b[0]);
929
- });
930
- for (const group of sortedGroups) {
931
- const laneIdx = findLightestLane();
932
- assignGroupToLane(group, laneIdx);
933
- }
934
- } else {
935
- // affinity-first: multi-task groups get priority, then load-balanced fill
936
- const multiGroups = affinityGroups.filter((g) => g.length > 1);
937
- const singleGroups = affinityGroups.filter((g) => g.length === 1);
938
-
939
- // Assign multi-task affinity groups first (heaviest first)
940
- const sortedMulti = [...multiGroups].sort((a, b) => {
941
- const weightA = a.reduce((sum, id) => sum + getWeight(id), 0);
942
- const weightB = b.reduce((sum, id) => sum + getWeight(id), 0);
943
- if (weightB !== weightA) return weightB - weightA;
944
- return a[0].localeCompare(b[0]);
945
- });
946
- for (const group of sortedMulti) {
947
- const laneIdx = findLightestLane();
948
- assignGroupToLane(group, laneIdx);
949
- }
950
-
951
- // Fill remaining with single-task groups (load-balanced)
952
- const sortedSingles = [...singleGroups].sort((a, b) => {
953
- const weightA = getWeight(a[0]);
954
- const weightB = getWeight(b[0]);
955
- if (weightB !== weightA) return weightB - weightA;
956
- return a[0].localeCompare(b[0]);
957
- });
958
- for (const group of sortedSingles) {
959
- const laneIdx = findLightestLane();
960
- assignGroupToLane(group, laneIdx);
961
- }
962
- }
963
-
964
- // Flatten all lane assignments into a single array
965
- const result: LaneAssignment[] = [];
966
- for (const assignments of laneAssignments) {
967
- result.push(...assignments);
968
- }
969
-
970
- return result;
971
- }
972
-
973
-
974
- // ── Global Lane Cap (TP-148) ─────────────────────────────────────────
975
-
976
- /**
977
- * Enforce a global lane cap across all repo groups.
978
- *
979
- * In workspace mode, each repo independently allocates up to `maxLanes`.
980
- * This function reduces the total across all repos to fit within the
981
- * global `maxLanes` budget by consolidating lanes in repos with the
982
- * most headroom (most lanes relative to their minimum of 1).
983
- *
984
- * Algorithm:
985
- * 1. If total lanes ≤ maxLanes, no-op.
986
- * 2. Group lanes by repo, sort repos by lane count descending.
987
- * 3. Iteratively remove the last lane from the repo with the most
988
- * lanes, redistributing its tasks to the lightest remaining lane
989
- * in that repo.
990
- * 4. Stop when total ≤ maxLanes or all repos are at 1 lane.
991
- * 5. Renumber global lanes sequentially.
992
- *
993
- * Mutates `entries` in place: removes excess entries and renumbers.
994
- *
995
- * @param entries - Global lane entries from per-repo allocation
996
- * @param maxLanes - Global maximum lane count
997
- */
998
- export function enforceGlobalLaneCap(
999
- entries: Array<{
1000
- globalLane: number;
1001
- localLane: number;
1002
- repoId: string | undefined;
1003
- assignments: LaneAssignment[];
1004
- }>,
1005
- maxLanes: number,
1006
- ): void {
1007
- if (entries.length <= maxLanes) return;
1008
-
1009
- // Group entries by repoId
1010
- const byRepo = new Map<string, typeof entries>();
1011
- for (const entry of entries) {
1012
- const key = entry.repoId ?? "";
1013
- const group = byRepo.get(key) || [];
1014
- group.push(entry);
1015
- byRepo.set(key, group);
1016
- }
1017
-
1018
- let excess = entries.length - maxLanes;
1019
-
1020
- while (excess > 0) {
1021
- // Find the repo with the most lanes (ties broken by key for determinism)
1022
- let bestKey = "";
1023
- let bestCount = 0;
1024
- for (const [key, group] of byRepo) {
1025
- if (group.length > bestCount || (group.length === bestCount && key < bestKey)) {
1026
- bestKey = key;
1027
- bestCount = group.length;
1028
- }
1029
- }
1030
-
1031
- // All repos at 1 lane — can't reduce further
1032
- if (bestCount <= 1) break;
1033
-
1034
- // Remove the last lane from this repo and redistribute its tasks
1035
- const group = byRepo.get(bestKey)!;
1036
- const removed = group.pop()!;
1037
- // Merge into the first lane of the same repo (deterministic target)
1038
- group[0].assignments.push(...removed.assignments);
1039
- excess--;
1040
- }
1041
-
1042
- // Warn if cap could not be fully enforced (more repos than maxLanes)
1043
- const finalTotal = [...byRepo.values()].reduce((sum, g) => sum + g.length, 0);
1044
- if (finalTotal > maxLanes) {
1045
- console.error(
1046
- `[taskplane] warning: global maxLanes=${maxLanes} could not be enforced — ` +
1047
- `${byRepo.size} repos each need at least 1 lane (total: ${finalTotal}). ` +
1048
- `Increase maxLanes to at least ${byRepo.size} to avoid this.`,
1049
- );
1050
- }
1051
-
1052
- // Rebuild entries array with sequential global lane numbers
1053
- entries.length = 0;
1054
- let globalLane = 1;
1055
- for (const key of [...byRepo.keys()].sort()) {
1056
- const group = byRepo.get(key)!;
1057
- for (const entry of group) {
1058
- entry.globalLane = globalLane++;
1059
- entries.push(entry);
1060
- }
1061
- }
1062
- }
1063
-
1064
-
1065
- /**
1066
- * Result of `allocateLanes()`.
1067
- *
1068
- * On success: `success=true`, `lanes` contains all allocated lanes.
1069
- * On failure: `success=false`, `error` describes what went wrong,
1070
- * `rolledBack` indicates whether partial worktrees were cleaned up.
1071
- */
1072
- export interface AllocateLanesResult {
1073
- /** Whether all lanes were allocated successfully */
1074
- success: boolean;
1075
- /** Allocated lanes, sorted by laneNumber. Empty on failure. */
1076
- lanes: AllocatedLane[];
1077
- /** Number of lanes allocated */
1078
- laneCount: number;
1079
- /** Error details (null on success) */
1080
- error: {
1081
- code: AllocationErrorCode;
1082
- message: string;
1083
- details?: string;
1084
- } | null;
1085
- /** Whether partial worktrees were rolled back on failure */
1086
- rolledBack: boolean;
1087
- /** Batch ID used for branch/session naming */
1088
- batchId: string;
1089
- }
1090
-
1091
- /**
1092
- * Validate allocation inputs before proceeding.
1093
- *
1094
- * Checks:
1095
- * - max_lanes >= 1
1096
- * - waveTasks is non-empty
1097
- * - All task IDs in waveTasks exist in pending map
1098
- * - Config has valid strategy and size_weights
1099
- *
1100
- * @returns null if valid, AllocationError if invalid
1101
- */
1102
- export function validateAllocationInputs(
1103
- waveTasks: string[],
1104
- pending: Map<string, ParsedTask>,
1105
- config: OrchestratorConfig,
1106
- ): AllocationError | null {
1107
- // Validate max_lanes
1108
- if (
1109
- !config.orchestrator.max_lanes ||
1110
- config.orchestrator.max_lanes < 1 ||
1111
- !Number.isInteger(config.orchestrator.max_lanes)
1112
- ) {
1113
- return new AllocationError(
1114
- "ALLOC_INVALID_CONFIG",
1115
- `max_lanes must be a positive integer, got: ${config.orchestrator.max_lanes}`,
1116
- );
1117
- }
1118
-
1119
- // Validate wave has tasks
1120
- if (!waveTasks || waveTasks.length === 0) {
1121
- return new AllocationError(
1122
- "ALLOC_EMPTY_WAVE",
1123
- "Cannot allocate lanes for an empty wave (no tasks provided)",
1124
- );
1125
- }
1126
-
1127
- // Validate all task IDs exist in pending map
1128
- const missingTasks: string[] = [];
1129
- for (const taskId of waveTasks) {
1130
- if (!pending.has(taskId)) {
1131
- missingTasks.push(taskId);
1132
- }
1133
- }
1134
- if (missingTasks.length > 0) {
1135
- return new AllocationError(
1136
- "ALLOC_TASK_NOT_FOUND",
1137
- `Task IDs not found in pending map: ${missingTasks.join(", ")}`,
1138
- `These tasks may have been completed or removed between discovery and allocation.`,
1139
- );
1140
- }
1141
-
1142
- // Validate strategy is recognized
1143
- const validStrategies = ["affinity-first", "round-robin", "load-balanced"];
1144
- if (!validStrategies.includes(config.assignment.strategy)) {
1145
- return new AllocationError(
1146
- "ALLOC_INVALID_CONFIG",
1147
- `Unknown assignment strategy: "${config.assignment.strategy}". ` +
1148
- `Valid strategies: ${validStrategies.join(", ")}`,
1149
- );
1150
- }
1151
-
1152
- // Validate worktree prefix is non-empty
1153
- if (!config.orchestrator.worktree_prefix?.trim()) {
1154
- return new AllocationError(
1155
- "ALLOC_INVALID_CONFIG",
1156
- `worktree_prefix must be a non-empty string`,
1157
- );
1158
- }
1159
-
1160
- return null;
1161
- }
1162
-
1163
- /**
1164
- * Allocate lanes for a wave: assign tasks, create worktrees, return ready-to-execute lanes.
1165
- *
1166
- * This is the Phase 3 implementation from §5 of the design doc.
1167
- * It coordinates four stages:
1168
- *
1169
- * 0. **Input validation** — config, tasks, strategy checks.
1170
- *
1171
- * 1. **Repo grouping** — tasks are grouped by `resolvedRepoId` via
1172
- * `groupTasksByRepo()`. In repo mode (no resolvedRepoId), all tasks
1173
- * go to a single group, preserving existing behavior exactly.
1174
- *
1175
- * 2. **Per-repo affinity grouping + strategy assignment** — for each repo
1176
- * group, `assignTasksToLanes()` runs independently with its own
1177
- * max_lanes budget. Lane numbers within each group are 1-indexed.
1178
- * Groups are processed in deterministic order (sorted by repoId).
1179
- * Global lane numbers are assigned sequentially across repo groups
1180
- * (repo A gets lanes 1..Na, repo B gets lanes Na+1..Na+Nb, etc.).
1181
- *
1182
- * 3. **Worktree provisioning** — ensure one worktree per global lane via
1183
- * `ensureLaneWorktrees()`. Existing lanes are reused across waves;
1184
- * missing lanes are created. If creating a missing lane fails,
1185
- * newly-created lanes in this call are rolled back.
1186
- *
1187
- * 4. **Build AllocatedLane[]** — each lane gets repo-aware `laneId` and
1188
- * `laneSessionId`. In workspace mode: `"api/lane-1"`, `"orch-api-lane-1"`.
1189
- * In repo mode: `"lane-1"`,
1190
- * `"orch-lane-1"` (unchanged).
1191
- *
1192
- * **Determinism guarantee:** Given the same `waveTasks`, `pending`, and `config`,
1193
- * this function always produces the same lane assignments and task ordering.
1194
- * Repo group order is sorted alphabetically by repoId. Lane assignment within
1195
- * each group uses the configured strategy deterministically.
1196
- *
1197
- * @param waveTasks - Task IDs in this wave (from topological sort)
1198
- * @param pending - Full pending task map (from discovery)
1199
- * @param config - Orchestrator configuration
1200
- * @param repoRoot - Absolute path to the main/default repository root
1201
- * @param batchId - Batch ID for branch/session naming (e.g., "20260308T111750")
1202
- * @param baseBranch - Branch to base worktrees on (captured at batch start)
1203
- * @param workspaceConfig - Workspace configuration for repo routing (null/undefined = repo mode)
1204
- * @returns - AllocateLanesResult with success flag and lane details
1205
- */
1206
- export function allocateLanes(
1207
- waveTasks: string[],
1208
- pending: Map<string, ParsedTask>,
1209
- config: OrchestratorConfig,
1210
- repoRoot: string,
1211
- batchId: string,
1212
- baseBranch: string,
1213
- workspaceConfig?: WorkspaceConfig | null,
1214
- ): AllocateLanesResult {
1215
- // ── Stage 0: Input validation ────────────────────────────────
1216
- const validationError = validateAllocationInputs(waveTasks, pending, config);
1217
- if (validationError) {
1218
- return {
1219
- success: false,
1220
- lanes: [],
1221
- laneCount: 0,
1222
- error: {
1223
- code: validationError.code,
1224
- message: validationError.message,
1225
- details: validationError.details,
1226
- },
1227
- rolledBack: false,
1228
- batchId,
1229
- };
1230
- }
1231
-
1232
- // ── Stage 1: Group tasks by repo ─────────────────────────────
1233
- const repoGroups = groupTasksByRepo(waveTasks, pending);
1234
-
1235
- // ── Stage 2: Per-repo affinity grouping + strategy assignment ─
1236
- // Each repo group gets independent lane assignment. Lane numbers
1237
- // within each group start at 1. We track a globalLaneOffset to
1238
- // produce globally unique lane numbers across all repo groups.
1239
- //
1240
- // The structure tracks: global lane number → { repoId, localLane, assignments }
1241
- const globalLaneEntries: Array<{
1242
- globalLane: number;
1243
- localLane: number;
1244
- repoId: string | undefined;
1245
- assignments: LaneAssignment[];
1246
- }> = [];
1247
-
1248
- let globalLaneOffset = 0;
1249
-
1250
- for (const group of repoGroups) {
1251
- const groupAssignments = assignTasksToLanes(
1252
- group.taskIds,
1253
- pending,
1254
- config.orchestrator.max_lanes,
1255
- config.assignment.strategy,
1256
- config.assignment.size_weights,
1257
- );
1258
-
1259
- // Determine local lane numbers used in this group's assignment
1260
- const localLaneNumbers = new Set(groupAssignments.map((a) => a.lane));
1261
- const sortedLocalLanes = [...localLaneNumbers].sort((a, b) => a - b);
1262
-
1263
- // Map local lane numbers to global lane numbers
1264
- const localToGlobal = new Map<number, number>();
1265
- for (let i = 0; i < sortedLocalLanes.length; i++) {
1266
- localToGlobal.set(sortedLocalLanes[i], globalLaneOffset + i + 1);
1267
- }
1268
-
1269
- // Group assignments by local lane number
1270
- const byLocalLane = new Map<number, LaneAssignment[]>();
1271
- for (const a of groupAssignments) {
1272
- const existing = byLocalLane.get(a.lane) || [];
1273
- existing.push(a);
1274
- byLocalLane.set(a.lane, existing);
1275
- }
1276
-
1277
- // Produce global lane entries
1278
- for (const localLane of sortedLocalLanes) {
1279
- globalLaneEntries.push({
1280
- globalLane: localToGlobal.get(localLane)!,
1281
- localLane,
1282
- repoId: group.repoId,
1283
- assignments: byLocalLane.get(localLane) || [],
1284
- });
1285
- }
1286
-
1287
- globalLaneOffset += sortedLocalLanes.length;
1288
- }
1289
-
1290
- // ── Stage 2b: Enforce global lane cap (TP-148) ─────────────────
1291
- // In workspace mode, each repo group independently allocates up to
1292
- // maxLanes. If total lanes across all repos exceeds the global
1293
- // maxLanes limit, reduce lanes in repos with the most headroom.
1294
- // Preserves at least 1 lane per repo with tasks.
1295
- enforceGlobalLaneCap(globalLaneEntries, config.orchestrator.max_lanes);
1296
-
1297
- const laneCount = globalLaneEntries.length;
1298
-
1299
- if (laneCount === 0) {
1300
- return {
1301
- success: false,
1302
- lanes: [],
1303
- laneCount: 0,
1304
- error: {
1305
- code: "ALLOC_EMPTY_WAVE",
1306
- message: "Lane assignment produced zero lanes (no tasks could be assigned)",
1307
- },
1308
- rolledBack: false,
1309
- batchId,
1310
- };
1311
- }
1312
-
1313
- // ── Stage 3: Ensure lane worktrees exist per repo group ──────
1314
- // In repo mode: all lanes use the single repoRoot/baseBranch (unchanged).
1315
- // In workspace mode: each repo group's lanes are created against that
1316
- // repo's root with its resolved base branch. Cross-repo rollback on
1317
- // partial failure ensures atomic wave provisioning.
1318
- //
1319
- // Group globalLaneEntries by repoId for per-repo worktree provisioning.
1320
- const repoLaneGroups = new Map<string, number[]>(); // key → global lane numbers
1321
- const repoIdForGroup = new Map<string, string | undefined>(); // key → repoId
1322
- for (const entry of globalLaneEntries) {
1323
- const key = entry.repoId ?? "";
1324
- const existing = repoLaneGroups.get(key) || [];
1325
- existing.push(entry.globalLane);
1326
- repoLaneGroups.set(key, existing);
1327
- repoIdForGroup.set(key, entry.repoId);
1328
- }
1329
- const sortedGroupKeys = [...repoLaneGroups.keys()].sort();
1330
-
1331
- // Track all worktrees created across all repo groups for cross-repo rollback
1332
- const allWorktrees = new Map<number, WorktreeInfo>(); // global lane → worktree
1333
- const createdGroupKeys: string[] = []; // groups that succeeded (for rollback tracking)
1334
-
1335
- for (const groupKey of sortedGroupKeys) {
1336
- const groupLaneNumbers = repoLaneGroups.get(groupKey)!;
1337
- const groupRepoId = repoIdForGroup.get(groupKey);
1338
- const groupRepoRoot = resolveRepoRoot(groupRepoId, repoRoot, workspaceConfig);
1339
- const groupBaseBranch = resolveBaseBranch(groupRepoId, groupRepoRoot, baseBranch, workspaceConfig);
1340
-
1341
- const worktreeResult = ensureLaneWorktrees(
1342
- groupLaneNumbers,
1343
- batchId,
1344
- config,
1345
- groupRepoRoot,
1346
- groupBaseBranch,
1347
- );
1348
-
1349
- if (!worktreeResult.success) {
1350
- // ── Cross-repo rollback: remove worktrees from all previously-succeeded groups ─
1351
- const rollbackErrors: string[] = [];
1352
- for (const prevKey of createdGroupKeys) {
1353
- const prevRepoId = repoIdForGroup.get(prevKey);
1354
- const prevRepoRoot = resolveRepoRoot(prevRepoId, repoRoot, workspaceConfig);
1355
- const prevLanes = repoLaneGroups.get(prevKey)!;
1356
- for (const lane of prevLanes) {
1357
- const wt = allWorktrees.get(lane);
1358
- if (wt) {
1359
- try {
1360
- removeWorktree(wt, prevRepoRoot);
1361
- } catch (rbErr: unknown) {
1362
- rollbackErrors.push(
1363
- `Lane ${lane} (repo ${prevRepoId ?? "default"}): ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`,
1364
- );
1365
- }
1366
- }
1367
- }
1368
- }
1369
-
1370
- const failedLanes = worktreeResult.errors
1371
- .map((e) => `Lane ${e.laneNumber}: [${e.code}] ${e.message}`)
1372
- .join("\n");
1373
- const withinGroupRollbackIssues = worktreeResult.rollbackErrors.length > 0
1374
- ? "\nWithin-group rollback issues:\n" +
1375
- worktreeResult.rollbackErrors
1376
- .map((e) => ` Lane ${e.laneNumber}: [${e.code}] ${e.message}`)
1377
- .join("\n")
1378
- : "";
1379
- const crossRepoRollbackIssues = rollbackErrors.length > 0
1380
- ? "\nCross-repo rollback issues:\n" +
1381
- rollbackErrors.map((e) => ` ${e}`).join("\n")
1382
- : "";
1383
-
1384
- return {
1385
- success: false,
1386
- lanes: [],
1387
- laneCount: 0,
1388
- error: {
1389
- code: "ALLOC_WORKTREE_FAILED",
1390
- message: `Failed to create worktrees for repo "${groupRepoId ?? "default"}" (${groupLaneNumbers.length} lane(s))`,
1391
- details: failedLanes + withinGroupRollbackIssues + crossRepoRollbackIssues,
1392
- },
1393
- rolledBack: true,
1394
- batchId,
1395
- };
1396
- }
1397
-
1398
- // Record successful worktrees
1399
- for (const wt of worktreeResult.worktrees) {
1400
- allWorktrees.set(wt.laneNumber, wt);
1401
- }
1402
- createdGroupKeys.push(groupKey);
1403
- }
1404
-
1405
- // ── Stage 4: Build AllocatedLane[] from assignments + worktrees ─
1406
- const sessionPrefix = config.orchestrator.sessionPrefix || "orch";
1407
- const opId = resolveOperatorId(config);
1408
- const strategy = config.assignment.strategy as AllocatedLane["strategy"];
1409
- const sizeWeights = config.assignment.size_weights;
1410
-
1411
- const allocatedLanes: AllocatedLane[] = [];
1412
-
1413
- for (const entry of globalLaneEntries) {
1414
- const wt = allWorktrees.get(entry.globalLane);
1415
- if (!wt) {
1416
- // This should never happen if ensureLaneWorktrees and assignTasksToLanes
1417
- // agree on lane numbers, but handle defensively.
1418
- // Roll back all worktrees across all repos on this unexpected failure.
1419
- // Pass batchId + config for batch-scoped cleanup (only remove this batch's worktrees).
1420
- for (const groupKey of createdGroupKeys) {
1421
- const groupRepoId = repoIdForGroup.get(groupKey);
1422
- const groupRepoRoot = resolveRepoRoot(groupRepoId, repoRoot, workspaceConfig);
1423
- removeAllWorktrees(config.orchestrator.worktree_prefix, groupRepoRoot, opId, undefined, batchId, config);
1424
- }
1425
- return {
1426
- success: false,
1427
- lanes: [],
1428
- laneCount: 0,
1429
- error: {
1430
- code: "ALLOC_WORKTREE_FAILED",
1431
- message: `No worktree found for lane ${entry.globalLane} — lane count mismatch between assignment and worktree creation`,
1432
- },
1433
- rolledBack: true,
1434
- batchId,
1435
- };
1436
- }
1437
-
1438
- // Build ordered task list (preserve assignment order from assignTasksToLanes)
1439
- const allocatedTasks: AllocatedTask[] = entry.assignments.map((a, idx) => ({
1440
- taskId: a.taskId,
1441
- order: idx,
1442
- task: a.task,
1443
- estimatedMinutes: getTaskDurationMinutes(a.task.size, sizeWeights),
1444
- }));
1445
-
1446
- const estimatedLoad = allocatedTasks.reduce(
1447
- (sum, t) => sum + (sizeWeights[t.task.size] || sizeWeights["M"] || 2),
1448
- 0,
1449
- );
1450
- const estimatedMinutes = allocatedTasks.reduce(
1451
- (sum, t) => sum + t.estimatedMinutes,
1452
- 0,
1453
- );
1454
-
1455
- const laneSessionId = generateLaneSessionId(sessionPrefix, entry.localLane, opId, entry.repoId);
1456
- allocatedLanes.push({
1457
- laneNumber: entry.globalLane,
1458
- laneId: generateLaneId(entry.localLane, entry.repoId),
1459
- laneSessionId,
1460
- worktreePath: wt.path,
1461
- branch: wt.branch,
1462
- tasks: allocatedTasks,
1463
- strategy,
1464
- estimatedLoad,
1465
- estimatedMinutes,
1466
- repoId: entry.repoId,
1467
- });
1468
- }
1469
-
1470
- // Sort by global lane number for deterministic output
1471
- allocatedLanes.sort((a, b) => a.laneNumber - b.laneNumber);
1472
-
1473
- return {
1474
- success: true,
1475
- lanes: allocatedLanes,
1476
- laneCount: allocatedLanes.length,
1477
- error: null,
1478
- rolledBack: false,
1479
- batchId,
1480
- };
1481
- }
1482
-
1483
-
1484
- // ── Full Wave Pipeline ───────────────────────────────────────────────
1485
-
1486
- /**
1487
- * Run the full wave computation pipeline:
1488
- * 1. Build dependency graph from registry
1489
- * 2. Validate graph (self-edges, duplicates, cycles, missing targets)
1490
- * 3. Compute topological waves
1491
- * 4. Assign tasks to lanes within each wave
1492
- *
1493
- * Returns WaveAssignment[] with wave numbers and lane assignments,
1494
- * plus any errors encountered.
1495
- */
1496
- export interface WaveComputationOptions {
1497
- /** Optional workspace repo IDs used by segment inference in workspace mode. */
1498
- workspaceRepoIds?: Iterable<string>;
1499
- }
1500
-
1501
- export function computeWaveAssignments(
1502
- pending: Map<string, ParsedTask>,
1503
- completed: Set<string>,
1504
- config: OrchestratorConfig,
1505
- options: WaveComputationOptions = {},
1506
- ): WaveComputationResult {
1507
- const errors: DiscoveryError[] = [];
1508
-
1509
- // Step 1: Build dependency graph
1510
- const graph = buildDependencyGraph(pending, completed);
1511
-
1512
- // Step 2: Validate graph
1513
- const validation = validateGraph(graph, pending, completed);
1514
- if (!validation.valid) {
1515
- return { waves: [], errors: validation.errors };
1516
- }
1517
-
1518
- // Step 3: Compute topological waves
1519
- const { waves: rawWaves, errors: waveErrors } = computeWaves(graph, completed, pending);
1520
- if (waveErrors.length > 0) {
1521
- return { waves: [], errors: waveErrors };
1522
- }
1523
-
1524
- // Step 3.5: Build additive segment planning output (deterministic map)
1525
- const segmentPlans = buildTaskSegmentPlans(pending, {
1526
- workspaceRepoIds: options.workspaceRepoIds,
1527
- });
1528
-
1529
- // Step 4: Assign tasks to lanes within each wave
1530
- const waveAssignments: WaveAssignment[] = [];
1531
- for (let i = 0; i < rawWaves.length; i++) {
1532
- const waveTasks = rawWaves[i];
1533
- const laneAssignments = assignTasksToLanes(
1534
- waveTasks,
1535
- pending,
1536
- config.orchestrator.max_lanes,
1537
- config.assignment.strategy,
1538
- config.assignment.size_weights,
1539
- );
1540
-
1541
- waveAssignments.push({
1542
- waveNumber: i + 1,
1543
- tasks: laneAssignments,
1544
- });
1545
- }
1546
-
1547
- return { waves: waveAssignments, errors, segmentPlans };
1548
- }
1
+ /**
2
+ * Wave computation, graph validation, lane assignment/allocation
3
+ * @module orch/waves
4
+ */
5
+ import { join } from "path";
6
+
7
+ import { parseDependencyReference } from "./discovery.ts";
8
+ import { resolveOperatorId } from "./naming.ts";
9
+ import { AllocationError, buildSegmentId, getTaskDurationMinutes } from "./types.ts";
10
+ import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, TaskSegmentPlan, TaskSegmentPlanMap, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts";
11
+ import { getCurrentBranch, runGit } from "./git.ts";
12
+ import { ensureLaneWorktrees, removeAllWorktrees, removeWorktree } from "./worktree.ts";
13
+
14
+ // ── Dependency Graph Construction ────────────────────────────────────
15
+
16
+ /**
17
+ * Build a dependency graph from the task registry.
18
+ *
19
+ * Source of truth: `ParsedTask.dependencies` from discovery phase (Step 4).
20
+ * No re-parsing of PROMPT.md. The graph only contains pending tasks as nodes.
21
+ * Completed tasks are NOT added as nodes — they are treated as pre-satisfied
22
+ * in-degree contributors during wave computation.
23
+ */
24
+ export function buildDependencyGraph(
25
+ pending: Map<string, ParsedTask>,
26
+ completed: Set<string>,
27
+ ): DependencyGraph {
28
+ const dependencies = new Map<string, string[]>();
29
+ const dependents = new Map<string, string[]>();
30
+ const nodes = new Set<string>();
31
+
32
+ // Initialize all pending tasks as graph nodes
33
+ for (const taskId of pending.keys()) {
34
+ nodes.add(taskId);
35
+ dependencies.set(taskId, []);
36
+ dependents.set(taskId, []);
37
+ }
38
+
39
+ // Build adjacency lists from parsed dependencies
40
+ for (const [taskId, task] of pending) {
41
+ const edgeSet = new Set<string>();
42
+ for (const depRaw of task.dependencies) {
43
+ const depId = parseDependencyReference(depRaw).taskId;
44
+ if (edgeSet.has(depId)) continue;
45
+ edgeSet.add(depId);
46
+ // Only add edges to other pending tasks (completed = already satisfied)
47
+ if (pending.has(depId)) {
48
+ dependencies.get(taskId)!.push(depId);
49
+ dependents.get(depId)!.push(taskId);
50
+ }
51
+ // If depId is completed, it's pre-satisfied — no edge needed
52
+ // If depId is unknown, that's a validation error caught by validateGraph()
53
+ }
54
+ }
55
+
56
+ return { dependencies, dependents, nodes };
57
+ }
58
+
59
+
60
+ // ── Graph Validation ─────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Validate the dependency graph for correctness.
64
+ *
65
+ * Checks performed (in order):
66
+ * 1. Self-edges: task depends on itself (A → A)
67
+ * 2. Duplicate dependencies: same dep listed twice
68
+ * 3. Missing targets: dependency on unknown task (not pending, not completed)
69
+ * 4. Circular dependencies: DFS cycle detection with full cycle path
70
+ *
71
+ * Returns all errors found (does not stop at first error).
72
+ */
73
+ export function validateGraph(
74
+ graph: DependencyGraph,
75
+ pending: Map<string, ParsedTask>,
76
+ completed: Set<string>,
77
+ ): GraphValidationResult {
78
+ const errors: DiscoveryError[] = [];
79
+
80
+ // 1. Self-edge check
81
+ for (const [taskId, task] of pending) {
82
+ for (const depRaw of task.dependencies) {
83
+ const depId = parseDependencyReference(depRaw).taskId;
84
+ if (depId === taskId) {
85
+ errors.push({
86
+ code: "DEP_UNRESOLVED",
87
+ message: `${taskId} has a self-dependency (depends on itself)`,
88
+ taskId,
89
+ taskPath: task.promptPath,
90
+ });
91
+ }
92
+ }
93
+ }
94
+
95
+ // 2. Duplicate dependency check (same target task referenced multiple times)
96
+ for (const [taskId, task] of pending) {
97
+ const seenTargets = new Set<string>();
98
+ for (const depRaw of task.dependencies) {
99
+ const depId = parseDependencyReference(depRaw).taskId;
100
+ if (seenTargets.has(depId)) {
101
+ errors.push({
102
+ code: "DEP_UNRESOLVED",
103
+ message: `${taskId} lists duplicate dependency targeting ${depId}`,
104
+ taskId,
105
+ taskPath: task.promptPath,
106
+ });
107
+ }
108
+ seenTargets.add(depId);
109
+ }
110
+ }
111
+
112
+ // 3. Missing target check (not in pending AND not in completed)
113
+ for (const [taskId, task] of pending) {
114
+ for (const depRaw of task.dependencies) {
115
+ const depId = parseDependencyReference(depRaw).taskId;
116
+ if (!pending.has(depId) && !completed.has(depId)) {
117
+ errors.push({
118
+ code: "DEP_UNRESOLVED",
119
+ message: `${taskId} depends on ${depRaw} which is neither pending nor completed`,
120
+ taskId,
121
+ taskPath: task.promptPath,
122
+ });
123
+ }
124
+ }
125
+ }
126
+
127
+ // 4. Circular dependency detection (DFS with cycle path extraction)
128
+ const visited = new Set<string>();
129
+ const inStack = new Set<string>();
130
+
131
+ function dfs(node: string): string[] | null {
132
+ if (inStack.has(node)) {
133
+ // Found a cycle — reconstruct path
134
+ return [node];
135
+ }
136
+ if (visited.has(node)) return null;
137
+
138
+ visited.add(node);
139
+ inStack.add(node);
140
+
141
+ const deps = graph.dependencies.get(node) || [];
142
+ // Deterministic order: sort dependencies alphabetically
143
+ const sortedDeps = [...deps].sort();
144
+
145
+ for (const dep of sortedDeps) {
146
+ const cyclePath = dfs(dep);
147
+ if (cyclePath) {
148
+ // If we haven't closed the cycle yet, keep adding nodes
149
+ if (cyclePath.length === 1 || cyclePath[0] !== cyclePath[cyclePath.length - 1]) {
150
+ cyclePath.push(node);
151
+ }
152
+ return cyclePath;
153
+ }
154
+ }
155
+
156
+ inStack.delete(node);
157
+ return null;
158
+ }
159
+
160
+ // Process nodes in deterministic (sorted) order
161
+ const sortedNodes = [...graph.nodes].sort();
162
+ for (const node of sortedNodes) {
163
+ if (!visited.has(node)) {
164
+ const cyclePath = dfs(node);
165
+ if (cyclePath) {
166
+ // Reverse so the path reads naturally: A → B → C → A
167
+ cyclePath.reverse();
168
+ const cycleStr = cyclePath.join(" → ");
169
+ errors.push({
170
+ code: "DEP_UNRESOLVED",
171
+ message: `Circular dependency detected: ${cycleStr}`,
172
+ });
173
+ // Only report first cycle to avoid noisy output
174
+ break;
175
+ }
176
+ }
177
+ }
178
+
179
+ return {
180
+ valid: errors.length === 0,
181
+ errors,
182
+ };
183
+ }
184
+
185
+
186
+ // ── Wave Computation (Topological Sort) ──────────────────────────────
187
+
188
+ /**
189
+ * Compute execution waves via Kahn's algorithm (topological sort).
190
+ *
191
+ * Algorithm contract:
192
+ * - Completed tasks are pre-satisfied: they contribute 0 in-degree but are
193
+ * excluded from the scheduled output.
194
+ * - Wave 1: all pending tasks with 0 unmet dependencies (deps are either
195
+ * completed or have no deps).
196
+ * - Wave N+1: tasks whose deps are all in waves 1..N or completed.
197
+ * - Deterministic ordering: within each wave, tasks are sorted by task ID
198
+ * alphabetically. Queue initialization and zero in-degree pops both use
199
+ * sorted order.
200
+ * - If not all tasks are placed (cycle exists), returns an error.
201
+ */
202
+ export function computeWaves(
203
+ graph: DependencyGraph,
204
+ completed: Set<string>,
205
+ pending: Map<string, ParsedTask>,
206
+ ): { waves: string[][]; errors: DiscoveryError[] } {
207
+ const errors: DiscoveryError[] = [];
208
+ const waves: string[][] = [];
209
+
210
+ // Calculate in-degree for each node (only counting edges from other pending tasks)
211
+ const inDegree = new Map<string, number>();
212
+ for (const node of graph.nodes) {
213
+ const deps = graph.dependencies.get(node) || [];
214
+ // Only count deps that are in the pending set (completed are pre-satisfied)
215
+ const pendingDeps = deps.filter((d) => graph.nodes.has(d));
216
+ inDegree.set(node, pendingDeps.length);
217
+ }
218
+
219
+ const placed = new Set<string>();
220
+ const remaining = new Set(graph.nodes);
221
+
222
+ while (remaining.size > 0) {
223
+ // Collect all nodes with in-degree 0 (all deps satisfied)
224
+ const waveNodes: string[] = [];
225
+ for (const node of remaining) {
226
+ if ((inDegree.get(node) || 0) === 0) {
227
+ waveNodes.push(node);
228
+ }
229
+ }
230
+
231
+ // Deterministic ordering: sort alphabetically by task ID
232
+ waveNodes.sort();
233
+
234
+ if (waveNodes.length === 0) {
235
+ // Remaining nodes all have unsatisfied deps — cycle exists
236
+ const stuckNodes = [...remaining].sort().join(", ");
237
+ errors.push({
238
+ code: "DEP_UNRESOLVED",
239
+ message: `Cannot schedule remaining tasks (possible cycle): ${stuckNodes}`,
240
+ });
241
+ break;
242
+ }
243
+
244
+ waves.push(waveNodes);
245
+
246
+ // Remove placed nodes and reduce in-degree for dependents
247
+ for (const node of waveNodes) {
248
+ placed.add(node);
249
+ remaining.delete(node);
250
+
251
+ const deps = graph.dependents.get(node) || [];
252
+ for (const dependent of deps) {
253
+ const current = inDegree.get(dependent) || 0;
254
+ inDegree.set(dependent, current - 1);
255
+ }
256
+ }
257
+ }
258
+
259
+ return { waves, errors };
260
+ }
261
+
262
+
263
+ // ── File Scope Affinity ──────────────────────────────────────────────
264
+
265
+ /**
266
+ * Group tasks with overlapping file scopes into affinity groups.
267
+ *
268
+ * Uses connected components over a file-scope overlap graph:
269
+ * - Nodes are task IDs within the wave
270
+ * - Edges connect tasks that share at least one file scope entry
271
+ * - Connected components form affinity groups
272
+ *
273
+ * Affinity groups should be assigned to the same lane for serial execution
274
+ * to avoid file-writing conflicts.
275
+ *
276
+ * Edge cases:
277
+ * - Tasks with empty file scope: no affinity edges (independent)
278
+ * - Partial overlaps: if A overlaps B and B overlaps C, all three
279
+ * are in the same affinity group (transitive closure)
280
+ * - Oversized groups (> maxLanes): group stays together on one lane
281
+ * (serial fallback — correctness over parallelism)
282
+ */
283
+ export function normalizeScope(scope: string): string {
284
+ return scope.replace(/\\/g, "/").trim().replace(/\/+/g, "/").replace(/\/$/, "");
285
+ }
286
+
287
+ export function isGlobScope(scope: string): boolean {
288
+ return scope.includes("*");
289
+ }
290
+
291
+ export function prefixOfGlob(scope: string): string {
292
+ const idx = scope.indexOf("*");
293
+ if (idx < 0) return scope;
294
+ return scope.slice(0, idx).replace(/\/$/, "");
295
+ }
296
+
297
+ export function pathStartsWithSegment(pathValue: string, prefix: string): boolean {
298
+ if (!prefix) return true;
299
+ return pathValue === prefix || pathValue.startsWith(`${prefix}/`);
300
+ }
301
+
302
+ export function scopesOverlap(aRaw: string, bRaw: string): boolean {
303
+ const a = normalizeScope(aRaw);
304
+ const b = normalizeScope(bRaw);
305
+ if (!a || !b) return false;
306
+ if (a === b) return true;
307
+
308
+ const aGlob = isGlobScope(a);
309
+ const bGlob = isGlobScope(b);
310
+
311
+ // file vs file (no wildcards): overlap only on exact match
312
+ if (!aGlob && !bGlob) return false;
313
+
314
+ if (aGlob && !bGlob) {
315
+ return pathStartsWithSegment(b, prefixOfGlob(a));
316
+ }
317
+ if (!aGlob && bGlob) {
318
+ return pathStartsWithSegment(a, prefixOfGlob(b));
319
+ }
320
+
321
+ // glob vs glob: overlap if either prefix contains the other
322
+ const aPrefix = prefixOfGlob(a);
323
+ const bPrefix = prefixOfGlob(b);
324
+ return pathStartsWithSegment(aPrefix, bPrefix) || pathStartsWithSegment(bPrefix, aPrefix);
325
+ }
326
+
327
+ export function taskScopesOverlap(taskA: ParsedTask, taskB: ParsedTask): boolean {
328
+ if (taskA.fileScope.length === 0 || taskB.fileScope.length === 0) return false;
329
+ for (const scopeA of taskA.fileScope) {
330
+ for (const scopeB of taskB.fileScope) {
331
+ if (scopesOverlap(scopeA, scopeB)) return true;
332
+ }
333
+ }
334
+ return false;
335
+ }
336
+
337
+ export function applyFileScopeAffinity(
338
+ waveTasks: string[],
339
+ pending: Map<string, ParsedTask>,
340
+ ): string[][] {
341
+ if (waveTasks.length === 0) return [];
342
+
343
+ // Build overlap graph using Union-Find
344
+ const parent = new Map<string, string>();
345
+ const rank = new Map<string, number>();
346
+
347
+ for (const taskId of waveTasks) {
348
+ parent.set(taskId, taskId);
349
+ rank.set(taskId, 0);
350
+ }
351
+
352
+ function find(x: string): string {
353
+ while (parent.get(x) !== x) {
354
+ parent.set(x, parent.get(parent.get(x)!)!);
355
+ x = parent.get(x)!;
356
+ }
357
+ return x;
358
+ }
359
+
360
+ function union(a: string, b: string): void {
361
+ const ra = find(a);
362
+ const rb = find(b);
363
+ if (ra === rb) return;
364
+ const rankA = rank.get(ra) || 0;
365
+ const rankB = rank.get(rb) || 0;
366
+ if (rankA < rankB) {
367
+ parent.set(ra, rb);
368
+ } else if (rankA > rankB) {
369
+ parent.set(rb, ra);
370
+ } else {
371
+ parent.set(rb, ra);
372
+ rank.set(ra, rankA + 1);
373
+ }
374
+ }
375
+
376
+ // Pairwise overlap check (handles exact + wildcard overlaps)
377
+ for (let i = 0; i < waveTasks.length; i++) {
378
+ for (let j = i + 1; j < waveTasks.length; j++) {
379
+ const taskA = pending.get(waveTasks[i]);
380
+ const taskB = pending.get(waveTasks[j]);
381
+ if (!taskA || !taskB) continue;
382
+ if (taskScopesOverlap(taskA, taskB)) {
383
+ union(taskA.taskId, taskB.taskId);
384
+ }
385
+ }
386
+ }
387
+
388
+ const groups = new Map<string, string[]>();
389
+ for (const taskId of waveTasks) {
390
+ const root = find(taskId);
391
+ const group = groups.get(root) || [];
392
+ group.push(taskId);
393
+ groups.set(root, group);
394
+ }
395
+
396
+ const result: string[][] = [];
397
+ for (const group of groups.values()) {
398
+ group.sort();
399
+ result.push(group);
400
+ }
401
+ result.sort((a, b) => a[0].localeCompare(b[0]));
402
+
403
+ return result;
404
+ }
405
+
406
+
407
+ // ── Repo-Scoped Lane Helpers ─────────────────────────────────────────
408
+
409
+ /**
410
+ * A group of tasks targeting the same repository.
411
+ *
412
+ * In repo mode: all tasks are in one group with `repoId` undefined.
413
+ * In workspace mode: tasks are grouped by `resolvedRepoId`.
414
+ */
415
+ export interface RepoTaskGroup {
416
+ /** Repo ID (undefined for repo mode / tasks without resolvedRepoId) */
417
+ repoId: string | undefined;
418
+ /** Task IDs in this group (sorted alphabetically) */
419
+ taskIds: string[];
420
+ }
421
+
422
+ /**
423
+ * Group wave tasks by their resolved repo ID.
424
+ *
425
+ * In workspace mode, tasks carry `resolvedRepoId` from the discovery/routing
426
+ * phase. This function groups them so each repo gets independent lane
427
+ * allocation (own affinity groups, own max_lanes budget).
428
+ *
429
+ * In repo mode, all tasks have `resolvedRepoId === undefined`, so they all
430
+ * land in a single group keyed by `""` (empty string). This preserves
431
+ * existing single-repo behavior exactly.
432
+ *
433
+ * Deterministic ordering guarantees:
434
+ * 1. Groups are sorted by repoId (undefined sorts first as empty string)
435
+ * 2. Task IDs within each group are sorted alphabetically
436
+ *
437
+ * @param waveTasks - Task IDs in this wave
438
+ * @param pending - Full pending task map (from discovery)
439
+ * @returns RepoTaskGroup[] sorted by repoId then by task IDs within group
440
+ */
441
+ export function groupTasksByRepo(
442
+ waveTasks: string[],
443
+ pending: Map<string, ParsedTask>,
444
+ ): RepoTaskGroup[] {
445
+ const groupMap = new Map<string, string[]>();
446
+
447
+ for (const taskId of waveTasks) {
448
+ const task = pending.get(taskId);
449
+ // Use resolvedRepoId or empty string as group key (undefined → "" for Map key)
450
+ const key = task?.resolvedRepoId ?? "";
451
+ const existing = groupMap.get(key) || [];
452
+ existing.push(taskId);
453
+ groupMap.set(key, existing);
454
+ }
455
+
456
+ // Build sorted groups
457
+ const groups: RepoTaskGroup[] = [];
458
+ const sortedKeys = [...groupMap.keys()].sort();
459
+ for (const key of sortedKeys) {
460
+ const taskIds = groupMap.get(key)!;
461
+ taskIds.sort(); // Deterministic task order within group
462
+ groups.push({
463
+ repoId: key || undefined, // Convert "" back to undefined for repo mode
464
+ taskIds,
465
+ });
466
+ }
467
+
468
+ return groups;
469
+ }
470
+
471
+ /**
472
+ * Generate a lane identifier string.
473
+ *
474
+ * - Repo mode (repoId undefined): `"lane-{N}"` — preserves legacy format
475
+ * - Workspace mode (repoId set): `"{repoId}/lane-{N}"` — collision-safe across repos
476
+ *
477
+ * The `laneLocalNumber` is the 1-indexed lane number within the repo group
478
+ * (NOT the global lane number). This gives operators clear per-repo context.
479
+ *
480
+ * @param laneLocalNumber - Lane number within the repo group (1-indexed)
481
+ * @param repoId - Repo identifier (undefined in repo mode)
482
+ */
483
+ export function generateLaneId(laneLocalNumber: number, repoId?: string): string {
484
+ if (repoId) {
485
+ return `${repoId}/lane-${laneLocalNumber}`;
486
+ }
487
+ return `lane-${laneLocalNumber}`;
488
+ }
489
+
490
+ /**
491
+ * Generate a lane session identifier for a lane.
492
+ *
493
+ * Includes the operator identifier (`opId`) for collision resistance
494
+ * across concurrent operators on the same machine.
495
+ *
496
+ * - Repo mode: `"{prefix}-{opId}-lane-{N}"` — operator-scoped
497
+ * - Workspace mode: `"{prefix}-{opId}-{repoId}-lane-{N}"` — operator + repo scoped
498
+ *
499
+ * Session identifiers must not contain periods or colons. Both `opId`
500
+ * and `repoId` are assumed to be sanitized identifiers (alphanumeric
501
+ * + hyphens only).
502
+ *
503
+ * @param sessionPrefix - Session prefix from config (e.g., "orch")
504
+ * @param laneLocalNumber - Lane number within the repo group (1-indexed)
505
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
506
+ * @param repoId - Repo identifier (undefined in repo mode)
507
+ */
508
+ export function generateLaneSessionId(sessionPrefix: string, laneLocalNumber: number, opId: string, repoId?: string): string {
509
+ if (repoId) {
510
+ return `${sessionPrefix}-${opId}-${repoId}-lane-${laneLocalNumber}`;
511
+ }
512
+ return `${sessionPrefix}-${opId}-lane-${laneLocalNumber}`;
513
+ }
514
+
515
+
516
+ // ── Repo-Scoped Worktree Resolution ─────────────────────────────────
517
+
518
+ /**
519
+ * Resolve the repo root path for a given repo group.
520
+ *
521
+ * - Repo mode (repoId undefined): returns the passed `defaultRepoRoot`.
522
+ * - Workspace mode (repoId set): looks up `workspaceConfig.repos.get(repoId).path`.
523
+ * Falls back to `defaultRepoRoot` if repoId is not found in config (defensive).
524
+ *
525
+ * @param repoId - Repo identifier (undefined in repo mode)
526
+ * @param defaultRepoRoot - Default repo root (the single repoRoot in repo mode)
527
+ * @param workspaceConfig - Workspace configuration (null in repo mode)
528
+ * @returns Absolute path to the repo root for this group
529
+ */
530
+ export function resolveRepoRoot(
531
+ repoId: string | undefined,
532
+ defaultRepoRoot: string,
533
+ workspaceConfig?: WorkspaceConfig | null,
534
+ ): string {
535
+ if (!repoId || !workspaceConfig) {
536
+ return defaultRepoRoot;
537
+ }
538
+ const repoConfig = workspaceConfig.repos.get(repoId);
539
+ if (!repoConfig) {
540
+ // Defensive fallback — discovery/routing should have caught this
541
+ return defaultRepoRoot;
542
+ }
543
+ return repoConfig.path;
544
+ }
545
+
546
+ /**
547
+ * Resolve the base branch for worktree creation in a given repo.
548
+ *
549
+ * Fallback chain (first non-empty wins):
550
+ * 1. `WorkspaceRepoConfig.defaultBranch` — explicit per-repo override from workspace config
551
+ * 2. Detected current branch via `getCurrentBranch(repoRoot)` — runtime detection
552
+ * 3. `batchBaseBranch` — the branch captured at batch start (ultimate fallback)
553
+ *
554
+ * In repo mode (repoId undefined), step 1 is skipped and step 2 uses
555
+ * the same repo root as the batch, so the result is equivalent to
556
+ * `batchBaseBranch` (which was itself detected from that repo).
557
+ *
558
+ * @param repoId - Repo identifier (undefined in repo mode)
559
+ * @param repoRoot - Absolute path to this repo's root
560
+ * @param batchBaseBranch - The base branch captured at batch start
561
+ * @param workspaceConfig - Workspace configuration (null in repo mode)
562
+ * @returns Branch name to base worktrees on for this repo
563
+ */
564
+ export function resolveBaseBranch(
565
+ repoId: string | undefined,
566
+ repoRoot: string,
567
+ batchBaseBranch: string,
568
+ workspaceConfig?: WorkspaceConfig | null,
569
+ ): string {
570
+ // Step 0: If the batch base branch is an orch branch (wave 2+), check if
571
+ // it exists in this repo. The orch branch has merged work from previous
572
+ // waves — worktrees MUST branch from it so workers see prior wave output.
573
+ // Without this, wave 2 worktrees branch from the repo's HEAD (e.g. develop)
574
+ // which lacks wave 1's code, causing dependency satisfaction failures.
575
+ if (batchBaseBranch.startsWith("orch/") && repoId) {
576
+ try {
577
+ const check = runGit(["rev-parse", "--verify", `refs/heads/${batchBaseBranch}`], repoRoot);
578
+ if (check.ok) {
579
+ return batchBaseBranch;
580
+ }
581
+ // TP-146: Orch branch exists as batch base but not in this repo.
582
+ // This means worktrees will branch from the repo's current HEAD
583
+ // instead of the orch branch, bypassing batch isolation.
584
+ console.error(
585
+ `[taskplane] resolveBaseBranch WARNING: orch branch "${batchBaseBranch}" not found in repo "${repoId}" at ${repoRoot} — falling back to repo HEAD. ` +
586
+ `This bypasses orch branch isolation. Ensure the orch branch was created in all workspace repos.`,
587
+ );
588
+ } catch (err) {
589
+ console.error(
590
+ `[taskplane] resolveBaseBranch WARNING: orch branch check failed for repo "${repoId}" at ${repoRoot}: ${err}`,
591
+ );
592
+ }
593
+ }
594
+
595
+ // Step 1: Detect current branch of this specific repo.
596
+ // This is the branch the developer is working on — worktrees should
597
+ // branch from here so task files committed on this branch are visible.
598
+ // In repo mode this equals batchBaseBranch. In workspace mode this
599
+ // detects each repo's actual HEAD independently.
600
+ if (repoId) {
601
+ const detected = getCurrentBranch(repoRoot);
602
+ if (detected) {
603
+ return detected;
604
+ }
605
+ }
606
+
607
+ // Step 2: Per-repo default branch from workspace config.
608
+ // Used when repo HEAD is detached or undetectable.
609
+ if (repoId && workspaceConfig) {
610
+ const repoConfig = workspaceConfig.repos.get(repoId);
611
+ if (repoConfig?.defaultBranch) {
612
+ return repoConfig.defaultBranch;
613
+ }
614
+ }
615
+
616
+ // Step 3: Ultimate fallback — batch-level base branch.
617
+ // In workspace mode the batch base branch is the orch branch (e.g.
618
+ // "orch/op-batch123"), which only exists in the primary repo. Using it
619
+ // for a secondary repo would cause worktree creation failure because the
620
+ // ref doesn't exist there. Fail fast with an actionable message instead.
621
+ if (repoId && batchBaseBranch.startsWith("orch/")) {
622
+ throw new Error(
623
+ `Cannot resolve base branch for repo "${repoId}" at ${repoRoot}: ` +
624
+ `HEAD is detached and no defaultBranch is configured. ` +
625
+ `The batch base branch "${batchBaseBranch}" is an orch branch that does not exist in this repo. ` +
626
+ `Configure a defaultBranch for this repo in task-orchestrator.yaml workspace settings.`,
627
+ );
628
+ }
629
+
630
+ return batchBaseBranch;
631
+ }
632
+
633
+
634
+ // ── Segment Planning (TP-080) ───────────────────────────────────────
635
+
636
+ const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
637
+ const INFERRED_LINEAR_REASON = "inferred:first-appearance-linear-chain";
638
+
639
+ function normalizeRepoIdCandidate(raw: string): string | null {
640
+ const candidate = raw.trim().toLowerCase();
641
+ if (!SEGMENT_REPO_ID_PATTERN.test(candidate)) return null;
642
+ return candidate;
643
+ }
644
+
645
+ interface SegmentPlanBuildOptions {
646
+ /** Optional workspace repo IDs used to validate file-scope repo prefixes. */
647
+ workspaceRepoIds?: Iterable<string>;
648
+ }
649
+
650
+ function collectKnownRepoIds(
651
+ pending: Map<string, ParsedTask>,
652
+ workspaceRepoIds?: Iterable<string>,
653
+ ): Set<string> {
654
+ const known = new Set<string>();
655
+
656
+ if (workspaceRepoIds) {
657
+ for (const repoIdRaw of workspaceRepoIds) {
658
+ const repoId = normalizeRepoIdCandidate(String(repoIdRaw));
659
+ if (repoId) known.add(repoId);
660
+ }
661
+ }
662
+
663
+ for (const task of pending.values()) {
664
+ if (task.resolvedRepoId) {
665
+ const repoId = normalizeRepoIdCandidate(task.resolvedRepoId);
666
+ if (repoId) known.add(repoId);
667
+ }
668
+ if (task.explicitSegmentDag) {
669
+ for (const repoIdRaw of task.explicitSegmentDag.repoIds) {
670
+ const repoId = normalizeRepoIdCandidate(repoIdRaw);
671
+ if (repoId) known.add(repoId);
672
+ }
673
+ }
674
+ }
675
+ return known;
676
+ }
677
+
678
+ function extractRepoPrefixFromFileScope(fileScopeEntry: string): string | null {
679
+ const normalized = fileScopeEntry.replace(/\\/g, "/").trim();
680
+ if (!normalized) return null;
681
+ const firstSegment = normalized.split("/")[0]?.trim();
682
+ if (!firstSegment) return null;
683
+ return normalizeRepoIdCandidate(firstSegment);
684
+ }
685
+
686
+ interface InferredRepoOrder {
687
+ repoIds: string[];
688
+ usedFallback: boolean;
689
+ }
690
+
691
+ /**
692
+ * Build deterministic repo ordering for inferred segment plans.
693
+ *
694
+ * Signal precedence:
695
+ * 1) file scope repo prefixes (first appearance)
696
+ * 2) dependency task repos (first appearance)
697
+ * 3) fallback to `resolvedRepoId`, then synthetic `default`
698
+ */
699
+ export function inferTaskRepoOrder(
700
+ task: ParsedTask,
701
+ pending: Map<string, ParsedTask>,
702
+ knownRepoIds: Set<string>,
703
+ ): InferredRepoOrder {
704
+ const firstAppearance = new Map<string, number>();
705
+ let cursor = 0;
706
+
707
+ function record(repoIdRaw: string, requireKnown = false): string | null {
708
+ const repoId = normalizeRepoIdCandidate(repoIdRaw);
709
+ if (!repoId) return null;
710
+ if (requireKnown && knownRepoIds.size > 0 && !knownRepoIds.has(repoId)) return null;
711
+ if (!firstAppearance.has(repoId)) {
712
+ firstAppearance.set(repoId, cursor++);
713
+ }
714
+ return repoId;
715
+ }
716
+
717
+ let hasPrimarySignal = false;
718
+
719
+ for (const scopeEntry of task.fileScope) {
720
+ if (knownRepoIds.size === 0) {
721
+ // Repo-mode guard: without known workspace repo IDs, fileScope prefixes like
722
+ // "src/" or "lib/" are ambiguous and should not create synthetic segments.
723
+ continue;
724
+ }
725
+ const repoId = extractRepoPrefixFromFileScope(scopeEntry);
726
+ if (!repoId) continue;
727
+ if (record(repoId, true) !== null) {
728
+ hasPrimarySignal = true;
729
+ }
730
+ }
731
+
732
+ for (const depRaw of task.dependencies) {
733
+ const depId = parseDependencyReference(depRaw).taskId;
734
+ const depTask = pending.get(depId);
735
+ if (depTask?.resolvedRepoId && record(depTask.resolvedRepoId, true) !== null) {
736
+ hasPrimarySignal = true;
737
+ }
738
+ }
739
+
740
+ if (!hasPrimarySignal) {
741
+ const fallback = normalizeRepoIdCandidate(task.resolvedRepoId ?? "") || "default";
742
+ return {
743
+ repoIds: [fallback],
744
+ usedFallback: true,
745
+ };
746
+ }
747
+
748
+ if (task.resolvedRepoId) {
749
+ record(task.resolvedRepoId, true);
750
+ }
751
+
752
+ const repoIds = [...firstAppearance.entries()]
753
+ .sort((a, b) => {
754
+ if (a[1] !== b[1]) return a[1] - b[1];
755
+ return a[0].localeCompare(b[0]);
756
+ })
757
+ .map(([repoId]) => repoId);
758
+
759
+ return {
760
+ repoIds,
761
+ usedFallback: false,
762
+ };
763
+ }
764
+
765
+ function sortSegmentEdges<T extends { fromSegmentId: string; toSegmentId: string }>(
766
+ edges: T[],
767
+ ): T[] {
768
+ return [...edges].sort((a, b) => {
769
+ if (a.fromSegmentId !== b.fromSegmentId) return a.fromSegmentId.localeCompare(b.fromSegmentId);
770
+ return a.toSegmentId.localeCompare(b.toSegmentId);
771
+ });
772
+ }
773
+
774
+ function buildSegmentNodes(taskId: string, repoIds: string[]) {
775
+ const nodes = repoIds.map((repoId, order) => ({
776
+ segmentId: buildSegmentId(taskId, repoId),
777
+ taskId,
778
+ repoId,
779
+ order,
780
+ }));
781
+ return nodes.sort((a, b) => (a.order - b.order) || a.repoId.localeCompare(b.repoId));
782
+ }
783
+
784
+ export function buildSegmentPlanForTask(
785
+ task: ParsedTask,
786
+ pending: Map<string, ParsedTask>,
787
+ knownRepoIds: Set<string>,
788
+ ): TaskSegmentPlan {
789
+ if (task.explicitSegmentDag) {
790
+ const repoIds = [...task.explicitSegmentDag.repoIds];
791
+ const segments = buildSegmentNodes(task.taskId, repoIds);
792
+ const edges = sortSegmentEdges(
793
+ task.explicitSegmentDag.edges.map((edge) => ({
794
+ fromSegmentId: buildSegmentId(task.taskId, edge.fromRepoId),
795
+ toSegmentId: buildSegmentId(task.taskId, edge.toRepoId),
796
+ provenance: "explicit" as const,
797
+ reason: "prompt:segment-dag",
798
+ })),
799
+ );
800
+ return {
801
+ taskId: task.taskId,
802
+ segments,
803
+ edges,
804
+ mode: "explicit-dag",
805
+ };
806
+ }
807
+
808
+ const inferred = inferTaskRepoOrder(task, pending, knownRepoIds);
809
+ const segments = buildSegmentNodes(task.taskId, inferred.repoIds);
810
+ const edges = sortSegmentEdges(
811
+ segments.slice(0, -1).map((segment, idx) => ({
812
+ fromSegmentId: segment.segmentId,
813
+ toSegmentId: segments[idx + 1].segmentId,
814
+ provenance: "inferred" as const,
815
+ reason: INFERRED_LINEAR_REASON,
816
+ })),
817
+ );
818
+
819
+ return {
820
+ taskId: task.taskId,
821
+ segments,
822
+ edges,
823
+ mode: inferred.usedFallback ? "repo-singleton" : "inferred-sequential",
824
+ };
825
+ }
826
+
827
+ /** Build a deterministic taskId→segmentPlan map for the whole pending set. */
828
+ export function buildTaskSegmentPlans(
829
+ pending: Map<string, ParsedTask>,
830
+ options: SegmentPlanBuildOptions = {},
831
+ ): TaskSegmentPlanMap {
832
+ const knownRepoIds = collectKnownRepoIds(pending, options.workspaceRepoIds);
833
+ const plans: TaskSegmentPlanMap = new Map();
834
+ for (const taskId of [...pending.keys()].sort()) {
835
+ const task = pending.get(taskId);
836
+ if (!task) continue;
837
+ plans.set(taskId, buildSegmentPlanForTask(task, pending, knownRepoIds));
838
+ }
839
+ return plans;
840
+ }
841
+
842
+
843
+ // ── Lane Assignment ──────────────────────────────────────────────────
844
+
845
+ /**
846
+ * Assign tasks within a wave to lanes.
847
+ *
848
+ * Algorithm (affinity-first strategy):
849
+ * 1. Compute affinity groups via file scope overlap
850
+ * 2. Each affinity group goes to one lane (serial within lane)
851
+ * 3. Remaining single-task "groups" are distributed via round-robin
852
+ * or load-balanced fill
853
+ * 4. Lane count: min(number of groups, maxLanes)
854
+ *
855
+ * Deterministic tie-breaking: groups are sorted by first task ID,
856
+ * then assigned in order. Round-robin assignment is deterministic
857
+ * given deterministic group ordering.
858
+ *
859
+ * For "round-robin" strategy: simple sequential assignment.
860
+ * For "load-balanced" strategy: assign to lane with lowest total weight.
861
+ * For "affinity-first": affinity groups first, then load-balanced fill.
862
+ */
863
+ export function assignTasksToLanes(
864
+ waveTasks: string[],
865
+ pending: Map<string, ParsedTask>,
866
+ maxLanes: number,
867
+ strategy: string,
868
+ sizeWeights: Record<string, number>,
869
+ ): LaneAssignment[] {
870
+ if (waveTasks.length === 0) return [];
871
+
872
+ // Step 1: Compute affinity groups
873
+ const affinityGroups = applyFileScopeAffinity(waveTasks, pending);
874
+
875
+ // Step 2: Determine lane count
876
+ const laneCount = Math.min(affinityGroups.length, maxLanes);
877
+
878
+ // Step 3: Initialize lane weights (for load-balanced assignment)
879
+ const laneWeights: number[] = new Array(laneCount).fill(0);
880
+ const laneAssignments: LaneAssignment[][] = new Array(laneCount)
881
+ .fill(null)
882
+ .map(() => []);
883
+
884
+ function getWeight(taskId: string): number {
885
+ const task = pending.get(taskId);
886
+ if (!task) return sizeWeights["M"] || 2;
887
+ return sizeWeights[task.size] || sizeWeights["M"] || 2;
888
+ }
889
+
890
+ function assignGroupToLane(group: string[], laneIndex: number): void {
891
+ for (const taskId of group) {
892
+ const task = pending.get(taskId);
893
+ if (!task) continue;
894
+ laneAssignments[laneIndex].push({
895
+ taskId,
896
+ lane: laneIndex + 1, // 1-indexed lanes
897
+ task,
898
+ });
899
+ laneWeights[laneIndex] += getWeight(taskId);
900
+ }
901
+ }
902
+
903
+ function findLightestLane(): number {
904
+ let minIdx = 0;
905
+ let minWeight = laneWeights[0];
906
+ for (let i = 1; i < laneCount; i++) {
907
+ if (laneWeights[i] < minWeight) {
908
+ minWeight = laneWeights[i];
909
+ minIdx = i;
910
+ }
911
+ }
912
+ return minIdx;
913
+ }
914
+
915
+ // Step 4: Assign groups to lanes based on strategy
916
+ if (strategy === "round-robin") {
917
+ for (let i = 0; i < affinityGroups.length; i++) {
918
+ const laneIdx = i % laneCount;
919
+ assignGroupToLane(affinityGroups[i], laneIdx);
920
+ }
921
+ } else if (strategy === "load-balanced") {
922
+ // Sort groups by weight (heaviest first for better balance)
923
+ const sortedGroups = [...affinityGroups].sort((a, b) => {
924
+ const weightA = a.reduce((sum, id) => sum + getWeight(id), 0);
925
+ const weightB = b.reduce((sum, id) => sum + getWeight(id), 0);
926
+ if (weightB !== weightA) return weightB - weightA;
927
+ // Deterministic tie-break: alphabetical by first task ID
928
+ return a[0].localeCompare(b[0]);
929
+ });
930
+ for (const group of sortedGroups) {
931
+ const laneIdx = findLightestLane();
932
+ assignGroupToLane(group, laneIdx);
933
+ }
934
+ } else {
935
+ // affinity-first: multi-task groups get priority, then load-balanced fill
936
+ const multiGroups = affinityGroups.filter((g) => g.length > 1);
937
+ const singleGroups = affinityGroups.filter((g) => g.length === 1);
938
+
939
+ // Assign multi-task affinity groups first (heaviest first)
940
+ const sortedMulti = [...multiGroups].sort((a, b) => {
941
+ const weightA = a.reduce((sum, id) => sum + getWeight(id), 0);
942
+ const weightB = b.reduce((sum, id) => sum + getWeight(id), 0);
943
+ if (weightB !== weightA) return weightB - weightA;
944
+ return a[0].localeCompare(b[0]);
945
+ });
946
+ for (const group of sortedMulti) {
947
+ const laneIdx = findLightestLane();
948
+ assignGroupToLane(group, laneIdx);
949
+ }
950
+
951
+ // Fill remaining with single-task groups (load-balanced)
952
+ const sortedSingles = [...singleGroups].sort((a, b) => {
953
+ const weightA = getWeight(a[0]);
954
+ const weightB = getWeight(b[0]);
955
+ if (weightB !== weightA) return weightB - weightA;
956
+ return a[0].localeCompare(b[0]);
957
+ });
958
+ for (const group of sortedSingles) {
959
+ const laneIdx = findLightestLane();
960
+ assignGroupToLane(group, laneIdx);
961
+ }
962
+ }
963
+
964
+ // Flatten all lane assignments into a single array
965
+ const result: LaneAssignment[] = [];
966
+ for (const assignments of laneAssignments) {
967
+ result.push(...assignments);
968
+ }
969
+
970
+ return result;
971
+ }
972
+
973
+
974
+ // ── Global Lane Cap (TP-148) ─────────────────────────────────────────
975
+
976
+ /**
977
+ * Enforce a global lane cap across all repo groups.
978
+ *
979
+ * In workspace mode, each repo independently allocates up to `maxLanes`.
980
+ * This function reduces the total across all repos to fit within the
981
+ * global `maxLanes` budget by consolidating lanes in repos with the
982
+ * most headroom (most lanes relative to their minimum of 1).
983
+ *
984
+ * Algorithm:
985
+ * 1. If total lanes ≤ maxLanes, no-op.
986
+ * 2. Group lanes by repo, sort repos by lane count descending.
987
+ * 3. Iteratively remove the last lane from the repo with the most
988
+ * lanes, redistributing its tasks to the lightest remaining lane
989
+ * in that repo.
990
+ * 4. Stop when total ≤ maxLanes or all repos are at 1 lane.
991
+ * 5. Renumber global lanes sequentially.
992
+ *
993
+ * Mutates `entries` in place: removes excess entries and renumbers.
994
+ *
995
+ * @param entries - Global lane entries from per-repo allocation
996
+ * @param maxLanes - Global maximum lane count
997
+ */
998
+ export function enforceGlobalLaneCap(
999
+ entries: Array<{
1000
+ globalLane: number;
1001
+ localLane: number;
1002
+ repoId: string | undefined;
1003
+ assignments: LaneAssignment[];
1004
+ }>,
1005
+ maxLanes: number,
1006
+ ): void {
1007
+ if (entries.length <= maxLanes) return;
1008
+
1009
+ // Group entries by repoId
1010
+ const byRepo = new Map<string, typeof entries>();
1011
+ for (const entry of entries) {
1012
+ const key = entry.repoId ?? "";
1013
+ const group = byRepo.get(key) || [];
1014
+ group.push(entry);
1015
+ byRepo.set(key, group);
1016
+ }
1017
+
1018
+ let excess = entries.length - maxLanes;
1019
+
1020
+ while (excess > 0) {
1021
+ // Find the repo with the most lanes (ties broken by key for determinism)
1022
+ let bestKey = "";
1023
+ let bestCount = 0;
1024
+ for (const [key, group] of byRepo) {
1025
+ if (group.length > bestCount || (group.length === bestCount && key < bestKey)) {
1026
+ bestKey = key;
1027
+ bestCount = group.length;
1028
+ }
1029
+ }
1030
+
1031
+ // All repos at 1 lane — can't reduce further
1032
+ if (bestCount <= 1) break;
1033
+
1034
+ // Remove the last lane from this repo and redistribute its tasks
1035
+ const group = byRepo.get(bestKey)!;
1036
+ const removed = group.pop()!;
1037
+ // Merge into the first lane of the same repo (deterministic target)
1038
+ group[0].assignments.push(...removed.assignments);
1039
+ excess--;
1040
+ }
1041
+
1042
+ // Warn if cap could not be fully enforced (more repos than maxLanes)
1043
+ const finalTotal = [...byRepo.values()].reduce((sum, g) => sum + g.length, 0);
1044
+ if (finalTotal > maxLanes) {
1045
+ console.error(
1046
+ `[taskplane] warning: global maxLanes=${maxLanes} could not be enforced — ` +
1047
+ `${byRepo.size} repos each need at least 1 lane (total: ${finalTotal}). ` +
1048
+ `Increase maxLanes to at least ${byRepo.size} to avoid this.`,
1049
+ );
1050
+ }
1051
+
1052
+ // Rebuild entries array with sequential global lane numbers
1053
+ entries.length = 0;
1054
+ let globalLane = 1;
1055
+ for (const key of [...byRepo.keys()].sort()) {
1056
+ const group = byRepo.get(key)!;
1057
+ for (const entry of group) {
1058
+ entry.globalLane = globalLane++;
1059
+ entries.push(entry);
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+
1065
+ /**
1066
+ * Result of `allocateLanes()`.
1067
+ *
1068
+ * On success: `success=true`, `lanes` contains all allocated lanes.
1069
+ * On failure: `success=false`, `error` describes what went wrong,
1070
+ * `rolledBack` indicates whether partial worktrees were cleaned up.
1071
+ */
1072
+ export interface AllocateLanesResult {
1073
+ /** Whether all lanes were allocated successfully */
1074
+ success: boolean;
1075
+ /** Allocated lanes, sorted by laneNumber. Empty on failure. */
1076
+ lanes: AllocatedLane[];
1077
+ /** Number of lanes allocated */
1078
+ laneCount: number;
1079
+ /** Error details (null on success) */
1080
+ error: {
1081
+ code: AllocationErrorCode;
1082
+ message: string;
1083
+ details?: string;
1084
+ } | null;
1085
+ /** Whether partial worktrees were rolled back on failure */
1086
+ rolledBack: boolean;
1087
+ /** Batch ID used for branch/session naming */
1088
+ batchId: string;
1089
+ }
1090
+
1091
+ /**
1092
+ * Validate allocation inputs before proceeding.
1093
+ *
1094
+ * Checks:
1095
+ * - max_lanes >= 1
1096
+ * - waveTasks is non-empty
1097
+ * - All task IDs in waveTasks exist in pending map
1098
+ * - Config has valid strategy and size_weights
1099
+ *
1100
+ * @returns null if valid, AllocationError if invalid
1101
+ */
1102
+ export function validateAllocationInputs(
1103
+ waveTasks: string[],
1104
+ pending: Map<string, ParsedTask>,
1105
+ config: OrchestratorConfig,
1106
+ ): AllocationError | null {
1107
+ // Validate max_lanes
1108
+ if (
1109
+ !config.orchestrator.max_lanes ||
1110
+ config.orchestrator.max_lanes < 1 ||
1111
+ !Number.isInteger(config.orchestrator.max_lanes)
1112
+ ) {
1113
+ return new AllocationError(
1114
+ "ALLOC_INVALID_CONFIG",
1115
+ `max_lanes must be a positive integer, got: ${config.orchestrator.max_lanes}`,
1116
+ );
1117
+ }
1118
+
1119
+ // Validate wave has tasks
1120
+ if (!waveTasks || waveTasks.length === 0) {
1121
+ return new AllocationError(
1122
+ "ALLOC_EMPTY_WAVE",
1123
+ "Cannot allocate lanes for an empty wave (no tasks provided)",
1124
+ );
1125
+ }
1126
+
1127
+ // Validate all task IDs exist in pending map
1128
+ const missingTasks: string[] = [];
1129
+ for (const taskId of waveTasks) {
1130
+ if (!pending.has(taskId)) {
1131
+ missingTasks.push(taskId);
1132
+ }
1133
+ }
1134
+ if (missingTasks.length > 0) {
1135
+ return new AllocationError(
1136
+ "ALLOC_TASK_NOT_FOUND",
1137
+ `Task IDs not found in pending map: ${missingTasks.join(", ")}`,
1138
+ `These tasks may have been completed or removed between discovery and allocation.`,
1139
+ );
1140
+ }
1141
+
1142
+ // Validate strategy is recognized
1143
+ const validStrategies = ["affinity-first", "round-robin", "load-balanced"];
1144
+ if (!validStrategies.includes(config.assignment.strategy)) {
1145
+ return new AllocationError(
1146
+ "ALLOC_INVALID_CONFIG",
1147
+ `Unknown assignment strategy: "${config.assignment.strategy}". ` +
1148
+ `Valid strategies: ${validStrategies.join(", ")}`,
1149
+ );
1150
+ }
1151
+
1152
+ // Validate worktree prefix is non-empty
1153
+ if (!config.orchestrator.worktree_prefix?.trim()) {
1154
+ return new AllocationError(
1155
+ "ALLOC_INVALID_CONFIG",
1156
+ `worktree_prefix must be a non-empty string`,
1157
+ );
1158
+ }
1159
+
1160
+ return null;
1161
+ }
1162
+
1163
+ /**
1164
+ * Allocate lanes for a wave: assign tasks, create worktrees, return ready-to-execute lanes.
1165
+ *
1166
+ * This is the Phase 3 implementation from §5 of the design doc.
1167
+ * It coordinates four stages:
1168
+ *
1169
+ * 0. **Input validation** — config, tasks, strategy checks.
1170
+ *
1171
+ * 1. **Repo grouping** — tasks are grouped by `resolvedRepoId` via
1172
+ * `groupTasksByRepo()`. In repo mode (no resolvedRepoId), all tasks
1173
+ * go to a single group, preserving existing behavior exactly.
1174
+ *
1175
+ * 2. **Per-repo affinity grouping + strategy assignment** — for each repo
1176
+ * group, `assignTasksToLanes()` runs independently with its own
1177
+ * max_lanes budget. Lane numbers within each group are 1-indexed.
1178
+ * Groups are processed in deterministic order (sorted by repoId).
1179
+ * Global lane numbers are assigned sequentially across repo groups
1180
+ * (repo A gets lanes 1..Na, repo B gets lanes Na+1..Na+Nb, etc.).
1181
+ *
1182
+ * 3. **Worktree provisioning** — ensure one worktree per global lane via
1183
+ * `ensureLaneWorktrees()`. Existing lanes are reused across waves;
1184
+ * missing lanes are created. If creating a missing lane fails,
1185
+ * newly-created lanes in this call are rolled back.
1186
+ *
1187
+ * 4. **Build AllocatedLane[]** — each lane gets repo-aware `laneId` and
1188
+ * `laneSessionId`. In workspace mode: `"api/lane-1"`, `"orch-api-lane-1"`.
1189
+ * In repo mode: `"lane-1"`,
1190
+ * `"orch-lane-1"` (unchanged).
1191
+ *
1192
+ * **Determinism guarantee:** Given the same `waveTasks`, `pending`, and `config`,
1193
+ * this function always produces the same lane assignments and task ordering.
1194
+ * Repo group order is sorted alphabetically by repoId. Lane assignment within
1195
+ * each group uses the configured strategy deterministically.
1196
+ *
1197
+ * @param waveTasks - Task IDs in this wave (from topological sort)
1198
+ * @param pending - Full pending task map (from discovery)
1199
+ * @param config - Orchestrator configuration
1200
+ * @param repoRoot - Absolute path to the main/default repository root
1201
+ * @param batchId - Batch ID for branch/session naming (e.g., "20260308T111750")
1202
+ * @param baseBranch - Branch to base worktrees on (captured at batch start)
1203
+ * @param workspaceConfig - Workspace configuration for repo routing (null/undefined = repo mode)
1204
+ * @returns - AllocateLanesResult with success flag and lane details
1205
+ */
1206
+ export function allocateLanes(
1207
+ waveTasks: string[],
1208
+ pending: Map<string, ParsedTask>,
1209
+ config: OrchestratorConfig,
1210
+ repoRoot: string,
1211
+ batchId: string,
1212
+ baseBranch: string,
1213
+ workspaceConfig?: WorkspaceConfig | null,
1214
+ ): AllocateLanesResult {
1215
+ // ── Stage 0: Input validation ────────────────────────────────
1216
+ const validationError = validateAllocationInputs(waveTasks, pending, config);
1217
+ if (validationError) {
1218
+ return {
1219
+ success: false,
1220
+ lanes: [],
1221
+ laneCount: 0,
1222
+ error: {
1223
+ code: validationError.code,
1224
+ message: validationError.message,
1225
+ details: validationError.details,
1226
+ },
1227
+ rolledBack: false,
1228
+ batchId,
1229
+ };
1230
+ }
1231
+
1232
+ // ── Stage 1: Group tasks by repo ─────────────────────────────
1233
+ const repoGroups = groupTasksByRepo(waveTasks, pending);
1234
+
1235
+ // ── Stage 2: Per-repo affinity grouping + strategy assignment ─
1236
+ // Each repo group gets independent lane assignment. Lane numbers
1237
+ // within each group start at 1. We track a globalLaneOffset to
1238
+ // produce globally unique lane numbers across all repo groups.
1239
+ //
1240
+ // The structure tracks: global lane number → { repoId, localLane, assignments }
1241
+ const globalLaneEntries: Array<{
1242
+ globalLane: number;
1243
+ localLane: number;
1244
+ repoId: string | undefined;
1245
+ assignments: LaneAssignment[];
1246
+ }> = [];
1247
+
1248
+ let globalLaneOffset = 0;
1249
+
1250
+ for (const group of repoGroups) {
1251
+ const groupAssignments = assignTasksToLanes(
1252
+ group.taskIds,
1253
+ pending,
1254
+ config.orchestrator.max_lanes,
1255
+ config.assignment.strategy,
1256
+ config.assignment.size_weights,
1257
+ );
1258
+
1259
+ // Determine local lane numbers used in this group's assignment
1260
+ const localLaneNumbers = new Set(groupAssignments.map((a) => a.lane));
1261
+ const sortedLocalLanes = [...localLaneNumbers].sort((a, b) => a - b);
1262
+
1263
+ // Map local lane numbers to global lane numbers
1264
+ const localToGlobal = new Map<number, number>();
1265
+ for (let i = 0; i < sortedLocalLanes.length; i++) {
1266
+ localToGlobal.set(sortedLocalLanes[i], globalLaneOffset + i + 1);
1267
+ }
1268
+
1269
+ // Group assignments by local lane number
1270
+ const byLocalLane = new Map<number, LaneAssignment[]>();
1271
+ for (const a of groupAssignments) {
1272
+ const existing = byLocalLane.get(a.lane) || [];
1273
+ existing.push(a);
1274
+ byLocalLane.set(a.lane, existing);
1275
+ }
1276
+
1277
+ // Produce global lane entries
1278
+ for (const localLane of sortedLocalLanes) {
1279
+ globalLaneEntries.push({
1280
+ globalLane: localToGlobal.get(localLane)!,
1281
+ localLane,
1282
+ repoId: group.repoId,
1283
+ assignments: byLocalLane.get(localLane) || [],
1284
+ });
1285
+ }
1286
+
1287
+ globalLaneOffset += sortedLocalLanes.length;
1288
+ }
1289
+
1290
+ // ── Stage 2b: Enforce global lane cap (TP-148) ─────────────────
1291
+ // In workspace mode, each repo group independently allocates up to
1292
+ // maxLanes. If total lanes across all repos exceeds the global
1293
+ // maxLanes limit, reduce lanes in repos with the most headroom.
1294
+ // Preserves at least 1 lane per repo with tasks.
1295
+ enforceGlobalLaneCap(globalLaneEntries, config.orchestrator.max_lanes);
1296
+
1297
+ const laneCount = globalLaneEntries.length;
1298
+
1299
+ if (laneCount === 0) {
1300
+ return {
1301
+ success: false,
1302
+ lanes: [],
1303
+ laneCount: 0,
1304
+ error: {
1305
+ code: "ALLOC_EMPTY_WAVE",
1306
+ message: "Lane assignment produced zero lanes (no tasks could be assigned)",
1307
+ },
1308
+ rolledBack: false,
1309
+ batchId,
1310
+ };
1311
+ }
1312
+
1313
+ // ── Stage 3: Ensure lane worktrees exist per repo group ──────
1314
+ // In repo mode: all lanes use the single repoRoot/baseBranch (unchanged).
1315
+ // In workspace mode: each repo group's lanes are created against that
1316
+ // repo's root with its resolved base branch. Cross-repo rollback on
1317
+ // partial failure ensures atomic wave provisioning.
1318
+ //
1319
+ // Group globalLaneEntries by repoId for per-repo worktree provisioning.
1320
+ const repoLaneGroups = new Map<string, number[]>(); // key → global lane numbers
1321
+ const repoIdForGroup = new Map<string, string | undefined>(); // key → repoId
1322
+ for (const entry of globalLaneEntries) {
1323
+ const key = entry.repoId ?? "";
1324
+ const existing = repoLaneGroups.get(key) || [];
1325
+ existing.push(entry.globalLane);
1326
+ repoLaneGroups.set(key, existing);
1327
+ repoIdForGroup.set(key, entry.repoId);
1328
+ }
1329
+ const sortedGroupKeys = [...repoLaneGroups.keys()].sort();
1330
+
1331
+ // Track all worktrees created across all repo groups for cross-repo rollback
1332
+ const allWorktrees = new Map<number, WorktreeInfo>(); // global lane → worktree
1333
+ const createdGroupKeys: string[] = []; // groups that succeeded (for rollback tracking)
1334
+
1335
+ for (const groupKey of sortedGroupKeys) {
1336
+ const groupLaneNumbers = repoLaneGroups.get(groupKey)!;
1337
+ const groupRepoId = repoIdForGroup.get(groupKey);
1338
+ const groupRepoRoot = resolveRepoRoot(groupRepoId, repoRoot, workspaceConfig);
1339
+ const groupBaseBranch = resolveBaseBranch(groupRepoId, groupRepoRoot, baseBranch, workspaceConfig);
1340
+
1341
+ const worktreeResult = ensureLaneWorktrees(
1342
+ groupLaneNumbers,
1343
+ batchId,
1344
+ config,
1345
+ groupRepoRoot,
1346
+ groupBaseBranch,
1347
+ );
1348
+
1349
+ if (!worktreeResult.success) {
1350
+ // ── Cross-repo rollback: remove worktrees from all previously-succeeded groups ─
1351
+ const rollbackErrors: string[] = [];
1352
+ for (const prevKey of createdGroupKeys) {
1353
+ const prevRepoId = repoIdForGroup.get(prevKey);
1354
+ const prevRepoRoot = resolveRepoRoot(prevRepoId, repoRoot, workspaceConfig);
1355
+ const prevLanes = repoLaneGroups.get(prevKey)!;
1356
+ for (const lane of prevLanes) {
1357
+ const wt = allWorktrees.get(lane);
1358
+ if (wt) {
1359
+ try {
1360
+ removeWorktree(wt, prevRepoRoot);
1361
+ } catch (rbErr: unknown) {
1362
+ rollbackErrors.push(
1363
+ `Lane ${lane} (repo ${prevRepoId ?? "default"}): ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`,
1364
+ );
1365
+ }
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ const failedLanes = worktreeResult.errors
1371
+ .map((e) => `Lane ${e.laneNumber}: [${e.code}] ${e.message}`)
1372
+ .join("\n");
1373
+ const withinGroupRollbackIssues = worktreeResult.rollbackErrors.length > 0
1374
+ ? "\nWithin-group rollback issues:\n" +
1375
+ worktreeResult.rollbackErrors
1376
+ .map((e) => ` Lane ${e.laneNumber}: [${e.code}] ${e.message}`)
1377
+ .join("\n")
1378
+ : "";
1379
+ const crossRepoRollbackIssues = rollbackErrors.length > 0
1380
+ ? "\nCross-repo rollback issues:\n" +
1381
+ rollbackErrors.map((e) => ` ${e}`).join("\n")
1382
+ : "";
1383
+
1384
+ return {
1385
+ success: false,
1386
+ lanes: [],
1387
+ laneCount: 0,
1388
+ error: {
1389
+ code: "ALLOC_WORKTREE_FAILED",
1390
+ message: `Failed to create worktrees for repo "${groupRepoId ?? "default"}" (${groupLaneNumbers.length} lane(s))`,
1391
+ details: failedLanes + withinGroupRollbackIssues + crossRepoRollbackIssues,
1392
+ },
1393
+ rolledBack: true,
1394
+ batchId,
1395
+ };
1396
+ }
1397
+
1398
+ // Record successful worktrees
1399
+ for (const wt of worktreeResult.worktrees) {
1400
+ allWorktrees.set(wt.laneNumber, wt);
1401
+ }
1402
+ createdGroupKeys.push(groupKey);
1403
+ }
1404
+
1405
+ // ── Stage 4: Build AllocatedLane[] from assignments + worktrees ─
1406
+ const sessionPrefix = config.orchestrator.sessionPrefix || "orch";
1407
+ const opId = resolveOperatorId(config);
1408
+ const strategy = config.assignment.strategy as AllocatedLane["strategy"];
1409
+ const sizeWeights = config.assignment.size_weights;
1410
+
1411
+ const allocatedLanes: AllocatedLane[] = [];
1412
+
1413
+ for (const entry of globalLaneEntries) {
1414
+ const wt = allWorktrees.get(entry.globalLane);
1415
+ if (!wt) {
1416
+ // This should never happen if ensureLaneWorktrees and assignTasksToLanes
1417
+ // agree on lane numbers, but handle defensively.
1418
+ // Roll back all worktrees across all repos on this unexpected failure.
1419
+ // Pass batchId + config for batch-scoped cleanup (only remove this batch's worktrees).
1420
+ for (const groupKey of createdGroupKeys) {
1421
+ const groupRepoId = repoIdForGroup.get(groupKey);
1422
+ const groupRepoRoot = resolveRepoRoot(groupRepoId, repoRoot, workspaceConfig);
1423
+ removeAllWorktrees(config.orchestrator.worktree_prefix, groupRepoRoot, opId, undefined, batchId, config);
1424
+ }
1425
+ return {
1426
+ success: false,
1427
+ lanes: [],
1428
+ laneCount: 0,
1429
+ error: {
1430
+ code: "ALLOC_WORKTREE_FAILED",
1431
+ message: `No worktree found for lane ${entry.globalLane} — lane count mismatch between assignment and worktree creation`,
1432
+ },
1433
+ rolledBack: true,
1434
+ batchId,
1435
+ };
1436
+ }
1437
+
1438
+ // Build ordered task list (preserve assignment order from assignTasksToLanes)
1439
+ const allocatedTasks: AllocatedTask[] = entry.assignments.map((a, idx) => ({
1440
+ taskId: a.taskId,
1441
+ order: idx,
1442
+ task: a.task,
1443
+ estimatedMinutes: getTaskDurationMinutes(a.task.size, sizeWeights),
1444
+ }));
1445
+
1446
+ const estimatedLoad = allocatedTasks.reduce(
1447
+ (sum, t) => sum + (sizeWeights[t.task.size] || sizeWeights["M"] || 2),
1448
+ 0,
1449
+ );
1450
+ const estimatedMinutes = allocatedTasks.reduce(
1451
+ (sum, t) => sum + t.estimatedMinutes,
1452
+ 0,
1453
+ );
1454
+
1455
+ const laneSessionId = generateLaneSessionId(sessionPrefix, entry.localLane, opId, entry.repoId);
1456
+ allocatedLanes.push({
1457
+ laneNumber: entry.globalLane,
1458
+ laneId: generateLaneId(entry.localLane, entry.repoId),
1459
+ laneSessionId,
1460
+ worktreePath: wt.path,
1461
+ branch: wt.branch,
1462
+ tasks: allocatedTasks,
1463
+ strategy,
1464
+ estimatedLoad,
1465
+ estimatedMinutes,
1466
+ repoId: entry.repoId,
1467
+ });
1468
+ }
1469
+
1470
+ // Sort by global lane number for deterministic output
1471
+ allocatedLanes.sort((a, b) => a.laneNumber - b.laneNumber);
1472
+
1473
+ return {
1474
+ success: true,
1475
+ lanes: allocatedLanes,
1476
+ laneCount: allocatedLanes.length,
1477
+ error: null,
1478
+ rolledBack: false,
1479
+ batchId,
1480
+ };
1481
+ }
1482
+
1483
+
1484
+ // ── Full Wave Pipeline ───────────────────────────────────────────────
1485
+
1486
+ /**
1487
+ * Run the full wave computation pipeline:
1488
+ * 1. Build dependency graph from registry
1489
+ * 2. Validate graph (self-edges, duplicates, cycles, missing targets)
1490
+ * 3. Compute topological waves
1491
+ * 4. Assign tasks to lanes within each wave
1492
+ *
1493
+ * Returns WaveAssignment[] with wave numbers and lane assignments,
1494
+ * plus any errors encountered.
1495
+ */
1496
+ export interface WaveComputationOptions {
1497
+ /** Optional workspace repo IDs used by segment inference in workspace mode. */
1498
+ workspaceRepoIds?: Iterable<string>;
1499
+ }
1500
+
1501
+ export function computeWaveAssignments(
1502
+ pending: Map<string, ParsedTask>,
1503
+ completed: Set<string>,
1504
+ config: OrchestratorConfig,
1505
+ options: WaveComputationOptions = {},
1506
+ ): WaveComputationResult {
1507
+ const errors: DiscoveryError[] = [];
1508
+
1509
+ // Step 1: Build dependency graph
1510
+ const graph = buildDependencyGraph(pending, completed);
1511
+
1512
+ // Step 2: Validate graph
1513
+ const validation = validateGraph(graph, pending, completed);
1514
+ if (!validation.valid) {
1515
+ return { waves: [], errors: validation.errors };
1516
+ }
1517
+
1518
+ // Step 3: Compute topological waves
1519
+ const { waves: rawWaves, errors: waveErrors } = computeWaves(graph, completed, pending);
1520
+ if (waveErrors.length > 0) {
1521
+ return { waves: [], errors: waveErrors };
1522
+ }
1523
+
1524
+ // Step 3.5: Build additive segment planning output (deterministic map)
1525
+ const segmentPlans = buildTaskSegmentPlans(pending, {
1526
+ workspaceRepoIds: options.workspaceRepoIds,
1527
+ });
1528
+
1529
+ // Step 4: Assign tasks to lanes within each wave
1530
+ const waveAssignments: WaveAssignment[] = [];
1531
+ for (let i = 0; i < rawWaves.length; i++) {
1532
+ const waveTasks = rawWaves[i];
1533
+ const laneAssignments = assignTasksToLanes(
1534
+ waveTasks,
1535
+ pending,
1536
+ config.orchestrator.max_lanes,
1537
+ config.assignment.strategy,
1538
+ config.assignment.size_weights,
1539
+ );
1540
+
1541
+ waveAssignments.push({
1542
+ waveNumber: i + 1,
1543
+ tasks: laneAssignments,
1544
+ });
1545
+ }
1546
+
1547
+ return { waves: waveAssignments, errors, segmentPlans };
1548
+ }