dual-brain 7.1.2 → 7.1.4
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/bin/dual-brain.mjs +38 -28
- package/mcp-server/index.mjs +1 -1
- package/package.json +44 -4
- package/src/decide.mjs +32 -0
- package/src/index.mjs +1 -1
- package/src/profile.mjs +7 -4
- package/src/session.mjs +50 -10
- package/src/tui.mjs +10 -1
- package/hooks/agent-fleet.mjs +0 -659
- package/hooks/context-guard.mjs +0 -468
- package/hooks/dag-scheduler.mjs +0 -1249
- package/hooks/head-guard.sh +0 -41
- package/hooks/hook-dispatch.mjs +0 -254
- package/hooks/ledger-analysis.mjs +0 -337
- package/hooks/parallelism-scaler.mjs +0 -572
- package/hooks/quality-tiers.mjs +0 -642
- package/src/test.mjs +0 -1374
package/hooks/dag-scheduler.mjs
DELETED
|
@@ -1,1249 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* DAG Scheduler — replaces flat wave execution with a ready-queue scheduler
|
|
5
|
-
* that maximizes parallelism by tracking fine-grained task dependencies.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* node hooks/dag-scheduler.mjs --from-manifest <manifestId>
|
|
9
|
-
* node hooks/dag-scheduler.mjs --visualize <manifestId>
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
13
|
-
import { dirname, join } from 'path';
|
|
14
|
-
import { fileURLToPath } from 'url';
|
|
15
|
-
|
|
16
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
-
const ROOT_DIR = join(__dirname, '..');
|
|
18
|
-
const STATE_DIR = join(ROOT_DIR, '.dualbrain');
|
|
19
|
-
const MANIFEST_DIR = join(STATE_DIR, 'manifests');
|
|
20
|
-
const CHECKPOINT_DIR = join(STATE_DIR, 'checkpoints');
|
|
21
|
-
|
|
22
|
-
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
const DEFAULT_TASK_DURATION_MS = 120_000; // 2 minutes fallback
|
|
25
|
-
const MIN_PARALLELISM = 1;
|
|
26
|
-
const DEFAULT_MAX_PARALLELISM = 4;
|
|
27
|
-
const CHECKPOINT_EVERY_N_COMPLETIONS = 3;
|
|
28
|
-
const SUCCESS_RAMP_THRESHOLD = 0.85; // ramp up parallelism above this success rate
|
|
29
|
-
const FAILURE_RAMP_THRESHOLD = 0.40; // ramp down below this success rate
|
|
30
|
-
|
|
31
|
-
const STATUS = Object.freeze({
|
|
32
|
-
PENDING: 'pending',
|
|
33
|
-
RUNNING: 'running',
|
|
34
|
-
COMPLETED: 'completed',
|
|
35
|
-
FAILED: 'failed',
|
|
36
|
-
SKIPPED: 'skipped', // downstream of a failed task
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
// ─── Utility helpers ─────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
function isoNow() {
|
|
42
|
-
return new Date().toISOString();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function safeJsonParse(raw, fallback = null) {
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(raw);
|
|
48
|
-
} catch {
|
|
49
|
-
return fallback;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function uniq(items) {
|
|
54
|
-
return [...new Set((items || []).filter(Boolean))];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function trimText(value, max = 120) {
|
|
58
|
-
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
59
|
-
if (text.length <= max) return text;
|
|
60
|
-
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function ensureStateDirs() {
|
|
64
|
-
mkdirSync(MANIFEST_DIR, { recursive: true });
|
|
65
|
-
mkdirSync(CHECKPOINT_DIR, { recursive: true });
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function loadManifest(manifestId) {
|
|
69
|
-
const path = join(MANIFEST_DIR, `${manifestId}.json`);
|
|
70
|
-
if (!existsSync(path)) {
|
|
71
|
-
throw new Error(`Manifest not found: ${manifestId}`);
|
|
72
|
-
}
|
|
73
|
-
const manifest = safeJsonParse(readFileSync(path, 'utf8'), null);
|
|
74
|
-
if (!manifest) {
|
|
75
|
-
throw new Error(`Manifest is unreadable: ${manifestId}`);
|
|
76
|
-
}
|
|
77
|
-
return manifest;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Check whether two file paths conflict (same file, parent/child directory, or sibling).
|
|
82
|
-
* Matches the logic in wave-orchestrator.mjs for consistency.
|
|
83
|
-
*/
|
|
84
|
-
function pathsConflict(a, b) {
|
|
85
|
-
if (!a || !b) return false;
|
|
86
|
-
if (a === b) return true;
|
|
87
|
-
if (a.startsWith(`${b}/`) || b.startsWith(`${a}/`)) return true;
|
|
88
|
-
const aDir = a.includes('/') ? a.slice(0, a.lastIndexOf('/')) : a;
|
|
89
|
-
const bDir = b.includes('/') ? b.slice(0, b.lastIndexOf('/')) : b;
|
|
90
|
-
return Boolean(aDir && aDir === bDir);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function ownershipOverlaps(ownsA, ownsB) {
|
|
94
|
-
for (const a of ownsA) {
|
|
95
|
-
for (const b of ownsB) {
|
|
96
|
-
if (pathsConflict(a, b)) return true;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ─── class TaskDAG ────────────────────────────────────────────────────────────
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Core DAG data structure for task dependency management.
|
|
106
|
-
*
|
|
107
|
-
* Each node holds:
|
|
108
|
-
* taskId, description, dependencies, owns, reads,
|
|
109
|
-
* tier, riskLevel, estimatedDurationMs, provider, model, effort
|
|
110
|
-
*
|
|
111
|
-
* Internal state per node:
|
|
112
|
-
* status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped'
|
|
113
|
-
* result: any
|
|
114
|
-
* error: any
|
|
115
|
-
* startedAt, completedAt: ISO strings
|
|
116
|
-
* durationMs: number
|
|
117
|
-
*/
|
|
118
|
-
export class TaskDAG {
|
|
119
|
-
constructor() {
|
|
120
|
-
/** @type {Map<string, object>} taskId → node */
|
|
121
|
-
this._nodes = new Map();
|
|
122
|
-
/** @type {Map<string, Set<string>>} taskId → Set of taskIds it depends on */
|
|
123
|
-
this._deps = new Map();
|
|
124
|
-
/** @type {Map<string, Set<string>>} taskId → Set of taskIds that depend on it */
|
|
125
|
-
this._rdeps = new Map();
|
|
126
|
-
/** @type {Set<string>} files currently locked by running tasks */
|
|
127
|
-
this._lockedFiles = new Set();
|
|
128
|
-
/** @type {Map<string, string>} file → taskId that holds the lock */
|
|
129
|
-
this._fileLockOwner = new Map();
|
|
130
|
-
/** @type {Map<string, number>} cached critical path lengths from each node */
|
|
131
|
-
this._cpCache = null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ── Mutation ────────────────────────────────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Add a task node. Idempotent if same taskId.
|
|
138
|
-
* @param {object} task
|
|
139
|
-
*/
|
|
140
|
-
addTask(task) {
|
|
141
|
-
if (!task || !task.taskId) {
|
|
142
|
-
throw new Error('addTask: task.taskId is required');
|
|
143
|
-
}
|
|
144
|
-
const node = {
|
|
145
|
-
taskId: task.taskId,
|
|
146
|
-
description: task.description || task.title || task.taskId,
|
|
147
|
-
dependencies: uniq(task.dependencies || []),
|
|
148
|
-
owns: uniq(task.owns || task.files || []),
|
|
149
|
-
reads: uniq(task.reads || []),
|
|
150
|
-
tier: task.tier || 'execute',
|
|
151
|
-
riskLevel: task.riskLevel || task.risk || 'low',
|
|
152
|
-
estimatedDurationMs: task.estimatedDurationMs || DEFAULT_TASK_DURATION_MS,
|
|
153
|
-
provider: task.provider || null,
|
|
154
|
-
model: task.model || null,
|
|
155
|
-
effort: task.effort || null,
|
|
156
|
-
topic: task.topic || null,
|
|
157
|
-
// runtime state
|
|
158
|
-
status: task.status || STATUS.PENDING,
|
|
159
|
-
result: task.result || null,
|
|
160
|
-
error: task.error || null,
|
|
161
|
-
startedAt: task.startedAt || null,
|
|
162
|
-
completedAt: task.completedAt || null,
|
|
163
|
-
durationMs: task.durationMs || null,
|
|
164
|
-
retryCount: task.retryCount || 0,
|
|
165
|
-
};
|
|
166
|
-
this._nodes.set(node.taskId, node);
|
|
167
|
-
if (!this._deps.has(node.taskId)) this._deps.set(node.taskId, new Set());
|
|
168
|
-
if (!this._rdeps.has(node.taskId)) this._rdeps.set(node.taskId, new Set());
|
|
169
|
-
|
|
170
|
-
// Register declared dependencies
|
|
171
|
-
for (const depId of node.dependencies) {
|
|
172
|
-
this._deps.get(node.taskId).add(depId);
|
|
173
|
-
if (!this._rdeps.has(depId)) this._rdeps.set(depId, new Set());
|
|
174
|
-
this._rdeps.get(depId).add(node.taskId);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
this._cpCache = null;
|
|
178
|
-
return this;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Explicitly add a dependency edge (fromId depends on toId).
|
|
183
|
-
* @param {string} fromId the task that needs toId to finish first
|
|
184
|
-
* @param {string} toId the task that must complete before fromId starts
|
|
185
|
-
*/
|
|
186
|
-
addDependency(fromId, toId) {
|
|
187
|
-
if (!this._nodes.has(fromId)) throw new Error(`addDependency: unknown task '${fromId}'`);
|
|
188
|
-
if (!this._nodes.has(toId)) throw new Error(`addDependency: unknown task '${toId}'`);
|
|
189
|
-
this._deps.get(fromId).add(toId);
|
|
190
|
-
this._rdeps.get(toId).add(fromId);
|
|
191
|
-
const node = this._nodes.get(fromId);
|
|
192
|
-
if (!node.dependencies.includes(toId)) node.dependencies.push(toId);
|
|
193
|
-
this._cpCache = null;
|
|
194
|
-
return this;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ── Status transitions ──────────────────────────────────────────────────────
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Mark a task as running and lock its owned files.
|
|
201
|
-
* @param {string} taskId
|
|
202
|
-
*/
|
|
203
|
-
markRunning(taskId) {
|
|
204
|
-
const node = this._getNode(taskId);
|
|
205
|
-
node.status = STATUS.RUNNING;
|
|
206
|
-
node.startedAt = isoNow();
|
|
207
|
-
for (const file of node.owns) {
|
|
208
|
-
this._lockedFiles.add(file);
|
|
209
|
-
this._fileLockOwner.set(file, taskId);
|
|
210
|
-
}
|
|
211
|
-
this._cpCache = null;
|
|
212
|
-
return this;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Mark a task as completed, release file locks, invalidate CP cache.
|
|
217
|
-
* @param {string} taskId
|
|
218
|
-
* @param {*} result
|
|
219
|
-
* @returns {string[]} taskIds that are newly ready (all deps now satisfied)
|
|
220
|
-
*/
|
|
221
|
-
markCompleted(taskId, result = null) {
|
|
222
|
-
const node = this._getNode(taskId);
|
|
223
|
-
node.status = STATUS.COMPLETED;
|
|
224
|
-
node.result = result;
|
|
225
|
-
node.completedAt = isoNow();
|
|
226
|
-
if (node.startedAt) {
|
|
227
|
-
node.durationMs = Date.now() - new Date(node.startedAt).getTime();
|
|
228
|
-
}
|
|
229
|
-
this._releaseLocks(taskId);
|
|
230
|
-
this._cpCache = null;
|
|
231
|
-
return this._findNewlyReady(taskId);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Mark a task as failed, release locks, propagate skip to impacted tasks.
|
|
236
|
-
* @param {string} taskId
|
|
237
|
-
* @param {*} error
|
|
238
|
-
* @returns {{ newlyReady: string[], skipped: string[] }}
|
|
239
|
-
*/
|
|
240
|
-
markFailed(taskId, error = null) {
|
|
241
|
-
const node = this._getNode(taskId);
|
|
242
|
-
node.status = STATUS.FAILED;
|
|
243
|
-
node.error = error;
|
|
244
|
-
node.completedAt = isoNow();
|
|
245
|
-
if (node.startedAt) {
|
|
246
|
-
node.durationMs = Date.now() - new Date(node.startedAt).getTime();
|
|
247
|
-
}
|
|
248
|
-
this._releaseLocks(taskId);
|
|
249
|
-
|
|
250
|
-
// Cascade skips to all strictly downstream tasks
|
|
251
|
-
const impacted = this.getImpactedTasks(taskId);
|
|
252
|
-
const skipped = [];
|
|
253
|
-
for (const downstreamId of impacted) {
|
|
254
|
-
const downstream = this._nodes.get(downstreamId);
|
|
255
|
-
if (downstream && downstream.status === STATUS.PENDING) {
|
|
256
|
-
downstream.status = STATUS.SKIPPED;
|
|
257
|
-
downstream.error = `Upstream task '${taskId}' failed`;
|
|
258
|
-
skipped.push(downstreamId);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
this._cpCache = null;
|
|
263
|
-
return { newlyReady: [], skipped };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ── Queries ─────────────────────────────────────────────────────────────────
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Return tasks whose all dependencies are completed AND whose owned files
|
|
270
|
-
* don't conflict with any currently running task's owned files.
|
|
271
|
-
* @returns {object[]} array of task nodes
|
|
272
|
-
*/
|
|
273
|
-
getReadyTasks() {
|
|
274
|
-
const ready = [];
|
|
275
|
-
for (const [taskId, node] of this._nodes) {
|
|
276
|
-
if (node.status !== STATUS.PENDING) continue;
|
|
277
|
-
|
|
278
|
-
// All dependencies must be completed (not just not-running)
|
|
279
|
-
const deps = this._deps.get(taskId) || new Set();
|
|
280
|
-
let depsOk = true;
|
|
281
|
-
for (const depId of deps) {
|
|
282
|
-
const dep = this._nodes.get(depId);
|
|
283
|
-
// Unknown dep IDs are treated as satisfied (external deps)
|
|
284
|
-
if (dep && dep.status !== STATUS.COMPLETED) {
|
|
285
|
-
depsOk = false;
|
|
286
|
-
break;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
if (!depsOk) continue;
|
|
290
|
-
|
|
291
|
-
// Owned files must not conflict with any currently locked file
|
|
292
|
-
if (this._hasFileConflict(node.owns)) continue;
|
|
293
|
-
|
|
294
|
-
ready.push(node);
|
|
295
|
-
}
|
|
296
|
-
return ready;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Return tasks that are waiting because dependencies are not yet done.
|
|
301
|
-
* @returns {object[]}
|
|
302
|
-
*/
|
|
303
|
-
getBlockedTasks() {
|
|
304
|
-
const blocked = [];
|
|
305
|
-
for (const [taskId, node] of this._nodes) {
|
|
306
|
-
if (node.status !== STATUS.PENDING) continue;
|
|
307
|
-
const deps = this._deps.get(taskId) || new Set();
|
|
308
|
-
for (const depId of deps) {
|
|
309
|
-
const dep = this._nodes.get(depId);
|
|
310
|
-
if (dep && dep.status !== STATUS.COMPLETED) {
|
|
311
|
-
blocked.push(node);
|
|
312
|
-
break;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
return blocked;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Return all downstream taskIds that (transitively) depend on taskId.
|
|
321
|
-
* Does NOT include taskId itself.
|
|
322
|
-
* @param {string} taskId
|
|
323
|
-
* @returns {string[]}
|
|
324
|
-
*/
|
|
325
|
-
getImpactedTasks(taskId) {
|
|
326
|
-
const visited = new Set();
|
|
327
|
-
const queue = [...(this._rdeps.get(taskId) || [])];
|
|
328
|
-
while (queue.length > 0) {
|
|
329
|
-
const id = queue.shift();
|
|
330
|
-
if (visited.has(id)) continue;
|
|
331
|
-
visited.add(id);
|
|
332
|
-
for (const downId of (this._rdeps.get(id) || [])) {
|
|
333
|
-
if (!visited.has(downId)) queue.push(downId);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
return [...visited];
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Find the longest (critical) path through remaining incomplete tasks,
|
|
341
|
-
* returning the sequence of taskIds from start to end.
|
|
342
|
-
* Uses topological sort + longest-path on remaining subgraph.
|
|
343
|
-
* @returns {{ path: string[], totalDurationMs: number }}
|
|
344
|
-
*/
|
|
345
|
-
getCriticalPath() {
|
|
346
|
-
const weights = this._computeCriticalPathWeights();
|
|
347
|
-
// Find the node with the maximum weight
|
|
348
|
-
let maxWeight = -1;
|
|
349
|
-
let tail = null;
|
|
350
|
-
for (const [taskId, w] of weights) {
|
|
351
|
-
const node = this._nodes.get(taskId);
|
|
352
|
-
if (!node || node.status === STATUS.COMPLETED || node.status === STATUS.SKIPPED) continue;
|
|
353
|
-
if (w > maxWeight) {
|
|
354
|
-
maxWeight = w;
|
|
355
|
-
tail = taskId;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
if (!tail) return { path: [], totalDurationMs: 0 };
|
|
359
|
-
|
|
360
|
-
// Reconstruct path by walking back through predecessors
|
|
361
|
-
const pred = this._computePredecessors();
|
|
362
|
-
const path = [];
|
|
363
|
-
let current = tail;
|
|
364
|
-
while (current) {
|
|
365
|
-
path.unshift(current);
|
|
366
|
-
current = pred.get(current) || null;
|
|
367
|
-
}
|
|
368
|
-
return { path, totalDurationMs: maxWeight };
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Validate the DAG:
|
|
373
|
-
* 1. No cycles
|
|
374
|
-
* 2. All declared dependencies reference known taskIds
|
|
375
|
-
* 3. No two pending tasks own the exact same file (ownership conflict)
|
|
376
|
-
* @returns {{ valid: boolean, errors: string[] }}
|
|
377
|
-
*/
|
|
378
|
-
validate() {
|
|
379
|
-
const errors = [];
|
|
380
|
-
|
|
381
|
-
// Check for unknown dependency references
|
|
382
|
-
for (const [taskId, deps] of this._deps) {
|
|
383
|
-
for (const depId of deps) {
|
|
384
|
-
if (!this._nodes.has(depId)) {
|
|
385
|
-
errors.push(`Task '${taskId}' depends on unknown task '${depId}'`);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Check for cycles using DFS with coloring (white=0, gray=1, black=2)
|
|
391
|
-
const color = new Map();
|
|
392
|
-
const dfsStack = [];
|
|
393
|
-
const visit = (id) => {
|
|
394
|
-
if (color.get(id) === 2) return; // already fully processed
|
|
395
|
-
if (color.get(id) === 1) {
|
|
396
|
-
// cycle found — reconstruct cycle from stack
|
|
397
|
-
const cycleStart = dfsStack.indexOf(id);
|
|
398
|
-
const cycle = dfsStack.slice(cycleStart).concat(id);
|
|
399
|
-
errors.push(`Cycle detected: ${cycle.join(' → ')}`);
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
color.set(id, 1);
|
|
403
|
-
dfsStack.push(id);
|
|
404
|
-
for (const depId of (this._deps.get(id) || [])) {
|
|
405
|
-
visit(depId);
|
|
406
|
-
}
|
|
407
|
-
dfsStack.pop();
|
|
408
|
-
color.set(id, 2);
|
|
409
|
-
};
|
|
410
|
-
for (const taskId of this._nodes.keys()) {
|
|
411
|
-
if (!color.has(taskId)) visit(taskId);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Check for ownership conflicts among pending tasks
|
|
415
|
-
const pendingNodes = [...this._nodes.values()].filter(n => n.status === STATUS.PENDING);
|
|
416
|
-
for (let i = 0; i < pendingNodes.length; i++) {
|
|
417
|
-
for (let j = i + 1; j < pendingNodes.length; j++) {
|
|
418
|
-
const a = pendingNodes[i];
|
|
419
|
-
const b = pendingNodes[j];
|
|
420
|
-
if (!a.dependencies.includes(b.taskId) && !b.dependencies.includes(a.taskId)) {
|
|
421
|
-
// Not directly ordered — check for ownership overlap
|
|
422
|
-
for (const fa of a.owns) {
|
|
423
|
-
for (const fb of b.owns) {
|
|
424
|
-
if (pathsConflict(fa, fb)) {
|
|
425
|
-
errors.push(
|
|
426
|
-
`Ownership conflict: '${a.taskId}' and '${b.taskId}' both own '${fa === fb ? fa : `${fa} ~ ${fb}`}' with no ordering dependency`,
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
return { valid: errors.length === 0, errors };
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* Generate a wave-style grouping purely for display.
|
|
440
|
-
* Assigns tasks to wave indices based on their longest dependency chain depth.
|
|
441
|
-
* @returns {Array<{ waveIndex: number, tasks: object[] }>}
|
|
442
|
-
*/
|
|
443
|
-
toWaveView() {
|
|
444
|
-
// Compute topological depth of each node
|
|
445
|
-
const depth = new Map();
|
|
446
|
-
const sorted = this._topoSort();
|
|
447
|
-
|
|
448
|
-
for (const taskId of sorted) {
|
|
449
|
-
const deps = this._deps.get(taskId) || new Set();
|
|
450
|
-
let maxDepDepth = -1;
|
|
451
|
-
for (const depId of deps) {
|
|
452
|
-
maxDepDepth = Math.max(maxDepDepth, depth.get(depId) ?? -1);
|
|
453
|
-
}
|
|
454
|
-
depth.set(taskId, maxDepDepth + 1);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const waveMap = new Map();
|
|
458
|
-
for (const [taskId, d] of depth) {
|
|
459
|
-
if (!waveMap.has(d)) waveMap.set(d, []);
|
|
460
|
-
waveMap.get(d).push(this._nodes.get(taskId));
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
return [...waveMap.entries()]
|
|
464
|
-
.sort(([a], [b]) => a - b)
|
|
465
|
-
.map(([waveIndex, tasks]) => ({ waveIndex, tasks }));
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* Summary statistics.
|
|
470
|
-
* @returns {{ total, ready, running, completed, failed, skipped, blocked, criticalPathLength }}
|
|
471
|
-
*/
|
|
472
|
-
getStats() {
|
|
473
|
-
let running = 0, completed = 0, failed = 0, skipped = 0;
|
|
474
|
-
for (const node of this._nodes.values()) {
|
|
475
|
-
if (node.status === STATUS.RUNNING) running++;
|
|
476
|
-
else if (node.status === STATUS.COMPLETED) completed++;
|
|
477
|
-
else if (node.status === STATUS.FAILED) failed++;
|
|
478
|
-
else if (node.status === STATUS.SKIPPED) skipped++;
|
|
479
|
-
}
|
|
480
|
-
const ready = this.getReadyTasks().length;
|
|
481
|
-
const blocked = this.getBlockedTasks().length;
|
|
482
|
-
const { totalDurationMs } = this.getCriticalPath();
|
|
483
|
-
return {
|
|
484
|
-
total: this._nodes.size,
|
|
485
|
-
ready,
|
|
486
|
-
running,
|
|
487
|
-
completed,
|
|
488
|
-
failed,
|
|
489
|
-
skipped,
|
|
490
|
-
blocked,
|
|
491
|
-
criticalPathLength: totalDurationMs,
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// ── Serialisation ───────────────────────────────────────────────────────────
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Export DAG state as plain object (for checkpointing / manifest embedding).
|
|
499
|
-
*/
|
|
500
|
-
toJSON() {
|
|
501
|
-
return {
|
|
502
|
-
nodes: [...this._nodes.values()].map(n => ({
|
|
503
|
-
...n,
|
|
504
|
-
dependencies: [...(this._deps.get(n.taskId) || [])],
|
|
505
|
-
})),
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Restore a DAG from toJSON() output.
|
|
511
|
-
* @param {object} json
|
|
512
|
-
* @returns {TaskDAG}
|
|
513
|
-
*/
|
|
514
|
-
static fromJSON(json) {
|
|
515
|
-
const dag = new TaskDAG();
|
|
516
|
-
for (const node of json.nodes || []) {
|
|
517
|
-
dag.addTask(node);
|
|
518
|
-
}
|
|
519
|
-
return dag;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
523
|
-
|
|
524
|
-
_getNode(taskId) {
|
|
525
|
-
const node = this._nodes.get(taskId);
|
|
526
|
-
if (!node) throw new Error(`Unknown task: '${taskId}'`);
|
|
527
|
-
return node;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
_releaseLocks(taskId) {
|
|
531
|
-
const node = this._nodes.get(taskId);
|
|
532
|
-
if (!node) return;
|
|
533
|
-
for (const file of node.owns) {
|
|
534
|
-
if (this._fileLockOwner.get(file) === taskId) {
|
|
535
|
-
this._lockedFiles.delete(file);
|
|
536
|
-
this._fileLockOwner.delete(file);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
_hasFileConflict(owns) {
|
|
542
|
-
for (const file of owns) {
|
|
543
|
-
for (const locked of this._lockedFiles) {
|
|
544
|
-
if (pathsConflict(file, locked)) return true;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
return false;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
_findNewlyReady(completedTaskId) {
|
|
551
|
-
const candidates = [...(this._rdeps.get(completedTaskId) || [])];
|
|
552
|
-
return candidates.filter(taskId => {
|
|
553
|
-
const node = this._nodes.get(taskId);
|
|
554
|
-
if (!node || node.status !== STATUS.PENDING) return false;
|
|
555
|
-
const deps = this._deps.get(taskId) || new Set();
|
|
556
|
-
return [...deps].every(depId => {
|
|
557
|
-
const dep = this._nodes.get(depId);
|
|
558
|
-
return !dep || dep.status === STATUS.COMPLETED;
|
|
559
|
-
});
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Topological sort (Kahn's algorithm) over all nodes.
|
|
565
|
-
* Returns taskIds in topological order.
|
|
566
|
-
*/
|
|
567
|
-
_topoSort() {
|
|
568
|
-
const inDegree = new Map();
|
|
569
|
-
for (const taskId of this._nodes.keys()) inDegree.set(taskId, 0);
|
|
570
|
-
for (const [taskId, deps] of this._deps) {
|
|
571
|
-
for (const depId of deps) {
|
|
572
|
-
if (this._nodes.has(depId)) {
|
|
573
|
-
inDegree.set(taskId, (inDegree.get(taskId) || 0) + 1);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([id]) => id);
|
|
578
|
-
const sorted = [];
|
|
579
|
-
while (queue.length > 0) {
|
|
580
|
-
const id = queue.shift();
|
|
581
|
-
sorted.push(id);
|
|
582
|
-
for (const rdepId of (this._rdeps.get(id) || [])) {
|
|
583
|
-
const newDeg = (inDegree.get(rdepId) || 1) - 1;
|
|
584
|
-
inDegree.set(rdepId, newDeg);
|
|
585
|
-
if (newDeg === 0) queue.push(rdepId);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
// Append any remaining nodes (handles disconnected subgraphs gracefully)
|
|
589
|
-
for (const taskId of this._nodes.keys()) {
|
|
590
|
-
if (!sorted.includes(taskId)) sorted.push(taskId);
|
|
591
|
-
}
|
|
592
|
-
return sorted;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* Compute the longest-path weight (in ms) ending at each node.
|
|
597
|
-
* Considers only nodes that are not yet completed/skipped.
|
|
598
|
-
* @returns {Map<string, number>}
|
|
599
|
-
*/
|
|
600
|
-
_computeCriticalPathWeights() {
|
|
601
|
-
if (this._cpCache) return this._cpCache;
|
|
602
|
-
const sorted = this._topoSort();
|
|
603
|
-
const dist = new Map();
|
|
604
|
-
|
|
605
|
-
for (const taskId of sorted) {
|
|
606
|
-
const node = this._nodes.get(taskId);
|
|
607
|
-
if (!node) continue;
|
|
608
|
-
if (node.status === STATUS.COMPLETED || node.status === STATUS.SKIPPED) {
|
|
609
|
-
dist.set(taskId, 0);
|
|
610
|
-
continue;
|
|
611
|
-
}
|
|
612
|
-
const duration = node.status === STATUS.RUNNING ? 0 : (node.estimatedDurationMs || DEFAULT_TASK_DURATION_MS);
|
|
613
|
-
let maxPred = 0;
|
|
614
|
-
for (const depId of (this._deps.get(taskId) || [])) {
|
|
615
|
-
maxPred = Math.max(maxPred, dist.get(depId) || 0);
|
|
616
|
-
}
|
|
617
|
-
dist.set(taskId, maxPred + duration);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
this._cpCache = dist;
|
|
621
|
-
return dist;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* For each node, compute which predecessor gives the longest path (for path reconstruction).
|
|
626
|
-
* @returns {Map<string, string|null>}
|
|
627
|
-
*/
|
|
628
|
-
_computePredecessors() {
|
|
629
|
-
const sorted = this._topoSort();
|
|
630
|
-
const dist = this._computeCriticalPathWeights();
|
|
631
|
-
const pred = new Map();
|
|
632
|
-
|
|
633
|
-
for (const taskId of sorted) {
|
|
634
|
-
const node = this._nodes.get(taskId);
|
|
635
|
-
if (!node) continue;
|
|
636
|
-
let bestPredId = null;
|
|
637
|
-
let bestDist = -1;
|
|
638
|
-
for (const depId of (this._deps.get(taskId) || [])) {
|
|
639
|
-
const d = dist.get(depId) || 0;
|
|
640
|
-
if (d > bestDist) {
|
|
641
|
-
bestDist = d;
|
|
642
|
-
bestPredId = depId;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
pred.set(taskId, bestPredId);
|
|
646
|
-
}
|
|
647
|
-
return pred;
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// ─── class DAGScheduler ───────────────────────────────────────────────────────
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Scheduler that executes tasks from a TaskDAG using a ready-queue model.
|
|
655
|
-
*
|
|
656
|
-
* @param {TaskDAG} dag
|
|
657
|
-
* @param {object} options
|
|
658
|
-
* @param {number} [options.maxParallelism=4]
|
|
659
|
-
* @param {Function} [options.onTaskReady] async (task) → void
|
|
660
|
-
* @param {Function} [options.onTaskComplete] async (task, result) → void
|
|
661
|
-
* @param {Function} [options.onTaskFailed] async (task, error) → void
|
|
662
|
-
* @param {Function} [options.onCheckpoint] async (stats) → void
|
|
663
|
-
* @param {Function} [options.budgetCheck] () → { recommend: 'claude'|'gpt'|'either', pressure: number }
|
|
664
|
-
* @param {Function} [options.executeTask] async (task) → result (override for testing)
|
|
665
|
-
*/
|
|
666
|
-
export class DAGScheduler {
|
|
667
|
-
constructor(dag, options = {}) {
|
|
668
|
-
this._dag = dag;
|
|
669
|
-
this._maxParallelism = options.maxParallelism ?? DEFAULT_MAX_PARALLELISM;
|
|
670
|
-
this._currentParallelism = Math.min(2, this._maxParallelism); // ramp-up start
|
|
671
|
-
this._onTaskReady = options.onTaskReady || null;
|
|
672
|
-
this._onTaskComplete = options.onTaskComplete || null;
|
|
673
|
-
this._onTaskFailed = options.onTaskFailed || null;
|
|
674
|
-
this._onCheckpoint = options.onCheckpoint || null;
|
|
675
|
-
this._budgetCheck = options.budgetCheck || null;
|
|
676
|
-
this._executeTask = options.executeTask || this._defaultExecuteTask.bind(this);
|
|
677
|
-
|
|
678
|
-
// Metrics for adaptive parallelism
|
|
679
|
-
this._successCount = 0;
|
|
680
|
-
this._failureCount = 0;
|
|
681
|
-
this._conflictCount = 0;
|
|
682
|
-
this._completionsSinceCheckpoint = 0;
|
|
683
|
-
this._lastDepthCompleted = -1;
|
|
684
|
-
|
|
685
|
-
// For progress reporting
|
|
686
|
-
this._startedAt = null;
|
|
687
|
-
this._runningTasks = new Map(); // taskId → Promise
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// ── Public API ──────────────────────────────────────────────────────────────
|
|
691
|
-
|
|
692
|
-
/**
|
|
693
|
-
* Main scheduler loop. Runs until all tasks are done or unresolvable.
|
|
694
|
-
* @returns {Promise<{ completed: number, failed: number, skipped: number }>}
|
|
695
|
-
*/
|
|
696
|
-
async run() {
|
|
697
|
-
this._startedAt = Date.now();
|
|
698
|
-
this._log('DAG Scheduler starting…');
|
|
699
|
-
|
|
700
|
-
const stats = this._dag.getStats();
|
|
701
|
-
this._log(` ${stats.total} tasks | max parallelism: ${this._maxParallelism}`);
|
|
702
|
-
|
|
703
|
-
while (true) {
|
|
704
|
-
const stats = this._dag.getStats();
|
|
705
|
-
|
|
706
|
-
// Terminal conditions
|
|
707
|
-
if (stats.running === 0 && stats.ready === 0) {
|
|
708
|
-
if (stats.blocked > 0) {
|
|
709
|
-
this._log(`\n⚠ ${stats.blocked} tasks remain blocked — possible cycle or all dependencies failed.`);
|
|
710
|
-
}
|
|
711
|
-
break;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// Fill available slots
|
|
715
|
-
const availableSlots = this._currentParallelism - stats.running;
|
|
716
|
-
if (availableSlots > 0) {
|
|
717
|
-
const ready = this._dag.getReadyTasks();
|
|
718
|
-
const toStart = this.pickNextTasks(ready, availableSlots);
|
|
719
|
-
|
|
720
|
-
for (const task of toStart) {
|
|
721
|
-
this._startTask(task);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// If nothing is running and nothing to start, we're stuck
|
|
726
|
-
if (this._runningTasks.size === 0) break;
|
|
727
|
-
|
|
728
|
-
// Wait for any running task to finish
|
|
729
|
-
await Promise.race([...this._runningTasks.values()]);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
const final = this._dag.getStats();
|
|
733
|
-
this._log(
|
|
734
|
-
`\nScheduler done in ${((Date.now() - this._startedAt) / 1000).toFixed(1)}s — ` +
|
|
735
|
-
`${final.completed} completed, ${final.failed} failed, ${final.skipped} skipped`
|
|
736
|
-
);
|
|
737
|
-
return { completed: final.completed, failed: final.failed, skipped: final.skipped };
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
/**
|
|
741
|
-
* Score and pick the best tasks to run next from the available ready list.
|
|
742
|
-
* Scoring formula:
|
|
743
|
-
* score = criticalPathWeight * 3
|
|
744
|
-
* + dependencyUnblockValue * 2
|
|
745
|
-
* + (1 / estimatedDurationMs) * 1 (shorter tasks fill gaps)
|
|
746
|
-
* - conflictRisk * 2
|
|
747
|
-
* - providerPressure * 1
|
|
748
|
-
*
|
|
749
|
-
* @param {object[]} available ready task nodes
|
|
750
|
-
* @param {number} maxSlots how many we can start
|
|
751
|
-
* @returns {object[]} chosen task nodes (up to maxSlots, non-conflicting)
|
|
752
|
-
*/
|
|
753
|
-
pickNextTasks(available, maxSlots) {
|
|
754
|
-
if (available.length === 0 || maxSlots <= 0) return [];
|
|
755
|
-
|
|
756
|
-
const cpWeights = this._dag._computeCriticalPathWeights();
|
|
757
|
-
const maxCpWeight = Math.max(...[...cpWeights.values()], 1);
|
|
758
|
-
const budgetInfo = this._budgetCheck ? this._budgetCheck() : null;
|
|
759
|
-
|
|
760
|
-
const scored = available.map(task => {
|
|
761
|
-
const cpWeight = (cpWeights.get(task.taskId) || 0) / maxCpWeight;
|
|
762
|
-
|
|
763
|
-
const unblockValue = this._dag.getImpactedTasks(task.taskId).length /
|
|
764
|
-
Math.max(this._dag._nodes.size, 1);
|
|
765
|
-
|
|
766
|
-
const durationScore = 1 / Math.max(task.estimatedDurationMs || DEFAULT_TASK_DURATION_MS, 1000);
|
|
767
|
-
|
|
768
|
-
// Rough conflict risk: fraction of owned files that are near any locked file
|
|
769
|
-
const totalOwned = task.owns.length;
|
|
770
|
-
let conflictsNear = 0;
|
|
771
|
-
if (totalOwned > 0) {
|
|
772
|
-
for (const file of task.owns) {
|
|
773
|
-
for (const locked of this._dag._lockedFiles) {
|
|
774
|
-
if (pathsConflict(file, locked)) conflictsNear++;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
const conflictRisk = totalOwned > 0 ? conflictsNear / totalOwned : 0;
|
|
779
|
-
|
|
780
|
-
// Provider pressure penalty
|
|
781
|
-
let providerPressure = 0;
|
|
782
|
-
if (budgetInfo && task.provider) {
|
|
783
|
-
const p = budgetInfo[task.provider];
|
|
784
|
-
if (p && typeof p.pressure === 'number') providerPressure = p.pressure;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
const score =
|
|
788
|
-
cpWeight * 3 +
|
|
789
|
-
unblockValue * 2 +
|
|
790
|
-
durationScore * 1 -
|
|
791
|
-
conflictRisk * 2 -
|
|
792
|
-
providerPressure * 1;
|
|
793
|
-
|
|
794
|
-
return { task, score };
|
|
795
|
-
});
|
|
796
|
-
|
|
797
|
-
scored.sort((a, b) => b.score - a.score);
|
|
798
|
-
|
|
799
|
-
// Pick greedily, respecting that selected tasks must not conflict with each other
|
|
800
|
-
const chosen = [];
|
|
801
|
-
const chosenFiles = [];
|
|
802
|
-
|
|
803
|
-
for (const { task } of scored) {
|
|
804
|
-
if (chosen.length >= maxSlots) break;
|
|
805
|
-
// Check that this task's files don't conflict with already-chosen tasks
|
|
806
|
-
let conflicts = false;
|
|
807
|
-
for (const file of task.owns) {
|
|
808
|
-
for (const cf of chosenFiles) {
|
|
809
|
-
if (pathsConflict(file, cf)) { conflicts = true; break; }
|
|
810
|
-
}
|
|
811
|
-
if (conflicts) break;
|
|
812
|
-
}
|
|
813
|
-
if (!conflicts) {
|
|
814
|
-
chosen.push(task);
|
|
815
|
-
chosenFiles.push(...task.owns);
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
return chosen;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/**
|
|
823
|
-
* Dynamically adjust parallelism based on recent success/failure rates.
|
|
824
|
-
* Called after each task completion or failure.
|
|
825
|
-
*/
|
|
826
|
-
adjustParallelism() {
|
|
827
|
-
const total = this._successCount + this._failureCount;
|
|
828
|
-
if (total < 2) return; // not enough data
|
|
829
|
-
|
|
830
|
-
const successRate = this._successCount / total;
|
|
831
|
-
|
|
832
|
-
if (successRate >= SUCCESS_RAMP_THRESHOLD && this._conflictCount === 0) {
|
|
833
|
-
// Ramp up
|
|
834
|
-
this._currentParallelism = Math.min(this._currentParallelism + 1, this._maxParallelism);
|
|
835
|
-
} else if (successRate <= FAILURE_RAMP_THRESHOLD || this._conflictCount > 2) {
|
|
836
|
-
// Ramp down
|
|
837
|
-
this._currentParallelism = Math.max(this._currentParallelism - 1, MIN_PARALLELISM);
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* Return true if we've crossed a logical wave boundary or hit the N-completion threshold.
|
|
843
|
-
* A "logical wave boundary" is when all tasks at a particular dependency depth are done.
|
|
844
|
-
* @returns {boolean}
|
|
845
|
-
*/
|
|
846
|
-
shouldCheckpoint() {
|
|
847
|
-
if (this._completionsSinceCheckpoint >= CHECKPOINT_EVERY_N_COMPLETIONS) return true;
|
|
848
|
-
|
|
849
|
-
// Check if a full wave-depth layer just cleared
|
|
850
|
-
const waveView = this._dag.toWaveView();
|
|
851
|
-
for (const { waveIndex, tasks } of waveView) {
|
|
852
|
-
if (waveIndex <= this._lastDepthCompleted) continue;
|
|
853
|
-
const allDone = tasks.every(t =>
|
|
854
|
-
t.status === STATUS.COMPLETED || t.status === STATUS.FAILED || t.status === STATUS.SKIPPED
|
|
855
|
-
);
|
|
856
|
-
if (allDone) {
|
|
857
|
-
this._lastDepthCompleted = waveIndex;
|
|
858
|
-
return true;
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
return false;
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
/**
|
|
866
|
-
* Current scheduler state for display.
|
|
867
|
-
* @returns {object}
|
|
868
|
-
*/
|
|
869
|
-
getProgress() {
|
|
870
|
-
const stats = this._dag.getStats();
|
|
871
|
-
const cp = this._dag.getCriticalPath();
|
|
872
|
-
const elapsedMs = this._startedAt ? Date.now() - this._startedAt : 0;
|
|
873
|
-
return {
|
|
874
|
-
...stats,
|
|
875
|
-
currentParallelism: this._currentParallelism,
|
|
876
|
-
criticalPath: cp.path,
|
|
877
|
-
criticalPathMs: cp.totalDurationMs,
|
|
878
|
-
elapsedMs,
|
|
879
|
-
successCount: this._successCount,
|
|
880
|
-
failureCount: this._failureCount,
|
|
881
|
-
conflictCount: this._conflictCount,
|
|
882
|
-
};
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
886
|
-
|
|
887
|
-
_startTask(task) {
|
|
888
|
-
this._dag.markRunning(task.taskId);
|
|
889
|
-
if (this._onTaskReady) this._onTaskReady(task).catch(() => {});
|
|
890
|
-
this._log(` > [${task.taskId}] starting (${task.tier}, ${task.riskLevel})`);
|
|
891
|
-
|
|
892
|
-
const promise = (async () => {
|
|
893
|
-
try {
|
|
894
|
-
const result = await this._executeTask(task);
|
|
895
|
-
const newlyReady = this._dag.markCompleted(task.taskId, result);
|
|
896
|
-
this._successCount++;
|
|
897
|
-
this._completionsSinceCheckpoint++;
|
|
898
|
-
this._log(` ✓ [${task.taskId}] completed — ${newlyReady.length} task(s) newly ready`);
|
|
899
|
-
|
|
900
|
-
if (this._onTaskComplete) {
|
|
901
|
-
await this._onTaskComplete(task, result).catch(() => {});
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
this.adjustParallelism();
|
|
905
|
-
|
|
906
|
-
if (this.shouldCheckpoint() && this._onCheckpoint) {
|
|
907
|
-
this._completionsSinceCheckpoint = 0;
|
|
908
|
-
await this._onCheckpoint(this.getProgress()).catch(() => {});
|
|
909
|
-
}
|
|
910
|
-
} catch (err) {
|
|
911
|
-
const { skipped } = this._dag.markFailed(task.taskId, err?.message || String(err));
|
|
912
|
-
this._failureCount++;
|
|
913
|
-
this._completionsSinceCheckpoint++;
|
|
914
|
-
this._log(` ✕ [${task.taskId}] failed — ${skipped.length} downstream task(s) skipped`);
|
|
915
|
-
|
|
916
|
-
if (this._onTaskFailed) {
|
|
917
|
-
await this._onTaskFailed(task, err).catch(() => {});
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
this.adjustParallelism();
|
|
921
|
-
} finally {
|
|
922
|
-
this._runningTasks.delete(task.taskId);
|
|
923
|
-
}
|
|
924
|
-
})();
|
|
925
|
-
|
|
926
|
-
this._runningTasks.set(task.taskId, promise);
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* Default task executor — no-op placeholder.
|
|
931
|
-
* Override via options.executeTask for real dispatch.
|
|
932
|
-
* @param {object} _task
|
|
933
|
-
* @returns {Promise<object>}
|
|
934
|
-
*/
|
|
935
|
-
async _defaultExecuteTask(_task) {
|
|
936
|
-
// Simulate work for testing
|
|
937
|
-
const duration = _task.estimatedDurationMs || 1000;
|
|
938
|
-
await new Promise(resolve => setTimeout(resolve, Math.min(duration, 100)));
|
|
939
|
-
return { provider: _task.provider || 'claude', status: 'simulated' };
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
_log(msg) {
|
|
943
|
-
process.stdout.write(`${msg}\n`);
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
// ─── Factory functions ────────────────────────────────────────────────────────
|
|
948
|
-
|
|
949
|
-
/**
|
|
950
|
-
* Convert an existing wave-orchestrator manifest into a TaskDAG.
|
|
951
|
-
* Backward compatible — works with any manifest produced by wave-orchestrator.mjs.
|
|
952
|
-
*
|
|
953
|
-
* @param {object} manifest loaded manifest object
|
|
954
|
-
* @returns {TaskDAG}
|
|
955
|
-
*/
|
|
956
|
-
export function fromManifest(manifest) {
|
|
957
|
-
const dag = new TaskDAG();
|
|
958
|
-
|
|
959
|
-
const allTasks = (manifest.waves || []).flatMap(wave => wave.tasks || []);
|
|
960
|
-
|
|
961
|
-
for (const task of allTasks) {
|
|
962
|
-
dag.addTask({
|
|
963
|
-
taskId: task.taskId,
|
|
964
|
-
description: task.description,
|
|
965
|
-
dependencies: task.dependencies || [],
|
|
966
|
-
owns: task.owns || [],
|
|
967
|
-
reads: task.reads || [],
|
|
968
|
-
tier: task.tier || 'execute',
|
|
969
|
-
riskLevel: task.riskLevel || 'low',
|
|
970
|
-
estimatedDurationMs: task.durationMs || DEFAULT_TASK_DURATION_MS,
|
|
971
|
-
provider: task.provider,
|
|
972
|
-
model: task.model,
|
|
973
|
-
effort: task.effort,
|
|
974
|
-
topic: task.topic,
|
|
975
|
-
status: task.status || STATUS.PENDING,
|
|
976
|
-
result: task.result || null,
|
|
977
|
-
startedAt: task.startedAt || null,
|
|
978
|
-
completedAt: task.completedAt || null,
|
|
979
|
-
durationMs: task.durationMs || null,
|
|
980
|
-
retryCount: task.retryCount || 0,
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
// Wire up explicit wave ordering: every task in wave N+1 that has no declared
|
|
985
|
-
// dependencies gets an implicit dependency on all tasks in wave N that own files
|
|
986
|
-
// it also touches (conservative backward-compat ordering).
|
|
987
|
-
const waves = manifest.waves || [];
|
|
988
|
-
for (let wi = 1; wi < waves.length; wi++) {
|
|
989
|
-
const prevWaveTasks = waves[wi - 1].tasks || [];
|
|
990
|
-
const currWaveTasks = waves[wi].tasks || [];
|
|
991
|
-
for (const curr of currWaveTasks) {
|
|
992
|
-
if ((curr.dependencies || []).length === 0) {
|
|
993
|
-
for (const prev of prevWaveTasks) {
|
|
994
|
-
// Only add wave-ordering dep if there's no explicit dep already
|
|
995
|
-
try {
|
|
996
|
-
dag.addDependency(curr.taskId, prev.taskId);
|
|
997
|
-
} catch {
|
|
998
|
-
// Ignore if nodes don't exist
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
return dag;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
/**
|
|
1009
|
-
* Build a TaskDAG from a flat list of tasks and an optional ownership map.
|
|
1010
|
-
* Replaces planWaves() in wave-orchestrator.mjs.
|
|
1011
|
-
*
|
|
1012
|
-
* @param {object[]} tasks array of task objects (with taskId, dependencies, etc.)
|
|
1013
|
-
* @param {object} ownership optional { byTask: { [taskId]: { owns, reads } } }
|
|
1014
|
-
* @returns {TaskDAG}
|
|
1015
|
-
*/
|
|
1016
|
-
export function fromTasks(tasks, ownership = null) {
|
|
1017
|
-
const dag = new TaskDAG();
|
|
1018
|
-
|
|
1019
|
-
for (const task of tasks) {
|
|
1020
|
-
const owned = ownership?.byTask?.[task.taskId]?.owns || task.owns || task.files || [];
|
|
1021
|
-
const reads = ownership?.byTask?.[task.taskId]?.reads || task.reads || [];
|
|
1022
|
-
dag.addTask({
|
|
1023
|
-
...task,
|
|
1024
|
-
owns: owned,
|
|
1025
|
-
reads,
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
return dag;
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
// ─── ASCII Visualiser ─────────────────────────────────────────────────────────
|
|
1033
|
-
|
|
1034
|
-
/**
|
|
1035
|
-
* Render a simple ASCII representation of the DAG.
|
|
1036
|
-
*
|
|
1037
|
-
* Output style:
|
|
1038
|
-
* task-1 ──┬── task-3 ──── task-5
|
|
1039
|
-
* task-2 ──┘
|
|
1040
|
-
* task-4 ──────────────── task-6
|
|
1041
|
-
*
|
|
1042
|
-
* @param {TaskDAG} dag
|
|
1043
|
-
* @returns {string}
|
|
1044
|
-
*/
|
|
1045
|
-
function renderAsciiDAG(dag) {
|
|
1046
|
-
const waveView = dag.toWaveView();
|
|
1047
|
-
if (waveView.length === 0) return '(empty DAG)';
|
|
1048
|
-
|
|
1049
|
-
const STATUS_CHAR = {
|
|
1050
|
-
[STATUS.PENDING]: 'o',
|
|
1051
|
-
[STATUS.RUNNING]: '>',
|
|
1052
|
-
[STATUS.COMPLETED]: 'v',
|
|
1053
|
-
[STATUS.FAILED]: 'x',
|
|
1054
|
-
[STATUS.SKIPPED]: '-',
|
|
1055
|
-
};
|
|
1056
|
-
|
|
1057
|
-
// Build columns: each wave is a column
|
|
1058
|
-
const cols = waveView.map(({ tasks }) => tasks);
|
|
1059
|
-
const numCols = cols.length;
|
|
1060
|
-
|
|
1061
|
-
// For each task, assign row = its position within its wave column
|
|
1062
|
-
const taskRow = new Map();
|
|
1063
|
-
const taskCol = new Map();
|
|
1064
|
-
let maxRows = 0;
|
|
1065
|
-
for (let ci = 0; ci < cols.length; ci++) {
|
|
1066
|
-
for (let ri = 0; ri < cols[ci].length; ri++) {
|
|
1067
|
-
taskRow.set(cols[ci][ri].taskId, ri);
|
|
1068
|
-
taskCol.set(cols[ci][ri].taskId, ci);
|
|
1069
|
-
}
|
|
1070
|
-
maxRows = Math.max(maxRows, cols[ci].length);
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
// Render as a grid of label cells
|
|
1074
|
-
const COL_WIDTH = 18;
|
|
1075
|
-
const lines = [];
|
|
1076
|
-
|
|
1077
|
-
for (let row = 0; row < maxRows; row++) {
|
|
1078
|
-
let line = '';
|
|
1079
|
-
for (let col = 0; col < numCols; col++) {
|
|
1080
|
-
const task = cols[col][row];
|
|
1081
|
-
if (!task) {
|
|
1082
|
-
// empty slot — check if any task on this row extends through this col
|
|
1083
|
-
line += ' '.repeat(COL_WIDTH);
|
|
1084
|
-
continue;
|
|
1085
|
-
}
|
|
1086
|
-
const sc = STATUS_CHAR[task.status] || 'o';
|
|
1087
|
-
const label = `[${sc}] ${task.taskId}`.slice(0, COL_WIDTH - 4);
|
|
1088
|
-
const connector = col < numCols - 1 ? ' --> ' : '';
|
|
1089
|
-
line += label.padEnd(COL_WIDTH - connector.length) + connector;
|
|
1090
|
-
}
|
|
1091
|
-
lines.push(line.trimEnd());
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
// Add a legend
|
|
1095
|
-
const legend = [
|
|
1096
|
-
'',
|
|
1097
|
-
'Legend: [o]=pending [>]=running [v]=completed [x]=failed [-]=skipped',
|
|
1098
|
-
];
|
|
1099
|
-
|
|
1100
|
-
// Render dependency summary
|
|
1101
|
-
const depLines = [];
|
|
1102
|
-
for (const [taskId, deps] of dag._deps) {
|
|
1103
|
-
if (deps.size > 0) {
|
|
1104
|
-
depLines.push(` ${taskId} depends on: ${[...deps].join(', ')}`);
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
if (depLines.length > 0) {
|
|
1108
|
-
legend.push('', 'Dependencies:');
|
|
1109
|
-
legend.push(...depLines);
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
return lines.join('\n') + legend.join('\n');
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
// ─── CLI entry point ──────────────────────────────────────────────────────────
|
|
1116
|
-
|
|
1117
|
-
async function main() {
|
|
1118
|
-
const args = process.argv.slice(2);
|
|
1119
|
-
|
|
1120
|
-
if (args.includes('--help') || args.includes('-h')) {
|
|
1121
|
-
process.stdout.write([
|
|
1122
|
-
'dag-scheduler.mjs — DAG-based task scheduler for dual-brain',
|
|
1123
|
-
'',
|
|
1124
|
-
'Usage:',
|
|
1125
|
-
' node hooks/dag-scheduler.mjs --from-manifest <manifestId>',
|
|
1126
|
-
' node hooks/dag-scheduler.mjs --visualize <manifestId>',
|
|
1127
|
-
'',
|
|
1128
|
-
'Options:',
|
|
1129
|
-
' --from-manifest <id> Load manifest and run scheduler (dry-run, no real dispatch)',
|
|
1130
|
-
' --visualize <id> Print ASCII DAG and exit',
|
|
1131
|
-
' --validate <id> Validate DAG structure (cycles, conflicts)',
|
|
1132
|
-
' --stats <id> Print DAG statistics',
|
|
1133
|
-
'',
|
|
1134
|
-
].join('\n'));
|
|
1135
|
-
process.exit(0);
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
ensureStateDirs();
|
|
1139
|
-
|
|
1140
|
-
const fromManifestIdx = args.indexOf('--from-manifest');
|
|
1141
|
-
const visualizeIdx = args.indexOf('--visualize');
|
|
1142
|
-
const validateIdx = args.indexOf('--validate');
|
|
1143
|
-
const statsIdx = args.indexOf('--stats');
|
|
1144
|
-
|
|
1145
|
-
if (visualizeIdx >= 0) {
|
|
1146
|
-
const manifestId = args[visualizeIdx + 1];
|
|
1147
|
-
if (!manifestId) {
|
|
1148
|
-
process.stderr.write('Error: --visualize requires a manifest ID\n');
|
|
1149
|
-
process.exit(1);
|
|
1150
|
-
}
|
|
1151
|
-
const manifest = loadManifest(manifestId);
|
|
1152
|
-
const dag = fromManifest(manifest);
|
|
1153
|
-
process.stdout.write(`\nDAG Visualization for manifest: ${manifestId}\n`);
|
|
1154
|
-
process.stdout.write(`${'─'.repeat(60)}\n`);
|
|
1155
|
-
process.stdout.write(renderAsciiDAG(dag) + '\n');
|
|
1156
|
-
return;
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
if (validateIdx >= 0) {
|
|
1160
|
-
const manifestId = args[validateIdx + 1];
|
|
1161
|
-
if (!manifestId) {
|
|
1162
|
-
process.stderr.write('Error: --validate requires a manifest ID\n');
|
|
1163
|
-
process.exit(1);
|
|
1164
|
-
}
|
|
1165
|
-
const manifest = loadManifest(manifestId);
|
|
1166
|
-
const dag = fromManifest(manifest);
|
|
1167
|
-
const { valid, errors } = dag.validate();
|
|
1168
|
-
if (valid) {
|
|
1169
|
-
process.stdout.write(`✓ DAG is valid (${dag._nodes.size} tasks)\n`);
|
|
1170
|
-
} else {
|
|
1171
|
-
process.stdout.write(`✕ DAG has ${errors.length} issue(s):\n`);
|
|
1172
|
-
for (const err of errors) process.stdout.write(` - ${err}\n`);
|
|
1173
|
-
process.exit(1);
|
|
1174
|
-
}
|
|
1175
|
-
return;
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
if (statsIdx >= 0) {
|
|
1179
|
-
const manifestId = args[statsIdx + 1];
|
|
1180
|
-
if (!manifestId) {
|
|
1181
|
-
process.stderr.write('Error: --stats requires a manifest ID\n');
|
|
1182
|
-
process.exit(1);
|
|
1183
|
-
}
|
|
1184
|
-
const manifest = loadManifest(manifestId);
|
|
1185
|
-
const dag = fromManifest(manifest);
|
|
1186
|
-
const stats = dag.getStats();
|
|
1187
|
-
const cp = dag.getCriticalPath();
|
|
1188
|
-
process.stdout.write(`\nDAG Stats for manifest: ${manifestId}\n`);
|
|
1189
|
-
process.stdout.write(`${'─'.repeat(40)}\n`);
|
|
1190
|
-
process.stdout.write(`Total tasks: ${stats.total}\n`);
|
|
1191
|
-
process.stdout.write(`Ready: ${stats.ready}\n`);
|
|
1192
|
-
process.stdout.write(`Running: ${stats.running}\n`);
|
|
1193
|
-
process.stdout.write(`Completed: ${stats.completed}\n`);
|
|
1194
|
-
process.stdout.write(`Failed: ${stats.failed}\n`);
|
|
1195
|
-
process.stdout.write(`Skipped: ${stats.skipped}\n`);
|
|
1196
|
-
process.stdout.write(`Blocked: ${stats.blocked}\n`);
|
|
1197
|
-
process.stdout.write(`Critical path: ${cp.path.join(' → ') || '(none)'}\n`);
|
|
1198
|
-
process.stdout.write(`CP duration: ${(stats.criticalPathLength / 1000).toFixed(1)}s\n`);
|
|
1199
|
-
const waveView = dag.toWaveView();
|
|
1200
|
-
process.stdout.write(`Wave depth: ${waveView.length}\n`);
|
|
1201
|
-
return;
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
if (fromManifestIdx >= 0) {
|
|
1205
|
-
const manifestId = args[fromManifestIdx + 1];
|
|
1206
|
-
if (!manifestId) {
|
|
1207
|
-
process.stderr.write('Error: --from-manifest requires a manifest ID\n');
|
|
1208
|
-
process.exit(1);
|
|
1209
|
-
}
|
|
1210
|
-
const manifest = loadManifest(manifestId);
|
|
1211
|
-
const dag = fromManifest(manifest);
|
|
1212
|
-
|
|
1213
|
-
process.stdout.write(`\nRunning DAG scheduler for manifest: ${manifestId}\n`);
|
|
1214
|
-
process.stdout.write(`(Dry-run: tasks are simulated, no real dispatch)\n\n`);
|
|
1215
|
-
|
|
1216
|
-
const { valid, errors } = dag.validate();
|
|
1217
|
-
if (!valid) {
|
|
1218
|
-
process.stdout.write(`Warning: DAG has validation issues:\n`);
|
|
1219
|
-
for (const err of errors) process.stdout.write(` - ${err}\n`);
|
|
1220
|
-
process.stdout.write('\n');
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
const scheduler = new DAGScheduler(dag, {
|
|
1224
|
-
maxParallelism: DEFAULT_MAX_PARALLELISM,
|
|
1225
|
-
onCheckpoint: async (progress) => {
|
|
1226
|
-
process.stdout.write(
|
|
1227
|
-
` [checkpoint] ${progress.completed}/${progress.total} done, ` +
|
|
1228
|
-
`${progress.running} running, parallelism=${progress.currentParallelism}\n`
|
|
1229
|
-
);
|
|
1230
|
-
},
|
|
1231
|
-
});
|
|
1232
|
-
|
|
1233
|
-
const result = await scheduler.run();
|
|
1234
|
-
process.stdout.write(`\nResult: ${JSON.stringify(result, null, 2)}\n`);
|
|
1235
|
-
return;
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
// Default: print help
|
|
1239
|
-
process.stdout.write('No command given. Use --help for usage.\n');
|
|
1240
|
-
process.exit(1);
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
// Run CLI if invoked directly
|
|
1244
|
-
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
1245
|
-
main().catch(err => {
|
|
1246
|
-
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
1247
|
-
process.exit(1);
|
|
1248
|
-
});
|
|
1249
|
-
}
|