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.
- package/LICENSE +21 -0
- package/README.md +2 -20
- package/bin/taskplane.mjs +706 -0
- package/dashboard/public/app.js +900 -0
- package/dashboard/public/index.html +92 -0
- package/dashboard/public/style.css +924 -0
- package/dashboard/server.cjs +531 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/extensions/task-runner.ts +1923 -0
- package/extensions/taskplane/abort.ts +466 -0
- package/extensions/taskplane/config.ts +102 -0
- package/extensions/taskplane/discovery.ts +988 -0
- package/extensions/taskplane/engine.ts +758 -0
- package/extensions/taskplane/execution.ts +1752 -0
- package/extensions/taskplane/extension.ts +577 -0
- package/extensions/taskplane/formatting.ts +718 -0
- package/extensions/taskplane/git.ts +38 -0
- package/extensions/taskplane/index.ts +22 -0
- package/extensions/taskplane/merge.ts +795 -0
- package/extensions/taskplane/messages.ts +134 -0
- package/extensions/taskplane/persistence.ts +1121 -0
- package/extensions/taskplane/resume.ts +1092 -0
- package/extensions/taskplane/sessions.ts +92 -0
- package/extensions/taskplane/types.ts +1514 -0
- package/extensions/taskplane/waves.ts +900 -0
- package/extensions/taskplane/worktree.ts +1624 -0
- package/package.json +48 -3
- package/skills/create-taskplane-task/SKILL.md +326 -0
- package/skills/create-taskplane-task/references/context-template.md +78 -0
- package/skills/create-taskplane-task/references/prompt-template.md +246 -0
- package/templates/agents/task-merger.md +256 -0
- package/templates/agents/task-reviewer.md +81 -0
- package/templates/agents/task-worker.md +140 -0
- package/templates/config/task-orchestrator.yaml +89 -0
- package/templates/config/task-runner.yaml +99 -0
- package/templates/tasks/CONTEXT.md +31 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
- 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
|
+
|