kiro-spec-engine 1.44.0 → 1.45.2

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.
@@ -0,0 +1,631 @@
1
+ /**
2
+ * Orchestration Engine — Batch Scheduling Engine (Core)
3
+ *
4
+ * Coordinates all orchestrator components: builds dependency graphs via
5
+ * DependencyManager, computes topological batches, spawns agents via
6
+ * AgentSpawner, tracks status via StatusMonitor, and integrates with
7
+ * SpecLifecycleManager and AgentRegistry.
8
+ *
9
+ * Requirements: 3.1-3.7 (dependency graph, batches, parallel, failure propagation)
10
+ * 5.1-5.6 (crash detection, retry, timeout, graceful stop, deregister)
11
+ * 8.1-8.5 (SLM transitions, AgentRegistry, TaskLockManager, CSM sync)
12
+ */
13
+
14
+ const { EventEmitter } = require('events');
15
+ const path = require('path');
16
+ const fsUtils = require('../utils/fs-utils');
17
+
18
+ const SPECS_DIR = '.kiro/specs';
19
+
20
+ class OrchestrationEngine extends EventEmitter {
21
+ /**
22
+ * @param {string} workspaceRoot - Absolute path to the project root
23
+ * @param {object} options
24
+ * @param {import('./agent-spawner').AgentSpawner} options.agentSpawner
25
+ * @param {import('../collab/dependency-manager')} options.dependencyManager
26
+ * @param {import('../collab/spec-lifecycle-manager').SpecLifecycleManager} options.specLifecycleManager
27
+ * @param {import('./status-monitor').StatusMonitor} options.statusMonitor
28
+ * @param {import('./orchestrator-config').OrchestratorConfig} options.orchestratorConfig
29
+ * @param {import('../collab/agent-registry').AgentRegistry} options.agentRegistry
30
+ */
31
+ constructor(workspaceRoot, options) {
32
+ super();
33
+ this._workspaceRoot = workspaceRoot;
34
+ this._agentSpawner = options.agentSpawner;
35
+ this._dependencyManager = options.dependencyManager;
36
+ this._specLifecycleManager = options.specLifecycleManager;
37
+ this._statusMonitor = options.statusMonitor;
38
+ this._orchestratorConfig = options.orchestratorConfig;
39
+ this._agentRegistry = options.agentRegistry;
40
+
41
+ /** @type {'idle'|'running'|'completed'|'failed'|'stopped'} */
42
+ this._state = 'idle';
43
+ /** @type {Map<string, string>} specName → agentId */
44
+ this._runningAgents = new Map();
45
+ /** @type {Map<string, number>} specName → retry count */
46
+ this._retryCounts = new Map();
47
+ /** @type {Set<string>} specs marked as final failure */
48
+ this._failedSpecs = new Set();
49
+ /** @type {Set<string>} specs skipped due to dependency failure */
50
+ this._skippedSpecs = new Set();
51
+ /** @type {Set<string>} specs completed successfully */
52
+ this._completedSpecs = new Set();
53
+ /** @type {boolean} whether stop() has been called */
54
+ this._stopped = false;
55
+ /** @type {object|null} execution plan */
56
+ this._executionPlan = null;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Public API
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Start orchestration execution.
65
+ *
66
+ * 1. Validate spec existence
67
+ * 2. Build dependency graph via DependencyManager (Req 3.1, 3.7)
68
+ * 3. Detect circular dependencies (Req 3.2)
69
+ * 4. Compute batches via topological sort (Req 3.3)
70
+ * 5. Execute batches sequentially, specs within batch in parallel (Req 3.4, 3.5)
71
+ *
72
+ * @param {string[]} specNames - Specs to orchestrate
73
+ * @param {object} [options]
74
+ * @param {number} [options.maxParallel] - Override max parallel from config
75
+ * @returns {Promise<object>} OrchestrationResult
76
+ */
77
+ async start(specNames, options = {}) {
78
+ if (this._state === 'running') {
79
+ throw new Error('Orchestration is already running');
80
+ }
81
+
82
+ this._reset();
83
+ this._state = 'running';
84
+ this._stopped = false;
85
+ this._statusMonitor.setOrchestrationState('running');
86
+
87
+ try {
88
+ // Step 1: Validate spec existence (Req 6.4)
89
+ const missingSpecs = await this._validateSpecExistence(specNames);
90
+ if (missingSpecs.length > 0) {
91
+ const error = `Specs not found: ${missingSpecs.join(', ')}`;
92
+ this._state = 'failed';
93
+ this._statusMonitor.setOrchestrationState('failed');
94
+ return this._buildResult('failed', error);
95
+ }
96
+
97
+ // Step 2: Build dependency graph (Req 3.1, 3.7)
98
+ const graph = await this._dependencyManager.buildDependencyGraph(specNames);
99
+
100
+ // Step 3: Detect circular dependencies (Req 3.2)
101
+ const cyclePath = this._dependencyManager.detectCircularDependencies(graph);
102
+ if (cyclePath) {
103
+ const error = `Circular dependency detected: ${cyclePath.join(' → ')}`;
104
+ this._state = 'failed';
105
+ this._statusMonitor.setOrchestrationState('failed');
106
+ this._executionPlan = {
107
+ specs: specNames,
108
+ batches: [],
109
+ dependencies: this._extractDependencies(graph, specNames),
110
+ hasCycle: true,
111
+ cyclePath,
112
+ };
113
+ return this._buildResult('failed', error);
114
+ }
115
+
116
+ // Step 4: Compute batches (Req 3.3)
117
+ const dependencies = this._extractDependencies(graph, specNames);
118
+ const batches = this._computeBatches(specNames, dependencies);
119
+
120
+ this._executionPlan = {
121
+ specs: specNames,
122
+ batches,
123
+ dependencies,
124
+ hasCycle: false,
125
+ cyclePath: null,
126
+ };
127
+
128
+ // Initialize specs in StatusMonitor
129
+ for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
130
+ for (const specName of batches[batchIdx]) {
131
+ this._statusMonitor.initSpec(specName, batchIdx);
132
+ }
133
+ }
134
+ this._statusMonitor.setBatchInfo(0, batches.length);
135
+
136
+ // Get config for maxParallel and maxRetries
137
+ const config = await this._orchestratorConfig.getConfig();
138
+ const maxParallel = options.maxParallel || config.maxParallel || 3;
139
+ const maxRetries = config.maxRetries || 2;
140
+
141
+ // Step 5: Execute batches (Req 3.4)
142
+ await this._executeBatches(batches, maxParallel, maxRetries);
143
+
144
+ // Determine final state
145
+ if (this._stopped) {
146
+ this._state = 'stopped';
147
+ this._statusMonitor.setOrchestrationState('stopped');
148
+ } else if (this._failedSpecs.size > 0) {
149
+ this._state = 'failed';
150
+ this._statusMonitor.setOrchestrationState('failed');
151
+ } else {
152
+ this._state = 'completed';
153
+ this._statusMonitor.setOrchestrationState('completed');
154
+ }
155
+
156
+ this.emit('orchestration:complete', this._buildResult(this._state));
157
+ return this._buildResult(this._state);
158
+ } catch (err) {
159
+ this._state = 'failed';
160
+ this._statusMonitor.setOrchestrationState('failed');
161
+ return this._buildResult('failed', err.message);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Gracefully stop all running agents and halt orchestration (Req 5.5).
167
+ * @returns {Promise<void>}
168
+ */
169
+ async stop() {
170
+ this._stopped = true;
171
+
172
+ if (this._state !== 'running') {
173
+ return;
174
+ }
175
+
176
+ // Kill all running agents
177
+ await this._agentSpawner.killAll();
178
+
179
+ // Mark running specs as stopped
180
+ for (const [specName] of this._runningAgents) {
181
+ this._statusMonitor.updateSpecStatus(specName, 'skipped', null, 'Orchestration stopped');
182
+ }
183
+ this._runningAgents.clear();
184
+
185
+ this._state = 'stopped';
186
+ this._statusMonitor.setOrchestrationState('stopped');
187
+ }
188
+
189
+ /**
190
+ * Get current orchestration status.
191
+ * @returns {object} OrchestrationStatus
192
+ */
193
+ getStatus() {
194
+ return this._statusMonitor.getOrchestrationStatus();
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Batch Execution
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /**
202
+ * Execute all batches sequentially.
203
+ * Within each batch, specs run in parallel up to maxParallel.
204
+ *
205
+ * @param {string[][]} batches
206
+ * @param {number} maxParallel
207
+ * @param {number} maxRetries
208
+ * @returns {Promise<void>}
209
+ * @private
210
+ */
211
+ async _executeBatches(batches, maxParallel, maxRetries) {
212
+ for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
213
+ if (this._stopped) break;
214
+
215
+ const batch = batches[batchIdx];
216
+ this._statusMonitor.setBatchInfo(batchIdx + 1, batches.length);
217
+
218
+ // Filter out skipped specs (dependency failures)
219
+ const executableSpecs = batch.filter(s => !this._skippedSpecs.has(s));
220
+
221
+ if (executableSpecs.length === 0) {
222
+ continue;
223
+ }
224
+
225
+ this.emit('batch:start', { batch: batchIdx, specs: executableSpecs });
226
+
227
+ // Execute specs in parallel with maxParallel limit
228
+ await this._executeSpecsInParallel(executableSpecs, maxParallel, maxRetries);
229
+
230
+ this.emit('batch:complete', {
231
+ batch: batchIdx,
232
+ completed: executableSpecs.filter(s => this._completedSpecs.has(s)),
233
+ failed: executableSpecs.filter(s => this._failedSpecs.has(s)),
234
+ skipped: executableSpecs.filter(s => this._skippedSpecs.has(s)),
235
+ });
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Execute a set of specs in parallel, respecting maxParallel limit (Req 3.5).
241
+ *
242
+ * @param {string[]} specNames
243
+ * @param {number} maxParallel
244
+ * @param {number} maxRetries
245
+ * @returns {Promise<void>}
246
+ * @private
247
+ */
248
+ async _executeSpecsInParallel(specNames, maxParallel, maxRetries) {
249
+ const pending = [...specNames];
250
+ const inFlight = new Map(); // specName → Promise
251
+
252
+ const launchNext = async () => {
253
+ while (pending.length > 0 && inFlight.size < maxParallel && !this._stopped) {
254
+ const specName = pending.shift();
255
+ if (this._skippedSpecs.has(specName)) continue;
256
+
257
+ const promise = this._executeSpec(specName, maxRetries);
258
+ inFlight.set(specName, promise);
259
+
260
+ // When done, remove from inFlight and try to launch more
261
+ promise.then(() => {
262
+ inFlight.delete(specName);
263
+ });
264
+ }
265
+ };
266
+
267
+ // Initial launch
268
+ await launchNext();
269
+
270
+ // Wait for all in-flight specs to complete, launching new ones as slots open
271
+ while (inFlight.size > 0 && !this._stopped) {
272
+ // Wait for any one to complete
273
+ await Promise.race(inFlight.values());
274
+ // Launch more if slots available
275
+ await launchNext();
276
+ }
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Single Spec Execution
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Execute a single spec with retry support (Req 5.2, 5.3).
285
+ *
286
+ * @param {string} specName
287
+ * @param {number} maxRetries
288
+ * @returns {Promise<void>}
289
+ * @private
290
+ */
291
+ async _executeSpec(specName, maxRetries) {
292
+ if (this._stopped) return;
293
+
294
+ this._retryCounts.set(specName, this._retryCounts.get(specName) || 0);
295
+
296
+ // Transition to assigned then in-progress via SLM (Req 8.1)
297
+ await this._transitionSafe(specName, 'assigned');
298
+ await this._transitionSafe(specName, 'in-progress');
299
+
300
+ this._statusMonitor.updateSpecStatus(specName, 'running');
301
+ this.emit('spec:start', { specName });
302
+
303
+ try {
304
+ // Spawn agent via AgentSpawner
305
+ const agent = await this._agentSpawner.spawn(specName);
306
+ this._runningAgents.set(specName, agent.agentId);
307
+
308
+ // Wait for agent completion
309
+ const result = await this._waitForAgent(specName, agent.agentId);
310
+
311
+ this._runningAgents.delete(specName);
312
+
313
+ if (result.status === 'completed') {
314
+ await this._handleSpecCompleted(specName, agent.agentId);
315
+ } else {
316
+ // failed or timeout (Req 5.1, 5.4)
317
+ await this._handleSpecFailed(specName, agent.agentId, maxRetries, result.error);
318
+ }
319
+ } catch (err) {
320
+ // Spawn failure (Req 5.1)
321
+ this._runningAgents.delete(specName);
322
+ await this._handleSpecFailed(specName, null, maxRetries, err.message);
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Wait for an agent to complete, fail, or timeout.
328
+ * Returns a promise that resolves with the outcome.
329
+ *
330
+ * @param {string} specName
331
+ * @param {string} agentId
332
+ * @returns {Promise<{status: string, error: string|null}>}
333
+ * @private
334
+ */
335
+ _waitForAgent(specName, agentId) {
336
+ return new Promise((resolve) => {
337
+ const onCompleted = (data) => {
338
+ if (data.agentId === agentId) {
339
+ cleanup();
340
+ resolve({ status: 'completed', error: null });
341
+ }
342
+ };
343
+
344
+ const onFailed = (data) => {
345
+ if (data.agentId === agentId) {
346
+ cleanup();
347
+ const error = data.stderr || data.error || `Exit code: ${data.exitCode}`;
348
+ resolve({ status: 'failed', error });
349
+ }
350
+ };
351
+
352
+ const onTimeout = (data) => {
353
+ if (data.agentId === agentId) {
354
+ cleanup();
355
+ resolve({ status: 'timeout', error: `Timeout after ${data.timeoutSeconds}s` });
356
+ }
357
+ };
358
+
359
+ const cleanup = () => {
360
+ this._agentSpawner.removeListener('agent:completed', onCompleted);
361
+ this._agentSpawner.removeListener('agent:failed', onFailed);
362
+ this._agentSpawner.removeListener('agent:timeout', onTimeout);
363
+ };
364
+
365
+ this._agentSpawner.on('agent:completed', onCompleted);
366
+ this._agentSpawner.on('agent:failed', onFailed);
367
+ this._agentSpawner.on('agent:timeout', onTimeout);
368
+ });
369
+ }
370
+
371
+ /**
372
+ * Handle successful spec completion (Req 8.2, 5.6).
373
+ *
374
+ * @param {string} specName
375
+ * @param {string} agentId
376
+ * @returns {Promise<void>}
377
+ * @private
378
+ */
379
+ async _handleSpecCompleted(specName, agentId) {
380
+ this._completedSpecs.add(specName);
381
+ this._statusMonitor.updateSpecStatus(specName, 'completed', agentId);
382
+
383
+ // Transition to completed via SLM (Req 8.2)
384
+ await this._transitionSafe(specName, 'completed');
385
+
386
+ // Sync external status (Req 8.5)
387
+ await this._syncExternalSafe(specName, 'completed');
388
+
389
+ this.emit('spec:complete', { specName, agentId });
390
+ }
391
+
392
+ /**
393
+ * Handle spec failure — retry or propagate (Req 5.2, 5.3, 3.6).
394
+ *
395
+ * @param {string} specName
396
+ * @param {string|null} agentId
397
+ * @param {number} maxRetries
398
+ * @param {string} error
399
+ * @returns {Promise<void>}
400
+ * @private
401
+ */
402
+ async _handleSpecFailed(specName, agentId, maxRetries, error) {
403
+ const retryCount = this._retryCounts.get(specName) || 0;
404
+
405
+ if (retryCount < maxRetries && !this._stopped) {
406
+ // Retry (Req 5.2)
407
+ this._retryCounts.set(specName, retryCount + 1);
408
+ this._statusMonitor.incrementRetry(specName);
409
+ this._statusMonitor.updateSpecStatus(specName, 'pending', null, error);
410
+
411
+ // Re-execute
412
+ await this._executeSpec(specName, maxRetries);
413
+ } else {
414
+ // Final failure (Req 5.3)
415
+ this._failedSpecs.add(specName);
416
+ this._statusMonitor.updateSpecStatus(specName, 'failed', agentId, error);
417
+
418
+ // Sync external status
419
+ await this._syncExternalSafe(specName, 'failed');
420
+
421
+ this.emit('spec:failed', { specName, agentId, error, retryCount });
422
+
423
+ // Propagate failure to dependents (Req 3.6)
424
+ this._propagateFailure(specName);
425
+ }
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Dependency Graph & Batch Computation
430
+ // ---------------------------------------------------------------------------
431
+
432
+ /**
433
+ * Extract dependency map from the graph for the given specs.
434
+ * edges go FROM dependent TO dependency (from: specA, to: specB means specA depends on specB).
435
+ *
436
+ * @param {object} graph - {nodes, edges}
437
+ * @param {string[]} specNames
438
+ * @returns {object} {[specName]: string[]} - each spec maps to its dependencies
439
+ * @private
440
+ */
441
+ _extractDependencies(graph, specNames) {
442
+ const specSet = new Set(specNames);
443
+ const deps = {};
444
+
445
+ for (const specName of specNames) {
446
+ deps[specName] = [];
447
+ }
448
+
449
+ for (const edge of graph.edges) {
450
+ if (specSet.has(edge.from) && specSet.has(edge.to)) {
451
+ deps[edge.from].push(edge.to);
452
+ }
453
+ }
454
+
455
+ return deps;
456
+ }
457
+
458
+ /**
459
+ * Compute execution batches via topological sort (Req 3.3).
460
+ * Specs with no dependencies → batch 0.
461
+ * Specs whose dependencies are all in earlier batches → next batch.
462
+ *
463
+ * @param {string[]} specNames
464
+ * @param {object} dependencies - {[specName]: string[]}
465
+ * @returns {string[][]} Array of batches
466
+ * @private
467
+ */
468
+ _computeBatches(specNames, dependencies) {
469
+ const batches = [];
470
+ const assigned = new Set(); // specs already assigned to a batch
471
+
472
+ while (assigned.size < specNames.length) {
473
+ const batch = [];
474
+
475
+ for (const specName of specNames) {
476
+ if (assigned.has(specName)) continue;
477
+
478
+ // Check if all dependencies are in earlier batches
479
+ const deps = dependencies[specName] || [];
480
+ const allDepsAssigned = deps.every(d => assigned.has(d));
481
+
482
+ if (allDepsAssigned) {
483
+ batch.push(specName);
484
+ }
485
+ }
486
+
487
+ if (batch.length === 0) {
488
+ // Should not happen if cycle detection passed, but safety guard
489
+ break;
490
+ }
491
+
492
+ batches.push(batch);
493
+ for (const specName of batch) {
494
+ assigned.add(specName);
495
+ }
496
+ }
497
+
498
+ return batches;
499
+ }
500
+
501
+ /**
502
+ * Propagate failure: mark all direct and indirect dependents as skipped (Req 3.6).
503
+ *
504
+ * @param {string} failedSpec
505
+ * @private
506
+ */
507
+ _propagateFailure(failedSpec) {
508
+ if (!this._executionPlan) return;
509
+
510
+ const deps = this._executionPlan.dependencies;
511
+ const toSkip = new Set();
512
+
513
+ // Find all specs that directly or indirectly depend on failedSpec
514
+ const findDependents = (specName) => {
515
+ for (const candidate of this._executionPlan.specs) {
516
+ if (toSkip.has(candidate) || this._completedSpecs.has(candidate)) continue;
517
+ const candidateDeps = deps[candidate] || [];
518
+ if (candidateDeps.includes(specName)) {
519
+ toSkip.add(candidate);
520
+ findDependents(candidate); // recursive: indirect dependents
521
+ }
522
+ }
523
+ };
524
+
525
+ findDependents(failedSpec);
526
+
527
+ for (const specName of toSkip) {
528
+ this._skippedSpecs.add(specName);
529
+ this._statusMonitor.updateSpecStatus(
530
+ specName, 'skipped', null,
531
+ `Skipped: dependency '${failedSpec}' failed`
532
+ );
533
+ }
534
+ }
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Validation & Helpers
538
+ // ---------------------------------------------------------------------------
539
+
540
+ /**
541
+ * Validate that all spec directories exist (Req 6.4).
542
+ *
543
+ * @param {string[]} specNames
544
+ * @returns {Promise<string[]>} List of missing spec names
545
+ * @private
546
+ */
547
+ async _validateSpecExistence(specNames) {
548
+ const missing = [];
549
+ for (const specName of specNames) {
550
+ const specDir = path.join(this._workspaceRoot, SPECS_DIR, specName);
551
+ const exists = await fsUtils.pathExists(specDir);
552
+ if (!exists) {
553
+ missing.push(specName);
554
+ }
555
+ }
556
+ return missing;
557
+ }
558
+
559
+ /**
560
+ * Safely transition a spec via SpecLifecycleManager (Req 8.1, 8.2).
561
+ * Failures are logged but do not propagate (non-fatal).
562
+ *
563
+ * @param {string} specName
564
+ * @param {string} newStatus
565
+ * @returns {Promise<void>}
566
+ * @private
567
+ */
568
+ async _transitionSafe(specName, newStatus) {
569
+ try {
570
+ await this._specLifecycleManager.transition(specName, newStatus);
571
+ } catch (err) {
572
+ console.warn(
573
+ `[OrchestrationEngine] SLM transition failed for ${specName} → ${newStatus}: ${err.message}`
574
+ );
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Safely sync external status via StatusMonitor (Req 8.5).
580
+ * Failures are logged but do not propagate (non-fatal).
581
+ *
582
+ * @param {string} specName
583
+ * @param {string} status
584
+ * @returns {Promise<void>}
585
+ * @private
586
+ */
587
+ async _syncExternalSafe(specName, status) {
588
+ try {
589
+ await this._statusMonitor.syncExternalStatus(specName, status);
590
+ } catch (err) {
591
+ console.warn(
592
+ `[OrchestrationEngine] External sync failed for ${specName}: ${err.message}`
593
+ );
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Build the orchestration result object.
599
+ *
600
+ * @param {string} status
601
+ * @param {string|null} [error=null]
602
+ * @returns {object}
603
+ * @private
604
+ */
605
+ _buildResult(status, error = null) {
606
+ return {
607
+ status,
608
+ plan: this._executionPlan,
609
+ completed: [...this._completedSpecs],
610
+ failed: [...this._failedSpecs],
611
+ skipped: [...this._skippedSpecs],
612
+ error,
613
+ };
614
+ }
615
+
616
+ /**
617
+ * Reset internal state for a new orchestration run.
618
+ * @private
619
+ */
620
+ _reset() {
621
+ this._runningAgents.clear();
622
+ this._retryCounts.clear();
623
+ this._failedSpecs.clear();
624
+ this._skippedSpecs.clear();
625
+ this._completedSpecs.clear();
626
+ this._executionPlan = null;
627
+ this._stopped = false;
628
+ }
629
+ }
630
+
631
+ module.exports = { OrchestrationEngine };