@pi-agents/orchid 0.1.0-beta.0

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