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.
- package/CHANGELOG.md +32 -0
- package/README.md +29 -2
- package/README.zh.md +29 -2
- package/bin/kiro-spec-engine.js +4 -0
- package/lib/commands/orchestrate.js +315 -0
- package/lib/orchestrator/agent-spawner.js +432 -0
- package/lib/orchestrator/bootstrap-prompt-builder.js +236 -0
- package/lib/orchestrator/index.js +19 -0
- package/lib/orchestrator/orchestration-engine.js +631 -0
- package/lib/orchestrator/orchestrator-config.js +157 -0
- package/lib/orchestrator/status-monitor.js +438 -0
- package/package.json +1 -1
- package/template/.kiro/README.md +14 -1
|
@@ -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 };
|