aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
1,543 lines (1,330 loc) • 54.4 kB
JavaScript
/**
* Master Orchestrator - Epic 0: Autonomous Development Engine
*
* Story: 0.1 - Master Orchestrator Core
*
* Central orchestrator that connects all ADE epics (3→4→5→6) in a unified
* execution pipeline for truly autonomous development.
*
* Features:
* - AC1: Located in `.aios-core/core/orchestration/`
* - AC2: constructor(projectRoot, options) with storyId, maxRetries, autoRecovery
* - AC3: executeFullPipeline() runs Epics 3→4→5→6
* - AC4: executeEpic(epicNum, options) for individual epic execution
* - AC5: resumeFromEpic(epicNum) to continue from specific point
* - AC6: State machine: INITIALIZED, READY, IN_PROGRESS, BLOCKED, COMPLETE
* - AC7: Integrates with TechStackDetector for pre-flight detection
*
* @module core/orchestration/master-orchestrator
* @version 1.0.0
* @author @dev (Dex)
*/
const fs = require('fs-extra');
const path = require('path');
const { EventEmitter } = require('events');
// Core dependencies
const TechStackDetector = require('./tech-stack-detector');
// Epic Executors (Story 0.3)
const { createExecutor, hasExecutor } = require('./executors');
// Recovery Handler (Story 0.5)
const { RecoveryHandler, RecoveryStrategy: _RecoveryStrategy } = require('./recovery-handler');
// Gate Evaluator (Story 0.6)
const { GateEvaluator, GateVerdict } = require('./gate-evaluator');
// Agent Invoker (Story 0.7)
const { AgentInvoker, SUPPORTED_AGENTS: _SUPPORTED_AGENTS } = require('./agent-invoker');
// Dashboard Integration (Story 0.8)
const { DashboardIntegration, NotificationType: _NotificationType } = require('./dashboard-integration');
// Optional chalk for colored output
let chalk;
try {
chalk = require('chalk');
} catch {
chalk = {
blue: (s) => s,
green: (s) => s,
red: (s) => s,
yellow: (s) => s,
cyan: (s) => s,
gray: (s) => s,
bold: (s) => s,
dim: (s) => s,
magenta: (s) => s,
};
}
// ═══════════════════════════════════════════════════════════════════════════════════
// STATE MACHINE (AC6)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Orchestrator state machine states
* @enum {string}
*/
const OrchestratorState = {
/** Initial state after construction */
INITIALIZED: 'initialized',
/** Ready to execute after pre-flight checks */
READY: 'ready',
/** Currently executing epics */
IN_PROGRESS: 'in_progress',
/** Blocked due to error or requiring intervention */
BLOCKED: 'blocked',
/** All epics completed successfully */
COMPLETE: 'complete',
};
/**
* Epic execution status
* @enum {string}
*/
const EpicStatus = {
PENDING: 'pending',
IN_PROGRESS: 'in_progress',
COMPLETED: 'completed',
FAILED: 'failed',
SKIPPED: 'skipped',
};
/**
* Epic configuration - maps epic numbers to their metadata
*/
const EPIC_CONFIG = {
3: {
name: 'Spec Pipeline',
description: 'Requirements → Specification generation',
executor: 'Epic3Executor',
icon: '📝',
},
4: {
name: 'Execution Engine',
description: 'Plan tracking and subtask execution',
executor: 'Epic4Executor',
icon: '⚙️',
},
5: {
name: 'Recovery System',
description: 'Error detection and recovery',
executor: 'Epic5Executor',
icon: '🔄',
onDemand: true, // Only triggered on errors
},
6: {
name: 'QA Loop',
description: 'Quality assurance and validation',
executor: 'Epic6Executor',
icon: '🔍',
},
};
// ═══════════════════════════════════════════════════════════════════════════════════
// MASTER ORCHESTRATOR CLASS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Master Orchestrator - Coordinates all ADE epics in unified execution
* @extends EventEmitter
*/
class MasterOrchestrator extends EventEmitter {
/**
* Create a new MasterOrchestrator instance (AC2)
*
* @param {string} projectRoot - Project root directory
* @param {Object} options - Configuration options
* @param {string} [options.storyId] - Story identifier (e.g., 'STORY-42')
* @param {number} [options.maxRetries=3] - Maximum retry attempts per epic
* @param {boolean} [options.autoRecovery=true] - Enable automatic recovery on failures
* @param {string} [options.source='story'] - Source type: 'story', 'prd', 'prompt'
* @param {string} [options.prdPath] - Path to PRD if source is 'prd'
* @param {boolean} [options.strictGates=false] - Fail on any gate failure
* @param {Function} [options.onEpicStart] - Callback when epic starts
* @param {Function} [options.onEpicComplete] - Callback when epic completes
* @param {Function} [options.onStateChange] - Callback on state transitions
* @param {Function} [options.invokeAgent] - Function to invoke agents for tasks
*/
constructor(projectRoot, options = {}) {
super();
// Core configuration
this.projectRoot = projectRoot;
this.storyId = options.storyId || null;
this.maxRetries = options.maxRetries ?? 3;
this.autoRecovery = options.autoRecovery ?? true;
this.source = options.source || 'story';
this.prdPath = options.prdPath || null;
this.strictGates = options.strictGates ?? false;
// Callbacks
this.onEpicStart = options.onEpicStart || this._defaultEpicStart.bind(this);
this.onEpicComplete = options.onEpicComplete || this._defaultEpicComplete.bind(this);
this.onStateChange = options.onStateChange || this._defaultStateChange.bind(this);
this.invokeAgent = options.invokeAgent || null;
// State machine (AC6)
this._state = OrchestratorState.INITIALIZED;
this._previousState = null;
this._inFullPipeline = false; // Flag for gate evaluation during full pipeline
// Execution state
this.executionState = {
workflowId: null,
storyId: this.storyId,
status: 'initialized',
startedAt: null,
updatedAt: null,
techStackProfile: null,
epics: this._initializeEpicsState(),
currentEpic: null,
errors: [],
insights: [],
retryCount: {},
};
// Components (AC7)
this.techStackDetector = new TechStackDetector(projectRoot);
this.executors = {}; // Will be loaded lazily
// Recovery Handler (Story 0.5)
this.recoveryHandler = new RecoveryHandler({
projectRoot,
storyId: this.storyId,
maxRetries: this.maxRetries,
autoEscalate: this.autoRecovery,
orchestrator: this,
});
// Gate Evaluator (Story 0.6)
this.gateEvaluator = new GateEvaluator({
projectRoot,
strictMode: this.strictGates,
});
// Agent Invoker (Story 0.7)
this.agentInvoker = new AgentInvoker({
projectRoot,
defaultTimeout: options.taskTimeout || 300000, // 5 min
maxRetries: this.maxRetries,
executor: this.invokeAgent, // Use custom executor if provided
});
// Dashboard Integration (Story 0.8)
this.dashboardIntegration = new DashboardIntegration({
projectRoot,
orchestrator: this,
autoUpdate: options.dashboardAutoUpdate ?? false, // Default off for tests
updateInterval: options.dashboardUpdateInterval || 5000,
});
// State persistence paths
this.statePath = path.join(
projectRoot,
'.aios',
'master-orchestrator',
this.storyId ? `${this.storyId}.json` : 'current.json',
);
// Log initialization
this._log('MasterOrchestrator initialized', {
projectRoot,
storyId: this.storyId,
maxRetries: this.maxRetries,
autoRecovery: this.autoRecovery,
});
}
// ═══════════════════════════════════════════════════════════════════════════════════
// STATE MACHINE (AC6)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Get current state
* @returns {OrchestratorState}
*/
get state() {
return this._state;
}
/**
* Transition to a new state
* @param {OrchestratorState} newState - Target state
* @param {Object} [context] - Additional context for the transition
* @private
*/
_transitionTo(newState, context = {}) {
const validTransitions = {
[OrchestratorState.INITIALIZED]: [OrchestratorState.READY, OrchestratorState.BLOCKED],
[OrchestratorState.READY]: [OrchestratorState.IN_PROGRESS, OrchestratorState.BLOCKED],
[OrchestratorState.IN_PROGRESS]: [
OrchestratorState.COMPLETE,
OrchestratorState.BLOCKED,
OrchestratorState.IN_PROGRESS, // Allow staying in progress for multiple epics
],
[OrchestratorState.BLOCKED]: [
OrchestratorState.READY,
OrchestratorState.IN_PROGRESS,
OrchestratorState.COMPLETE,
],
[OrchestratorState.COMPLETE]: [], // Terminal state
};
const allowed = validTransitions[this._state] || [];
if (!allowed.includes(newState) && newState !== this._state) {
this._log(`Invalid state transition: ${this._state} → ${newState}`, { level: 'warn' });
return false;
}
this._previousState = this._state;
this._state = newState;
this.executionState.status = newState;
this.executionState.updatedAt = new Date().toISOString();
// Emit state change event
this.emit('stateChange', {
from: this._previousState,
to: newState,
context,
});
this.onStateChange(this._previousState, newState, context);
this._log(`State transition: ${this._previousState} → ${newState}`, context);
return true;
}
/**
* Initialize epics state structure
* @private
*/
_initializeEpicsState() {
const epics = {};
for (const epicNum of Object.keys(EPIC_CONFIG)) {
epics[epicNum] = {
status: EpicStatus.PENDING,
startedAt: null,
completedAt: null,
result: null,
attempts: 0,
errors: [],
};
}
return epics;
}
// ═══════════════════════════════════════════════════════════════════════════════════
// INITIALIZATION
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Initialize the orchestrator - run pre-flight checks (AC7)
* @returns {Promise<void>}
*/
async initialize() {
this._log('Starting initialization...');
try {
// Ensure state directory exists
await fs.ensureDir(path.dirname(this.statePath));
// Try to load existing state
const existingState = await this._loadState();
if (existingState && existingState.status !== OrchestratorState.COMPLETE) {
this._log('Found existing state, resuming...', { storyId: existingState.storyId });
// Deep merge for nested objects like epics
this.executionState = {
...this.executionState,
...existingState,
epics: {
...this.executionState.epics,
...existingState.epics,
},
retryCount: {
...this.executionState.retryCount,
...(existingState.retryCount || {}),
},
};
this._state = existingState.status || OrchestratorState.INITIALIZED;
}
// Generate workflow ID if not exists
if (!this.executionState.workflowId) {
this.executionState.workflowId = `master-ade-${this.storyId || Date.now()}`;
}
// Record start time if fresh start
if (!this.executionState.startedAt) {
this.executionState.startedAt = new Date().toISOString();
}
// Run pre-flight tech stack detection (AC7)
this._log('Running pre-flight tech stack detection...');
const techStack = await this._detectTechStack();
this.executionState.techStackProfile = techStack;
// Log detection summary
const summary = TechStackDetector.getSummary(techStack);
this._log(`Tech stack detected: ${summary}`, { confidence: techStack.confidence });
// Transition to ready
this._transitionTo(OrchestratorState.READY, { techStack });
// Save initial state
await this._saveState();
this._log('Initialization complete');
} catch (error) {
this._log(`Initialization failed: ${error.message}`, { level: 'error' });
this._transitionTo(OrchestratorState.BLOCKED, { error: error.message });
throw error;
}
}
/**
* Detect tech stack using TechStackDetector (AC7)
* @private
* @returns {Promise<Object>} Tech stack profile
*/
async _detectTechStack() {
return await this.techStackDetector.detect();
}
// ═══════════════════════════════════════════════════════════════════════════════════
// PIPELINE EXECUTION (AC3)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Execute the full ADE pipeline: Epics 3→4→5→6→7 (AC3)
*
* @returns {Promise<Object>} Pipeline execution result
*/
async executeFullPipeline() {
this._log('Starting full pipeline execution...');
// Ensure initialized
if (this._state === OrchestratorState.INITIALIZED) {
await this.initialize();
}
// Validate ready state
if (this._state !== OrchestratorState.READY && this._state !== OrchestratorState.IN_PROGRESS) {
throw new Error(`Cannot execute pipeline in state: ${this._state}`);
}
// Transition to in progress
this._transitionTo(OrchestratorState.IN_PROGRESS, { action: 'executeFullPipeline' });
const pipelineResult = {
success: true,
epicsExecuted: [],
epicsFailed: [],
epicsSkipped: [],
startTime: Date.now(),
endTime: null,
techStack: this.executionState.techStackProfile,
};
// Flag for gate evaluation (only during full pipeline)
this._inFullPipeline = true;
try {
// Execute epics in sequence: 3 → 4 → 6
// Note: Epic 5 (Recovery) is triggered on-demand, not sequentially
const epicSequence = [3, 4, 6];
for (const epicNum of epicSequence) {
// Check if already completed (for resume scenarios)
if (this.executionState.epics[epicNum]?.status === EpicStatus.COMPLETED) {
this._log(`Epic ${epicNum} already completed, skipping...`);
pipelineResult.epicsSkipped.push(epicNum);
continue;
}
// Check if blocked
if (this._state === OrchestratorState.BLOCKED) {
this._log(`Pipeline blocked, cannot continue to Epic ${epicNum}`);
break;
}
try {
// Execute epic
const result = await this.executeEpic(epicNum);
if (result.success) {
pipelineResult.epicsExecuted.push(epicNum);
} else {
pipelineResult.epicsFailed.push(epicNum);
// In strict mode or if epic is critical, stop pipeline
if (this.strictGates || this._isEpicCritical(epicNum)) {
this._log(`Critical epic ${epicNum} failed, halting pipeline`);
pipelineResult.success = false;
break;
}
}
} catch (error) {
this._log(`Epic ${epicNum} threw error: ${error.message}`, { level: 'error' });
pipelineResult.epicsFailed.push(epicNum);
pipelineResult.success = false;
// Record error
this.executionState.errors.push({
epic: epicNum,
error: error.message,
timestamp: new Date().toISOString(),
});
// Try recovery if enabled
if (this.autoRecovery) {
const recovered = await this._attemptRecovery(epicNum, error);
if (recovered) {
pipelineResult.epicsFailed = pipelineResult.epicsFailed.filter((e) => e !== epicNum);
pipelineResult.epicsExecuted.push(epicNum);
pipelineResult.success = true;
}
}
// If not recovered and strict, stop
if (!pipelineResult.success && this.strictGates) {
break;
}
}
// Save state after each epic
await this._saveState();
}
// Finalize pipeline
pipelineResult.endTime = Date.now();
pipelineResult.duration = pipelineResult.endTime - pipelineResult.startTime;
// Transition to final state
if (pipelineResult.success && pipelineResult.epicsFailed.length === 0) {
this._transitionTo(OrchestratorState.COMPLETE, { result: pipelineResult });
} else if (pipelineResult.epicsFailed.length > 0) {
this._transitionTo(OrchestratorState.BLOCKED, {
failedEpics: pipelineResult.epicsFailed,
});
}
// Save final state
await this._saveState();
return this.finalize(pipelineResult);
} catch (error) {
this._log(`Pipeline execution failed: ${error.message}`, { level: 'error' });
this._transitionTo(OrchestratorState.BLOCKED, { error: error.message });
await this._saveState();
throw error;
} finally {
// Reset pipeline flag
this._inFullPipeline = false;
}
}
/**
* Check if an epic is critical (failure should halt pipeline)
* @private
*/
_isEpicCritical(epicNum) {
// Epics 3 (Spec) and 4 (Execution) are critical
return [3, 4].includes(epicNum);
}
// ═══════════════════════════════════════════════════════════════════════════════════
// SINGLE EPIC EXECUTION (AC4)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Execute a single epic (AC4)
*
* @param {number} epicNum - Epic number (3, 4, 5, 6, or 7)
* @param {Object} [options] - Epic-specific options
* @returns {Promise<Object>} Epic execution result
*/
async executeEpic(epicNum, options = {}) {
const epicConfig = EPIC_CONFIG[epicNum];
if (!epicConfig) {
throw new Error(`Unknown epic number: ${epicNum}`);
}
this._log(`Starting Epic ${epicNum}: ${epicConfig.name}`, { icon: epicConfig.icon });
// Update epic state
this.executionState.currentEpic = epicNum;
this.executionState.epics[epicNum] = {
...this.executionState.epics[epicNum],
status: EpicStatus.IN_PROGRESS,
startedAt: new Date().toISOString(),
attempts: (this.executionState.epics[epicNum]?.attempts || 0) + 1,
};
// Emit epic start event
this.emit('epicStart', { epicNum, config: epicConfig });
this.onEpicStart(epicNum, epicConfig);
try {
// Build context for this epic
const context = this._buildContextForEpic(epicNum);
// Get or create executor
const executor = await this._getExecutor(epicNum);
// Execute the epic
const result = await executor.execute({
...context,
...options,
orchestrator: this,
});
// Mark as completed
this.executionState.epics[epicNum] = {
...this.executionState.epics[epicNum],
status: EpicStatus.COMPLETED,
completedAt: new Date().toISOString(),
result,
};
// Evaluate quality gate (Story 0.6) - only in full pipeline mode
// Skip gate evaluation if result is from stub executor
let gateResult = null;
const isStubResult = result && result.status === 'stub';
if (this._inFullPipeline && result && result.success !== false && !isStubResult) {
gateResult = await this._evaluateGate(epicNum, result);
// Store gate result if exists
if (gateResult) {
this.executionState.epics[epicNum].gateResult = gateResult;
}
}
// Save state after epic completion
await this._saveState();
// Emit epic complete event
this.emit('epicComplete', { epicNum, result, gateResult });
this.onEpicComplete(epicNum, result);
this._log(`Epic ${epicNum} completed successfully`, { icon: '✅' });
// Check gate verdict (only block/warn during full pipeline)
if (gateResult && this._inFullPipeline) {
if (this.gateEvaluator.shouldBlock(gateResult.verdict)) {
this._log(`Gate blocked: ${gateResult.gate}`, { level: 'warn', icon: '🚫' });
this._transitionTo(OrchestratorState.BLOCKED, {
reason: 'gate_blocked',
gate: gateResult.gate,
issues: gateResult.issues,
});
return { success: false, epicNum, result, gateResult, blocked: true };
}
if (this.gateEvaluator.needsRevision(gateResult.verdict)) {
this._log(`Gate needs revision: ${gateResult.gate}`, { level: 'warn', icon: '⚠️' });
result.needsRevision = true;
}
}
return { success: true, epicNum, result, gateResult };
} catch (error) {
// Mark as failed
this.executionState.epics[epicNum] = {
...this.executionState.epics[epicNum],
status: EpicStatus.FAILED,
errors: [
...(this.executionState.epics[epicNum]?.errors || []),
{ message: error.message, timestamp: new Date().toISOString() },
],
};
this._log(`Epic ${epicNum} failed: ${error.message}`, { level: 'error', icon: '❌' });
// Check retry count
const retries = this.executionState.retryCount[epicNum] || 0;
if (retries < this.maxRetries && this.autoRecovery) {
this.executionState.retryCount[epicNum] = retries + 1;
this._log(`Retrying Epic ${epicNum} (attempt ${retries + 1}/${this.maxRetries})...`);
return this.executeEpic(epicNum, options);
}
return { success: false, epicNum, error: error.message };
}
}
/**
* Get or create an executor for the epic (Story 0.3)
* @private
*/
async _getExecutor(epicNum) {
if (!this.executors[epicNum]) {
// Use real executors from Story 0.3
if (hasExecutor(epicNum)) {
this.executors[epicNum] = createExecutor(epicNum, this);
} else {
// Fallback to stub for unknown epics
this.executors[epicNum] = new StubEpicExecutor(this, epicNum);
}
}
return this.executors[epicNum];
}
// ═══════════════════════════════════════════════════════════════════════════════════
// RESUME EXECUTION (AC5)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Resume execution from a specific epic (AC5)
*
* @param {number} fromEpic - Epic number to resume from
* @returns {Promise<Object>} Pipeline execution result
*/
async resumeFromEpic(fromEpic) {
this._log(`Resuming from Epic ${fromEpic}...`);
// Load existing state
const existingState = await this._loadState();
if (existingState) {
Object.assign(this.executionState, existingState);
}
// Reset state for epics >= fromEpic
for (const epicNumStr of Object.keys(this.executionState.epics)) {
const epicNum = parseInt(epicNumStr, 10);
if (epicNum >= fromEpic) {
this.executionState.epics[epicNum] = {
status: EpicStatus.PENDING,
startedAt: null,
completedAt: null,
result: null,
attempts: 0,
errors: [],
};
this.executionState.retryCount[epicNum] = 0;
}
}
// Clear blocked state if set
if (this._state === OrchestratorState.BLOCKED) {
this._transitionTo(OrchestratorState.READY, { action: 'resume', fromEpic });
}
// Execute pipeline from this point
return this.executeFullPipeline();
}
// ═══════════════════════════════════════════════════════════════════════════════════
// CONTEXT BUILDING
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Build context for a specific epic
* This will be expanded in Story 0.4 (Context Threading)
* @private
*/
_buildContextForEpic(epicNum) {
const baseContext = {
storyId: this.storyId,
workflowId: this.executionState.workflowId,
techStack: this.executionState.techStackProfile,
projectRoot: this.projectRoot,
previousGates: this._getCompletedGates(),
};
switch (epicNum) {
case 3: // Spec Pipeline
return {
...baseContext,
source: this.source,
prdPath: this.prdPath,
};
case 4: // Execution Engine
return {
...baseContext,
spec: this.executionState.epics[3]?.result?.specPath,
complexity: this.executionState.epics[3]?.result?.complexity,
requirements: this.executionState.epics[3]?.result?.requirements,
};
case 5: // Recovery (on-demand)
return {
...baseContext,
implementationPath: this.executionState.epics[4]?.result?.implementationPath,
errors: this.executionState.errors,
attempts: this.executionState.epics[4]?.attempts,
};
case 6: // QA Loop
return {
...baseContext,
buildResult: this.executionState.epics[4]?.result,
testResults: this.executionState.epics[4]?.result?.testResults,
codeChanges: this.executionState.epics[4]?.result?.codeChanges,
};
case 7: // Memory Layer
return {
...baseContext,
qaReport: this.executionState.epics[6]?.result,
patterns: this.executionState.insights,
sessionInsights: this._collectSessionInsights(),
};
default:
return baseContext;
}
}
/**
* Get list of completed gates/epics
* @private
*/
_getCompletedGates() {
return Object.entries(this.executionState.epics)
.filter(([_, epic]) => epic.status === EpicStatus.COMPLETED)
.map(([num, _]) => parseInt(num, 10));
}
/**
* Collect session insights for memory layer
* @private
*/
_collectSessionInsights() {
return {
duration: this.executionState.startedAt
? Date.now() - new Date(this.executionState.startedAt).getTime()
: 0,
errorsEncountered: this.executionState.errors.length,
recoveryAttempts: Object.values(this.executionState.retryCount).reduce((a, b) => a + b, 0),
completedEpics: this._getCompletedGates().length,
};
}
// ═══════════════════════════════════════════════════════════════════════════════════
// QUALITY GATES (Story 0.6)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Evaluate quality gate after epic completion (AC1)
* @private
* @param {number} epicNum - Completed epic number
* @param {Object} result - Epic result
* @returns {Promise<Object|null>} Gate result or null if no gate
*/
async _evaluateGate(epicNum, result) {
// Determine next epic for gate evaluation
const epicSequence = [3, 4, 6, 7];
const currentIndex = epicSequence.indexOf(epicNum);
// No gate after last epic or if epic not in sequence
if (currentIndex === -1 || currentIndex === epicSequence.length - 1) {
return null;
}
const nextEpic = epicSequence[currentIndex + 1];
this._log(`Evaluating gate: Epic ${epicNum} -> Epic ${nextEpic}`, { icon: '🚦' });
try {
const gateResult = await this.gateEvaluator.evaluate(epicNum, nextEpic, result);
// Log gate result
this._log(`Gate verdict: ${gateResult.verdict} (score: ${gateResult.score.toFixed(1)})`, {
level: gateResult.verdict === GateVerdict.APPROVED ? 'info' : 'warn',
});
return gateResult;
} catch (error) {
this._log(`Gate evaluation error: ${error.message}`, { level: 'error' });
// Return blocked gate on error if strict
if (this.strictGates) {
return {
gate: `epic${epicNum}_to_epic${nextEpic}`,
verdict: GateVerdict.BLOCKED,
issues: [{ check: 'evaluation_error', message: error.message, severity: 'critical' }],
};
}
return null;
}
}
/**
* Get gate evaluator for external access (AC6)
* @returns {GateEvaluator}
*/
getGateEvaluator() {
return this.gateEvaluator;
}
/**
* Get all gate results (AC6)
* @returns {Object[]}
*/
getGateResults() {
return this.gateEvaluator.getResults();
}
// ═══════════════════════════════════════════════════════════════════════════════════
// AGENT INVOCATION (Story 0.7)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Get the agent invoker instance (Story 0.7)
* @returns {AgentInvoker}
*/
getAgentInvoker() {
return this.agentInvoker;
}
/**
* Invoke an agent to execute a task (Story 0.7 - AC1)
*
* @param {string} agentName - Agent name (e.g., 'dev', 'qa', 'architect')
* @param {string} taskPath - Path to task file or task name
* @param {Object} [inputs={}] - Inputs to pass to the task
* @returns {Promise<Object>} Invocation result
*/
async invokeAgentForTask(agentName, taskPath, inputs = {}) {
// Add orchestration context to inputs
const enrichedInputs = {
...inputs,
orchestration: {
storyId: this.storyId,
currentEpic: this.executionState.currentEpic,
techStack: this.executionState.techStackProfile,
state: this._state,
},
};
return this.agentInvoker.invokeAgent(agentName, taskPath, enrichedInputs);
}
/**
* Get supported agents (Story 0.7 - AC2)
* @returns {Object} Map of supported agents
*/
getSupportedAgents() {
return this.agentInvoker.getSupportedAgents();
}
/**
* Get agent invocation history (Story 0.7 - AC7)
* @returns {Object[]} Invocation records
*/
getAgentInvocations() {
return this.agentInvoker.getInvocations();
}
// ═══════════════════════════════════════════════════════════════════════════════════
// DASHBOARD INTEGRATION (Story 0.8)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Get the dashboard integration instance (Story 0.8)
* @returns {DashboardIntegration}
*/
getDashboardIntegration() {
return this.dashboardIntegration;
}
/**
* Start dashboard monitoring (Story 0.8 - AC1)
*/
async startDashboard() {
await this.dashboardIntegration.start();
}
/**
* Stop dashboard monitoring (Story 0.8)
*/
stopDashboard() {
this.dashboardIntegration.stop();
}
/**
* Get dashboard status (Story 0.8 - AC1, AC2)
* @returns {Object} Dashboard status
*/
getDashboardStatus() {
return this.dashboardIntegration.buildStatus();
}
/**
* Get execution history (Story 0.8 - AC5)
* @returns {Object[]} History entries
*/
getExecutionHistory() {
return this.dashboardIntegration.getHistory();
}
/**
* Get notifications (Story 0.8 - AC7)
* @param {boolean} [unreadOnly=false] - Only unread notifications
* @returns {Object[]} Notifications
*/
getNotifications(unreadOnly = false) {
return this.dashboardIntegration.getNotifications(unreadOnly);
}
/**
* Add notification (Story 0.8 - AC7)
* @param {Object} notification - Notification object
*/
addNotification(notification) {
this.dashboardIntegration.addNotification(notification);
}
// ═══════════════════════════════════════════════════════════════════════════════════
// RECOVERY (Story 0.5)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Attempt recovery for a failed epic (AC1-AC7 of Story 0.5)
* Uses RecoveryHandler for intelligent recovery with stuck detection
* @private
* @param {number} epicNum - Failed epic number
* @param {Error} error - Error that caused failure
* @returns {Promise<boolean>} True if should retry
*/
async _attemptRecovery(epicNum, error) {
this._log(`Attempting recovery for Epic ${epicNum}...`, { error: error.message });
// Don't try to recover from recovery failures (avoid infinite loop)
if (epicNum === 5) {
this._log('Cannot recover from recovery epic failure', { level: 'warn' });
return false;
}
// Check if we can still retry (AC5)
if (!this.recoveryHandler.canRetry(epicNum)) {
this._log(`Max retries reached for Epic ${epicNum}`, { level: 'warn' });
return false;
}
try {
// Use RecoveryHandler for intelligent recovery (AC1)
const result = await this.recoveryHandler.handleEpicFailure(epicNum, error, {
approach: this.executionState.epics[epicNum]?.currentApproach || 'default',
subtaskId: this.executionState.epics[epicNum]?.currentSubtask,
affectedFiles: this.executionState.epics[epicNum]?.result?.codeChanges || [],
});
// Log recovery result (AC7)
this._log(
`Recovery result: ${JSON.stringify({
strategy: result.strategy,
success: result.success,
shouldRetry: result.shouldRetry,
escalated: result.escalated,
})}`,
{ level: result.success ? 'info' : 'warn' },
);
// Track retry count
this.executionState.retryCount[epicNum] = (this.executionState.retryCount[epicNum] || 0) + 1;
// Handle escalation
if (result.escalated) {
this._transitionTo(OrchestratorState.BLOCKED, {
reason: 'escalated_to_human',
epicNum,
error: error.message,
});
return false;
}
// Should we retry?
return result.shouldRetry;
} catch (recoveryError) {
this._log(`Recovery handler error: ${recoveryError.message}`, { level: 'error' });
return false;
}
}
/**
* Get recovery handler for external access
* @returns {RecoveryHandler}
*/
getRecoveryHandler() {
return this.recoveryHandler;
}
// ═══════════════════════════════════════════════════════════════════════════════════
// STATE PERSISTENCE (Story 0.2)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Save current state to disk (AC1, AC3)
* Called after each epic completion and state transition
* @returns {Promise<boolean>} Success status
*/
async saveState() {
try {
await fs.ensureDir(path.dirname(this.statePath));
// Build comprehensive state object (AC2, AC6, AC7)
const stateToSave = {
// Metadata
schemaVersion: '1.0',
workflowId: this.executionState.workflowId,
storyId: this.storyId,
status: this._state,
// Timestamps (AC6)
timestamps: {
startedAt: this.executionState.startedAt,
updatedAt: new Date().toISOString(),
savedAt: new Date().toISOString(),
completedAt: this._state === OrchestratorState.COMPLETE ? new Date().toISOString() : null,
},
// Tech stack profile (AC7)
techStackProfile: this.executionState.techStackProfile,
// Epic tracking (AC2)
currentEpic: this.executionState.currentEpic,
completedEpics: this._getCompletedGates(),
failedEpics: this._getFailedEpics(),
pendingEpics: this._getPendingEpics(),
// Full epics state
epics: this.executionState.epics,
// Recovery tracking
retryCount: this.executionState.retryCount,
// Context for resume (AC2)
context: {
source: this.source,
prdPath: this.prdPath,
strictGates: this.strictGates,
maxRetries: this.maxRetries,
autoRecovery: this.autoRecovery,
},
// Errors and insights
errors: this.executionState.errors,
insights: this.executionState.insights,
// Session insights
sessionInsights: this._collectSessionInsights(),
};
await fs.writeJson(this.statePath, stateToSave, { spaces: 2 });
this._log('State saved successfully', { path: this.statePath });
return true;
} catch (error) {
this._log(`Failed to save state: ${error.message}`, { level: 'warn' });
return false;
}
}
/**
* Internal save state wrapper (calls public method)
* @private
*/
async _saveState() {
return this.saveState();
}
/**
* Load state for a specific story ID (AC4)
* @param {string} [storyId] - Story ID to load (defaults to this.storyId)
* @returns {Promise<Object|null>} Loaded state or null
*/
async loadState(storyId = null) {
const targetStoryId = storyId || this.storyId;
const targetPath = targetStoryId
? path.join(this.projectRoot, '.aios', 'master-orchestrator', `${targetStoryId}.json`)
: this.statePath;
try {
if (await fs.pathExists(targetPath)) {
const state = await fs.readJson(targetPath);
// Validate state schema
if (!this._validateStateSchema(state)) {
this._log('Invalid state schema, ignoring saved state', { level: 'warn' });
return null;
}
this._log('State loaded successfully', { storyId: targetStoryId });
return state;
}
} catch (error) {
this._log(`Failed to load state: ${error.message}`, { level: 'warn' });
}
return null;
}
/**
* Internal load state wrapper (calls public method)
* @private
*/
async _loadState() {
return this.loadState();
}
/**
* Find and load the most recent valid state (AC5)
* Searches for any resumable state in the master-orchestrator directory
* @returns {Promise<Object|null>} Most recent valid state or null
*/
async findLatestValidState() {
const stateDir = path.join(this.projectRoot, '.aios', 'master-orchestrator');
try {
if (!(await fs.pathExists(stateDir))) {
return null;
}
const files = await fs.readdir(stateDir);
const stateFiles = files.filter((f) => f.endsWith('.json'));
if (stateFiles.length === 0) {
return null;
}
// Load all states and find most recent valid one
const states = [];
for (const file of stateFiles) {
try {
const filePath = path.join(stateDir, file);
const state = await fs.readJson(filePath);
if (this._validateStateSchema(state) && this._isResumable(state)) {
const updatedAt = new Date(state.timestamps?.updatedAt || 0).getTime();
states.push({ file, state, updatedAt });
}
} catch {
// Skip invalid files
}
}
if (states.length === 0) {
return null;
}
// Sort by most recent
states.sort((a, b) => b.updatedAt - a.updatedAt);
this._log(`Found ${states.length} resumable state(s), using most recent`, {
storyId: states[0].state.storyId,
});
return states[0].state;
} catch (error) {
this._log(`Failed to find latest state: ${error.message}`, { level: 'warn' });
return null;
}
}
/**
* Validate state object against expected schema
* @private
*/
_validateStateSchema(state) {
if (!state || typeof state !== 'object') return false;
// Required fields
const requiredFields = ['workflowId', 'status', 'epics'];
for (const field of requiredFields) {
if (state[field] === undefined) {
return false;
}
}
// Validate epics structure
if (typeof state.epics !== 'object') return false;
return true;
}
/**
* Check if state is resumable (not complete, not too old)
* @private
*/
_isResumable(state) {
// Can't resume completed states
if (state.status === OrchestratorState.COMPLETE) {
return false;
}
// Check if state is not too old (24 hours)
const updatedAt = new Date(state.timestamps?.updatedAt || 0).getTime();
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
if (Date.now() - updatedAt > maxAge) {
return false;
}
return true;
}
/**
* Get list of failed epics
* @private
*/
_getFailedEpics() {
return Object.entries(this.executionState.epics)
.filter(([_, epic]) => epic.status === EpicStatus.FAILED)
.map(([num, _]) => parseInt(num, 10));
}
/**
* Get list of pending epics
* @private
*/
_getPendingEpics() {
return Object.entries(this.executionState.epics)
.filter(([_, epic]) => epic.status === EpicStatus.PENDING)
.map(([num, _]) => parseInt(num, 10));
}
/**
* Clear saved state for current story
* @returns {Promise<boolean>} Success status
*/
async clearState() {
try {
if (await fs.pathExists(this.statePath)) {
await fs.remove(this.statePath);
this._log('State cleared', { path: this.statePath });
return true;
}
} catch (error) {
this._log(`Failed to clear state: ${error.message}`, { level: 'warn' });
}
return false;
}
/**
* List all saved states
* @returns {Promise<Array>} List of state summaries
*/
async listSavedStates() {
const stateDir = path.join(this.projectRoot, '.aios', 'master-orchestrator');
try {
if (!(await fs.pathExists(stateDir))) {
return [];
}
const files = await fs.readdir(stateDir);
const states = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const filePath = path.join(stateDir, file);
const state = await fs.readJson(filePath);
states.push({
storyId: state.storyId || file.replace('.json', ''),
workflowId: state.workflowId,
status: state.status,
progress: this._calculateProgressFromState(state),
updatedAt: state.timestamps?.updatedAt,
resumable: this._isResumable(state),
});
} catch {
// Skip invalid files
}
}
return states.sort(
(a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime(),
);
} catch (error) {
this._log(`Failed to list states: ${error.message}`, { level: 'warn' });
return [];
}
}
/**
* Calculate progress percentage from a state object
* @private
*/
_calculateProgressFromState(state) {
if (!state.epics) return 0;
const totalEpics = Object.keys(EPIC_CONFIG).filter((num) => !EPIC_CONFIG[num].onDemand).length;
const completedEpics = Object.values(state.epics).filter(
(epic) => epic.status === EpicStatus.COMPLETED,
).length;
return Math.round((completedEpics / totalEpics) * 100);
}
// ═══════════════════════════════════════════════════════════════════════════════════
// FINALIZATION
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Finalize pipeline execution and generate summary
* @param {Object} pipelineResult - Raw pipeline result
* @returns {Object} Finalized result
*/
finalize(pipelineResult = {}) {
const duration =
pipelineResult.duration ||
(this.executionState.startedAt
? Date.now() - new Date(this.executionState.startedAt).getTime()
: 0);
const minutes = Math.floor(duration / 60000);
const seconds = Math.floor((duration % 60000) / 1000);
return {
workflowId: this.executionState.workflowId,
storyId: this.storyId,
status: this._state,
success: pipelineResult.success ?? this._state === OrchestratorState.COMPLETE,
duration: `${minutes}m ${seconds}s`,
durationMs: duration,
techStack: TechStackDetector.getSummary(this.executionState.techStackProfile || {}),
epics: {
executed: pipelineResult.epicsExecuted || [],
failed: pipelineResult.epicsFailed || [],
skipped: pipelineResult.epicsSkipped || [],
},
errors: this.executionState.errors,
insights: this.executionState.insights,
state: this.executionState,
};
}
// ═══════════════════════════════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Calculate overall progress percentage
* @returns {number} Progress 0-100
*/
getProgressPercentage() {
const totalEpics = Object.keys(EPIC_CONFIG).filter((num) => !EPIC_CONFIG[num].onDemand).length;
const completedEpics = Object.values(this.executionState.epics).filter(
(epic) => epic.status === EpicStatus.COMPLETED,
).length;
return Math.round((completedEpics / totalEpics) * 100);
}
/**
* Get current execution status summary
* @returns {Object} Status summary
*/
getStatus() {
return {
state: this._state,
storyId: this.storyId,
currentEpic: this.executionState.currentEpic,
progress: this.getProgressPercentage(),
epics: Object.fromEntries(
Object.entries(this.executionState.epics).map(([num, epic]) => [
num,
{ status: epic.status, attempts: epic.attempts },
]),
),
errors: this.executionState.errors.length,
};
}
// ═══════════════════════════════════════════════════════════════════════════════════
// LOGGING & CALLBACKS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Internal logging
* @private
*/
_log(message, options = {}) {
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
const level = options.level || 'info';
const icon = options.icon || '';
let coloredMessage;
switch (level) {
case 'error':
coloredMessage = chalk.red(message);
break;
case 'warn':
coloredMessage = chalk.yellow(message);
break;
case 'success':
coloredMessage = chalk.green(message);
break;
default:
coloredMessage = chalk.gray(message);
}
console.log(
`${chalk.dim(`[${timestamp}]`)} ${chalk.cyan('[MasterOrchestrator]')} ${icon} ${coloredMessage}`,
);
// Emit log event for external listeners
this.emit('log', { timestamp, level, message, ...options });
}
/**
* Default epic start callback
* @private
*/
_defaultEpicStart(epicNum, config) {
console.log(chalk.blue(`\n${config.icon} Starting Epic ${epicNum}: ${config.name}`));
console.log(chalk.gray(` ${config.description}`));
}
/**
* Default epic complete callback
* @private
*/
_defaultEpicComplete(epicNum, result) {
const status = result?.success !== false ? '✅' : '❌';
console.log(chalk.green(` ${status} Epic ${epicNum} complete`));
}
/**
* Default state change callback
* @private
*/
_defaultStateChange(from, to, _context) {
console.log(chalk.magenta(` 📊 State: ${from} → ${to}`));
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// STUB EXECUTOR (placeholder)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Stub Epic Executor - placeholder for Story 0.3
* Real executors will be implemented in Story 0.3
*/
class StubEpicExecutor {
constructor(orchestrator, epicNum) {
this.orchestrator = orchestrator;
this.epicNum = epicNum;
this.config = EPIC_CONFIG[epicNum];
}
async execute(_context) {
console.log(chalk.yellow(` ⚠️ Using stub executor for Epic ${this.epicNum}`));
console.log(chalk.gray(` Real executor (${this.config.executor}) not yet implemented`));
console.log(chalk.gray(' See Story 0.3: Epic Executors'));
// Return minimal success result for pipeline to continue
return {
status: 'stub',
epicNum: this.epicNum,
message: `Stub executor for ${this.config.name}`,
timestamp: new Date().toISOString(),
};
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════════════
module.exports = MasterOrchestrator;
module.exports.OrchestratorState = OrchestratorState;
module.exports.EpicStatus = EpicStatus;
module.exports.EPIC_CONFIG = EPIC_CONFIG;