UNPKG

aios-core

Version:

Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework

721 lines (623 loc) 24.4 kB
/** * Recovery Handler - Story 0.5 * * Epic: Epic 0 - ADE Master Orchestrator * * Manages automatic error recovery for the orchestration pipeline. * Integrates with stuck-detector, rollback-manager, and recovery-tracker. * * Features: * - AC1: handleEpicFailure(epicNum, error) method * - AC2: Recovery strategies: RETRY, ROLLBACK_AND_RETRY, SKIP, ESCALATE * - AC3: Integrates with stuck-detector for loop detection * - AC4: Integrates with rollback-manager for state rollback * - AC5: Max retries configurable (default: 3) * - AC6: Automatic escalation after max retries * - AC7: Detailed logs for each recovery attempt * * @module core/orchestration/recovery-handler * @version 1.0.0 */ const fs = require('fs-extra'); const path = require('path'); const EventEmitter = require('events'); // ═══════════════════════════════════════════════════════════════════════════════════ // RECOVERY STRATEGIES (AC2) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Recovery strategies enum */ const RecoveryStrategy = { /** Retry the same epic with same approach */ RETRY_SAME_APPROACH: 'retry_same_approach', /** Rollback and retry with different approach */ ROLLBACK_AND_RETRY: 'rollback_and_retry', /** Skip the failed phase and continue */ SKIP_PHASE: 'skip_phase', /** Escalate to human for manual intervention */ ESCALATE_TO_HUMAN: 'escalate_to_human', /** Trigger recovery workflow (Epic 5) */ TRIGGER_RECOVERY_WORKFLOW: 'trigger_recovery_workflow', }; /** * Recovery result enum */ const RecoveryResult = { SUCCESS: 'success', FAILED: 'failed', ESCALATED: 'escalated', SKIPPED: 'skipped', }; // ═══════════════════════════════════════════════════════════════════════════════════ // RECOVERY HANDLER CLASS // ═══════════════════════════════════════════════════════════════════════════════════ /** * RecoveryHandler - Manages automatic error recovery (AC1) */ class RecoveryHandler extends EventEmitter { /** * @param {Object} options - Configuration options * @param {string} options.projectRoot - Project root path * @param {string} options.storyId - Story ID * @param {number} [options.maxRetries=3] - Max retries per epic (AC5) * @param {boolean} [options.autoEscalate=true] - Auto-escalate after max retries (AC6) * @param {boolean} [options.circularDetection=true] - Enable circular approach detection * @param {Object} [options.orchestrator] - Parent orchestrator instance */ constructor(options = {}) { super(); this.projectRoot = options.projectRoot || process.cwd(); this.storyId = options.storyId; this.maxRetries = options.maxRetries ?? 3; // AC5 this.autoEscalate = options.autoEscalate ?? true; // AC6 this.circularDetection = options.circularDetection ?? true; this.orchestrator = options.orchestrator; // Track attempts and logs (AC7) this.attempts = {}; this.logs = []; // Lazy-load external recovery modules (AC3, AC4) this._stuckDetector = null; this._rollbackManager = null; this._recoveryTracker = null; } // ═══════════════════════════════════════════════════════════════════════════════════ // EXTERNAL MODULE INTEGRATION (AC3, AC4) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Get StuckDetector instance (AC3) * @private */ _getStuckDetector() { if (!this._stuckDetector) { try { const { StuckDetector } = require('../../infrastructure/scripts/stuck-detector'); this._stuckDetector = new StuckDetector({ maxAttempts: this.maxRetries, circularDetection: this.circularDetection, verbose: false, }); } catch (error) { this._log(`StuckDetector not available: ${error.message}`, 'warn'); } } return this._stuckDetector; } /** * Get RollbackManager instance (AC4) * @private */ _getRollbackManager() { if (!this._rollbackManager) { try { const { RollbackManager } = require('../../infrastructure/scripts/rollback-manager'); this._rollbackManager = new RollbackManager({ storyId: this.storyId, rootPath: this.projectRoot, }); } catch (error) { this._log(`RollbackManager not available: ${error.message}`, 'warn'); } } return this._rollbackManager; } /** * Get RecoveryTracker instance * @private */ _getRecoveryTracker() { if (!this._recoveryTracker) { try { const { RecoveryTracker } = require('../../infrastructure/scripts/recovery-tracker'); this._recoveryTracker = new RecoveryTracker({ storyId: this.storyId, rootPath: this.projectRoot, }); } catch (error) { this._log(`RecoveryTracker not available: ${error.message}`, 'warn'); } } return this._recoveryTracker; } // ═══════════════════════════════════════════════════════════════════════════════════ // MAIN RECOVERY HANDLER (AC1) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Handle epic failure with automatic recovery (AC1) * * @param {number} epicNum - Failed epic number * @param {Error|string} error - Error that caused failure * @param {Object} [context={}] - Additional context * @returns {Promise<Object>} Recovery result */ async handleEpicFailure(epicNum, error, context = {}) { const errorMessage = error instanceof Error ? error.message : String(error); const timestamp = new Date().toISOString(); this._log(`Handling failure for Epic ${epicNum}: ${errorMessage}`, 'error'); // Initialize attempts tracking for this epic if (!this.attempts[epicNum]) { this.attempts[epicNum] = []; } // Record this failure attempt (AC7) const attemptNum = this.attempts[epicNum].length + 1; const attempt = { number: attemptNum, timestamp, error: errorMessage, approach: context.approach || 'default', epicNum, context, }; this.attempts[epicNum].push(attempt); // Check if stuck using StuckDetector (AC3) const stuckResult = this._checkIfStuck(epicNum); // Select recovery strategy (AC2) const strategy = this._selectRecoveryStrategy(epicNum, error, stuckResult, context); this._log(`Selected strategy: ${strategy}`, 'info'); // Execute recovery based on strategy const result = await this._executeRecoveryStrategy(epicNum, strategy, error, context); // Record recovery result (AC7) attempt.recoveryStrategy = strategy; attempt.recoveryResult = result.success ? RecoveryResult.SUCCESS : RecoveryResult.FAILED; attempt.recoveryDetails = result; // Emit event this.emit('recoveryAttempt', { epicNum, attempt: attemptNum, strategy, result, }); return result; } /** * Check if execution is stuck (AC3) * @private */ _checkIfStuck(epicNum) { const detector = this._getStuckDetector(); if (!detector) { return { stuck: false, reason: null }; } const attempts = this.attempts[epicNum] || []; const formattedAttempts = attempts.map((a) => ({ success: false, approach: a.approach, error: a.error, timestamp: a.timestamp, })); return detector.check(formattedAttempts); } /** * Select recovery strategy based on error type and context (AC2) * @private */ _selectRecoveryStrategy(epicNum, error, stuckResult, _context = {}) { const attemptCount = (this.attempts[epicNum] || []).length; const errorMessage = error instanceof Error ? error.message : String(error); // AC6: Escalate after max retries if (attemptCount >= this.maxRetries && this.autoEscalate) { this._log(`Max retries (${this.maxRetries}) reached, escalating...`, 'warn'); return RecoveryStrategy.ESCALATE_TO_HUMAN; } // Circular approach detected - need different approach (AC3) if (stuckResult.stuck && stuckResult.reason?.includes('circular')) { this._log('Circular approach detected, triggering rollback...', 'warn'); return RecoveryStrategy.ROLLBACK_AND_RETRY; } // Too many consecutive failures - escalate (only if autoEscalate enabled) if ( stuckResult.stuck && stuckResult.context?.consecutiveFailures >= this.maxRetries && this.autoEscalate ) { this._log('Too many consecutive failures, escalating...', 'warn'); return RecoveryStrategy.ESCALATE_TO_HUMAN; } // Analyze error type to select strategy const errorType = this._classifyError(errorMessage); switch (errorType) { case 'transient': // Network, timeout errors - retry same approach return RecoveryStrategy.RETRY_SAME_APPROACH; case 'state': // State corruption, inconsistent data - rollback and retry return RecoveryStrategy.ROLLBACK_AND_RETRY; case 'configuration': // Missing config, env vars - skip if non-critical if (!this._isEpicCritical(epicNum)) { return RecoveryStrategy.SKIP_PHASE; } // For critical epics, escalate only if autoEscalate enabled return this.autoEscalate ? RecoveryStrategy.ESCALATE_TO_HUMAN : RecoveryStrategy.ROLLBACK_AND_RETRY; case 'dependency': // Missing deps, incompatible versions - trigger recovery workflow return RecoveryStrategy.TRIGGER_RECOVERY_WORKFLOW; case 'fatal': // Unrecoverable errors - escalate only if autoEscalate enabled return this.autoEscalate ? RecoveryStrategy.ESCALATE_TO_HUMAN : RecoveryStrategy.ROLLBACK_AND_RETRY; default: // First few attempts - retry if (attemptCount < 2) { return RecoveryStrategy.RETRY_SAME_APPROACH; } // After 2 failed attempts - try rollback if (attemptCount < this.maxRetries) { return RecoveryStrategy.ROLLBACK_AND_RETRY; } // Max attempts reached - escalate only if enabled return this.autoEscalate ? RecoveryStrategy.ESCALATE_TO_HUMAN : RecoveryStrategy.ROLLBACK_AND_RETRY; } } /** * Classify error type * @private */ _classifyError(errorMessage) { const lowerMessage = errorMessage.toLowerCase(); // Transient errors (network, timeout) if ( /timeout|econnrefused|etimedout|network|fetch.*failed|connection.*refused/.test(lowerMessage) ) { return 'transient'; } // State errors if (/state.*corrupt|inconsistent|invalid.*state|out.*of.*sync/.test(lowerMessage)) { return 'state'; } // Configuration errors if (/config.*missing|env.*not.*set|environment.*undefined|missing.*config/.test(lowerMessage)) { return 'configuration'; } // Dependency errors if ( /cannot.*find.*module|module.*not.*found|dependency|package.*not.*found/.test(lowerMessage) ) { return 'dependency'; } // Fatal errors if (/fatal|critical|unrecoverable|out.*of.*memory|heap.*overflow/.test(lowerMessage)) { return 'fatal'; } return 'unknown'; } /** * Check if epic is critical (cannot be skipped) * @private */ _isEpicCritical(epicNum) { // Epic 3 (Spec) and Epic 4 (Execution) are critical return [3, 4].includes(epicNum); } // ═══════════════════════════════════════════════════════════════════════════════════ // STRATEGY EXECUTION (AC2) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Execute recovery strategy (AC2) * @private */ async _executeRecoveryStrategy(epicNum, strategy, error, context) { const result = { epicNum, strategy, success: false, shouldRetry: false, newApproach: false, escalated: false, skipped: false, details: {}, }; try { switch (strategy) { case RecoveryStrategy.RETRY_SAME_APPROACH: result.shouldRetry = true; result.success = true; result.details.message = 'Retry with same approach'; this._log(`Will retry Epic ${epicNum} with same approach`, 'info'); break; case RecoveryStrategy.ROLLBACK_AND_RETRY: await this._executeRollback(epicNum, context); result.shouldRetry = true; result.newApproach = true; result.success = true; result.details.message = 'Rollback completed, retry with new approach'; this._log(`Rollback completed for Epic ${epicNum}, will retry with new approach`, 'info'); break; case RecoveryStrategy.SKIP_PHASE: result.skipped = true; result.success = true; result.details.message = `Epic ${epicNum} skipped due to non-critical failure`; this._log(`Skipping Epic ${epicNum}`, 'warn'); break; case RecoveryStrategy.ESCALATE_TO_HUMAN: await this._escalateToHuman(epicNum, error, context); result.escalated = true; result.success = false; result.details.message = 'Escalated to human for manual intervention'; this._log(`Escalated Epic ${epicNum} to human`, 'warn'); break; case RecoveryStrategy.TRIGGER_RECOVERY_WORKFLOW: { const recoveryResult = await this._triggerRecoveryWorkflow(epicNum, error, context); result.success = recoveryResult.success; result.shouldRetry = recoveryResult.shouldRetry; result.details = recoveryResult; break; } default: result.details.message = `Unknown strategy: ${strategy}`; this._log(`Unknown recovery strategy: ${strategy}`, 'error'); } } catch (recoveryError) { result.success = false; result.details.error = recoveryError.message; this._log(`Recovery failed: ${recoveryError.message}`, 'error'); } return result; } /** * Execute rollback (AC4) * @private */ async _executeRollback(epicNum, context) { const manager = this._getRollbackManager(); if (!manager) { this._log('RollbackManager not available, skipping rollback', 'warn'); return { success: false, reason: 'manager_unavailable' }; } try { // Get subtask ID from context or generate one const subtaskId = context.subtaskId || `epic-${epicNum}`; // Check if checkpoint exists const checkpoint = await manager.getCheckpoint(subtaskId); if (!checkpoint) { this._log(`No checkpoint found for ${subtaskId}, creating one now`, 'info'); await manager.saveCheckpoint(subtaskId, { files: context.affectedFiles || [], }); return { success: true, checkpointCreated: true }; } // Perform rollback const result = await manager.rollback(subtaskId, { hard: true, // Auto-confirm in autonomous mode reason: `Recovery from Epic ${epicNum} failure`, }); return result; } catch (error) { this._log(`Rollback failed: ${error.message}`, 'error'); return { success: false, error: error.message }; } } /** * Escalate to human (AC6) * @private */ async _escalateToHuman(epicNum, error, context) { const errorMessage = error instanceof Error ? error.message : String(error); // Generate escalation report const report = { timestamp: new Date().toISOString(), storyId: this.storyId, epicNum, epicName: this._getEpicName(epicNum), error: errorMessage, attempts: this.attempts[epicNum] || [], totalAttempts: (this.attempts[epicNum] || []).length, maxRetries: this.maxRetries, context, suggestions: this._generateSuggestions(epicNum, error), }; // Get suggestions from StuckDetector if available const detector = this._getStuckDetector(); if (detector) { const attempts = (this.attempts[epicNum] || []).map((a) => ({ success: false, approach: a.approach, error: a.error, })); const fullReport = detector.generateEscalationReport(`epic-${epicNum}`, attempts); report.stuckDetectorReport = fullReport; } // Save escalation report const reportPath = await this._saveEscalationReport(report); report.reportPath = reportPath; // Emit escalation event this.emit('escalation', report); this._log(`Escalation report saved to: ${reportPath}`, 'info'); return report; } /** * Generate suggestions for recovery * @private */ _generateSuggestions(epicNum, error) { const suggestions = []; const errorMessage = error instanceof Error ? error.message : String(error); const errorType = this._classifyError(errorMessage); switch (errorType) { case 'transient': suggestions.push('Check network connectivity'); suggestions.push('Verify external services are available'); suggestions.push('Wait and retry after a few minutes'); break; case 'state': suggestions.push('Check for conflicting changes'); suggestions.push('Verify state files are not corrupted'); suggestions.push('Consider starting fresh from last known good state'); break; case 'configuration': suggestions.push('Verify all required environment variables are set'); suggestions.push('Check configuration files for errors'); suggestions.push('Ensure .env file exists and is properly formatted'); break; case 'dependency': suggestions.push('Run npm install to ensure all dependencies are installed'); suggestions.push('Check package.json for correct versions'); suggestions.push('Try npm cache clean --force and reinstall'); break; default: suggestions.push('Review error logs for more details'); suggestions.push('Check recent code changes'); suggestions.push('Consider breaking the task into smaller steps'); } return suggestions; } /** * Trigger recovery workflow (Epic 5) * @private */ async _triggerRecoveryWorkflow(epicNum, error, context) { this._log(`Triggering recovery workflow for Epic ${epicNum}`, 'info'); // If orchestrator is available, use it to execute Epic 5 if (this.orchestrator && typeof this.orchestrator.executeEpic === 'function') { try { const result = await this.orchestrator.executeEpic(5, { failedEpic: epicNum, error, ...context, }); return { success: result.success, shouldRetry: result.shouldRetry ?? false, recoveryResult: result, }; } catch (error) { this._log(`Recovery workflow failed: ${error.message}`, 'error'); return { success: false, shouldRetry: false, error: error.message, }; } } // Fallback: manual recovery return { success: false, shouldRetry: false, message: 'Orchestrator not available for recovery workflow', }; } /** * Save escalation report to file * @private */ async _saveEscalationReport(report) { const reportsDir = path.join(this.projectRoot, '.aios', 'escalations'); await fs.ensureDir(reportsDir); const filename = `escalation-${this.storyId}-epic${report.epicNum}-${Date.now()}.json`; const reportPath = path.join(reportsDir, filename); await fs.writeJson(reportPath, report, { spaces: 2 }); return reportPath; } /** * Get epic name * @private */ _getEpicName(epicNum) { const names = { 3: 'Spec Pipeline', 4: 'Execution Engine', 5: 'Recovery System', 6: 'QA Loop', 7: 'Memory Layer', }; return names[epicNum] || `Epic ${epicNum}`; } // ═══════════════════════════════════════════════════════════════════════════════════ // LOGGING (AC7) // ═══════════════════════════════════════════════════════════════════════════════════ /** * Log message with timestamp (AC7) * @private */ _log(message, level = 'info') { const timestamp = new Date().toISOString(); const logEntry = { timestamp, level, message, }; this.logs.push(logEntry); // Also log to orchestrator if available if (this.orchestrator && typeof this.orchestrator._log === 'function') { this.orchestrator._log(`[Recovery] ${message}`, { level }); } } /** * Get all logs (AC7) */ getLogs() { return [...this.logs]; } /** * Get logs for specific epic */ getEpicLogs(epicNum) { return this.logs.filter( (log) => log.message.includes(`Epic ${epicNum}`) || log.message.includes(`epic-${epicNum}`), ); } /** * Get attempt history for all epics */ getAttemptHistory() { return { ...this.attempts }; } /** * Get attempt count for specific epic (AC5) */ getAttemptCount(epicNum) { return (this.attempts[epicNum] || []).length; } /** * Check if can retry (under max retries) (AC5) */ canRetry(epicNum) { return this.getAttemptCount(epicNum) < this.maxRetries; } /** * Reset attempts for an epic */ resetAttempts(epicNum) { this.attempts[epicNum] = []; this._log(`Reset attempts for Epic ${epicNum}`, 'info'); } /** * Clear all state */ clear() { this.attempts = {}; this.logs = []; } } // ═══════════════════════════════════════════════════════════════════════════════════ // EXPORTS // ═══════════════════════════════════════════════════════════════════════════════════ module.exports = { RecoveryHandler, RecoveryStrategy, RecoveryResult, };