taskplane 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -20
  3. package/bin/taskplane.mjs +706 -0
  4. package/dashboard/public/app.js +900 -0
  5. package/dashboard/public/index.html +92 -0
  6. package/dashboard/public/style.css +924 -0
  7. package/dashboard/server.cjs +531 -0
  8. package/extensions/task-orchestrator.ts +28 -0
  9. package/extensions/task-runner.ts +1923 -0
  10. package/extensions/taskplane/abort.ts +466 -0
  11. package/extensions/taskplane/config.ts +102 -0
  12. package/extensions/taskplane/discovery.ts +988 -0
  13. package/extensions/taskplane/engine.ts +758 -0
  14. package/extensions/taskplane/execution.ts +1752 -0
  15. package/extensions/taskplane/extension.ts +577 -0
  16. package/extensions/taskplane/formatting.ts +718 -0
  17. package/extensions/taskplane/git.ts +38 -0
  18. package/extensions/taskplane/index.ts +22 -0
  19. package/extensions/taskplane/merge.ts +795 -0
  20. package/extensions/taskplane/messages.ts +134 -0
  21. package/extensions/taskplane/persistence.ts +1121 -0
  22. package/extensions/taskplane/resume.ts +1092 -0
  23. package/extensions/taskplane/sessions.ts +92 -0
  24. package/extensions/taskplane/types.ts +1514 -0
  25. package/extensions/taskplane/waves.ts +900 -0
  26. package/extensions/taskplane/worktree.ts +1624 -0
  27. package/package.json +48 -3
  28. package/skills/create-taskplane-task/SKILL.md +326 -0
  29. package/skills/create-taskplane-task/references/context-template.md +78 -0
  30. package/skills/create-taskplane-task/references/prompt-template.md +246 -0
  31. package/templates/agents/task-merger.md +256 -0
  32. package/templates/agents/task-reviewer.md +81 -0
  33. package/templates/agents/task-worker.md +140 -0
  34. package/templates/config/task-orchestrator.yaml +89 -0
  35. package/templates/config/task-runner.yaml +99 -0
  36. package/templates/tasks/CONTEXT.md +31 -0
  37. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
  38. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
