@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.
- package/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/agents/AGENTS-MANIFEST.md +42 -0
- package/agents/brain.md +42 -0
- package/agents/context-builder.md +46 -0
- package/agents/delegate.md +12 -0
- package/agents/dev-1.md +42 -0
- package/agents/oracle.md +73 -0
- package/agents/planner.md +55 -0
- package/agents/researcher.md +52 -0
- package/agents/reviewer.md +79 -0
- package/agents/scout.md +50 -0
- package/agents/tester.md +45 -0
- package/agents/worker.md +55 -0
- package/extensions/ralph.ts +1 -0
- package/extensions/reviewer-extension.ts +125 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/package.json +63 -0
- package/prompts/gather-context-and-clarify.md +13 -0
- package/prompts/parallel-cleanup.md +59 -0
- package/prompts/parallel-context-build.md +53 -0
- package/prompts/parallel-handoff-plan.md +59 -0
- package/prompts/parallel-research.md +50 -0
- package/prompts/parallel-review.md +54 -0
- package/prompts/review-loop.md +41 -0
- package/skills/orchid/SKILL.md +214 -0
- package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
- package/skills/orchid/orchid-converge/SKILL.md +124 -0
- package/skills/orchid/orchid-decompose/SKILL.md +201 -0
- package/skills/orchid/orchid-doctor/SKILL.md +162 -0
- package/skills/orchid/orchid-investigate/SKILL.md +102 -0
- package/skills/orchid/orchid-launch/SKILL.md +147 -0
- package/skills/ralph/SKILL.md +73 -0
- package/skills/subagents/pi-subagents/SKILL.md +813 -0
- package/src/index.ts +7 -0
- package/src/orchestrator/abort.ts +534 -0
- package/src/orchestrator/agent-bridge-extension.ts +1020 -0
- package/src/orchestrator/agent-host.ts +954 -0
- package/src/orchestrator/cleanup.ts +776 -0
- package/src/orchestrator/config-loader.ts +1412 -0
- package/src/orchestrator/config-schema.ts +690 -0
- package/src/orchestrator/config.ts +81 -0
- package/src/orchestrator/context-window.ts +66 -0
- package/src/orchestrator/diagnostic-reports.ts +475 -0
- package/src/orchestrator/diagnostics.ts +394 -0
- package/src/orchestrator/discovery.ts +1833 -0
- package/src/orchestrator/engine-worker.ts +415 -0
- package/src/orchestrator/engine.ts +5940 -0
- package/src/orchestrator/execution.ts +3104 -0
- package/src/orchestrator/extension.ts +5934 -0
- package/src/orchestrator/formatting.ts +785 -0
- package/src/orchestrator/git.ts +88 -0
- package/src/orchestrator/index.ts +28 -0
- package/src/orchestrator/lane-runner.ts +1787 -0
- package/src/orchestrator/mailbox.ts +780 -0
- package/src/orchestrator/merge.ts +3414 -0
- package/src/orchestrator/messages.ts +1062 -0
- package/src/orchestrator/migrations.ts +278 -0
- package/src/orchestrator/naming.ts +117 -0
- package/src/orchestrator/path-resolver.ts +275 -0
- package/src/orchestrator/persistence.ts +2625 -0
- package/src/orchestrator/process-registry.ts +452 -0
- package/src/orchestrator/quality-gate.ts +1085 -0
- package/src/orchestrator/resume.ts +3488 -0
- package/src/orchestrator/sessions.ts +57 -0
- package/src/orchestrator/settings-loader.ts +136 -0
- package/src/orchestrator/settings-tui.ts +2208 -0
- package/src/orchestrator/sidecar-telemetry.ts +267 -0
- package/src/orchestrator/supervisor.ts +4548 -0
- package/src/orchestrator/task-executor-core.ts +675 -0
- package/src/orchestrator/tmux-compat.ts +37 -0
- package/src/orchestrator/tool-allowlist-constants.ts +37 -0
- package/src/orchestrator/types.ts +4465 -0
- package/src/orchestrator/verification.ts +547 -0
- package/src/orchestrator/waves.ts +1564 -0
- package/src/orchestrator/workspace.ts +707 -0
- package/src/orchestrator/worktree.ts +2725 -0
- package/src/ralph/index.ts +825 -0
- package/src/subagents/agents/agent-management.ts +648 -0
- package/src/subagents/agents/agent-scope.ts +6 -0
- package/src/subagents/agents/agent-selection.ts +23 -0
- package/src/subagents/agents/agent-serializer.ts +86 -0
- package/src/subagents/agents/agents.ts +832 -0
- package/src/subagents/agents/chain-serializer.ts +137 -0
- package/src/subagents/agents/frontmatter.ts +29 -0
- package/src/subagents/agents/identity.ts +30 -0
- package/src/subagents/agents/skills.ts +632 -0
- package/src/subagents/extension/config.ts +16 -0
- package/src/subagents/extension/control-notices.ts +92 -0
- package/src/subagents/extension/doctor.ts +199 -0
- package/src/subagents/extension/fanout-child.ts +170 -0
- package/src/subagents/extension/index.ts +573 -0
- package/src/subagents/extension/schemas.ts +168 -0
- package/src/subagents/intercom/intercom-bridge.ts +379 -0
- package/src/subagents/intercom/result-intercom.ts +377 -0
- package/src/subagents/runs/background/async-execution.ts +712 -0
- package/src/subagents/runs/background/async-job-tracker.ts +310 -0
- package/src/subagents/runs/background/async-resume.ts +345 -0
- package/src/subagents/runs/background/async-status.ts +325 -0
- package/src/subagents/runs/background/completion-dedupe.ts +63 -0
- package/src/subagents/runs/background/notify.ts +108 -0
- package/src/subagents/runs/background/parallel-groups.ts +45 -0
- package/src/subagents/runs/background/result-watcher.ts +307 -0
- package/src/subagents/runs/background/run-id-resolver.ts +83 -0
- package/src/subagents/runs/background/run-status.ts +269 -0
- package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
- package/src/subagents/runs/background/subagent-runner.ts +1808 -0
- package/src/subagents/runs/background/top-level-async.ts +13 -0
- package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
- package/src/subagents/runs/foreground/chain-execution.ts +938 -0
- package/src/subagents/runs/foreground/execution.ts +918 -0
- package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
- package/src/subagents/runs/shared/completion-guard.ts +147 -0
- package/src/subagents/runs/shared/long-running-guard.ts +175 -0
- package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
- package/src/subagents/runs/shared/model-fallback.ts +103 -0
- package/src/subagents/runs/shared/nested-events.ts +819 -0
- package/src/subagents/runs/shared/nested-path.ts +52 -0
- package/src/subagents/runs/shared/nested-render.ts +115 -0
- package/src/subagents/runs/shared/parallel-utils.ts +109 -0
- package/src/subagents/runs/shared/pi-args.ts +220 -0
- package/src/subagents/runs/shared/pi-spawn.ts +115 -0
- package/src/subagents/runs/shared/run-history.ts +60 -0
- package/src/subagents/runs/shared/single-output.ts +164 -0
- package/src/subagents/runs/shared/subagent-control.ts +226 -0
- package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
- package/src/subagents/runs/shared/worktree.ts +577 -0
- package/src/subagents/shared/artifacts.ts +98 -0
- package/src/subagents/shared/atomic-json.ts +16 -0
- package/src/subagents/shared/file-coalescer.ts +40 -0
- package/src/subagents/shared/fork-context.ts +76 -0
- package/src/subagents/shared/formatters.ts +133 -0
- package/src/subagents/shared/jsonl-writer.ts +81 -0
- package/src/subagents/shared/model-info.ts +78 -0
- package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
- package/src/subagents/shared/session-identity.ts +10 -0
- package/src/subagents/shared/session-tokens.ts +44 -0
- package/src/subagents/shared/settings.ts +397 -0
- package/src/subagents/shared/status-format.ts +49 -0
- package/src/subagents/shared/types.ts +822 -0
- package/src/subagents/shared/utils.ts +450 -0
- package/src/subagents/slash/prompt-template-bridge.ts +397 -0
- package/src/subagents/slash/slash-bridge.ts +174 -0
- package/src/subagents/slash/slash-commands.ts +528 -0
- package/src/subagents/slash/slash-live-state.ts +292 -0
- package/src/subagents/tui/render-helpers.ts +80 -0
- package/src/subagents/tui/render.ts +1358 -0
- package/templates/agents/local/supervisor.md +33 -0
- package/templates/agents/local/task-merger.md +27 -0
- package/templates/agents/local/task-reviewer.md +30 -0
- package/templates/agents/local/task-worker.md +34 -0
- package/templates/agents/supervisor-routing.md +92 -0
- package/templates/agents/supervisor.md +229 -0
- package/templates/agents/task-merger.md +214 -0
- package/templates/agents/task-reviewer.md +260 -0
- package/templates/agents/task-worker-segment.md +44 -0
- package/templates/agents/task-worker.md +557 -0
- package/templates/tasks/CONTEXT.md +30 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
- 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
|
+
}
|