agileflow 2.95.2 → 2.96.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 +10 -0
- package/README.md +6 -6
- package/lib/api-routes.js +605 -0
- package/lib/api-server.js +260 -0
- package/lib/claude-cli-bridge.js +221 -0
- package/lib/dashboard-protocol.js +541 -0
- package/lib/dashboard-server.js +1601 -0
- package/lib/drivers/claude-driver.ts +310 -0
- package/lib/drivers/codex-driver.ts +454 -0
- package/lib/drivers/driver-manager.ts +158 -0
- package/lib/drivers/gemini-driver.ts +485 -0
- package/lib/drivers/index.ts +17 -0
- package/lib/flag-detection.js +350 -0
- package/lib/git-operations.js +267 -0
- package/lib/lock-file.js +144 -0
- package/lib/merge-operations.js +959 -0
- package/lib/protocol/driver.ts +360 -0
- package/lib/protocol/index.ts +12 -0
- package/lib/protocol/ir.ts +271 -0
- package/lib/session-display.js +330 -0
- package/lib/worktree-operations.js +221 -0
- package/package.json +2 -2
- package/scripts/agileflow-welcome.js +272 -24
- package/scripts/api-server-runner.js +177 -0
- package/scripts/archive-completed-stories.sh +22 -0
- package/scripts/automation-run-due.js +126 -0
- package/scripts/backfill-ideation-status.js +124 -0
- package/scripts/claude-tmux.sh +62 -1
- package/scripts/context-loader.js +292 -0
- package/scripts/dashboard-serve.js +323 -0
- package/scripts/lib/automation-registry.js +544 -0
- package/scripts/lib/automation-runner.js +476 -0
- package/scripts/lib/concurrency-limiter.js +513 -0
- package/scripts/lib/configure-features.js +46 -0
- package/scripts/lib/context-formatter.js +61 -0
- package/scripts/lib/damage-control-utils.js +29 -4
- package/scripts/lib/hook-metrics.js +324 -0
- package/scripts/lib/ideation-index.js +1196 -0
- package/scripts/lib/process-cleanup.js +359 -0
- package/scripts/lib/quality-gates.js +574 -0
- package/scripts/lib/status-task-bridge.js +522 -0
- package/scripts/lib/sync-ideation-status.js +292 -0
- package/scripts/lib/task-registry-cache.js +490 -0
- package/scripts/lib/task-registry.js +1181 -0
- package/scripts/migrate-ideation-index.js +515 -0
- package/scripts/precompact-context.sh +104 -0
- package/scripts/ralph-loop.js +2 -2
- package/scripts/session-manager.js +363 -2770
- package/scripts/spawn-parallel.js +45 -9
- package/src/core/agents/api-validator.md +180 -0
- package/src/core/agents/api.md +2 -0
- package/src/core/agents/code-reviewer.md +289 -0
- package/src/core/agents/configuration/damage-control.md +17 -0
- package/src/core/agents/database.md +2 -0
- package/src/core/agents/error-analyzer.md +203 -0
- package/src/core/agents/logic-analyzer-edge.md +171 -0
- package/src/core/agents/logic-analyzer-flow.md +254 -0
- package/src/core/agents/logic-analyzer-invariant.md +207 -0
- package/src/core/agents/logic-analyzer-race.md +267 -0
- package/src/core/agents/logic-analyzer-type.md +218 -0
- package/src/core/agents/logic-consensus.md +256 -0
- package/src/core/agents/orchestrator.md +89 -1
- package/src/core/agents/schema-validator.md +451 -0
- package/src/core/agents/team-coordinator.md +328 -0
- package/src/core/agents/ui-validator.md +328 -0
- package/src/core/agents/ui.md +2 -0
- package/src/core/commands/api.md +267 -0
- package/src/core/commands/automate.md +415 -0
- package/src/core/commands/babysit.md +290 -9
- package/src/core/commands/ideate/history.md +403 -0
- package/src/core/commands/{ideate.md → ideate/new.md} +244 -34
- package/src/core/commands/logic/audit.md +368 -0
- package/src/core/commands/roadmap/analyze.md +1 -1
- package/src/core/experts/documentation/expertise.yaml +29 -2
- package/src/core/templates/CONTEXT.md.example +49 -0
- package/src/core/templates/claude-settings.advanced.example.json +4 -0
- package/tools/cli/commands/serve.js +456 -0
- package/tools/cli/installers/core/installer.js +7 -2
- package/tools/cli/installers/ide/claude-code.js +85 -0
- package/tools/cli/lib/content-injector.js +27 -1
- package/tools/cli/lib/ui.js +26 -57
|
@@ -0,0 +1,1181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-registry.js - Multi-Agent Task Orchestration System
|
|
3
|
+
*
|
|
4
|
+
* Provides CRUD operations for tasks with:
|
|
5
|
+
* - State machine with valid transitions (queued→running→completed/failed/blocked)
|
|
6
|
+
* - DAG validation to prevent circular dependencies
|
|
7
|
+
* - Atomic file writes (temp + rename) for crash recovery
|
|
8
|
+
* - Lock file for concurrent access
|
|
9
|
+
* - Event emissions for observability
|
|
10
|
+
*
|
|
11
|
+
* Task States:
|
|
12
|
+
* - queued: Task is waiting to be executed
|
|
13
|
+
* - running: Task is currently being executed by an agent
|
|
14
|
+
* - completed: Task finished successfully
|
|
15
|
+
* - failed: Task failed with an error
|
|
16
|
+
* - blocked: Task cannot proceed due to unmet dependencies
|
|
17
|
+
*
|
|
18
|
+
* Valid Transitions:
|
|
19
|
+
* - queued → running, blocked, cancelled
|
|
20
|
+
* - running → completed, failed, blocked
|
|
21
|
+
* - blocked → queued, running, cancelled
|
|
22
|
+
* - completed → (terminal)
|
|
23
|
+
* - failed → queued (retry), cancelled
|
|
24
|
+
* - cancelled → (terminal)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const EventEmitter = require('events');
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
const os = require('os');
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Constants
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
// Valid task states - SINGLE SOURCE OF TRUTH
|
|
40
|
+
const TASK_STATES = ['queued', 'running', 'completed', 'failed', 'blocked', 'cancelled'];
|
|
41
|
+
|
|
42
|
+
// Terminal states (no transitions out)
|
|
43
|
+
const TERMINAL_STATES = ['completed', 'cancelled'];
|
|
44
|
+
|
|
45
|
+
// States indicating work is done (for metrics)
|
|
46
|
+
const DONE_STATES = ['completed', 'failed', 'cancelled'];
|
|
47
|
+
|
|
48
|
+
// Valid state transitions
|
|
49
|
+
// Key = from state, Value = array of valid "to" states
|
|
50
|
+
const TRANSITIONS = {
|
|
51
|
+
queued: ['running', 'blocked', 'cancelled'],
|
|
52
|
+
running: ['completed', 'failed', 'blocked'],
|
|
53
|
+
blocked: ['queued', 'running', 'cancelled'],
|
|
54
|
+
completed: [], // Terminal
|
|
55
|
+
failed: ['queued', 'cancelled'], // Can retry
|
|
56
|
+
cancelled: [], // Terminal
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Join strategies for parallel task groups
|
|
60
|
+
const JOIN_STRATEGIES = ['all', 'first', 'any', 'any-N', 'majority'];
|
|
61
|
+
|
|
62
|
+
// Failure policies
|
|
63
|
+
const FAILURE_POLICIES = ['fail-fast', 'continue', 'ignore'];
|
|
64
|
+
|
|
65
|
+
// Default configuration
|
|
66
|
+
const DEFAULT_STATE_PATH = '.agileflow/state/task-dependencies.json';
|
|
67
|
+
const LOCK_FILE_NAME = 'task-registry.lock';
|
|
68
|
+
const LOCK_TIMEOUT_MS = 30000; // 30 seconds
|
|
69
|
+
const LOCK_STALE_MS = 60000; // 1 minute - consider lock stale if older
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Utility Functions
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate a unique task ID
|
|
77
|
+
* @returns {string} Unique task ID (e.g., "task-abc12345")
|
|
78
|
+
*/
|
|
79
|
+
function generateTaskId() {
|
|
80
|
+
const timestamp = Date.now().toString(36);
|
|
81
|
+
const random = crypto.randomBytes(4).toString('hex');
|
|
82
|
+
return `task-${timestamp}-${random}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Find project root by looking for .agileflow directory
|
|
87
|
+
* @returns {string} Project root path or current working directory
|
|
88
|
+
*/
|
|
89
|
+
function findProjectRoot() {
|
|
90
|
+
let dir = process.cwd();
|
|
91
|
+
while (dir !== '/' && dir !== path.parse(dir).root) {
|
|
92
|
+
if (fs.existsSync(path.join(dir, '.agileflow'))) {
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
95
|
+
dir = path.dirname(dir);
|
|
96
|
+
}
|
|
97
|
+
return process.cwd();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Atomic file write using temp file + rename
|
|
102
|
+
* @param {string} filePath - Target file path
|
|
103
|
+
* @param {string} content - Content to write
|
|
104
|
+
*/
|
|
105
|
+
function atomicWrite(filePath, content) {
|
|
106
|
+
const dir = path.dirname(filePath);
|
|
107
|
+
if (!fs.existsSync(dir)) {
|
|
108
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Write to temp file first
|
|
112
|
+
const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
113
|
+
fs.writeFileSync(tempPath, content, 'utf8');
|
|
114
|
+
|
|
115
|
+
// Atomic rename
|
|
116
|
+
fs.renameSync(tempPath, filePath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Safe JSON parse with default
|
|
121
|
+
* @param {string} content - JSON string
|
|
122
|
+
* @param {*} defaultValue - Default value if parse fails
|
|
123
|
+
* @returns {*} Parsed value or default
|
|
124
|
+
*/
|
|
125
|
+
function safeJSONParse(content, defaultValue = null) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(content);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return defaultValue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// DAG Validation
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Detect circular dependencies using DFS
|
|
139
|
+
* @param {Object} tasks - Map of task ID to task object
|
|
140
|
+
* @param {string} startId - Task ID to start from
|
|
141
|
+
* @param {Set} [visited] - Already visited nodes
|
|
142
|
+
* @param {Set} [recursionStack] - Current recursion path
|
|
143
|
+
* @returns {{ hasCycle: boolean, cycle: string[] | null }}
|
|
144
|
+
*/
|
|
145
|
+
function detectCycle(tasks, startId, visited = new Set(), recursionStack = new Set()) {
|
|
146
|
+
visited.add(startId);
|
|
147
|
+
recursionStack.add(startId);
|
|
148
|
+
|
|
149
|
+
const task = tasks[startId];
|
|
150
|
+
if (!task) {
|
|
151
|
+
return { hasCycle: false, cycle: null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check all dependencies (blockedBy)
|
|
155
|
+
const deps = task.blockedBy || [];
|
|
156
|
+
for (const depId of deps) {
|
|
157
|
+
if (!visited.has(depId)) {
|
|
158
|
+
const result = detectCycle(tasks, depId, visited, recursionStack);
|
|
159
|
+
if (result.hasCycle) {
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
} else if (recursionStack.has(depId)) {
|
|
163
|
+
// Found cycle - reconstruct it
|
|
164
|
+
const cycleNodes = [depId, startId];
|
|
165
|
+
return { hasCycle: true, cycle: cycleNodes };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
recursionStack.delete(startId);
|
|
170
|
+
return { hasCycle: false, cycle: null };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Validate entire task graph for cycles
|
|
175
|
+
* @param {Object} tasks - Map of task ID to task object
|
|
176
|
+
* @returns {{ valid: boolean, cycles: string[][] }}
|
|
177
|
+
*/
|
|
178
|
+
function validateDAG(tasks) {
|
|
179
|
+
const visited = new Set();
|
|
180
|
+
const cycles = [];
|
|
181
|
+
|
|
182
|
+
for (const taskId of Object.keys(tasks)) {
|
|
183
|
+
if (!visited.has(taskId)) {
|
|
184
|
+
const result = detectCycle(tasks, taskId, visited, new Set());
|
|
185
|
+
if (result.hasCycle && result.cycle) {
|
|
186
|
+
cycles.push(result.cycle);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
valid: cycles.length === 0,
|
|
193
|
+
cycles,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get topological sort of tasks (dependency order)
|
|
199
|
+
* @param {Object} tasks - Map of task ID to task object
|
|
200
|
+
* @returns {{ sorted: string[], valid: boolean }}
|
|
201
|
+
*/
|
|
202
|
+
function topologicalSort(tasks) {
|
|
203
|
+
const inDegree = {};
|
|
204
|
+
const adjList = {};
|
|
205
|
+
|
|
206
|
+
// Initialize
|
|
207
|
+
for (const taskId of Object.keys(tasks)) {
|
|
208
|
+
inDegree[taskId] = 0;
|
|
209
|
+
adjList[taskId] = [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Build adjacency list and in-degrees
|
|
213
|
+
for (const [taskId, task] of Object.entries(tasks)) {
|
|
214
|
+
const deps = task.blockedBy || [];
|
|
215
|
+
for (const depId of deps) {
|
|
216
|
+
if (adjList[depId]) {
|
|
217
|
+
adjList[depId].push(taskId);
|
|
218
|
+
inDegree[taskId]++;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Kahn's algorithm
|
|
224
|
+
const queue = [];
|
|
225
|
+
for (const taskId of Object.keys(tasks)) {
|
|
226
|
+
if (inDegree[taskId] === 0) {
|
|
227
|
+
queue.push(taskId);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const sorted = [];
|
|
232
|
+
while (queue.length > 0) {
|
|
233
|
+
const node = queue.shift();
|
|
234
|
+
sorted.push(node);
|
|
235
|
+
|
|
236
|
+
for (const neighbor of adjList[node] || []) {
|
|
237
|
+
inDegree[neighbor]--;
|
|
238
|
+
if (inDegree[neighbor] === 0) {
|
|
239
|
+
queue.push(neighbor);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// If sorted doesn't include all tasks, there's a cycle
|
|
245
|
+
const valid = sorted.length === Object.keys(tasks).length;
|
|
246
|
+
|
|
247
|
+
return { sorted, valid };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ============================================================================
|
|
251
|
+
// State Machine
|
|
252
|
+
// ============================================================================
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Check if a state is valid
|
|
256
|
+
* @param {string} state - State to check
|
|
257
|
+
* @returns {boolean}
|
|
258
|
+
*/
|
|
259
|
+
function isValidState(state) {
|
|
260
|
+
return TASK_STATES.includes(state);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Check if a transition is valid
|
|
265
|
+
* @param {string} fromState - Current state
|
|
266
|
+
* @param {string} toState - Target state
|
|
267
|
+
* @returns {boolean}
|
|
268
|
+
*/
|
|
269
|
+
function isValidTransition(fromState, toState) {
|
|
270
|
+
if (fromState === toState) return true; // No-op
|
|
271
|
+
if (!TRANSITIONS[fromState]) return false;
|
|
272
|
+
return TRANSITIONS[fromState].includes(toState);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get valid transitions from a state
|
|
277
|
+
* @param {string} fromState - Current state
|
|
278
|
+
* @returns {string[]}
|
|
279
|
+
*/
|
|
280
|
+
function getValidTransitions(fromState) {
|
|
281
|
+
return TRANSITIONS[fromState] || [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Check if state is terminal
|
|
286
|
+
* @param {string} state - State to check
|
|
287
|
+
* @returns {boolean}
|
|
288
|
+
*/
|
|
289
|
+
function isTerminalState(state) {
|
|
290
|
+
return TERMINAL_STATES.includes(state);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ============================================================================
|
|
294
|
+
// Lock Management
|
|
295
|
+
// ============================================================================
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Simple file-based lock for concurrent access
|
|
299
|
+
*/
|
|
300
|
+
class FileLock {
|
|
301
|
+
constructor(lockPath, options = {}) {
|
|
302
|
+
this.lockPath = lockPath;
|
|
303
|
+
this.timeout = options.timeout || LOCK_TIMEOUT_MS;
|
|
304
|
+
this.staleMs = options.staleMs || LOCK_STALE_MS;
|
|
305
|
+
this.held = false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Acquire lock with timeout
|
|
310
|
+
* @returns {boolean} True if lock acquired
|
|
311
|
+
*/
|
|
312
|
+
acquire() {
|
|
313
|
+
const startTime = Date.now();
|
|
314
|
+
|
|
315
|
+
while (Date.now() - startTime < this.timeout) {
|
|
316
|
+
// Check if existing lock is stale
|
|
317
|
+
if (fs.existsSync(this.lockPath)) {
|
|
318
|
+
try {
|
|
319
|
+
const stat = fs.statSync(this.lockPath);
|
|
320
|
+
const age = Date.now() - stat.mtimeMs;
|
|
321
|
+
if (age > this.staleMs) {
|
|
322
|
+
// Stale lock - remove it
|
|
323
|
+
fs.unlinkSync(this.lockPath);
|
|
324
|
+
} else {
|
|
325
|
+
// Lock held by another process - wait
|
|
326
|
+
this._sleep(50);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
} catch (e) {
|
|
330
|
+
// Lock file gone, continue to try acquiring
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Try to acquire
|
|
335
|
+
try {
|
|
336
|
+
const dir = path.dirname(this.lockPath);
|
|
337
|
+
if (!fs.existsSync(dir)) {
|
|
338
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Use exclusive flag to prevent race
|
|
342
|
+
const fd = fs.openSync(this.lockPath, 'wx');
|
|
343
|
+
const lockInfo = {
|
|
344
|
+
pid: process.pid,
|
|
345
|
+
hostname: os.hostname(),
|
|
346
|
+
acquired: new Date().toISOString(),
|
|
347
|
+
};
|
|
348
|
+
fs.writeSync(fd, JSON.stringify(lockInfo));
|
|
349
|
+
fs.closeSync(fd);
|
|
350
|
+
this.held = true;
|
|
351
|
+
return true;
|
|
352
|
+
} catch (e) {
|
|
353
|
+
if (e.code === 'EEXIST') {
|
|
354
|
+
// Another process got the lock
|
|
355
|
+
this._sleep(50);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
throw e;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Release lock
|
|
367
|
+
*/
|
|
368
|
+
release() {
|
|
369
|
+
if (this.held && fs.existsSync(this.lockPath)) {
|
|
370
|
+
try {
|
|
371
|
+
fs.unlinkSync(this.lockPath);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
// Ignore - lock may already be removed
|
|
374
|
+
}
|
|
375
|
+
this.held = false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Synchronous sleep
|
|
381
|
+
* @param {number} ms - Milliseconds to sleep
|
|
382
|
+
*/
|
|
383
|
+
_sleep(ms) {
|
|
384
|
+
const end = Date.now() + ms;
|
|
385
|
+
while (Date.now() < end) {
|
|
386
|
+
// Busy wait - not ideal but works for short durations
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ============================================================================
|
|
392
|
+
// Task Registry
|
|
393
|
+
// ============================================================================
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* TaskRegistry - Event-driven task management with state machine
|
|
397
|
+
*/
|
|
398
|
+
class TaskRegistry extends EventEmitter {
|
|
399
|
+
constructor(options = {}) {
|
|
400
|
+
super();
|
|
401
|
+
|
|
402
|
+
this.rootDir = options.rootDir || findProjectRoot();
|
|
403
|
+
this.statePath = path.join(this.rootDir, options.statePath || DEFAULT_STATE_PATH);
|
|
404
|
+
this.lockPath = path.join(path.dirname(this.statePath), LOCK_FILE_NAME);
|
|
405
|
+
|
|
406
|
+
// Cache
|
|
407
|
+
this._cache = null;
|
|
408
|
+
this._dirty = false;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// --------------------------------------------------------------------------
|
|
412
|
+
// State Persistence
|
|
413
|
+
// --------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Load task state from disk
|
|
417
|
+
* @returns {Object} Task state
|
|
418
|
+
*/
|
|
419
|
+
load() {
|
|
420
|
+
if (this._cache && !this._dirty) {
|
|
421
|
+
return this._cache;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const defaultState = this._createDefaultState();
|
|
425
|
+
|
|
426
|
+
if (!fs.existsSync(this.statePath)) {
|
|
427
|
+
this._cache = defaultState;
|
|
428
|
+
this.save();
|
|
429
|
+
return this._cache;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
const content = fs.readFileSync(this.statePath, 'utf8');
|
|
434
|
+
this._cache = safeJSONParse(content, defaultState);
|
|
435
|
+
this._dirty = false;
|
|
436
|
+
this.emit('loaded', { state: this._cache });
|
|
437
|
+
return this._cache;
|
|
438
|
+
} catch (e) {
|
|
439
|
+
this.emit('error', { type: 'load', error: e.message });
|
|
440
|
+
this._cache = defaultState;
|
|
441
|
+
return this._cache;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Save task state to disk (atomic)
|
|
447
|
+
*/
|
|
448
|
+
save() {
|
|
449
|
+
if (!this._cache) return;
|
|
450
|
+
|
|
451
|
+
this._cache.updated_at = new Date().toISOString();
|
|
452
|
+
const content = JSON.stringify(this._cache, null, 2) + '\n';
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
atomicWrite(this.statePath, content);
|
|
456
|
+
this._dirty = false;
|
|
457
|
+
this.emit('saved', { state: this._cache });
|
|
458
|
+
} catch (e) {
|
|
459
|
+
this.emit('error', { type: 'save', error: e.message });
|
|
460
|
+
throw e;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Create default state structure
|
|
466
|
+
* @returns {Object}
|
|
467
|
+
*/
|
|
468
|
+
_createDefaultState() {
|
|
469
|
+
return {
|
|
470
|
+
schema_version: '1.0.0',
|
|
471
|
+
created_at: new Date().toISOString(),
|
|
472
|
+
updated_at: new Date().toISOString(),
|
|
473
|
+
tasks: {},
|
|
474
|
+
task_groups: {},
|
|
475
|
+
audit_trail: [],
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// --------------------------------------------------------------------------
|
|
480
|
+
// CRUD Operations
|
|
481
|
+
// --------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Create a new task
|
|
485
|
+
* @param {Object} taskData - Task data
|
|
486
|
+
* @returns {{ success: boolean, task?: Object, error?: string }}
|
|
487
|
+
*/
|
|
488
|
+
create(taskData) {
|
|
489
|
+
const lock = new FileLock(this.lockPath);
|
|
490
|
+
if (!lock.acquire()) {
|
|
491
|
+
return { success: false, error: 'Could not acquire lock' };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
const state = this.load();
|
|
496
|
+
|
|
497
|
+
// Generate ID if not provided
|
|
498
|
+
const taskId = taskData.id || generateTaskId();
|
|
499
|
+
|
|
500
|
+
// Check for duplicate ID
|
|
501
|
+
if (state.tasks[taskId]) {
|
|
502
|
+
return { success: false, error: `Task ${taskId} already exists` };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Validate dependencies won't create cycle
|
|
506
|
+
if (taskData.blockedBy && taskData.blockedBy.length > 0) {
|
|
507
|
+
// Temporarily add task to check for cycles
|
|
508
|
+
const testTasks = {
|
|
509
|
+
...state.tasks,
|
|
510
|
+
[taskId]: { ...taskData, blockedBy: taskData.blockedBy },
|
|
511
|
+
};
|
|
512
|
+
const dagResult = validateDAG(testTasks);
|
|
513
|
+
if (!dagResult.valid) {
|
|
514
|
+
return {
|
|
515
|
+
success: false,
|
|
516
|
+
error: `Adding this task would create circular dependency: ${dagResult.cycles[0].join(' → ')}`,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Create task object
|
|
522
|
+
const task = {
|
|
523
|
+
id: taskId,
|
|
524
|
+
description: taskData.description || '',
|
|
525
|
+
prompt: taskData.prompt || '',
|
|
526
|
+
subagent_type: taskData.subagent_type || null,
|
|
527
|
+
state: 'queued',
|
|
528
|
+
created_at: new Date().toISOString(),
|
|
529
|
+
updated_at: new Date().toISOString(),
|
|
530
|
+
// Dependencies
|
|
531
|
+
blockedBy: taskData.blockedBy || [],
|
|
532
|
+
blocks: taskData.blocks || [],
|
|
533
|
+
// Execution config
|
|
534
|
+
join_strategy: taskData.join_strategy || 'all',
|
|
535
|
+
on_failure: taskData.on_failure || 'fail-fast',
|
|
536
|
+
run_in_background: taskData.run_in_background || false,
|
|
537
|
+
// Link to story (optional)
|
|
538
|
+
story_id: taskData.story_id || null,
|
|
539
|
+
// Results (populated on completion)
|
|
540
|
+
result: null,
|
|
541
|
+
error: null,
|
|
542
|
+
// Metadata
|
|
543
|
+
metadata: taskData.metadata || {},
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// Automatically set to blocked if has unmet dependencies
|
|
547
|
+
if (task.blockedBy.length > 0) {
|
|
548
|
+
const unmetDeps = task.blockedBy.filter(depId => {
|
|
549
|
+
const dep = state.tasks[depId];
|
|
550
|
+
return !dep || dep.state !== 'completed';
|
|
551
|
+
});
|
|
552
|
+
if (unmetDeps.length > 0) {
|
|
553
|
+
task.state = 'blocked';
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Update reverse dependencies (blocks)
|
|
558
|
+
for (const blockerId of task.blockedBy) {
|
|
559
|
+
if (state.tasks[blockerId]) {
|
|
560
|
+
if (!state.tasks[blockerId].blocks) {
|
|
561
|
+
state.tasks[blockerId].blocks = [];
|
|
562
|
+
}
|
|
563
|
+
if (!state.tasks[blockerId].blocks.includes(taskId)) {
|
|
564
|
+
state.tasks[blockerId].blocks.push(taskId);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Store task
|
|
570
|
+
state.tasks[taskId] = task;
|
|
571
|
+
this._dirty = true;
|
|
572
|
+
this.save();
|
|
573
|
+
|
|
574
|
+
// Emit event
|
|
575
|
+
this.emit('created', { task });
|
|
576
|
+
|
|
577
|
+
return { success: true, task };
|
|
578
|
+
} finally {
|
|
579
|
+
lock.release();
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Get a task by ID
|
|
585
|
+
* @param {string} taskId - Task ID
|
|
586
|
+
* @returns {Object|null}
|
|
587
|
+
*/
|
|
588
|
+
get(taskId) {
|
|
589
|
+
const state = this.load();
|
|
590
|
+
return state.tasks[taskId] || null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Get all tasks
|
|
595
|
+
* @param {Object} [filter] - Optional filter
|
|
596
|
+
* @returns {Object[]}
|
|
597
|
+
*/
|
|
598
|
+
getAll(filter = {}) {
|
|
599
|
+
const state = this.load();
|
|
600
|
+
let tasks = Object.values(state.tasks);
|
|
601
|
+
|
|
602
|
+
// Apply filters
|
|
603
|
+
if (filter.state) {
|
|
604
|
+
tasks = tasks.filter(t => t.state === filter.state);
|
|
605
|
+
}
|
|
606
|
+
if (filter.story_id) {
|
|
607
|
+
tasks = tasks.filter(t => t.story_id === filter.story_id);
|
|
608
|
+
}
|
|
609
|
+
if (filter.subagent_type) {
|
|
610
|
+
tasks = tasks.filter(t => t.subagent_type === filter.subagent_type);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return tasks;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Update a task
|
|
618
|
+
* @param {string} taskId - Task ID
|
|
619
|
+
* @param {Object} updates - Fields to update
|
|
620
|
+
* @returns {{ success: boolean, task?: Object, error?: string }}
|
|
621
|
+
*/
|
|
622
|
+
update(taskId, updates) {
|
|
623
|
+
const lock = new FileLock(this.lockPath);
|
|
624
|
+
if (!lock.acquire()) {
|
|
625
|
+
return { success: false, error: 'Could not acquire lock' };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
const state = this.load();
|
|
630
|
+
const task = state.tasks[taskId];
|
|
631
|
+
|
|
632
|
+
if (!task) {
|
|
633
|
+
return { success: false, error: `Task ${taskId} not found` };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Handle state transition
|
|
637
|
+
if (updates.state && updates.state !== task.state) {
|
|
638
|
+
if (!isValidTransition(task.state, updates.state)) {
|
|
639
|
+
return {
|
|
640
|
+
success: false,
|
|
641
|
+
error: `Invalid transition: ${task.state} → ${updates.state}. Valid: ${getValidTransitions(task.state).join(', ') || 'none'}`,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Log to audit trail
|
|
646
|
+
state.audit_trail.push({
|
|
647
|
+
task_id: taskId,
|
|
648
|
+
from_state: task.state,
|
|
649
|
+
to_state: updates.state,
|
|
650
|
+
at: new Date().toISOString(),
|
|
651
|
+
reason: updates.reason || null,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Handle dependency updates
|
|
656
|
+
if (updates.blockedBy) {
|
|
657
|
+
// Validate no cycles
|
|
658
|
+
const testTask = { ...task, blockedBy: updates.blockedBy };
|
|
659
|
+
const testTasks = { ...state.tasks, [taskId]: testTask };
|
|
660
|
+
const dagResult = validateDAG(testTasks);
|
|
661
|
+
if (!dagResult.valid) {
|
|
662
|
+
return {
|
|
663
|
+
success: false,
|
|
664
|
+
error: `Update would create circular dependency: ${dagResult.cycles[0].join(' → ')}`,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Apply updates
|
|
670
|
+
const changedFields = [];
|
|
671
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
672
|
+
if (key === 'reason') continue; // reason is for audit only
|
|
673
|
+
if (JSON.stringify(task[key]) !== JSON.stringify(value)) {
|
|
674
|
+
task[key] = value;
|
|
675
|
+
changedFields.push(key);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (changedFields.length > 0) {
|
|
680
|
+
task.updated_at = new Date().toISOString();
|
|
681
|
+
|
|
682
|
+
// If transitioning to completed, unblock dependent tasks AFTER state is applied
|
|
683
|
+
if (changedFields.includes('state') && task.state === 'completed') {
|
|
684
|
+
this._unblockDependents(state, taskId);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
this._dirty = true;
|
|
688
|
+
this.save();
|
|
689
|
+
|
|
690
|
+
this.emit('updated', { task, changes: changedFields });
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return { success: true, task };
|
|
694
|
+
} finally {
|
|
695
|
+
lock.release();
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Delete a task
|
|
701
|
+
* @param {string} taskId - Task ID
|
|
702
|
+
* @returns {{ success: boolean, error?: string }}
|
|
703
|
+
*/
|
|
704
|
+
delete(taskId) {
|
|
705
|
+
const lock = new FileLock(this.lockPath);
|
|
706
|
+
if (!lock.acquire()) {
|
|
707
|
+
return { success: false, error: 'Could not acquire lock' };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
try {
|
|
711
|
+
const state = this.load();
|
|
712
|
+
const task = state.tasks[taskId];
|
|
713
|
+
|
|
714
|
+
if (!task) {
|
|
715
|
+
return { success: false, error: `Task ${taskId} not found` };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Remove from blockedBy lists of other tasks
|
|
719
|
+
for (const otherTask of Object.values(state.tasks)) {
|
|
720
|
+
if (otherTask.blockedBy) {
|
|
721
|
+
otherTask.blockedBy = otherTask.blockedBy.filter(id => id !== taskId);
|
|
722
|
+
}
|
|
723
|
+
if (otherTask.blocks) {
|
|
724
|
+
otherTask.blocks = otherTask.blocks.filter(id => id !== taskId);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
delete state.tasks[taskId];
|
|
729
|
+
this._dirty = true;
|
|
730
|
+
this.save();
|
|
731
|
+
|
|
732
|
+
this.emit('deleted', { taskId });
|
|
733
|
+
|
|
734
|
+
return { success: true };
|
|
735
|
+
} finally {
|
|
736
|
+
lock.release();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// --------------------------------------------------------------------------
|
|
741
|
+
// State Transitions
|
|
742
|
+
// --------------------------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Transition a task to a new state
|
|
746
|
+
* @param {string} taskId - Task ID
|
|
747
|
+
* @param {string} toState - Target state
|
|
748
|
+
* @param {Object} [options] - Options
|
|
749
|
+
* @returns {{ success: boolean, task?: Object, error?: string }}
|
|
750
|
+
*/
|
|
751
|
+
transition(taskId, toState, options = {}) {
|
|
752
|
+
const { reason = null, result = null, error = null } = options;
|
|
753
|
+
|
|
754
|
+
const updates = { state: toState, reason };
|
|
755
|
+
if (result !== null) updates.result = result;
|
|
756
|
+
if (error !== null) updates.error = error;
|
|
757
|
+
|
|
758
|
+
return this.update(taskId, updates);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Mark task as running
|
|
763
|
+
* @param {string} taskId - Task ID
|
|
764
|
+
* @returns {{ success: boolean, task?: Object, error?: string }}
|
|
765
|
+
*/
|
|
766
|
+
start(taskId) {
|
|
767
|
+
return this.transition(taskId, 'running');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Mark task as completed
|
|
772
|
+
* @param {string} taskId - Task ID
|
|
773
|
+
* @param {*} result - Task result
|
|
774
|
+
* @returns {{ success: boolean, task?: Object, error?: string }}
|
|
775
|
+
*/
|
|
776
|
+
complete(taskId, result = null) {
|
|
777
|
+
return this.transition(taskId, 'completed', { result });
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Mark task as failed
|
|
782
|
+
* @param {string} taskId - Task ID
|
|
783
|
+
* @param {string} error - Error message
|
|
784
|
+
* @returns {{ success: boolean, task?: Object, error?: string }}
|
|
785
|
+
*/
|
|
786
|
+
fail(taskId, error) {
|
|
787
|
+
return this.transition(taskId, 'failed', { error });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Mark task as blocked
|
|
792
|
+
* @param {string} taskId - Task ID
|
|
793
|
+
* @param {string} reason - Reason for blocking
|
|
794
|
+
* @returns {{ success: boolean, task?: Object, error?: string }}
|
|
795
|
+
*/
|
|
796
|
+
block(taskId, reason) {
|
|
797
|
+
return this.transition(taskId, 'blocked', { reason });
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Cancel a task
|
|
802
|
+
* @param {string} taskId - Task ID
|
|
803
|
+
* @param {string} reason - Reason for cancellation
|
|
804
|
+
* @returns {{ success: boolean, task?: Object, error?: string }}
|
|
805
|
+
*/
|
|
806
|
+
cancel(taskId, reason) {
|
|
807
|
+
return this.transition(taskId, 'cancelled', { reason });
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Retry a failed task
|
|
812
|
+
* @param {string} taskId - Task ID
|
|
813
|
+
* @returns {{ success: boolean, task?: Object, error?: string }}
|
|
814
|
+
*/
|
|
815
|
+
retry(taskId) {
|
|
816
|
+
const task = this.get(taskId);
|
|
817
|
+
if (!task) {
|
|
818
|
+
return { success: false, error: `Task ${taskId} not found` };
|
|
819
|
+
}
|
|
820
|
+
if (task.state !== 'failed') {
|
|
821
|
+
return { success: false, error: `Can only retry failed tasks, current state: ${task.state}` };
|
|
822
|
+
}
|
|
823
|
+
return this.transition(taskId, 'queued', { reason: 'retry' });
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// --------------------------------------------------------------------------
|
|
827
|
+
// Dependency Management
|
|
828
|
+
// --------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Add a dependency (blockedBy)
|
|
832
|
+
* @param {string} taskId - Task that will be blocked
|
|
833
|
+
* @param {string} blockerId - Task that blocks
|
|
834
|
+
* @returns {{ success: boolean, error?: string }}
|
|
835
|
+
*/
|
|
836
|
+
addDependency(taskId, blockerId) {
|
|
837
|
+
const task = this.get(taskId);
|
|
838
|
+
if (!task) {
|
|
839
|
+
return { success: false, error: `Task ${taskId} not found` };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const blockedBy = [...(task.blockedBy || [])];
|
|
843
|
+
if (!blockedBy.includes(blockerId)) {
|
|
844
|
+
blockedBy.push(blockerId);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return this.update(taskId, { blockedBy });
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Remove a dependency
|
|
852
|
+
* @param {string} taskId - Task ID
|
|
853
|
+
* @param {string} blockerId - Blocker to remove
|
|
854
|
+
* @returns {{ success: boolean, error?: string }}
|
|
855
|
+
*/
|
|
856
|
+
removeDependency(taskId, blockerId) {
|
|
857
|
+
const task = this.get(taskId);
|
|
858
|
+
if (!task) {
|
|
859
|
+
return { success: false, error: `Task ${taskId} not found` };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const blockedBy = (task.blockedBy || []).filter(id => id !== blockerId);
|
|
863
|
+
return this.update(taskId, { blockedBy });
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Get tasks that are ready to run (queued with no unmet dependencies)
|
|
868
|
+
* @returns {Object[]}
|
|
869
|
+
*/
|
|
870
|
+
getReadyTasks() {
|
|
871
|
+
const state = this.load();
|
|
872
|
+
const ready = [];
|
|
873
|
+
|
|
874
|
+
for (const task of Object.values(state.tasks)) {
|
|
875
|
+
if (task.state !== 'queued') continue;
|
|
876
|
+
|
|
877
|
+
// Check all dependencies are complete
|
|
878
|
+
const unmetDeps = (task.blockedBy || []).filter(depId => {
|
|
879
|
+
const dep = state.tasks[depId];
|
|
880
|
+
return !dep || dep.state !== 'completed';
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
if (unmetDeps.length === 0) {
|
|
884
|
+
ready.push(task);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return ready;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Get the dependency graph for visualization
|
|
893
|
+
* @returns {{ nodes: Object[], edges: Object[] }}
|
|
894
|
+
*/
|
|
895
|
+
getDependencyGraph() {
|
|
896
|
+
const state = this.load();
|
|
897
|
+
const nodes = [];
|
|
898
|
+
const edges = [];
|
|
899
|
+
|
|
900
|
+
for (const task of Object.values(state.tasks)) {
|
|
901
|
+
nodes.push({
|
|
902
|
+
id: task.id,
|
|
903
|
+
label: task.description || task.id,
|
|
904
|
+
state: task.state,
|
|
905
|
+
subagent_type: task.subagent_type,
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
for (const blockerId of task.blockedBy || []) {
|
|
909
|
+
edges.push({
|
|
910
|
+
from: blockerId,
|
|
911
|
+
to: task.id,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return { nodes, edges };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Unblock dependent tasks when a task completes
|
|
921
|
+
* @param {Object} state - Current state
|
|
922
|
+
* @param {string} completedTaskId - ID of completed task
|
|
923
|
+
*/
|
|
924
|
+
_unblockDependents(state, completedTaskId) {
|
|
925
|
+
const completedTask = state.tasks[completedTaskId];
|
|
926
|
+
if (!completedTask) return;
|
|
927
|
+
|
|
928
|
+
// Find tasks that were blocked by this one
|
|
929
|
+
for (const dependentId of completedTask.blocks || []) {
|
|
930
|
+
const dependent = state.tasks[dependentId];
|
|
931
|
+
if (!dependent || dependent.state !== 'blocked') continue;
|
|
932
|
+
|
|
933
|
+
// Check if all blockers are now complete
|
|
934
|
+
const unmetDeps = (dependent.blockedBy || []).filter(depId => {
|
|
935
|
+
const dep = state.tasks[depId];
|
|
936
|
+
return !dep || dep.state !== 'completed';
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
if (unmetDeps.length === 0) {
|
|
940
|
+
dependent.state = 'queued';
|
|
941
|
+
dependent.updated_at = new Date().toISOString();
|
|
942
|
+
state.audit_trail.push({
|
|
943
|
+
task_id: dependentId,
|
|
944
|
+
from_state: 'blocked',
|
|
945
|
+
to_state: 'queued',
|
|
946
|
+
at: new Date().toISOString(),
|
|
947
|
+
reason: `Unblocked: ${completedTaskId} completed`,
|
|
948
|
+
});
|
|
949
|
+
this.emit('unblocked', { task: dependent, unblockedBy: completedTaskId });
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// --------------------------------------------------------------------------
|
|
955
|
+
// Task Groups
|
|
956
|
+
// --------------------------------------------------------------------------
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Create a task group for parallel execution
|
|
960
|
+
* @param {Object} groupData - Group configuration
|
|
961
|
+
* @returns {{ success: boolean, group?: Object, error?: string }}
|
|
962
|
+
*/
|
|
963
|
+
createGroup(groupData) {
|
|
964
|
+
const lock = new FileLock(this.lockPath);
|
|
965
|
+
if (!lock.acquire()) {
|
|
966
|
+
return { success: false, error: 'Could not acquire lock' };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
const state = this.load();
|
|
971
|
+
|
|
972
|
+
const groupId = groupData.id || `group-${Date.now().toString(36)}`;
|
|
973
|
+
|
|
974
|
+
if (state.task_groups[groupId]) {
|
|
975
|
+
return { success: false, error: `Group ${groupId} already exists` };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const group = {
|
|
979
|
+
id: groupId,
|
|
980
|
+
name: groupData.name || groupId,
|
|
981
|
+
task_ids: groupData.task_ids || [],
|
|
982
|
+
join_strategy: groupData.join_strategy || 'all',
|
|
983
|
+
on_failure: groupData.on_failure || 'fail-fast',
|
|
984
|
+
created_at: new Date().toISOString(),
|
|
985
|
+
state: 'pending', // pending, running, completed, failed
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
state.task_groups[groupId] = group;
|
|
989
|
+
this._dirty = true;
|
|
990
|
+
this.save();
|
|
991
|
+
|
|
992
|
+
this.emit('group_created', { group });
|
|
993
|
+
|
|
994
|
+
return { success: true, group };
|
|
995
|
+
} finally {
|
|
996
|
+
lock.release();
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Get group status
|
|
1002
|
+
* @param {string} groupId - Group ID
|
|
1003
|
+
* @returns {Object|null}
|
|
1004
|
+
*/
|
|
1005
|
+
getGroupStatus(groupId) {
|
|
1006
|
+
const state = this.load();
|
|
1007
|
+
const group = state.task_groups[groupId];
|
|
1008
|
+
if (!group) return null;
|
|
1009
|
+
|
|
1010
|
+
const tasks = group.task_ids.map(id => state.tasks[id]).filter(Boolean);
|
|
1011
|
+
const completed = tasks.filter(t => t.state === 'completed').length;
|
|
1012
|
+
const failed = tasks.filter(t => t.state === 'failed').length;
|
|
1013
|
+
const running = tasks.filter(t => t.state === 'running').length;
|
|
1014
|
+
|
|
1015
|
+
return {
|
|
1016
|
+
...group,
|
|
1017
|
+
total: tasks.length,
|
|
1018
|
+
completed,
|
|
1019
|
+
failed,
|
|
1020
|
+
running,
|
|
1021
|
+
pending: tasks.length - completed - failed - running,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// --------------------------------------------------------------------------
|
|
1026
|
+
// Metrics & Queries
|
|
1027
|
+
// --------------------------------------------------------------------------
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Get task statistics
|
|
1031
|
+
* @returns {Object}
|
|
1032
|
+
*/
|
|
1033
|
+
getStats() {
|
|
1034
|
+
const state = this.load();
|
|
1035
|
+
const tasks = Object.values(state.tasks);
|
|
1036
|
+
|
|
1037
|
+
const byState = {};
|
|
1038
|
+
for (const s of TASK_STATES) {
|
|
1039
|
+
byState[s] = tasks.filter(t => t.state === s).length;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const byAgent = {};
|
|
1043
|
+
for (const task of tasks) {
|
|
1044
|
+
const agent = task.subagent_type || 'unknown';
|
|
1045
|
+
byAgent[agent] = (byAgent[agent] || 0) + 1;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return {
|
|
1049
|
+
total: tasks.length,
|
|
1050
|
+
by_state: byState,
|
|
1051
|
+
by_agent: byAgent,
|
|
1052
|
+
groups: Object.keys(state.task_groups).length,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Get tasks for a story
|
|
1058
|
+
* @param {string} storyId - Story ID
|
|
1059
|
+
* @returns {Object[]}
|
|
1060
|
+
*/
|
|
1061
|
+
getTasksForStory(storyId) {
|
|
1062
|
+
return this.getAll({ story_id: storyId });
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Get audit trail
|
|
1067
|
+
* @param {Object} [filter] - Optional filter
|
|
1068
|
+
* @returns {Object[]}
|
|
1069
|
+
*/
|
|
1070
|
+
getAuditTrail(filter = {}) {
|
|
1071
|
+
const state = this.load();
|
|
1072
|
+
let trail = [...state.audit_trail];
|
|
1073
|
+
|
|
1074
|
+
if (filter.task_id) {
|
|
1075
|
+
trail = trail.filter(e => e.task_id === filter.task_id);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
return trail;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Clear completed/cancelled tasks older than threshold
|
|
1083
|
+
* @param {number} [maxAgeMs] - Max age in milliseconds (default: 7 days)
|
|
1084
|
+
* @returns {{ cleared: number }}
|
|
1085
|
+
*/
|
|
1086
|
+
cleanup(maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
|
|
1087
|
+
const lock = new FileLock(this.lockPath);
|
|
1088
|
+
if (!lock.acquire()) {
|
|
1089
|
+
return { cleared: 0 };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
try {
|
|
1093
|
+
const state = this.load();
|
|
1094
|
+
const now = Date.now();
|
|
1095
|
+
let cleared = 0;
|
|
1096
|
+
|
|
1097
|
+
for (const [taskId, task] of Object.entries(state.tasks)) {
|
|
1098
|
+
if (!DONE_STATES.includes(task.state)) continue;
|
|
1099
|
+
|
|
1100
|
+
const updatedAt = new Date(task.updated_at).getTime();
|
|
1101
|
+
if (now - updatedAt > maxAgeMs) {
|
|
1102
|
+
delete state.tasks[taskId];
|
|
1103
|
+
cleared++;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (cleared > 0) {
|
|
1108
|
+
this._dirty = true;
|
|
1109
|
+
this.save();
|
|
1110
|
+
this.emit('cleanup', { cleared });
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
return { cleared };
|
|
1114
|
+
} finally {
|
|
1115
|
+
lock.release();
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// ============================================================================
|
|
1121
|
+
// Singleton & Factory
|
|
1122
|
+
// ============================================================================
|
|
1123
|
+
|
|
1124
|
+
let _instance = null;
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Get singleton registry instance
|
|
1128
|
+
* @param {Object} [options] - Options
|
|
1129
|
+
* @returns {TaskRegistry}
|
|
1130
|
+
*/
|
|
1131
|
+
function getTaskRegistry(options = {}) {
|
|
1132
|
+
if (!_instance || options.forceNew) {
|
|
1133
|
+
_instance = new TaskRegistry(options);
|
|
1134
|
+
}
|
|
1135
|
+
return _instance;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Reset singleton (for testing)
|
|
1140
|
+
*/
|
|
1141
|
+
function resetTaskRegistry() {
|
|
1142
|
+
_instance = null;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// ============================================================================
|
|
1146
|
+
// Exports
|
|
1147
|
+
// ============================================================================
|
|
1148
|
+
|
|
1149
|
+
module.exports = {
|
|
1150
|
+
// Constants
|
|
1151
|
+
TASK_STATES,
|
|
1152
|
+
TERMINAL_STATES,
|
|
1153
|
+
DONE_STATES,
|
|
1154
|
+
TRANSITIONS,
|
|
1155
|
+
JOIN_STRATEGIES,
|
|
1156
|
+
FAILURE_POLICIES,
|
|
1157
|
+
|
|
1158
|
+
// State machine
|
|
1159
|
+
isValidState,
|
|
1160
|
+
isValidTransition,
|
|
1161
|
+
getValidTransitions,
|
|
1162
|
+
isTerminalState,
|
|
1163
|
+
|
|
1164
|
+
// DAG validation
|
|
1165
|
+
detectCycle,
|
|
1166
|
+
validateDAG,
|
|
1167
|
+
topologicalSort,
|
|
1168
|
+
|
|
1169
|
+
// Utilities
|
|
1170
|
+
generateTaskId,
|
|
1171
|
+
findProjectRoot,
|
|
1172
|
+
atomicWrite,
|
|
1173
|
+
|
|
1174
|
+
// Classes
|
|
1175
|
+
TaskRegistry,
|
|
1176
|
+
FileLock,
|
|
1177
|
+
|
|
1178
|
+
// Factory
|
|
1179
|
+
getTaskRegistry,
|
|
1180
|
+
resetTaskRegistry,
|
|
1181
|
+
};
|