@@ -0,0 +1,900 @@
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 { AllocationError, getTaskDurationMinutes } from "./types.ts";
9
+ import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, WaveAssignment, WaveComputationResult, WorktreeInfo } from "./types.ts";
10
+ import { ensureLaneWorktrees, removeAllWorktrees } from "./worktree.ts";
11
+
12
+ // ── Dependency Graph Construction ────────────────────────────────────
13
+
14
+ /**
15
+ * Build a dependency graph from the task registry.
16
+ *
17
+ * Source of truth: `ParsedTask.dependencies` from discovery phase (Step 4).
18
+ * No re-parsing of PROMPT.md. The graph only contains pending tasks as nodes.
19
+ * Completed tasks are NOT added as nodes — they are treated as pre-satisfied
20
+ * in-degree contributors during wave computation.
21
+ */
22
+ export function buildDependencyGraph(
23
+ pending: Map<string, ParsedTask>,
24
+ completed: Set<string>,
25
+ ): DependencyGraph {
26
+ const dependencies = new Map<string, string[]>();
27
+ const dependents = new Map<string, string[]>();
28
+ const nodes = new Set<string>();
29
+
30
+ // Initialize all pending tasks as graph nodes
31
+ for (const taskId of pending.keys()) {
32
+ nodes.add(taskId);
33
+ dependencies.set(taskId, []);
34
+ dependents.set(taskId, []);
35
+ }
36
+
37
+ // Build adjacency lists from parsed dependencies
38
+ for (const [taskId, task] of pending) {
39
+ const edgeSet = new Set<string>();
40
+ for (const depRaw of task.dependencies) {
41
+ const depId = parseDependencyReference(depRaw).taskId;
42
+ if (edgeSet.has(depId)) continue;
43
+ edgeSet.add(depId);
44
+ // Only add edges to other pending tasks (completed = already satisfied)
45
+ if (pending.has(depId)) {
46
+ dependencies.get(taskId)!.push(depId);
47
+ dependents.get(depId)!.push(taskId);
48
+ }
49
+ // If depId is completed, it's pre-satisfied — no edge needed
50
+ // If depId is unknown, that's a validation error caught by validateGraph()
51
+ }
52
+ }
53
+
54
+ return { dependencies, dependents, nodes };
55
+ }
56
+
57
+
58
+ // ── Graph Validation ─────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Validate the dependency graph for correctness.
62
+ *
63
+ * Checks performed (in order):
64
+ * 1. Self-edges: task depends on itself (A → A)
65
+ * 2. Duplicate dependencies: same dep listed twice
66
+ * 3. Missing targets: dependency on unknown task (not pending, not completed)
67
+ * 4. Circular dependencies: DFS cycle detection with full cycle path
68
+ *
69
+ * Returns all errors found (does not stop at first error).
70
+ */
71
+ export function validateGraph(
72
+ graph: DependencyGraph,
73
+ pending: Map<string, ParsedTask>,
74
+ completed: Set<string>,
75
+ ): GraphValidationResult {
76
+ const errors: DiscoveryError[] = [];
77
+
78
+ // 1. Self-edge check
79
+ for (const [taskId, task] of pending) {
80
+ for (const depRaw of task.dependencies) {
81
+ const depId = parseDependencyReference(depRaw).taskId;
82
+ if (depId === taskId) {
83
+ errors.push({
84
+ code: "DEP_UNRESOLVED",
85
+ message: `${taskId} has a self-dependency (depends on itself)`,
86
+ taskId,
87
+ taskPath: task.promptPath,
88
+ });
89
+ }
90
+ }
91
+ }
92
+
93
+ // 2. Duplicate dependency check (same target task referenced multiple times)
94
+ for (const [taskId, task] of pending) {
95
+ const seenTargets = new Set<string>();
96
+ for (const depRaw of task.dependencies) {
97
+ const depId = parseDependencyReference(depRaw).taskId;
98
+ if (seenTargets.has(depId)) {
99
+ errors.push({
100
+ code: "DEP_UNRESOLVED",
101
+ message: `${taskId} lists duplicate dependency targeting ${depId}`,
102
+ taskId,
103
+ taskPath: task.promptPath,
104
+ });
105
+ }
106
+ seenTargets.add(depId);
107
+ }
108
+ }
109
+
110
+ // 3. Missing target check (not in pending AND not in completed)
111
+ for (const [taskId, task] of pending) {
112
+ for (const depRaw of task.dependencies) {
113
+ const depId = parseDependencyReference(depRaw).taskId;
114
+ if (!pending.has(depId) && !completed.has(depId)) {
115
+ errors.push({
116
+ code: "DEP_UNRESOLVED",
117
+ message: `${taskId} depends on ${depRaw} which is neither pending nor completed`,
118
+ taskId,
119
+ taskPath: task.promptPath,
120
+ });
121
+ }
122
+ }
123
+ }
124
+
125
+ // 4. Circular dependency detection (DFS with cycle path extraction)
126
+ const visited = new Set<string>();
127
+ const inStack = new Set<string>();
128
+
129
+ function dfs(node: string): string[] | null {
130
+ if (inStack.has(node)) {
131
+ // Found a cycle — reconstruct path
132
+ return [node];
133
+ }
134
+ if (visited.has(node)) return null;
135
+
136
+ visited.add(node);
137
+ inStack.add(node);
138
+
139
+ const deps = graph.dependencies.get(node) || [];
140
+ // Deterministic order: sort dependencies alphabetically
141
+ const sortedDeps = [...deps].sort();
142
+
143
+ for (const dep of sortedDeps) {
144
+ const cyclePath = dfs(dep);
145
+ if (cyclePath) {
146
+ // If we haven't closed the cycle yet, keep adding nodes
147
+ if (cyclePath.length === 1 || cyclePath[0] !== cyclePath[cyclePath.length - 1]) {
148
+ cyclePath.push(node);
149
+ }
150
+ return cyclePath;
151
+ }
152
+ }
153
+
154
+ inStack.delete(node);
155
+ return null;
156
+ }
157
+
158
+ // Process nodes in deterministic (sorted) order
159
+ const sortedNodes = [...graph.nodes].sort();
160
+ for (const node of sortedNodes) {
161
+ if (!visited.has(node)) {
162
+ const cyclePath = dfs(node);
163
+ if (cyclePath) {
164
+ // Reverse so the path reads naturally: A → B → C → A
165
+ cyclePath.reverse();
166
+ const cycleStr = cyclePath.join(" → ");
167
+ errors.push({
168
+ code: "DEP_UNRESOLVED",
169
+ message: `Circular dependency detected: ${cycleStr}`,
170
+ });
171
+ // Only report first cycle to avoid noisy output
172
+ break;
173
+ }
174
+ }
175
+ }
176
+
177
+ return {
178
+ valid: errors.length === 0,
179
+ errors,
180
+ };
181
+ }
182
+
183
+
184
+ // ── Wave Computation (Topological Sort) ──────────────────────────────
185
+
186
+ /**
187
+ * Compute execution waves via Kahn's algorithm (topological sort).
188
+ *
189
+ * Algorithm contract:
190
+ * - Completed tasks are pre-satisfied: they contribute 0 in-degree but are
191
+ * excluded from the scheduled output.
192
+ * - Wave 1: all pending tasks with 0 unmet dependencies (deps are either
193
+ * completed or have no deps).
194
+ * - Wave N+1: tasks whose deps are all in waves 1..N or completed.
195
+ * - Deterministic ordering: within each wave, tasks are sorted by task ID
196
+ * alphabetically. Queue initialization and zero in-degree pops both use
197
+ * sorted order.
198
+ * - If not all tasks are placed (cycle exists), returns an error.
199
+ */
200
+ export function computeWaves(
201
+ graph: DependencyGraph,
202
+ completed: Set<string>,
203
+ pending: Map<string, ParsedTask>,
204
+ ): { waves: string[][]; errors: DiscoveryError[] } {
205
+ const errors: DiscoveryError[] = [];
206
+ const waves: string[][] = [];
207
+
208
+ // Calculate in-degree for each node (only counting edges from other pending tasks)
209
+ const inDegree = new Map<string, number>();
210
+ for (const node of graph.nodes) {
211
+ const deps = graph.dependencies.get(node) || [];
212
+ // Only count deps that are in the pending set (completed are pre-satisfied)
213
+ const pendingDeps = deps.filter((d) => graph.nodes.has(d));
214
+ inDegree.set(node, pendingDeps.length);
215
+ }
216
+
217
+ const placed = new Set<string>();
218
+ const remaining = new Set(graph.nodes);
219
+
220
+ while (remaining.size > 0) {
221
+ // Collect all nodes with in-degree 0 (all deps satisfied)
222
+ const waveNodes: string[] = [];
223
+ for (const node of remaining) {
224
+ if ((inDegree.get(node) || 0) === 0) {
225
+ waveNodes.push(node);
226
+ }
227
+ }
228
+
229
+ // Deterministic ordering: sort alphabetically by task ID
230
+ waveNodes.sort();
231
+
232
+ if (waveNodes.length === 0) {
233
+ // Remaining nodes all have unsatisfied deps — cycle exists
234
+ const stuckNodes = [...remaining].sort().join(", ");
235
+ errors.push({
236
+ code: "DEP_UNRESOLVED",
237
+ message: `Cannot schedule remaining tasks (possible cycle): ${stuckNodes}`,
238
+ });
239
+ break;
240
+ }
241
+
242
+ waves.push(waveNodes);
243
+
244
+ // Remove placed nodes and reduce in-degree for dependents
245
+ for (const node of waveNodes) {
246
+ placed.add(node);
247
+ remaining.delete(node);
248
+
249
+ const deps = graph.dependents.get(node) || [];
250
+ for (const dependent of deps) {
251
+ const current = inDegree.get(dependent) || 0;
252
+ inDegree.set(dependent, current - 1);
253
+ }
254
+ }
255
+ }
256
+
257
+ return { waves, errors };
258
+ }
259
+
260
+
261
+ // ── File Scope Affinity ──────────────────────────────────────────────
262
+
263
+ /**
264
+ * Group tasks with overlapping file scopes into affinity groups.
265
+ *
266
+ * Uses connected components over a file-scope overlap graph:
267
+ * - Nodes are task IDs within the wave
268
+ * - Edges connect tasks that share at least one file scope entry
269
+ * - Connected components form affinity groups
270
+ *
271
+ * Affinity groups should be assigned to the same lane for serial execution
272
+ * to avoid file-writing conflicts.
273
+ *
274
+ * Edge cases:
275
+ * - Tasks with empty file scope: no affinity edges (independent)
276
+ * - Partial overlaps: if A overlaps B and B overlaps C, all three
277
+ * are in the same affinity group (transitive closure)
278
+ * - Oversized groups (> maxLanes): group stays together on one lane
279
+ * (serial fallback — correctness over parallelism)
280
+ */
281
+ export function normalizeScope(scope: string): string {
282
+ return scope.replace(/\\/g, "/").trim().replace(/\/+/g, "/").replace(/\/$/, "");
283
+ }
284
+
285
+ export function isGlobScope(scope: string): boolean {
286
+ return scope.includes("*");
287
+ }
288
+
289
+ export function prefixOfGlob(scope: string): string {
290
+ const idx = scope.indexOf("*");
291
+ if (idx < 0) return scope;
292
+ return scope.slice(0, idx).replace(/\/$/, "");
293
+ }
294
+
295
+ export function pathStartsWithSegment(pathValue: string, prefix: string): boolean {
296
+ if (!prefix) return true;
297
+ return pathValue === prefix || pathValue.startsWith(`${prefix}/`);
298
+ }
299
+
300
+ export function scopesOverlap(aRaw: string, bRaw: string): boolean {
301
+ const a = normalizeScope(aRaw);
302
+ const b = normalizeScope(bRaw);
303
+ if (!a || !b) return false;
304
+ if (a === b) return true;
305
+
306
+ const aGlob = isGlobScope(a);
307
+ const bGlob = isGlobScope(b);
308
+
309
+ // file vs file (no wildcards): overlap only on exact match
310
+ if (!aGlob && !bGlob) return false;
311
+
312
+ if (aGlob && !bGlob) {
313
+ return pathStartsWithSegment(b, prefixOfGlob(a));
314
+ }
315
+ if (!aGlob && bGlob) {
316
+ return pathStartsWithSegment(a, prefixOfGlob(b));
317
+ }
318
+
319
+ // glob vs glob: overlap if either prefix contains the other
320
+ const aPrefix = prefixOfGlob(a);
321
+ const bPrefix = prefixOfGlob(b);
322
+ return pathStartsWithSegment(aPrefix, bPrefix) || pathStartsWithSegment(bPrefix, aPrefix);
323
+ }
324
+
325
+ export function taskScopesOverlap(taskA: ParsedTask, taskB: ParsedTask): boolean {
326
+ if (taskA.fileScope.length === 0 || taskB.fileScope.length === 0) return false;
327
+ for (const scopeA of taskA.fileScope) {
328
+ for (const scopeB of taskB.fileScope) {
329
+ if (scopesOverlap(scopeA, scopeB)) return true;
330
+ }
331
+ }
332
+ return false;
333
+ }
334
+
335
+ export function applyFileScopeAffinity(
336
+ waveTasks: string[],
337
+ pending: Map<string, ParsedTask>,
338
+ ): string[][] {
339
+ if (waveTasks.length === 0) return [];
340
+
341
+ // Build overlap graph using Union-Find
342
+ const parent = new Map<string, string>();
343
+ const rank = new Map<string, number>();
344
+
345
+ for (const taskId of waveTasks) {
346
+ parent.set(taskId, taskId);
347
+ rank.set(taskId, 0);
348
+ }
349
+
350
+ function find(x: string): string {
351
+ while (parent.get(x) !== x) {
352
+ parent.set(x, parent.get(parent.get(x)!)!);
353
+ x = parent.get(x)!;
354
+ }
355
+ return x;
356
+ }
357
+
358
+ function union(a: string, b: string): void {
359
+ const ra = find(a);
360
+ const rb = find(b);
361
+ if (ra === rb) return;
362
+ const rankA = rank.get(ra) || 0;
363
+ const rankB = rank.get(rb) || 0;
364
+ if (rankA < rankB) {
365
+ parent.set(ra, rb);
366
+ } else if (rankA > rankB) {
367
+ parent.set(rb, ra);
368
+ } else {
369
+ parent.set(rb, ra);
370
+ rank.set(ra, rankA + 1);
371
+ }
372
+ }
373
+
374
+ // Pairwise overlap check (handles exact + wildcard overlaps)
375
+ for (let i = 0; i < waveTasks.length; i++) {
376
+ for (let j = i + 1; j < waveTasks.length; j++) {
377
+ const taskA = pending.get(waveTasks[i]);
378
+ const taskB = pending.get(waveTasks[j]);
379
+ if (!taskA || !taskB) continue;
380
+ if (taskScopesOverlap(taskA, taskB)) {
381
+ union(taskA.taskId, taskB.taskId);
382
+ }
383
+ }
384
+ }
385
+
386
+ const groups = new Map<string, string[]>();
387
+ for (const taskId of waveTasks) {
388
+ const root = find(taskId);
389
+ const group = groups.get(root) || [];
390
+ group.push(taskId);
391
+ groups.set(root, group);
392
+ }
393
+
394
+ const result: string[][] = [];
395
+ for (const group of groups.values()) {
396
+ group.sort();
397
+ result.push(group);
398
+ }
399
+ result.sort((a, b) => a[0].localeCompare(b[0]));
400
+
401
+ return result;
402
+ }
403
+
404
+
405
+ // ── Lane Assignment ──────────────────────────────────────────────────
406
+
407
+ /**
408
+ * Assign tasks within a wave to lanes.
409
+ *
410
+ * Algorithm (affinity-first strategy):
411
+ * 1. Compute affinity groups via file scope overlap
412
+ * 2. Each affinity group goes to one lane (serial within lane)
413
+ * 3. Remaining single-task "groups" are distributed via round-robin
414
+ * or load-balanced fill
415
+ * 4. Lane count: min(number of groups, maxLanes)
416
+ *
417
+ * Deterministic tie-breaking: groups are sorted by first task ID,
418
+ * then assigned in order. Round-robin assignment is deterministic
419
+ * given deterministic group ordering.
420
+ *
421
+ * For "round-robin" strategy: simple sequential assignment.
422
+ * For "load-balanced" strategy: assign to lane with lowest total weight.
423
+ * For "affinity-first": affinity groups first, then load-balanced fill.
424
+ */
425
+ export function assignTasksToLanes(
426
+ waveTasks: string[],
427
+ pending: Map<string, ParsedTask>,
428
+ maxLanes: number,
429
+ strategy: string,
430
+ sizeWeights: Record<string, number>,
431
+ ): LaneAssignment[] {
432
+ if (waveTasks.length === 0) return [];
433
+
434
+ // Step 1: Compute affinity groups
435
+ const affinityGroups = applyFileScopeAffinity(waveTasks, pending);
436
+
437
+ // Step 2: Determine lane count
438
+ const laneCount = Math.min(affinityGroups.length, maxLanes);
439
+
440
+ // Step 3: Initialize lane weights (for load-balanced assignment)
441
+ const laneWeights: number[] = new Array(laneCount).fill(0);
442
+ const laneAssignments: LaneAssignment[][] = new Array(laneCount)
443
+ .fill(null)
444
+ .map(() => []);
445
+
446
+ function getWeight(taskId: string): number {
447
+ const task = pending.get(taskId);
448
+ if (!task) return sizeWeights["M"] || 2;
449
+ return sizeWeights[task.size] || sizeWeights["M"] || 2;
450
+ }
451
+
452
+ function assignGroupToLane(group: string[], laneIndex: number): void {
453
+ for (const taskId of group) {
454
+ const task = pending.get(taskId);
455
+ if (!task) continue;
456
+ laneAssignments[laneIndex].push({
457
+ taskId,
458
+ lane: laneIndex + 1, // 1-indexed lanes
459
+ task,
460
+ });
461
+ laneWeights[laneIndex] += getWeight(taskId);
462
+ }
463
+ }
464
+
465
+ function findLightestLane(): number {
466
+ let minIdx = 0;
467
+ let minWeight = laneWeights[0];
468
+ for (let i = 1; i < laneCount; i++) {
469
+ if (laneWeights[i] < minWeight) {
470
+ minWeight = laneWeights[i];
471
+ minIdx = i;
472
+ }
473
+ }
474
+ return minIdx;
475
+ }
476
+
477
+ // Step 4: Assign groups to lanes based on strategy
478
+ if (strategy === "round-robin") {
479
+ for (let i = 0; i < affinityGroups.length; i++) {
480
+ const laneIdx = i % laneCount;
481
+ assignGroupToLane(affinityGroups[i], laneIdx);
482
+ }
483
+ } else if (strategy === "load-balanced") {
484
+ // Sort groups by weight (heaviest first for better balance)
485
+ const sortedGroups = [...affinityGroups].sort((a, b) => {
486
+ const weightA = a.reduce((sum, id) => sum + getWeight(id), 0);
487
+ const weightB = b.reduce((sum, id) => sum + getWeight(id), 0);
488
+ if (weightB !== weightA) return weightB - weightA;
489
+ // Deterministic tie-break: alphabetical by first task ID
490
+ return a[0].localeCompare(b[0]);
491
+ });
492
+ for (const group of sortedGroups) {
493
+ const laneIdx = findLightestLane();
494
+ assignGroupToLane(group, laneIdx);
495
+ }
496
+ } else {
497
+ // affinity-first: multi-task groups get priority, then load-balanced fill
498
+ const multiGroups = affinityGroups.filter((g) => g.length > 1);
499
+ const singleGroups = affinityGroups.filter((g) => g.length === 1);
500
+
501
+ // Assign multi-task affinity groups first (heaviest first)
502
+ const sortedMulti = [...multiGroups].sort((a, b) => {
503
+ const weightA = a.reduce((sum, id) => sum + getWeight(id), 0);
504
+ const weightB = b.reduce((sum, id) => sum + getWeight(id), 0);
505
+ if (weightB !== weightA) return weightB - weightA;
506
+ return a[0].localeCompare(b[0]);
507
+ });
508
+ for (const group of sortedMulti) {
509
+ const laneIdx = findLightestLane();
510
+ assignGroupToLane(group, laneIdx);
511
+ }
512
+
513
+ // Fill remaining with single-task groups (load-balanced)
514
+ const sortedSingles = [...singleGroups].sort((a, b) => {
515
+ const weightA = getWeight(a[0]);
516
+ const weightB = getWeight(b[0]);
517
+ if (weightB !== weightA) return weightB - weightA;
518
+ return a[0].localeCompare(b[0]);
519
+ });
520
+ for (const group of sortedSingles) {
521
+ const laneIdx = findLightestLane();
522
+ assignGroupToLane(group, laneIdx);
523
+ }
524
+ }
525
+
526
+ // Flatten all lane assignments into a single array
527
+ const result: LaneAssignment[] = [];
528
+ for (const assignments of laneAssignments) {
529
+ result.push(...assignments);
530
+ }
531
+
532
+ return result;
533
+ }
534
+
535
+
536
+ /**
537
+ * Result of `allocateLanes()`.
538
+ *
539
+ * On success: `success=true`, `lanes` contains all allocated lanes.
540
+ * On failure: `success=false`, `error` describes what went wrong,
541
+ * `rolledBack` indicates whether partial worktrees were cleaned up.
542
+ */
543
+ export interface AllocateLanesResult {
544
+ /** Whether all lanes were allocated successfully */
545
+ success: boolean;
546
+ /** Allocated lanes, sorted by laneNumber. Empty on failure. */
547
+ lanes: AllocatedLane[];
548
+ /** Number of lanes allocated */
549
+ laneCount: number;
550
+ /** Error details (null on success) */
551
+ error: {
552
+ code: AllocationErrorCode;
553
+ message: string;
554
+ details?: string;
555
+ } | null;
556
+ /** Whether partial worktrees were rolled back on failure */
557
+ rolledBack: boolean;
558
+ /** Batch ID used for branch/session naming */
559
+ batchId: string;
560
+ }
561
+
562
+ /**
563
+ * Validate allocation inputs before proceeding.
564
+ *
565
+ * Checks:
566
+ * - max_lanes >= 1
567
+ * - waveTasks is non-empty
568
+ * - All task IDs in waveTasks exist in pending map
569
+ * - Config has valid strategy and size_weights
570
+ *
571
+ * @returns null if valid, AllocationError if invalid
572
+ */
573
+ export function validateAllocationInputs(
574
+ waveTasks: string[],
575
+ pending: Map<string, ParsedTask>,
576
+ config: OrchestratorConfig,
577
+ ): AllocationError | null {
578
+ // Validate max_lanes
579
+ if (
580
+ !config.orchestrator.max_lanes ||
581
+ config.orchestrator.max_lanes < 1 ||
582
+ !Number.isInteger(config.orchestrator.max_lanes)
583
+ ) {
584
+ return new AllocationError(
585
+ "ALLOC_INVALID_CONFIG",
586
+ `max_lanes must be a positive integer, got: ${config.orchestrator.max_lanes}`,
587
+ );
588
+ }
589
+
590
+ // Validate wave has tasks
591
+ if (!waveTasks || waveTasks.length === 0) {
592
+ return new AllocationError(
593
+ "ALLOC_EMPTY_WAVE",
594
+ "Cannot allocate lanes for an empty wave (no tasks provided)",
595
+ );
596
+ }
597
+
598
+ // Validate all task IDs exist in pending map
599
+ const missingTasks: string[] = [];
600
+ for (const taskId of waveTasks) {
601
+ if (!pending.has(taskId)) {
602
+ missingTasks.push(taskId);
603
+ }
604
+ }
605
+ if (missingTasks.length > 0) {
606
+ return new AllocationError(
607
+ "ALLOC_TASK_NOT_FOUND",
608
+ `Task IDs not found in pending map: ${missingTasks.join(", ")}`,
609
+ `These tasks may have been completed or removed between discovery and allocation.`,
610
+ );
611
+ }
612
+
613
+ // Validate strategy is recognized
614
+ const validStrategies = ["affinity-first", "round-robin", "load-balanced"];
615
+ if (!validStrategies.includes(config.assignment.strategy)) {
616
+ return new AllocationError(
617
+ "ALLOC_INVALID_CONFIG",
618
+ `Unknown assignment strategy: "${config.assignment.strategy}". ` +
619
+ `Valid strategies: ${validStrategies.join(", ")}`,
620
+ );
621
+ }
622
+
623
+ // Validate worktree prefix is non-empty
624
+ if (!config.orchestrator.worktree_prefix?.trim()) {
625
+ return new AllocationError(
626
+ "ALLOC_INVALID_CONFIG",
627
+ `worktree_prefix must be a non-empty string`,
628
+ );
629
+ }
630
+
631
+ // Validate integration branch is non-empty
632
+ if (!config.orchestrator.integration_branch?.trim()) {
633
+ return new AllocationError(
634
+ "ALLOC_INVALID_CONFIG",
635
+ `integration_branch must be a non-empty string`,
636
+ );
637
+ }
638
+
639
+ return null;
640
+ }
641
+
642
+ /**
643
+ * Allocate lanes for a wave: assign tasks, create worktrees, return ready-to-execute lanes.
644
+ *
645
+ * This is the Phase 3 implementation from §5 of the design doc.
646
+ * It coordinates three stages:
647
+ *
648
+ * 1. **Affinity grouping** — tasks with overlapping file scope are grouped
649
+ * together using `applyFileScopeAffinity()`. Overlap is detected from
650
+ * PROMPT.md's `## File Scope` section (parsed during discovery). Affinity
651
+ * groups have priority: they are assigned before independent tasks.
652
+ * Tie-breaking is deterministic (alphabetical by first task ID in group).
653
+ *
654
+ * 2. **Strategy assignment** — groups are distributed across lanes using
655
+ * the configured strategy via `assignTasksToLanes()`:
656
+ * - `affinity-first`: multi-task groups first (heaviest→lightest), then
657
+ * single tasks via load-balanced fill
658
+ * - `round-robin`: sequential assignment by group index mod lane count
659
+ * - `load-balanced`: heaviest group → lightest lane, repeated
660
+ *
661
+ * 3. **Worktree provisioning** — ensure one worktree per lane via
662
+ * `ensureLaneWorktrees()`.
663
+ * Existing lanes are reused across waves; missing lanes are created.
664
+ * If creating a missing lane fails, newly-created lanes in this call are
665
+ * rolled back.
666
+ *
667
+ * **Determinism guarantee:** Given the same `waveTasks`, `pending`, and `config`,
668
+ * this function always produces the same lane assignments and task ordering.
669
+ * This makes debugging and retry behavior predictable.
670
+ *
671
+ * @param waveTasks - Task IDs in this wave (from topological sort)
672
+ * @param pending - Full pending task map (from discovery)
673
+ * @param config - Orchestrator configuration
674
+ * @param repoRoot - Absolute path to the main repository root
675
+ * @param batchId - Batch ID for branch/session naming (e.g., "20260308T111750")
676
+ * @returns - AllocateLanesResult with success flag and lane details
677
+ */
678
+ export function allocateLanes(
679
+ waveTasks: string[],
680
+ pending: Map<string, ParsedTask>,
681
+ config: OrchestratorConfig,
682
+ repoRoot: string,
683
+ batchId: string,
684
+ ): AllocateLanesResult {
685
+ // ── Stage 0: Input validation ────────────────────────────────
686
+ const validationError = validateAllocationInputs(waveTasks, pending, config);
687
+ if (validationError) {
688
+ return {
689
+ success: false,
690
+ lanes: [],
691
+ laneCount: 0,
692
+ error: {
693
+ code: validationError.code,
694
+ message: validationError.message,
695
+ details: validationError.details,
696
+ },
697
+ rolledBack: false,
698
+ batchId,
699
+ };
700
+ }
701
+
702
+ // ── Stage 1+2: Affinity grouping + strategy assignment ───────
703
+ // assignTasksToLanes() internally calls applyFileScopeAffinity()
704
+ // and applies the configured strategy. It returns LaneAssignment[]
705
+ // with deterministic ordering.
706
+ const laneAssignments = assignTasksToLanes(
707
+ waveTasks,
708
+ pending,
709
+ config.orchestrator.max_lanes,
710
+ config.assignment.strategy,
711
+ config.assignment.size_weights,
712
+ );
713
+
714
+ // Determine actual lane count from assignments
715
+ const laneNumbers = new Set(laneAssignments.map((a) => a.lane));
716
+ const sortedLaneNumbers = [...laneNumbers].sort((a, b) => a - b);
717
+ const laneCount = laneNumbers.size;
718
+
719
+ if (laneCount === 0) {
720
+ return {
721
+ success: false,
722
+ lanes: [],
723
+ laneCount: 0,
724
+ error: {
725
+ code: "ALLOC_EMPTY_WAVE",
726
+ message: "Lane assignment produced zero lanes (no tasks could be assigned)",
727
+ },
728
+ rolledBack: false,
729
+ batchId,
730
+ };
731
+ }
732
+
733
+ // ── Stage 3: Ensure lane worktrees exist (reuse across waves + create missing) ─
734
+ const worktreeResult = ensureLaneWorktrees(sortedLaneNumbers, batchId, config, repoRoot);
735
+
736
+ if (!worktreeResult.success) {
737
+ const failedLanes = worktreeResult.errors
738
+ .map((e) => `Lane ${e.laneNumber}: [${e.code}] ${e.message}`)
739
+ .join("\n");
740
+ const rollbackIssues = worktreeResult.rollbackErrors.length > 0
741
+ ? "\nRollback issues:\n" +
742
+ worktreeResult.rollbackErrors
743
+ .map((e) => ` Lane ${e.laneNumber}: [${e.code}] ${e.message}`)
744
+ .join("\n")
745
+ : "";
746
+
747
+ return {
748
+ success: false,
749
+ lanes: [],
750
+ laneCount: 0,
751
+ error: {
752
+ code: "ALLOC_WORKTREE_FAILED",
753
+ message: `Failed to create worktrees for ${laneCount} lane(s)`,
754
+ details: failedLanes + rollbackIssues,
755
+ },
756
+ rolledBack: worktreeResult.rolledBack,
757
+ batchId,
758
+ };
759
+ }
760
+
761
+ // ── Stage 4: Build AllocatedLane[] from assignments + worktrees ─
762
+ const tmuxPrefix = config.orchestrator.tmux_prefix || "orch";
763
+ const strategy = config.assignment.strategy as AllocatedLane["strategy"];
764
+ const sizeWeights = config.assignment.size_weights;
765
+
766
+ // Build a worktree lookup by lane number
767
+ const worktreeByLane = new Map<number, WorktreeInfo>();
768
+ for (const wt of worktreeResult.worktrees) {
769
+ worktreeByLane.set(wt.laneNumber, wt);
770
+ }
771
+
772
+ // Group assignments by lane number and build AllocatedLane objects
773
+ const laneTaskMap = new Map<number, LaneAssignment[]>();
774
+ for (const assignment of laneAssignments) {
775
+ const existing = laneTaskMap.get(assignment.lane) || [];
776
+ existing.push(assignment);
777
+ laneTaskMap.set(assignment.lane, existing);
778
+ }
779
+
780
+ const allocatedLanes: AllocatedLane[] = [];
781
+
782
+ for (const [laneNum, assignments] of laneTaskMap) {
783
+ const wt = worktreeByLane.get(laneNum);
784
+ if (!wt) {
785
+ // This should never happen if ensureLaneWorktrees and assignTasksToLanes
786
+ // agree on lane numbers, but handle defensively
787
+ // Roll back all worktrees on this unexpected failure
788
+ removeAllWorktrees(config.orchestrator.worktree_prefix, repoRoot);
789
+ return {
790
+ success: false,
791
+ lanes: [],
792
+ laneCount: 0,
793
+ error: {
794
+ code: "ALLOC_WORKTREE_FAILED",
795
+ message: `No worktree found for lane ${laneNum} — lane count mismatch between assignment and worktree creation`,
796
+ },
797
+ rolledBack: true,
798
+ batchId,
799
+ };
800
+ }
801
+
802
+ // Build ordered task list (preserve assignment order from assignTasksToLanes)
803
+ const allocatedTasks: AllocatedTask[] = assignments.map((a, idx) => ({
804
+ taskId: a.taskId,
805
+ order: idx,
806
+ task: a.task,
807
+ estimatedMinutes: getTaskDurationMinutes(a.task.size, sizeWeights),
808
+ }));
809
+
810
+ const estimatedLoad = allocatedTasks.reduce(
811
+ (sum, t) => sum + (sizeWeights[t.task.size] || sizeWeights["M"] || 2),
812
+ 0,
813
+ );
814
+ const estimatedMinutes = allocatedTasks.reduce(
815
+ (sum, t) => sum + t.estimatedMinutes,
816
+ 0,
817
+ );
818
+
819
+ allocatedLanes.push({
820
+ laneNumber: laneNum,
821
+ laneId: `lane-${laneNum}`,
822
+ tmuxSessionName: `${tmuxPrefix}-lane-${laneNum}`,
823
+ worktreePath: wt.path,
824
+ branch: wt.branch,
825
+ tasks: allocatedTasks,
826
+ strategy,
827
+ estimatedLoad,
828
+ estimatedMinutes,
829
+ });
830
+ }
831
+
832
+ // Sort by lane number for deterministic output
833
+ allocatedLanes.sort((a, b) => a.laneNumber - b.laneNumber);
834
+
835
+ return {
836
+ success: true,
837
+ lanes: allocatedLanes,
838
+ laneCount: allocatedLanes.length,
839
+ error: null,
840
+ rolledBack: false,
841
+ batchId,
842
+ };
843
+ }
844
+
845
+
846
+ // ── Full Wave Pipeline ───────────────────────────────────────────────
847
+
848
+ /**
849
+ * Run the full wave computation pipeline:
850
+ * 1. Build dependency graph from registry
851
+ * 2. Validate graph (self-edges, duplicates, cycles, missing targets)
852
+ * 3. Compute topological waves
853
+ * 4. Assign tasks to lanes within each wave
854
+ *
855
+ * Returns WaveAssignment[] with wave numbers and lane assignments,
856
+ * plus any errors encountered.
857
+ */
858
+ export function computeWaveAssignments(
859
+ pending: Map<string, ParsedTask>,
860
+ completed: Set<string>,
861
+ config: OrchestratorConfig,
862
+ ): WaveComputationResult {
863
+ const errors: DiscoveryError[] = [];
864
+
865
+ // Step 1: Build dependency graph
866
+ const graph = buildDependencyGraph(pending, completed);
867
+
868
+ // Step 2: Validate graph
869
+ const validation = validateGraph(graph, pending, completed);
870
+ if (!validation.valid) {
871
+ return { waves: [], errors: validation.errors };
872
+ }
873
+
874
+ // Step 3: Compute topological waves
875
+ const { waves: rawWaves, errors: waveErrors } = computeWaves(graph, completed, pending);
876
+ if (waveErrors.length > 0) {
877
+ return { waves: [], errors: waveErrors };
878
+ }
879
+
880
+ // Step 4: Assign tasks to lanes within each wave
881
+ const waveAssignments: WaveAssignment[] = [];
882
+ for (let i = 0; i < rawWaves.length; i++) {
883
+ const waveTasks = rawWaves[i];
884
+ const laneAssignments = assignTasksToLanes(
885
+ waveTasks,
886
+ pending,
887
+ config.orchestrator.max_lanes,
888
+ config.assignment.strategy,
889
+ config.assignment.size_weights,
890
+ );
891
+
892
+ waveAssignments.push({
893
+ waveNumber: i + 1,
894
+ tasks: laneAssignments,
895
+ });
896
+ }
897
+
898
+ return { waves: waveAssignments, errors };
899
+ }
900
+