/** * Auto Mode Service - Autonomous feature implementation using Claude Agent SDK * * Manages: * - Worktree creation for isolated development * - Feature execution with Claude * - Concurrent execution with max concurrency limits * - Progress streaming via events * - Verification and merge workflows */ import { ProviderFactory } from '../providers/provider-factory.js'; import { simpleQuery } from '../providers/simple-query-service.js'; import type { ExecuteOptions, Feature, ModelProvider, PipelineStep, FeatureStatusWithPipeline, PipelineConfig, ThinkingLevel, PlanningMode, } from '@automaker/types'; import { DEFAULT_PHASE_MODELS, DEFAULT_MAX_CONCURRENCY, isClaudeModel, stripProviderPrefix, } from '@automaker/types'; import { buildPromptWithImages, classifyError, loadContextFiles, appendLearning, recordMemoryUsage, createLogger, atomicWriteJson, readJsonWithRecovery, logRecoveryWarning, DEFAULT_BACKUP_COUNT, } from '@automaker/utils'; const logger = createLogger('AutoMode'); import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; import { getFeatureDir, getAutomakerDir, getFeaturesDir, getExecutionStatePath, ensureAutomakerDir, } from '@automaker/platform'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import * as secureFs from '../lib/secure-fs.js'; import type { EventEmitter } from '../lib/events.js'; import { createAutoModeOptions, createCustomOptions, validateWorkingDirectory, } from '../lib/sdk-options.js'; import { FeatureLoader } from './feature-loader.js'; import type { SettingsService } from './settings-service.js'; import { pipelineService, PipelineService } from './pipeline-service.js'; import { getAutoLoadClaudeMdSetting, filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, getProviderByModelId, getPhaseModelWithOverrides, } from '../lib/settings-helpers.js'; import { getNotificationService } from './notification-service.js'; const execAsync = promisify(exec); /** * Get the current branch name for a git repository * @param projectPath - Path to the git repository * @returns The current branch name, or null if not in a git repo or on detached HEAD */ async function getCurrentBranch(projectPath: string): Promise { try { const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath }); const branch = stdout.trim(); return branch || null; } catch { return null; } } // PlanningMode type is imported from @automaker/types interface ParsedTask { id: string; // e.g., "T001" description: string; // e.g., "Create user model" filePath?: string; // e.g., "src/models/user.ts" phase?: string; // e.g., "Phase 1: Foundation" (for full mode) status: 'pending' | 'in_progress' | 'completed' | 'failed'; } interface PlanSpec { status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected'; content?: string; version: number; generatedAt?: string; approvedAt?: string; reviewedByUser: boolean; tasksCompleted?: number; tasksTotal?: number; currentTaskId?: string; tasks?: ParsedTask[]; } /** * Information about pipeline status when resuming a feature. * Used to determine how to handle features stuck in pipeline execution. * * @property {boolean} isPipeline - Whether the feature is in a pipeline step * @property {string | null} stepId - ID of the current pipeline step (e.g., 'step_123') * @property {number} stepIndex - Index of the step in the sorted pipeline steps (-1 if not found) * @property {number} totalSteps - Total number of steps in the pipeline * @property {PipelineStep | null} step - The pipeline step configuration, or null if step not found * @property {PipelineConfig | null} config - The full pipeline configuration, or null if no pipeline */ interface PipelineStatusInfo { isPipeline: boolean; stepId: string | null; stepIndex: number; totalSteps: number; step: PipelineStep | null; config: PipelineConfig | null; } /** * Parse tasks from generated spec content * Looks for the ```tasks code block and extracts task lines * Format: - [ ] T###: Description | File: path/to/file */ function parseTasksFromSpec(specContent: string): ParsedTask[] { const tasks: ParsedTask[] = []; // Extract content within ```tasks ... ``` block const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/); if (!tasksBlockMatch) { // Try fallback: look for task lines anywhere in content const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm); if (!taskLines) { return tasks; } // Parse fallback task lines let currentPhase: string | undefined; for (const line of taskLines) { const parsed = parseTaskLine(line, currentPhase); if (parsed) { tasks.push(parsed); } } return tasks; } const tasksContent = tasksBlockMatch[1]; const lines = tasksContent.split('\n'); let currentPhase: string | undefined; for (const line of lines) { const trimmedLine = line.trim(); // Check for phase header (e.g., "## Phase 1: Foundation") const phaseMatch = trimmedLine.match(/^##\s*(.+)$/); if (phaseMatch) { currentPhase = phaseMatch[1].trim(); continue; } // Check for task line if (trimmedLine.startsWith('- [ ]')) { const parsed = parseTaskLine(trimmedLine, currentPhase); if (parsed) { tasks.push(parsed); } } } return tasks; } /** * Parse a single task line * Format: - [ ] T###: Description | File: path/to/file */ function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { // Match pattern: - [ ] T###: Description | File: path const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); if (!taskMatch) { // Try simpler pattern without file const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); if (simpleMatch) { return { id: simpleMatch[1], description: simpleMatch[2].trim(), phase: currentPhase, status: 'pending', }; } return null; } return { id: taskMatch[1], description: taskMatch[2].trim(), filePath: taskMatch[3]?.trim(), phase: currentPhase, status: 'pending', }; } // Feature type is imported from feature-loader.js // Extended type with planning fields for local use interface FeatureWithPlanning extends Feature { planningMode?: PlanningMode; planSpec?: PlanSpec; requirePlanApproval?: boolean; } interface RunningFeature { featureId: string; projectPath: string; worktreePath: string | null; branchName: string | null; abortController: AbortController; isAutoMode: boolean; startTime: number; leaseCount: number; model?: string; provider?: ModelProvider; } interface AutoLoopState { projectPath: string; maxConcurrency: number; abortController: AbortController; isRunning: boolean; } interface PendingApproval { resolve: (result: { approved: boolean; editedPlan?: string; feedback?: string }) => void; reject: (error: Error) => void; featureId: string; projectPath: string; } interface AutoModeConfig { maxConcurrency: number; useWorktrees: boolean; projectPath: string; branchName: string | null; // null = main worktree } /** * Generate a unique key for worktree-scoped auto loop state * @param projectPath - The project path * @param branchName - The branch name, or null for main worktree */ function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { const normalizedBranch = branchName === 'main' ? null : branchName; return `${projectPath}::${normalizedBranch ?? '__main__'}`; } /** * Per-worktree autoloop state for multi-project/worktree support */ interface ProjectAutoLoopState { abortController: AbortController; config: AutoModeConfig; isRunning: boolean; consecutiveFailures: { timestamp: number; error: string }[]; pausedDueToFailures: boolean; hasEmittedIdleEvent: boolean; branchName: string | null; // null = main worktree } /** * Execution state for recovery after server restart * Tracks which features were running and auto-loop configuration */ interface ExecutionState { version: 1; autoLoopWasRunning: boolean; maxConcurrency: number; projectPath: string; branchName: string | null; // null = main worktree runningFeatureIds: string[]; savedAt: string; } // Default empty execution state const DEFAULT_EXECUTION_STATE: ExecutionState = { version: 1, autoLoopWasRunning: false, maxConcurrency: DEFAULT_MAX_CONCURRENCY, projectPath: '', branchName: null, runningFeatureIds: [], savedAt: '', }; // Constants for consecutive failure tracking const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive export class AutoModeService { private events: EventEmitter; private runningFeatures = new Map(); private autoLoop: AutoLoopState | null = null; private featureLoader = new FeatureLoader(); // Per-project autoloop state (supports multiple concurrent projects) private autoLoopsByProject = new Map(); // Legacy single-project properties (kept for backward compatibility during transition) private autoLoopRunning = false; private autoLoopAbortController: AbortController | null = null; private config: AutoModeConfig | null = null; private pendingApprovals = new Map(); private settingsService: SettingsService | null = null; // Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject) private consecutiveFailures: { timestamp: number; error: string }[] = []; private pausedDueToFailures = false; // Track if idle event has been emitted (legacy, now per-project in autoLoopsByProject) private hasEmittedIdleEvent = false; constructor(events: EventEmitter, settingsService?: SettingsService) { this.events = events; this.settingsService = settingsService ?? null; } /** * Acquire a slot in the runningFeatures map for a feature. * Implements reference counting via leaseCount to support nested calls * (e.g., resumeFeature -> executeFeature). * * @param params.featureId - ID of the feature to track * @param params.projectPath - Path to the project * @param params.isAutoMode - Whether this is an auto-mode execution * @param params.allowReuse - If true, allows incrementing leaseCount for already-running features * @param params.abortController - Optional abort controller to use * @returns The RunningFeature entry (existing or newly created) * @throws Error if feature is already running and allowReuse is false */ private acquireRunningFeature(params: { featureId: string; projectPath: string; isAutoMode: boolean; allowReuse?: boolean; abortController?: AbortController; }): RunningFeature { const existing = this.runningFeatures.get(params.featureId); if (existing) { if (!params.allowReuse) { throw new Error('already running'); } existing.leaseCount += 1; return existing; } const abortController = params.abortController ?? new AbortController(); const entry: RunningFeature = { featureId: params.featureId, projectPath: params.projectPath, worktreePath: null, branchName: null, abortController, isAutoMode: params.isAutoMode, startTime: Date.now(), leaseCount: 1, }; this.runningFeatures.set(params.featureId, entry); return entry; } /** * Release a slot in the runningFeatures map for a feature. * Decrements leaseCount and only removes the entry when it reaches zero, * unless force option is used. * * @param featureId - ID of the feature to release * @param options.force - If true, immediately removes the entry regardless of leaseCount */ private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void { const entry = this.runningFeatures.get(featureId); if (!entry) { return; } if (options?.force) { this.runningFeatures.delete(featureId); return; } entry.leaseCount -= 1; if (entry.leaseCount <= 0) { this.runningFeatures.delete(featureId); } } /** * Track a failure and check if we should pause due to consecutive failures. * This handles cases where the SDK doesn't return useful error messages. * @param projectPath - The project to track failure for * @param errorInfo - Error information */ private trackFailureAndCheckPauseForProject( projectPath: string, errorInfo: { type: string; message: string } ): boolean { const projectState = this.autoLoopsByProject.get(projectPath); if (!projectState) { // Fall back to legacy global tracking return this.trackFailureAndCheckPause(errorInfo); } const now = Date.now(); // Add this failure projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message }); // Remove old failures outside the window projectState.consecutiveFailures = projectState.consecutiveFailures.filter( (f) => now - f.timestamp < FAILURE_WINDOW_MS ); // Check if we've hit the threshold if (projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) { return true; // Should pause } // Also immediately pause for known quota/rate limit errors if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') { return true; } return false; } /** * Track a failure and check if we should pause due to consecutive failures (legacy global). */ private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean { const now = Date.now(); // Add this failure this.consecutiveFailures.push({ timestamp: now, error: errorInfo.message }); // Remove old failures outside the window this.consecutiveFailures = this.consecutiveFailures.filter( (f) => now - f.timestamp < FAILURE_WINDOW_MS ); // Check if we've hit the threshold if (this.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) { return true; // Should pause } // Also immediately pause for known quota/rate limit errors if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') { return true; } return false; } /** * Signal that we should pause due to repeated failures or quota exhaustion. * This will pause the auto loop for a specific project. * @param projectPath - The project to pause * @param errorInfo - Error information */ private signalShouldPauseForProject( projectPath: string, errorInfo: { type: string; message: string } ): void { const projectState = this.autoLoopsByProject.get(projectPath); if (!projectState) { // Fall back to legacy global pause this.signalShouldPause(errorInfo); return; } if (projectState.pausedDueToFailures) { return; // Already paused } projectState.pausedDueToFailures = true; const failureCount = projectState.consecutiveFailures.length; logger.info( `Pausing auto loop for ${projectPath} after ${failureCount} consecutive failures. Last error: ${errorInfo.type}` ); // Emit event to notify UI this.emitAutoModeEvent('auto_mode_paused_failures', { message: failureCount >= CONSECUTIVE_FAILURE_THRESHOLD ? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.` : 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.', errorType: errorInfo.type, originalError: errorInfo.message, failureCount, projectPath, }); // Stop the auto loop for this project this.stopAutoLoopForProject(projectPath); } /** * Signal that we should pause due to repeated failures or quota exhaustion (legacy global). */ private signalShouldPause(errorInfo: { type: string; message: string }): void { if (this.pausedDueToFailures) { return; // Already paused } this.pausedDueToFailures = true; const failureCount = this.consecutiveFailures.length; logger.info( `Pausing auto loop after ${failureCount} consecutive failures. Last error: ${errorInfo.type}` ); // Emit event to notify UI this.emitAutoModeEvent('auto_mode_paused_failures', { message: failureCount >= CONSECUTIVE_FAILURE_THRESHOLD ? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.` : 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.', errorType: errorInfo.type, originalError: errorInfo.message, failureCount, projectPath: this.config?.projectPath, }); // Stop the auto loop this.stopAutoLoop(); } /** * Reset failure tracking for a specific project * @param projectPath - The project to reset failure tracking for */ private resetFailureTrackingForProject(projectPath: string): void { const projectState = this.autoLoopsByProject.get(projectPath); if (projectState) { projectState.consecutiveFailures = []; projectState.pausedDueToFailures = false; } } /** * Reset failure tracking (called when user manually restarts auto mode) - legacy global */ private resetFailureTracking(): void { this.consecutiveFailures = []; this.pausedDueToFailures = false; } /** * Record a successful feature completion to reset consecutive failure count for a project * @param projectPath - The project to record success for */ private recordSuccessForProject(projectPath: string): void { const projectState = this.autoLoopsByProject.get(projectPath); if (projectState) { projectState.consecutiveFailures = []; } } /** * Record a successful feature completion to reset consecutive failure count - legacy global */ private recordSuccess(): void { this.consecutiveFailures = []; } private async resolveMaxConcurrency( projectPath: string, branchName: string | null, provided?: number ): Promise { if (typeof provided === 'number' && Number.isFinite(provided)) { return provided; } if (!this.settingsService) { return DEFAULT_MAX_CONCURRENCY; } try { const settings = await this.settingsService.getGlobalSettings(); const globalMax = typeof settings.maxConcurrency === 'number' ? settings.maxConcurrency : DEFAULT_MAX_CONCURRENCY; const projectId = settings.projects?.find((project) => project.path === projectPath)?.id; const autoModeByWorktree = settings.autoModeByWorktree; if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { // Normalize branch name to match UI convention: // - null or "main" -> "__main__" (UI treats "main" as the main worktree) // This ensures consistency with how the UI stores worktree settings const normalizedBranch = branchName === 'main' ? null : branchName; const key = `${projectId}::${normalizedBranch ?? '__main__'}`; const entry = autoModeByWorktree[key]; if (entry && typeof entry.maxConcurrency === 'number') { return entry.maxConcurrency; } } return globalMax; } catch { return DEFAULT_MAX_CONCURRENCY; } } /** * Start the auto mode loop for a specific project/worktree (supports multiple concurrent projects and worktrees) * @param projectPath - The project to start auto mode for * @param branchName - The branch name for worktree scoping, null for main worktree * @param maxConcurrency - Maximum concurrent features (default: DEFAULT_MAX_CONCURRENCY) */ async startAutoLoopForProject( projectPath: string, branchName: string | null = null, maxConcurrency?: number ): Promise { const resolvedMaxConcurrency = await this.resolveMaxConcurrency( projectPath, branchName, maxConcurrency ); // Use worktree-scoped key const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); // Check if this project/worktree already has an active autoloop const existingState = this.autoLoopsByProject.get(worktreeKey); if (existingState?.isRunning) { const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; throw new Error( `Auto mode is already running for ${worktreeDesc} in project: ${projectPath}` ); } // Create new project/worktree autoloop state const abortController = new AbortController(); const config: AutoModeConfig = { maxConcurrency: resolvedMaxConcurrency, useWorktrees: true, projectPath, branchName, }; const projectState: ProjectAutoLoopState = { abortController, config, isRunning: true, consecutiveFailures: [], pausedDueToFailures: false, hasEmittedIdleEvent: false, branchName, }; this.autoLoopsByProject.set(worktreeKey, projectState); const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( `Starting auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}` ); this.emitAutoModeEvent('auto_mode_started', { message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, projectPath, branchName, maxConcurrency: resolvedMaxConcurrency, }); // Save execution state for recovery after restart await this.saveExecutionStateForProject(projectPath, branchName, resolvedMaxConcurrency); // Run the loop in the background this.runAutoLoopForProject(worktreeKey).catch((error) => { const worktreeDescErr = branchName ? `worktree ${branchName}` : 'main worktree'; logger.error(`Loop error for ${worktreeDescErr} in ${projectPath}:`, error); const errorInfo = classifyError(error); this.emitAutoModeEvent('auto_mode_error', { error: errorInfo.message, errorType: errorInfo.type, projectPath, branchName, }); }); return resolvedMaxConcurrency; } /** * Run the auto loop for a specific project/worktree * @param worktreeKey - The worktree key (projectPath::branchName or projectPath::__main__) */ private async runAutoLoopForProject(worktreeKey: string): Promise { const projectState = this.autoLoopsByProject.get(worktreeKey); if (!projectState) { logger.warn(`No project state found for ${worktreeKey}, stopping loop`); return; } const { projectPath, branchName } = projectState.config; const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( `[AutoLoop] Starting loop for ${worktreeDesc} in ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}` ); let iterationCount = 0; while (projectState.isRunning && !projectState.abortController.signal.aborted) { iterationCount++; try { // Count running features for THIS project/worktree only const projectRunningCount = await this.getRunningCountForWorktree(projectPath, branchName); // Check if we have capacity for this project/worktree if (projectRunningCount >= projectState.config.maxConcurrency) { logger.debug( `[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...` ); await this.sleep(5000); continue; } // Load pending features for this project/worktree const pendingFeatures = await this.loadPendingFeatures(projectPath, branchName); logger.info( `[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount}/${projectState.config.maxConcurrency} running for ${worktreeDesc}` ); if (pendingFeatures.length === 0) { // Emit idle event only once when backlog is empty AND no features are running if (projectRunningCount === 0 && !projectState.hasEmittedIdleEvent) { this.emitAutoModeEvent('auto_mode_idle', { message: 'No pending features - auto mode idle', projectPath, branchName, }); projectState.hasEmittedIdleEvent = true; logger.info(`[AutoLoop] Backlog complete, auto mode now idle for ${worktreeDesc}`); } else if (projectRunningCount > 0) { logger.info( `[AutoLoop] No pending features available, ${projectRunningCount} still running, waiting...` ); } else { logger.warn( `[AutoLoop] No pending features found for ${worktreeDesc} (branchName: ${branchName === null ? 'null (main)' : branchName}). Check server logs for filtering details.` ); } await this.sleep(10000); continue; } // Find a feature not currently running and not yet finished const nextFeature = pendingFeatures.find( (f) => !this.runningFeatures.has(f.id) && !this.isFeatureFinished(f) ); if (nextFeature) { logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`); // Reset idle event flag since we're doing work again projectState.hasEmittedIdleEvent = false; // Start feature execution in background this.executeFeature( projectPath, nextFeature.id, projectState.config.useWorktrees, true ).catch((error) => { logger.error(`Feature ${nextFeature.id} error:`, error); }); } else { logger.debug(`[AutoLoop] All pending features are already running`); } await this.sleep(2000); } catch (error) { logger.error(`[AutoLoop] Loop iteration error for ${projectPath}:`, error); await this.sleep(5000); } } // Mark as not running when loop exits projectState.isRunning = false; logger.info( `[AutoLoop] Loop stopped for project: ${projectPath} after ${iterationCount} iterations` ); } /** * Get count of running features for a specific project */ private getRunningCountForProject(projectPath: string): number { let count = 0; for (const [, feature] of this.runningFeatures) { if (feature.projectPath === projectPath) { count++; } } return count; } /** * Get count of running features for a specific worktree * @param projectPath - The project path * @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch) */ private async getRunningCountForWorktree( projectPath: string, branchName: string | null ): Promise { // Get the actual primary branch name for the project const primaryBranch = await getCurrentBranch(projectPath); let count = 0; for (const [, feature] of this.runningFeatures) { // Filter by project path AND branchName to get accurate worktree-specific count const featureBranch = feature.branchName ?? null; if (branchName === null) { // Main worktree: match features with branchName === null OR branchName matching primary branch const isPrimaryBranch = featureBranch === null || (primaryBranch && featureBranch === primaryBranch); if (feature.projectPath === projectPath && isPrimaryBranch) { count++; } } else { // Feature worktree: exact match if (feature.projectPath === projectPath && featureBranch === branchName) { count++; } } } return count; } /** * Stop the auto mode loop for a specific project/worktree * @param projectPath - The project to stop auto mode for * @param branchName - The branch name, or null for main worktree */ async stopAutoLoopForProject( projectPath: string, branchName: string | null = null ): Promise { const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); const projectState = this.autoLoopsByProject.get(worktreeKey); if (!projectState) { const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.warn(`No auto loop running for ${worktreeDesc} in project: ${projectPath}`); return 0; } const wasRunning = projectState.isRunning; projectState.isRunning = false; projectState.abortController.abort(); // Clear execution state when auto-loop is explicitly stopped await this.clearExecutionState(projectPath, branchName); // Emit stop event if (wasRunning) { this.emitAutoModeEvent('auto_mode_stopped', { message: 'Auto mode stopped', projectPath, branchName, }); } // Remove from map this.autoLoopsByProject.delete(worktreeKey); return await this.getRunningCountForWorktree(projectPath, branchName); } /** * Check if auto mode is running for a specific project/worktree * @param projectPath - The project path * @param branchName - The branch name, or null for main worktree */ isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean { const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); const projectState = this.autoLoopsByProject.get(worktreeKey); return projectState?.isRunning ?? false; } /** * Get auto loop config for a specific project/worktree * @param projectPath - The project path * @param branchName - The branch name, or null for main worktree */ getAutoLoopConfigForProject( projectPath: string, branchName: string | null = null ): AutoModeConfig | null { const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); const projectState = this.autoLoopsByProject.get(worktreeKey); return projectState?.config ?? null; } /** * Save execution state for a specific project/worktree * @param projectPath - The project path * @param branchName - The branch name, or null for main worktree * @param maxConcurrency - Maximum concurrent features */ private async saveExecutionStateForProject( projectPath: string, branchName: string | null, maxConcurrency: number ): Promise { try { await ensureAutomakerDir(projectPath); const statePath = getExecutionStatePath(projectPath); const runningFeatureIds = Array.from(this.runningFeatures.entries()) .filter(([, f]) => f.projectPath === projectPath) .map(([id]) => id); const state: ExecutionState = { version: 1, autoLoopWasRunning: true, maxConcurrency, projectPath, branchName, runningFeatureIds, savedAt: new Date().toISOString(), }; await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( `Saved execution state for ${worktreeDesc} in ${projectPath}: ${runningFeatureIds.length} running features` ); } catch (error) { const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.error(`Failed to save execution state for ${worktreeDesc} in ${projectPath}:`, error); } } /** * Start the auto mode loop - continuously picks and executes pending features * @deprecated Use startAutoLoopForProject instead for multi-project support */ async startAutoLoop( projectPath: string, maxConcurrency = DEFAULT_MAX_CONCURRENCY ): Promise { // For backward compatibility, delegate to the new per-project method // But also maintain legacy state for existing code that might check it if (this.autoLoopRunning) { throw new Error('Auto mode is already running'); } // Reset failure tracking when user manually starts auto mode this.resetFailureTracking(); this.autoLoopRunning = true; this.autoLoopAbortController = new AbortController(); this.config = { maxConcurrency, useWorktrees: true, projectPath, branchName: null, }; this.emitAutoModeEvent('auto_mode_started', { message: `Auto mode started with max ${maxConcurrency} concurrent features`, projectPath, }); // Save execution state for recovery after restart await this.saveExecutionState(projectPath); // Note: Memory folder initialization is now handled by loadContextFiles // Run the loop in the background this.runAutoLoop().catch((error) => { logger.error('Loop error:', error); const errorInfo = classifyError(error); this.emitAutoModeEvent('auto_mode_error', { error: errorInfo.message, errorType: errorInfo.type, projectPath, }); }); } /** * @deprecated Use runAutoLoopForProject instead */ private async runAutoLoop(): Promise { while ( this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted ) { try { // Check if we have capacity if (this.runningFeatures.size >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) { await this.sleep(5000); continue; } // Load pending features const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); if (pendingFeatures.length === 0) { // Emit idle event only once when backlog is empty AND no features are running const runningCount = this.runningFeatures.size; if (runningCount === 0 && !this.hasEmittedIdleEvent) { this.emitAutoModeEvent('auto_mode_idle', { message: 'No pending features - auto mode idle', projectPath: this.config!.projectPath, }); this.hasEmittedIdleEvent = true; logger.info(`[AutoLoop] Backlog complete, auto mode now idle`); } else if (runningCount > 0) { logger.debug( `[AutoLoop] No pending features, ${runningCount} still running, waiting...` ); } else { logger.debug(`[AutoLoop] No pending features, waiting for new items...`); } await this.sleep(10000); continue; } // Find a feature not currently running const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); if (nextFeature) { // Reset idle event flag since we're doing work again this.hasEmittedIdleEvent = false; // Start feature execution in background this.executeFeature( this.config!.projectPath, nextFeature.id, this.config!.useWorktrees, true ).catch((error) => { logger.error(`Feature ${nextFeature.id} error:`, error); }); } await this.sleep(2000); } catch (error) { logger.error('Loop iteration error:', error); await this.sleep(5000); } } this.autoLoopRunning = false; } /** * Stop the auto mode loop * @deprecated Use stopAutoLoopForProject instead for multi-project support */ async stopAutoLoop(): Promise { const wasRunning = this.autoLoopRunning; const projectPath = this.config?.projectPath; this.autoLoopRunning = false; if (this.autoLoopAbortController) { this.autoLoopAbortController.abort(); this.autoLoopAbortController = null; } // Clear execution state when auto-loop is explicitly stopped if (projectPath) { await this.clearExecutionState(projectPath); } // Emit stop event immediately when user explicitly stops if (wasRunning) { this.emitAutoModeEvent('auto_mode_stopped', { message: 'Auto mode stopped', projectPath, }); } return this.runningFeatures.size; } /** * Check if there's capacity to start a feature on a worktree. * This respects per-worktree agent limits from autoModeByWorktree settings. * * @param projectPath - The main project path * @param featureId - The feature ID to check capacity for * @returns Object with hasCapacity boolean and details about current/max agents */ async checkWorktreeCapacity( projectPath: string, featureId: string ): Promise<{ hasCapacity: boolean; currentAgents: number; maxAgents: number; branchName: string | null; }> { // Load feature to get branchName const feature = await this.loadFeature(projectPath, featureId); const rawBranchName = feature?.branchName ?? null; // Normalize "main" to null to match UI convention for main worktree const branchName = rawBranchName === 'main' ? null : rawBranchName; // Get per-worktree limit const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName); // Get current running count for this worktree const currentAgents = await this.getRunningCountForWorktree(projectPath, branchName); return { hasCapacity: currentAgents < maxAgents, currentAgents, maxAgents, branchName, }; } /** * Execute a single feature * @param projectPath - The main project path * @param featureId - The feature ID to execute * @param useWorktrees - Whether to use worktrees for isolation * @param isAutoMode - Whether this is running in auto mode */ async executeFeature( projectPath: string, featureId: string, useWorktrees = false, isAutoMode = false, providedWorktreePath?: string, options?: { continuationPrompt?: string; /** Internal flag: set to true when called from a method that already tracks the feature */ _calledInternally?: boolean; } ): Promise { const tempRunningFeature = this.acquireRunningFeature({ featureId, projectPath, isAutoMode, allowReuse: options?._calledInternally, }); const abortController = tempRunningFeature.abortController; // Save execution state when feature starts if (isAutoMode) { await this.saveExecutionState(projectPath); } // Declare feature outside try block so it's available in catch for error reporting let feature: Awaited> | null = null; try { // Validate that project path is allowed using centralized validation validateWorkingDirectory(projectPath); // Load feature details FIRST to get status and plan info feature = await this.loadFeature(projectPath, featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } // Check if feature has existing context - if so, resume instead of starting fresh // Skip this check if we're already being called with a continuation prompt (from resumeFeature) if (!options?.continuationPrompt) { // If feature has an approved plan but we don't have a continuation prompt yet, // we should build one to ensure it proceeds with multi-agent execution if (feature.planSpec?.status === 'approved') { logger.info(`Feature ${featureId} has approved plan, building continuation prompt`); // Get customized prompts from settings const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); const planContent = feature.planSpec.content || ''; // Build continuation prompt using centralized template let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, ''); continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); // Recursively call executeFeature with the continuation prompt // Feature is already tracked, the recursive call will reuse the entry return await this.executeFeature( projectPath, featureId, useWorktrees, isAutoMode, providedWorktreePath, { continuationPrompt, _calledInternally: true, } ); } const hasExistingContext = await this.contextExists(projectPath, featureId); if (hasExistingContext) { logger.info( `Feature ${featureId} has existing context, resuming instead of starting fresh` ); // Feature is already tracked, resumeFeature will reuse the entry return await this.resumeFeature(projectPath, featureId, useWorktrees, true); } } // Derive workDir from feature.branchName // Worktrees should already be created when the feature is added/edited let worktreePath: string | null = null; const branchName = feature.branchName; if (useWorktrees && branchName) { // Try to find existing worktree for this branch // Worktree should already exist (created when feature was added/edited) worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); if (worktreePath) { logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); } else { // Worktree doesn't exist - log warning and continue with project path logger.warn(`Worktree for branch "${branchName}" not found, using project path`); } } // Ensure workDir is always an absolute path for cross-platform compatibility const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); // Validate that working directory is allowed using centralized validation validateWorkingDirectory(workDir); // Update running feature with actual worktree info tempRunningFeature.worktreePath = worktreePath; tempRunningFeature.branchName = branchName ?? null; // Update feature status to in_progress BEFORE emitting event // This ensures the frontend sees the updated status when it reloads features await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); // Emit feature start event AFTER status update so frontend sees correct status this.emitAutoModeEvent('auto_mode_feature_start', { featureId, projectPath, branchName: feature.branchName ?? null, feature: { id: featureId, title: feature.title || 'Loading...', description: feature.description || 'Feature is starting', }, }); // Load autoLoadClaudeMd setting to determine context loading strategy const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( projectPath, this.settingsService, '[AutoMode]' ); // Get customized prompts from settings const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); // Build the prompt - use continuation prompt if provided (for recovery after plan approval) let prompt: string; // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Context loader uses task context to select relevant memory files const contextResult = await loadContextFiles({ projectPath, fsModule: secureFs as Parameters[0]['fsModule'], taskContext: { title: feature.title ?? '', description: feature.description ?? '', }, }); // When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication // (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md // Note: contextResult.formattedPrompt now includes both context AND memory const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); if (options?.continuationPrompt) { // Continuation prompt is used when recovering from a plan approval // The plan was already approved, so skip the planning phase prompt = options.continuationPrompt; logger.info(`Using continuation prompt for feature ${featureId}`); } else { // Normal flow: build prompt with planning phase const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution); const planningPrefix = await this.getPlanningPromptPrefix(feature); prompt = planningPrefix + featurePrompt; // Emit planning mode info if (feature.planningMode && feature.planningMode !== 'skip') { this.emitAutoModeEvent('planning_started', { featureId: feature.id, mode: feature.planningMode, message: `Starting ${feature.planningMode} planning phase`, }); } } // Extract image paths from feature const imagePaths = feature.imagePaths?.map((img) => typeof img === 'string' ? img : img.path ); // Get model from feature and determine provider const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); const provider = ProviderFactory.getProviderNameForModel(model); logger.info( `Executing feature ${featureId} with model: ${model}, provider: ${provider} in ${workDir}` ); // Store model and provider in running feature for tracking tempRunningFeature.model = model; tempRunningFeature.provider = provider; // Run the agent with the feature's model and images // Context files are passed as system prompt for higher priority await this.runAgent( workDir, featureId, prompt, abortController, projectPath, imagePaths, model, { projectPath, planningMode: feature.planningMode, requirePlanApproval: feature.requirePlanApproval, systemPrompt: combinedSystemPrompt || undefined, autoLoadClaudeMd, thinkingLevel: feature.thinkingLevel, branchName: feature.branchName ?? null, } ); // Check for pipeline steps and execute them const pipelineConfig = await pipelineService.getPipelineConfig(projectPath); // Filter out excluded pipeline steps and sort by order const excludedStepIds = new Set(feature.excludedPipelineSteps || []); const sortedSteps = [...(pipelineConfig?.steps || [])] .sort((a, b) => a.order - b.order) .filter((step) => !excludedStepIds.has(step.id)); if (sortedSteps.length > 0) { // Execute pipeline steps sequentially await this.executePipelineSteps( projectPath, featureId, feature, sortedSteps, workDir, abortController, autoLoadClaudeMd ); } // Determine final status based on testing mode: // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) // - skipTests=true (manual verification): go to 'waiting_approval' for manual review const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); // Record success to reset consecutive failure tracking this.recordSuccess(); // Record learnings and memory usage after successful feature completion try { const featureDir = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDir, 'agent-output.md'); let agentOutput = ''; try { const outputContent = await secureFs.readFile(outputPath, 'utf-8'); agentOutput = typeof outputContent === 'string' ? outputContent : outputContent.toString(); } catch { // Agent output might not exist yet } // Record memory usage if we loaded any memory files if (contextResult.memoryFiles.length > 0 && agentOutput) { await recordMemoryUsage( projectPath, contextResult.memoryFiles, agentOutput, true, // success secureFs as Parameters[4] ); } // Extract and record learnings from the agent output await this.recordLearningsFromFeature(projectPath, feature, agentOutput); } catch (learningError) { console.warn('[AutoMode] Failed to record learnings:', learningError); } this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, branchName: feature.branchName ?? null, passes: true, message: `Feature completed in ${Math.round( (Date.now() - tempRunningFeature.startTime) / 1000 )}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`, projectPath, model: tempRunningFeature.model, provider: tempRunningFeature.provider, }); } catch (error) { const errorInfo = classifyError(error); if (errorInfo.isAbort) { this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature?.title, branchName: feature?.branchName ?? null, passes: false, message: 'Feature stopped by user', projectPath, }); } else { logger.error(`Feature ${featureId} failed:`, error); await this.updateFeatureStatus(projectPath, featureId, 'backlog'); this.emitAutoModeEvent('auto_mode_error', { featureId, featureName: feature?.title, branchName: feature?.branchName ?? null, error: errorInfo.message, errorType: errorInfo.type, projectPath, }); // Track this failure and check if we should pause auto mode // This handles both specific quota/rate limit errors AND generic failures // that may indicate quota exhaustion (SDK doesn't always return useful errors) const shouldPause = this.trackFailureAndCheckPause({ type: errorInfo.type, message: errorInfo.message, }); if (shouldPause) { this.signalShouldPause({ type: errorInfo.type, message: errorInfo.message, }); } } } finally { logger.info(`Feature ${featureId} execution ended, cleaning up runningFeatures`); logger.info( `Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); this.releaseRunningFeature(featureId); // Update execution state after feature completes if (this.autoLoopRunning && projectPath) { await this.saveExecutionState(projectPath); } } } /** * Execute pipeline steps sequentially after initial feature implementation */ private async executePipelineSteps( projectPath: string, featureId: string, feature: Feature, steps: PipelineStep[], workDir: string, abortController: AbortController, autoLoadClaudeMd: boolean ): Promise { logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`); // Get customized prompts from settings const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); // Load context files once with feature context for smart memory selection const contextResult = await loadContextFiles({ projectPath, fsModule: secureFs as Parameters[0]['fsModule'], taskContext: { title: feature.title ?? '', description: feature.description ?? '', }, }); const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); // Load previous agent output for context continuity const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, 'agent-output.md'); let previousContext = ''; try { previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; } catch { // No previous context } for (let i = 0; i < steps.length; i++) { const step = steps[i]; const pipelineStatus = `pipeline_${step.id}`; // Update feature status to current pipeline step await this.updateFeatureStatus(projectPath, featureId, pipelineStatus); this.emitAutoModeEvent('auto_mode_progress', { featureId, branchName: feature.branchName ?? null, content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`, projectPath, }); this.emitAutoModeEvent('pipeline_step_started', { featureId, stepId: step.id, stepName: step.name, stepIndex: i, totalSteps: steps.length, projectPath, }); // Build prompt for this pipeline step const prompt = this.buildPipelineStepPrompt( step, feature, previousContext, prompts.taskExecution ); // Get model from feature const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); // Run the agent for this pipeline step await this.runAgent( workDir, featureId, prompt, abortController, projectPath, undefined, // no images for pipeline steps model, { projectPath, planningMode: 'skip', // Pipeline steps don't need planning requirePlanApproval: false, previousContent: previousContext, systemPrompt: contextFilesPrompt || undefined, autoLoadClaudeMd, thinkingLevel: feature.thinkingLevel, } ); // Load updated context for next step try { previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; } catch { // No context update } this.emitAutoModeEvent('pipeline_step_complete', { featureId, stepId: step.id, stepName: step.name, stepIndex: i, totalSteps: steps.length, projectPath, }); logger.info( `Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}` ); } logger.info(`All pipeline steps completed for feature ${featureId}`); } /** * Build the prompt for a pipeline step */ private buildPipelineStepPrompt( step: PipelineStep, feature: Feature, previousContext: string, taskExecutionPrompts: { implementationInstructions: string; playwrightVerificationInstructions: string; } ): string { let prompt = `## Pipeline Step: ${step.name} This is an automated pipeline step following the initial feature implementation. ### Feature Context ${this.buildFeaturePrompt(feature, taskExecutionPrompts)} `; if (previousContext) { prompt += `### Previous Work The following is the output from the previous work on this feature: ${previousContext} `; } prompt += `### Pipeline Step Instructions ${step.instructions} ### Task Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`; return prompt; } /** * Stop a specific feature */ async stopFeature(featureId: string): Promise { const running = this.runningFeatures.get(featureId); if (!running) { return false; } // Cancel any pending plan approval for this feature this.cancelPlanApproval(featureId); running.abortController.abort(); // Remove from running features immediately to allow resume // The abort signal will still propagate to stop any ongoing execution this.releaseRunningFeature(featureId, { force: true }); return true; } /** * Resume a feature (continues from saved context or starts fresh if no context) * * This method handles interrupted features regardless of whether they have saved context: * - With context: Continues from where the agent left off using the saved agent-output.md * - Without context: Starts fresh execution (feature was interrupted before any agent output) * - Pipeline features: Delegates to resumePipelineFeature for specialized handling * * @param projectPath - Path to the project * @param featureId - ID of the feature to resume * @param useWorktrees - Whether to use git worktrees for isolation * @param _calledInternally - Internal flag to prevent double-tracking when called from other methods */ async resumeFeature( projectPath: string, featureId: string, useWorktrees = false, /** Internal flag: set to true when called from a method that already tracks the feature */ _calledInternally = false ): Promise { // Idempotent check: if feature is already being resumed/running, skip silently // This prevents race conditions when multiple callers try to resume the same feature if (!_calledInternally && this.isFeatureRunning(featureId)) { logger.info( `[AutoMode] Feature ${featureId} is already being resumed/running, skipping duplicate resume request` ); return; } this.acquireRunningFeature({ featureId, projectPath, isAutoMode: false, allowReuse: _calledInternally, }); try { // Load feature to check status const feature = await this.loadFeature(projectPath, featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } logger.info( `[AutoMode] Resuming feature ${featureId} (${feature.title}) - current status: ${feature.status}` ); // Check if feature is stuck in a pipeline step const pipelineInfo = await this.detectPipelineStatus( projectPath, featureId, (feature.status || '') as FeatureStatusWithPipeline ); if (pipelineInfo.isPipeline) { // Feature stuck in pipeline - use pipeline resume // Pass _alreadyTracked to prevent double-tracking logger.info( `[AutoMode] Feature ${featureId} is in pipeline step ${pipelineInfo.stepId}, using pipeline resume` ); return await this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo); } // Normal resume flow for non-pipeline features // Check if context exists in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, 'agent-output.md'); let hasContext = false; try { await secureFs.access(contextPath); hasContext = true; } catch { // No context - feature was interrupted before any agent output was saved } if (hasContext) { // Load previous context and continue // executeFeatureWithContext -> executeFeature will see feature is already tracked const context = (await secureFs.readFile(contextPath, 'utf-8')) as string; logger.info( `[AutoMode] Resuming feature ${featureId} with saved context (${context.length} chars)` ); // Emit event for UI notification this.emitAutoModeEvent('auto_mode_feature_resuming', { featureId, featureName: feature.title, projectPath, hasContext: true, message: `Resuming feature "${feature.title}" from saved context`, }); return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); } // No context - feature was interrupted before any agent output was saved // Start fresh execution instead of leaving the feature stuck logger.info( `[AutoMode] Feature ${featureId} has no saved context - starting fresh execution` ); // Emit event for UI notification this.emitAutoModeEvent('auto_mode_feature_resuming', { featureId, featureName: feature.title, projectPath, hasContext: false, message: `Starting fresh execution for interrupted feature "${feature.title}" (no previous context found)`, }); return await this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { _calledInternally: true, }); } finally { this.releaseRunningFeature(featureId); } } /** * Resume a feature that crashed during pipeline execution. * Handles multiple edge cases to ensure robust recovery: * - No context file: Restart entire pipeline from beginning * - Step deleted from config: Complete feature without remaining pipeline steps * - Valid step exists: Resume from the crashed step and continue * * @param {string} projectPath - Absolute path to the project directory * @param {Feature} feature - The feature object (already loaded to avoid redundant reads) * @param {boolean} useWorktrees - Whether to use git worktrees for isolation * @param {PipelineStatusInfo} pipelineInfo - Information about the pipeline status from detectPipelineStatus() * @returns {Promise} Resolves when resume operation completes or throws on error * @throws {Error} If pipeline config is null but stepIndex is valid (should never happen) * @private */ private async resumePipelineFeature( projectPath: string, feature: Feature, useWorktrees: boolean, pipelineInfo: PipelineStatusInfo ): Promise { const featureId = feature.id; console.log( `[AutoMode] Resuming feature ${featureId} from pipeline step ${pipelineInfo.stepId}` ); // Check for context file const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, 'agent-output.md'); let hasContext = false; try { await secureFs.access(contextPath); hasContext = true; } catch { // No context } // Edge Case 1: No context file - restart entire pipeline from beginning if (!hasContext) { console.warn( `[AutoMode] No context found for pipeline feature ${featureId}, restarting from beginning` ); // Reset status to in_progress and start fresh await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { _calledInternally: true, }); } // Edge Case 2: Step no longer exists in pipeline config if (pipelineInfo.stepIndex === -1) { console.warn( `[AutoMode] Step ${pipelineInfo.stepId} no longer exists in pipeline, completing feature without pipeline` ); const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, branchName: feature.branchName ?? null, passes: true, message: 'Pipeline step no longer exists - feature completed without remaining pipeline steps', projectPath, }); return; } // Normal case: Valid pipeline step exists, has context // Resume from the stuck step (re-execute the step that crashed) if (!pipelineInfo.config) { throw new Error('Pipeline config is null but stepIndex is valid - this should not happen'); } return this.resumeFromPipelineStep( projectPath, feature, useWorktrees, pipelineInfo.stepIndex, pipelineInfo.config ); } /** * Resume pipeline execution from a specific step index. * Re-executes the step that crashed (to handle partial completion), * then continues executing all remaining pipeline steps in order. * * This method handles the complete pipeline resume workflow: * - Validates feature and step index * - Locates or creates git worktree if needed * - Executes remaining steps starting from the crashed step * - Updates feature status to verified/waiting_approval when complete * - Emits progress events throughout execution * * @param {string} projectPath - Absolute path to the project directory * @param {Feature} feature - The feature object (already loaded to avoid redundant reads) * @param {boolean} useWorktrees - Whether to use git worktrees for isolation * @param {number} startFromStepIndex - Zero-based index of the step to resume from * @param {PipelineConfig} pipelineConfig - Pipeline config passed from detectPipelineStatus to avoid re-reading * @returns {Promise} Resolves when pipeline execution completes successfully * @throws {Error} If feature not found, step index invalid, or pipeline execution fails * @private */ private async resumeFromPipelineStep( projectPath: string, feature: Feature, useWorktrees: boolean, startFromStepIndex: number, pipelineConfig: PipelineConfig ): Promise { const featureId = feature.id; // Sort all steps first const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order); // Get the current step we're resuming from (using the index from unfiltered list) if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) { throw new Error(`Invalid step index: ${startFromStepIndex}`); } const currentStep = allSortedSteps[startFromStepIndex]; // Filter out excluded pipeline steps const excludedStepIds = new Set(feature.excludedPipelineSteps || []); // Check if the current step is excluded // If so, use getNextStatus to find the appropriate next step if (excludedStepIds.has(currentStep.id)) { logger.info( `Current step ${currentStep.id} is excluded for feature ${featureId}, finding next valid step` ); const nextStatus = pipelineService.getNextStatus( `pipeline_${currentStep.id}`, pipelineConfig, feature.skipTests ?? false, feature.excludedPipelineSteps ); // If next status is not a pipeline step, feature is done if (!pipelineService.isPipelineStatus(nextStatus)) { await this.updateFeatureStatus(projectPath, featureId, nextStatus); this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, branchName: feature.branchName ?? null, passes: true, message: 'Pipeline completed (remaining steps excluded)', projectPath, }); return; } // Find the next step and update the start index const nextStepId = pipelineService.getStepIdFromStatus(nextStatus); const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId); if (nextStepIndex === -1) { throw new Error(`Next step ${nextStepId} not found in pipeline config`); } startFromStepIndex = nextStepIndex; } // Get steps to execute (from startFromStepIndex onwards, excluding excluded steps) const stepsToExecute = allSortedSteps .slice(startFromStepIndex) .filter((step) => !excludedStepIds.has(step.id)); // If no steps left to execute, complete the feature if (stepsToExecute.length === 0) { const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, branchName: feature.branchName ?? null, passes: true, message: 'Pipeline completed (all remaining steps excluded)', projectPath, }); return; } // Use the filtered steps for counting const sortedSteps = allSortedSteps.filter((step) => !excludedStepIds.has(step.id)); logger.info( `Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}` ); const runningEntry = this.acquireRunningFeature({ featureId, projectPath, isAutoMode: false, allowReuse: true, }); const abortController = runningEntry.abortController; runningEntry.branchName = feature.branchName ?? null; try { // Validate project path validateWorkingDirectory(projectPath); // Derive workDir from feature.branchName let worktreePath: string | null = null; const branchName = feature.branchName; if (useWorktrees && branchName) { worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); if (worktreePath) { logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); } else { logger.warn(`Worktree for branch "${branchName}" not found, using project path`); } } const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); validateWorkingDirectory(workDir); // Update running feature with worktree info runningEntry.worktreePath = worktreePath; runningEntry.branchName = branchName ?? null; // Emit resume event this.emitAutoModeEvent('auto_mode_feature_start', { featureId, projectPath, branchName: branchName ?? null, feature: { id: featureId, title: feature.title || 'Resuming Pipeline', description: feature.description, }, }); this.emitAutoModeEvent('auto_mode_progress', { featureId, projectPath, branchName: branchName ?? null, content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`, }); // Load autoLoadClaudeMd setting const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( projectPath, this.settingsService, '[AutoMode]' ); // Execute remaining pipeline steps (starting from crashed step) await this.executePipelineSteps( projectPath, featureId, feature, stepsToExecute, workDir, abortController, autoLoadClaudeMd ); // Determine final status const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); logger.info(`Pipeline resume completed successfully for feature ${featureId}`); this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, branchName: feature.branchName ?? null, passes: true, message: 'Pipeline resumed and completed successfully', projectPath, }); } catch (error) { const errorInfo = classifyError(error); if (errorInfo.isAbort) { this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, branchName: feature.branchName ?? null, passes: false, message: 'Pipeline resume stopped by user', projectPath, }); } else { logger.error(`Pipeline resume failed for feature ${featureId}:`, error); await this.updateFeatureStatus(projectPath, featureId, 'backlog'); this.emitAutoModeEvent('auto_mode_error', { featureId, featureName: feature.title, branchName: feature.branchName ?? null, error: errorInfo.message, errorType: errorInfo.type, projectPath, }); } } finally { this.releaseRunningFeature(featureId); } } /** * Follow up on a feature with additional instructions */ async followUpFeature( projectPath: string, featureId: string, prompt: string, imagePaths?: string[], useWorktrees = true ): Promise { // Validate project path early for fast failure validateWorkingDirectory(projectPath); const runningEntry = this.acquireRunningFeature({ featureId, projectPath, isAutoMode: false, }); const abortController = runningEntry.abortController; // Load feature info for context FIRST to get branchName const feature = await this.loadFeature(projectPath, featureId); // Derive workDir from feature.branchName // If no branchName, derive from feature ID: feature/{featureId} let workDir = path.resolve(projectPath); let worktreePath: string | null = null; const branchName = feature?.branchName || `feature/${featureId}`; if (useWorktrees && branchName) { // Try to find existing worktree for this branch worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); if (worktreePath) { workDir = worktreePath; logger.info(`Follow-up using worktree for branch "${branchName}": ${workDir}`); } } // Load previous agent output if it exists const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, 'agent-output.md'); let previousContext = ''; try { previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; } catch { // No previous context } // Load autoLoadClaudeMd setting to determine context loading strategy const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( projectPath, this.settingsService, '[AutoMode]' ); // Get customized prompts from settings const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt const contextResult = await loadContextFiles({ projectPath, fsModule: secureFs as Parameters[0]['fsModule'], taskContext: { title: feature?.title ?? prompt.substring(0, 200), description: feature?.description ?? prompt, }, }); // When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication // (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); // Build complete prompt with feature info, previous context, and follow-up instructions let fullPrompt = `## Follow-up on Feature Implementation ${feature ? this.buildFeaturePrompt(feature, prompts.taskExecution) : `**Feature ID:** ${featureId}`} `; if (previousContext) { fullPrompt += ` ## Previous Agent Work The following is the output from the previous implementation attempt: ${previousContext} `; } fullPrompt += ` ## Follow-up Instructions ${prompt} ## Task Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`; // Get model from feature and determine provider early for tracking const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude); const provider = ProviderFactory.getProviderNameForModel(model); logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`); runningEntry.worktreePath = worktreePath; runningEntry.branchName = branchName; runningEntry.model = model; runningEntry.provider = provider; try { // Update feature status to in_progress BEFORE emitting event // This ensures the frontend sees the updated status when it reloads features await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); // Emit feature start event AFTER status update so frontend sees correct status this.emitAutoModeEvent('auto_mode_feature_start', { featureId, projectPath, branchName, feature: feature || { id: featureId, title: 'Follow-up', description: prompt.substring(0, 100), }, model, provider, }); // Copy follow-up images to feature folder const copiedImagePaths: string[] = []; if (imagePaths && imagePaths.length > 0) { const featureDirForImages = getFeatureDir(projectPath, featureId); const featureImagesDir = path.join(featureDirForImages, 'images'); await secureFs.mkdir(featureImagesDir, { recursive: true }); for (const imagePath of imagePaths) { try { // Get the filename from the path const filename = path.basename(imagePath); const destPath = path.join(featureImagesDir, filename); // Copy the image await secureFs.copyFile(imagePath, destPath); // Store the absolute path (external storage uses absolute paths) copiedImagePaths.push(destPath); } catch (error) { logger.error(`Failed to copy follow-up image ${imagePath}:`, error); } } } // Update feature object with new follow-up images BEFORE building prompt if (copiedImagePaths.length > 0 && feature) { const currentImagePaths = feature.imagePaths || []; const newImagePaths = copiedImagePaths.map((p) => ({ path: p, filename: path.basename(p), mimeType: 'image/png', // Default, could be improved })); feature.imagePaths = [...currentImagePaths, ...newImagePaths]; } // Combine original feature images with new follow-up images const allImagePaths: string[] = []; // Add all images from feature (now includes both original and new) if (feature?.imagePaths) { const allPaths = feature.imagePaths.map((img) => typeof img === 'string' ? img : img.path ); allImagePaths.push(...allPaths); } // Save updated feature.json with new images (atomic write with backup) if (copiedImagePaths.length > 0 && feature) { const featureDirForSave = getFeatureDir(projectPath, featureId); const featurePath = path.join(featureDirForSave, 'feature.json'); try { await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); } catch (error) { logger.error(`Failed to save feature.json:`, error); } } // Use fullPrompt (already built above) with model and all images // Note: Follow-ups skip planning mode - they continue from previous work // Pass previousContext so the history is preserved in the output file // Context files are passed as system prompt for higher priority await this.runAgent( workDir, featureId, fullPrompt, abortController, projectPath, allImagePaths.length > 0 ? allImagePaths : imagePaths, model, { projectPath, planningMode: 'skip', // Follow-ups don't require approval previousContent: previousContext || undefined, systemPrompt: contextFilesPrompt || undefined, autoLoadClaudeMd, thinkingLevel: feature?.thinkingLevel, } ); // Determine final status based on testing mode: // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) // - skipTests=true (manual verification): go to 'waiting_approval' for manual review const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); // Record success to reset consecutive failure tracking this.recordSuccess(); this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature?.title, branchName: branchName ?? null, passes: true, message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`, projectPath, model, provider, }); } catch (error) { const errorInfo = classifyError(error); if (!errorInfo.isCancellation) { this.emitAutoModeEvent('auto_mode_error', { featureId, featureName: feature?.title, branchName: branchName ?? null, error: errorInfo.message, errorType: errorInfo.type, projectPath, }); // Track this failure and check if we should pause auto mode const shouldPause = this.trackFailureAndCheckPause({ type: errorInfo.type, message: errorInfo.message, }); if (shouldPause) { this.signalShouldPause({ type: errorInfo.type, message: errorInfo.message, }); } } } finally { this.releaseRunningFeature(featureId); } } /** * Verify a feature's implementation */ async verifyFeature(projectPath: string, featureId: string): Promise { // Load feature to get the name for event reporting const feature = await this.loadFeature(projectPath, featureId); // Worktrees are in project dir // Sanitize featureId the same way it's sanitized when creating worktrees const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); let workDir = projectPath; try { await secureFs.access(worktreePath); workDir = worktreePath; } catch { // No worktree } // Run verification - check if tests pass, build works, etc. const verificationChecks = [ { cmd: 'npm run lint', name: 'Lint' }, { cmd: 'npm run typecheck', name: 'Type check' }, { cmd: 'npm test', name: 'Tests' }, { cmd: 'npm run build', name: 'Build' }, ]; let allPassed = true; const results: Array<{ check: string; passed: boolean; output?: string }> = []; for (const check of verificationChecks) { try { const { stdout, stderr } = await execAsync(check.cmd, { cwd: workDir, timeout: 120000, }); results.push({ check: check.name, passed: true, output: stdout || stderr, }); } catch (error) { allPassed = false; results.push({ check: check.name, passed: false, output: (error as Error).message, }); break; // Stop on first failure } } this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature?.title, branchName: feature?.branchName ?? null, passes: allPassed, message: allPassed ? 'All verification checks passed' : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, projectPath, }); return allPassed; } /** * Commit feature changes * @param projectPath - The main project path * @param featureId - The feature ID to commit * @param providedWorktreePath - Optional: the worktree path where the feature's changes are located */ async commitFeature( projectPath: string, featureId: string, providedWorktreePath?: string ): Promise { let workDir = projectPath; // Use the provided worktree path if given if (providedWorktreePath) { try { await secureFs.access(providedWorktreePath); workDir = providedWorktreePath; logger.info(`Committing in provided worktree: ${workDir}`); } catch { logger.info( `Provided worktree path doesn't exist: ${providedWorktreePath}, using project path` ); } } else { // Fallback: try to find worktree at legacy location // Sanitize featureId the same way it's sanitized when creating worktrees const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); const legacyWorktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(legacyWorktreePath); workDir = legacyWorktreePath; logger.info(`Committing in legacy worktree: ${workDir}`); } catch { logger.info(`No worktree found, committing in project path: ${workDir}`); } } try { // Check for changes const { stdout: status } = await execAsync('git status --porcelain', { cwd: workDir, }); if (!status.trim()) { return null; // No changes } // Load feature for commit message const feature = await this.loadFeature(projectPath, featureId); const commitMessage = feature ? `feat: ${this.extractTitleFromDescription( feature.description )}\n\nImplemented by Automaker auto-mode` : `feat: Feature ${featureId}`; // Stage and commit await execAsync('git add -A', { cwd: workDir }); await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { cwd: workDir, }); // Get commit hash const { stdout: hash } = await execAsync('git rev-parse HEAD', { cwd: workDir, }); this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature?.title, branchName: feature?.branchName ?? null, passes: true, message: `Changes committed: ${hash.trim().substring(0, 8)}`, projectPath, }); return hash.trim(); } catch (error) { logger.error(`Commit failed for ${featureId}:`, error); return null; } } /** * Check if context exists for a feature */ async contextExists(projectPath: string, featureId: string): Promise { // Context is stored in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, 'agent-output.md'); try { await secureFs.access(contextPath); return true; } catch { return false; } } /** * Analyze project to gather context */ async analyzeProject(projectPath: string): Promise { const abortController = new AbortController(); const analysisFeatureId = `analysis-${Date.now()}`; this.emitAutoModeEvent('auto_mode_feature_start', { featureId: analysisFeatureId, projectPath, branchName: null, // Project analysis is not worktree-specific feature: { id: analysisFeatureId, title: 'Project Analysis', description: 'Analyzing project structure', }, }); const prompt = `Analyze this project and provide a summary of: 1. Project structure and architecture 2. Main technologies and frameworks used 3. Key components and their responsibilities 4. Build and test commands 5. Any existing conventions or patterns Format your response as a structured markdown document.`; try { // Get model from phase settings with provider info const { phaseModel: phaseModelEntry, provider: analysisClaudeProvider, credentials, } = await getPhaseModelWithOverrides( 'projectAnalysisModel', this.settingsService, projectPath, '[AutoMode]' ); const { model: analysisModel, thinkingLevel: analysisThinkingLevel } = resolvePhaseModel(phaseModelEntry); logger.info( 'Using model for project analysis:', analysisModel, analysisClaudeProvider ? `via provider: ${analysisClaudeProvider.name}` : 'direct API' ); const provider = ProviderFactory.getProviderForModel(analysisModel); // Load autoLoadClaudeMd setting const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( projectPath, this.settingsService, '[AutoMode]' ); // Use createCustomOptions for centralized SDK configuration with CLAUDE.md support const sdkOptions = createCustomOptions({ cwd: projectPath, model: analysisModel, maxTurns: 5, allowedTools: ['Read', 'Glob', 'Grep'], abortController, autoLoadClaudeMd, thinkingLevel: analysisThinkingLevel, }); const options: ExecuteOptions = { prompt, model: sdkOptions.model ?? analysisModel, cwd: sdkOptions.cwd ?? projectPath, maxTurns: sdkOptions.maxTurns, allowedTools: sdkOptions.allowedTools as string[], abortController, settingSources: sdkOptions.settingSources, thinkingLevel: analysisThinkingLevel, // Pass thinking level credentials, // Pass credentials for resolving 'credentials' apiKeySource claudeCompatibleProvider: analysisClaudeProvider, // Pass provider for alternative endpoint configuration }; const stream = provider.executeQuery(options); let analysisResult = ''; for await (const msg of stream) { if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { analysisResult = block.text || ''; this.emitAutoModeEvent('auto_mode_progress', { featureId: analysisFeatureId, content: block.text, projectPath, }); } } } else if (msg.type === 'result' && msg.subtype === 'success') { analysisResult = msg.result || analysisResult; } } // Save analysis to .automaker directory const automakerDir = getAutomakerDir(projectPath); const analysisPath = path.join(automakerDir, 'project-analysis.md'); await secureFs.mkdir(automakerDir, { recursive: true }); await secureFs.writeFile(analysisPath, analysisResult); this.emitAutoModeEvent('auto_mode_feature_complete', { featureId: analysisFeatureId, featureName: 'Project Analysis', branchName: null, // Project analysis is not worktree-specific passes: true, message: 'Project analysis completed', projectPath, }); } catch (error) { const errorInfo = classifyError(error); this.emitAutoModeEvent('auto_mode_error', { featureId: analysisFeatureId, featureName: 'Project Analysis', branchName: null, // Project analysis is not worktree-specific error: errorInfo.message, errorType: errorInfo.type, projectPath, }); } } /** * Get current status */ getStatus(): { isRunning: boolean; runningFeatures: string[]; runningCount: number; } { return { isRunning: this.runningFeatures.size > 0, runningFeatures: Array.from(this.runningFeatures.keys()), runningCount: this.runningFeatures.size, }; } /** * Get status for a specific project/worktree * @param projectPath - The project path * @param branchName - The branch name, or null for main worktree */ getStatusForProject( projectPath: string, branchName: string | null = null ): { isAutoLoopRunning: boolean; runningFeatures: string[]; runningCount: number; maxConcurrency: number; branchName: string | null; } { const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); const projectState = this.autoLoopsByProject.get(worktreeKey); const runningFeatures: string[] = []; for (const [featureId, feature] of this.runningFeatures) { // Filter by project path AND branchName to get worktree-specific features if (feature.projectPath === projectPath && feature.branchName === branchName) { runningFeatures.push(featureId); } } return { isAutoLoopRunning: projectState?.isRunning ?? false, runningFeatures, runningCount: runningFeatures.length, maxConcurrency: projectState?.config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, branchName, }; } /** * Get all active auto loop worktrees with their project paths and branch names */ getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = []; for (const [, state] of this.autoLoopsByProject) { if (state.isRunning) { activeWorktrees.push({ projectPath: state.config.projectPath, branchName: state.branchName, }); } } return activeWorktrees; } /** * Get all projects that have auto mode running (legacy, returns unique project paths) * @deprecated Use getActiveAutoLoopWorktrees instead for full worktree information */ getActiveAutoLoopProjects(): string[] { const activeProjects = new Set(); for (const [, state] of this.autoLoopsByProject) { if (state.isRunning) { activeProjects.add(state.config.projectPath); } } return Array.from(activeProjects); } /** * Get detailed info about all running agents */ async getRunningAgents(): Promise< Array<{ featureId: string; projectPath: string; projectName: string; isAutoMode: boolean; model?: string; provider?: ModelProvider; title?: string; description?: string; branchName?: string; }> > { const agents = await Promise.all( Array.from(this.runningFeatures.values()).map(async (rf) => { // Try to fetch feature data to get title, description, and branchName let title: string | undefined; let description: string | undefined; let branchName: string | undefined; try { const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); if (feature) { title = feature.title; description = feature.description; branchName = feature.branchName; } } catch (error) { // Silently ignore errors - title/description/branchName are optional } return { featureId: rf.featureId, projectPath: rf.projectPath, projectName: path.basename(rf.projectPath), isAutoMode: rf.isAutoMode, model: rf.model, provider: rf.provider, title, description, branchName, }; }) ); return agents; } /** * Wait for plan approval from the user. * Returns a promise that resolves when the user approves/rejects the plan. * Times out after 30 minutes to prevent indefinite memory retention. */ waitForPlanApproval( featureId: string, projectPath: string ): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> { const APPROVAL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes logger.info(`Registering pending approval for feature ${featureId}`); logger.info( `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); return new Promise((resolve, reject) => { // Set up timeout to prevent indefinite waiting and memory leaks const timeoutId = setTimeout(() => { const pending = this.pendingApprovals.get(featureId); if (pending) { logger.warn(`Plan approval for feature ${featureId} timed out after 30 minutes`); this.pendingApprovals.delete(featureId); reject( new Error('Plan approval timed out after 30 minutes - feature execution cancelled') ); } }, APPROVAL_TIMEOUT_MS); // Wrap resolve/reject to clear timeout when approval is resolved const wrappedResolve = (result: { approved: boolean; editedPlan?: string; feedback?: string; }) => { clearTimeout(timeoutId); resolve(result); }; const wrappedReject = (error: Error) => { clearTimeout(timeoutId); reject(error); }; this.pendingApprovals.set(featureId, { resolve: wrappedResolve, reject: wrappedReject, featureId, projectPath, }); logger.info(`Pending approval registered for feature ${featureId} (timeout: 30 minutes)`); }); } /** * Resolve a pending plan approval. * Called when the user approves or rejects the plan via API. */ async resolvePlanApproval( featureId: string, approved: boolean, editedPlan?: string, feedback?: string, projectPathFromClient?: string ): Promise<{ success: boolean; error?: string }> { logger.info(`resolvePlanApproval called for feature ${featureId}, approved=${approved}`); logger.info( `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); const pending = this.pendingApprovals.get(featureId); if (!pending) { logger.info(`No pending approval in Map for feature ${featureId}`); // RECOVERY: If no pending approval but we have projectPath from client, // check if feature's planSpec.status is 'generated' and handle recovery if (projectPathFromClient) { logger.info(`Attempting recovery with projectPath: ${projectPathFromClient}`); const feature = await this.loadFeature(projectPathFromClient, featureId); if (feature?.planSpec?.status === 'generated') { logger.info(`Feature ${featureId} has planSpec.status='generated', performing recovery`); if (approved) { // Update planSpec to approved await this.updateFeaturePlanSpec(projectPathFromClient, featureId, { status: 'approved', approvedAt: new Date().toISOString(), reviewedByUser: true, content: editedPlan || feature.planSpec.content, }); // Get customized prompts from settings const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); // Build continuation prompt using centralized template const planContent = editedPlan || feature.planSpec.content || ''; let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; continuationPrompt = continuationPrompt.replace( /\{\{userFeedback\}\}/g, feedback || '' ); continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); logger.info(`Starting recovery execution for feature ${featureId}`); // Start feature execution with the continuation prompt (async, don't await) // Pass undefined for providedWorktreePath, use options for continuation prompt this.executeFeature(projectPathFromClient, featureId, true, false, undefined, { continuationPrompt, }).catch((error) => { logger.error(`Recovery execution failed for feature ${featureId}:`, error); }); return { success: true }; } else { // Rejected - update status and emit event await this.updateFeaturePlanSpec(projectPathFromClient, featureId, { status: 'rejected', reviewedByUser: true, }); await this.updateFeatureStatus(projectPathFromClient, featureId, 'backlog'); this.emitAutoModeEvent('plan_rejected', { featureId, projectPath: projectPathFromClient, feedback, }); return { success: true }; } } } logger.info( `ERROR: No pending approval found for feature ${featureId} and recovery not possible` ); return { success: false, error: `No pending approval for feature ${featureId}`, }; } logger.info(`Found pending approval for feature ${featureId}, proceeding...`); const { projectPath } = pending; // Update feature's planSpec status await this.updateFeaturePlanSpec(projectPath, featureId, { status: approved ? 'approved' : 'rejected', approvedAt: approved ? new Date().toISOString() : undefined, reviewedByUser: true, content: editedPlan, // Update content if user provided an edited version }); // If rejected with feedback, we can store it for the user to see if (!approved && feedback) { // Emit event so client knows the rejection reason this.emitAutoModeEvent('plan_rejected', { featureId, projectPath, feedback, }); } // Resolve the promise with all data including feedback pending.resolve({ approved, editedPlan, feedback }); this.pendingApprovals.delete(featureId); return { success: true }; } /** * Cancel a pending plan approval (e.g., when feature is stopped). */ cancelPlanApproval(featureId: string): void { logger.info(`cancelPlanApproval called for feature ${featureId}`); logger.info( `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); const pending = this.pendingApprovals.get(featureId); if (pending) { logger.info(`Found and cancelling pending approval for feature ${featureId}`); pending.reject(new Error('Plan approval cancelled - feature was stopped')); this.pendingApprovals.delete(featureId); } else { logger.info(`No pending approval to cancel for feature ${featureId}`); } } /** * Check if a feature has a pending plan approval. */ hasPendingApproval(featureId: string): boolean { return this.pendingApprovals.has(featureId); } // Private helpers /** * Find an existing worktree for a given branch by checking git worktree list */ private async findExistingWorktreeForBranch( projectPath: string, branchName: string ): Promise { try { const { stdout } = await execAsync('git worktree list --porcelain', { cwd: projectPath, }); const lines = stdout.split('\n'); let currentPath: string | null = null; let currentBranch: string | null = null; for (const line of lines) { if (line.startsWith('worktree ')) { currentPath = line.slice(9); } else if (line.startsWith('branch ')) { currentBranch = line.slice(7).replace('refs/heads/', ''); } else if (line === '' && currentPath && currentBranch) { // End of a worktree entry if (currentBranch === branchName) { // Resolve to absolute path - git may return relative paths // On Windows, this is critical for cwd to work correctly // On all platforms, absolute paths ensure consistent behavior const resolvedPath = path.isAbsolute(currentPath) ? path.resolve(currentPath) : path.resolve(projectPath, currentPath); return resolvedPath; } currentPath = null; currentBranch = null; } } // Check the last entry (if file doesn't end with newline) if (currentPath && currentBranch && currentBranch === branchName) { // Resolve to absolute path for cross-platform compatibility const resolvedPath = path.isAbsolute(currentPath) ? path.resolve(currentPath) : path.resolve(projectPath, currentPath); return resolvedPath; } return null; } catch { return null; } } private async loadFeature(projectPath: string, featureId: string): Promise { // Features are stored in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); const featurePath = path.join(featureDir, 'feature.json'); try { const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; return JSON.parse(data); } catch { return null; } } private async updateFeatureStatus( projectPath: string, featureId: string, status: string ): Promise { // Features are stored in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); const featurePath = path.join(featureDir, 'feature.json'); try { // Use recovery-enabled read for corrupted file handling const result = await readJsonWithRecovery(featurePath, null, { maxBackups: DEFAULT_BACKUP_COUNT, autoRestore: true, }); logRecoveryWarning(result, `Feature ${featureId}`, logger); const feature = result.data; if (!feature) { logger.warn(`Feature ${featureId} not found or could not be recovered`); return; } feature.status = status; feature.updatedAt = new Date().toISOString(); // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) // Badge will show for 2 minutes after this timestamp if (status === 'waiting_approval') { feature.justFinishedAt = new Date().toISOString(); } else { // Clear the timestamp when moving to other statuses feature.justFinishedAt = undefined; } // Use atomic write with backup support await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); // Create notifications for important status changes const notificationService = getNotificationService(); if (status === 'waiting_approval') { await notificationService.createNotification({ type: 'feature_waiting_approval', title: 'Feature Ready for Review', message: `"${feature.name || featureId}" is ready for your review and approval.`, featureId, projectPath, }); } else if (status === 'verified') { await notificationService.createNotification({ type: 'feature_verified', title: 'Feature Verified', message: `"${feature.name || featureId}" has been verified and is complete.`, featureId, projectPath, }); } // Sync completed/verified features to app_spec.txt if (status === 'verified' || status === 'completed') { try { await this.featureLoader.syncFeatureToAppSpec(projectPath, feature); } catch (syncError) { // Log but don't fail the status update if sync fails logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError); } } } catch (error) { logger.error(`Failed to update feature status for ${featureId}:`, error); } } /** * Mark a feature as interrupted due to server restart or other interruption. * * This is a convenience helper that updates the feature status to 'interrupted', * indicating the feature was in progress but execution was disrupted (e.g., server * restart, process crash, or manual stop). Features with this status can be * resumed later using the resume functionality. * * Note: Features with pipeline_* statuses are preserved rather than overwritten * to 'interrupted'. This ensures that resumePipelineFeature() can pick up from * the correct pipeline step after a restart. * * @param projectPath - Path to the project * @param featureId - ID of the feature to mark as interrupted * @param reason - Optional reason for the interruption (logged for debugging) */ async markFeatureInterrupted( projectPath: string, featureId: string, reason?: string ): Promise { // Load the feature to check its current status const feature = await this.loadFeature(projectPath, featureId); const currentStatus = feature?.status; // Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step if (currentStatus && currentStatus.startsWith('pipeline_')) { logger.info( `Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume` ); return; } if (reason) { logger.info(`Marking feature ${featureId} as interrupted: ${reason}`); } else { logger.info(`Marking feature ${featureId} as interrupted`); } await this.updateFeatureStatus(projectPath, featureId, 'interrupted'); } /** * Mark all currently running features as interrupted. * * This method is called during graceful server shutdown to ensure that all * features currently being executed are properly marked as 'interrupted'. * This allows them to be detected and resumed when the server restarts. * * @param reason - Optional reason for the interruption (logged for debugging) * @returns Promise that resolves when all features have been marked as interrupted */ async markAllRunningFeaturesInterrupted(reason?: string): Promise { const runningCount = this.runningFeatures.size; if (runningCount === 0) { logger.info('No running features to mark as interrupted'); return; } const logReason = reason || 'server shutdown'; logger.info(`Marking ${runningCount} running feature(s) as interrupted due to: ${logReason}`); const markPromises: Promise[] = []; for (const [featureId, runningFeature] of this.runningFeatures) { markPromises.push( this.markFeatureInterrupted(runningFeature.projectPath, featureId, logReason).catch( (error) => { logger.error(`Failed to mark feature ${featureId} as interrupted:`, error); } ) ); } await Promise.all(markPromises); logger.info(`Finished marking ${runningCount} feature(s) as interrupted`); } private isFeatureFinished(feature: Feature): boolean { const isCompleted = feature.status === 'completed' || feature.status === 'verified'; // Even if marked as completed, if it has an approved plan with pending tasks, it's not finished if (feature.planSpec?.status === 'approved') { const tasksCompleted = feature.planSpec.tasksCompleted ?? 0; const tasksTotal = feature.planSpec.tasksTotal ?? 0; if (tasksCompleted < tasksTotal) { return false; } } return isCompleted; } /** * Check if a feature is currently running (being executed or resumed). * This is used for idempotent checks to prevent race conditions when * multiple callers try to resume the same feature simultaneously. * * @param featureId - The ID of the feature to check * @returns true if the feature is currently running, false otherwise */ isFeatureRunning(featureId: string): boolean { return this.runningFeatures.has(featureId); } /** * Update the planSpec of a feature */ private async updateFeaturePlanSpec( projectPath: string, featureId: string, updates: Partial ): Promise { // Use getFeatureDir helper for consistent path resolution const featureDir = getFeatureDir(projectPath, featureId); const featurePath = path.join(featureDir, 'feature.json'); try { // Use recovery-enabled read for corrupted file handling const result = await readJsonWithRecovery(featurePath, null, { maxBackups: DEFAULT_BACKUP_COUNT, autoRestore: true, }); logRecoveryWarning(result, `Feature ${featureId}`, logger); const feature = result.data; if (!feature) { logger.warn(`Feature ${featureId} not found or could not be recovered`); return; } // Initialize planSpec if it doesn't exist if (!feature.planSpec) { feature.planSpec = { status: 'pending', version: 1, reviewedByUser: false, }; } // Apply updates Object.assign(feature.planSpec, updates); // If content is being updated and it's a new version, increment version if (updates.content && updates.content !== feature.planSpec.content) { feature.planSpec.version = (feature.planSpec.version || 0) + 1; } feature.updatedAt = new Date().toISOString(); // Use atomic write with backup support await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); } catch (error) { logger.error(`Failed to update planSpec for ${featureId}:`, error); } } /** * Load pending features for a specific project/worktree * @param projectPath - The project path * @param branchName - The branch name to filter by, or null for main worktree (features without branchName) */ private async loadPendingFeatures( projectPath: string, branchName: string | null = null ): Promise { // Features are stored in .automaker directory const featuresDir = getFeaturesDir(projectPath); // Get the actual primary branch name for the project (e.g., "main", "master", "develop") // This is needed to correctly match features when branchName is null (main worktree) const primaryBranch = await getCurrentBranch(projectPath); try { const entries = await secureFs.readdir(featuresDir, { withFileTypes: true, }); const allFeatures: Feature[] = []; const pendingFeatures: Feature[] = []; // Load all features (for dependency checking) with recovery support for (const entry of entries) { if (entry.isDirectory()) { const featurePath = path.join(featuresDir, entry.name, 'feature.json'); // Use recovery-enabled read for corrupted file handling const result = await readJsonWithRecovery(featurePath, null, { maxBackups: DEFAULT_BACKUP_COUNT, autoRestore: true, }); logRecoveryWarning(result, `Feature ${entry.name}`, logger); const feature = result.data; if (!feature) { // Skip features that couldn't be loaded or recovered continue; } allFeatures.push(feature); // Track pending features separately, filtered by worktree/branch // Note: waiting_approval is NOT included - those features have completed execution // and are waiting for user review, they should not be picked up again if ( feature.status === 'pending' || feature.status === 'ready' || feature.status === 'backlog' || (feature.planSpec?.status === 'approved' && (feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) ) { // Filter by branchName: // - If branchName is null (main worktree), include features with: // - branchName === null, OR // - branchName === primaryBranch (e.g., "main", "master", "develop") // - If branchName is set, only include features with matching branchName const featureBranch = feature.branchName ?? null; if (branchName === null) { // Main worktree: include features without branchName OR with branchName matching primary branch // This handles repos where the primary branch is named something other than "main" const isPrimaryBranch = featureBranch === null || (primaryBranch && featureBranch === primaryBranch); if (isPrimaryBranch) { pendingFeatures.push(feature); } else { logger.debug( `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, primaryBranch: ${primaryBranch}) for main worktree` ); } } else { // Feature worktree: include features with matching branchName if (featureBranch === branchName) { pendingFeatures.push(feature); } else { logger.debug( `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, expected: ${branchName}) for worktree ${branchName}` ); } } } } } const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/approved_with_pending_tasks) for ${worktreeDesc}` ); if (pendingFeatures.length === 0) { logger.warn( `[loadPendingFeatures] No pending features found for ${worktreeDesc}. Check branchName matching - looking for branchName: ${branchName === null ? 'null (main)' : branchName}` ); // Log all backlog features to help debug branchName matching const allBacklogFeatures = allFeatures.filter( (f) => f.status === 'backlog' || f.status === 'pending' || f.status === 'ready' || (f.planSpec?.status === 'approved' && (f.planSpec.tasksCompleted ?? 0) < (f.planSpec.tasksTotal ?? 0)) ); if (allBacklogFeatures.length > 0) { logger.info( `[loadPendingFeatures] Found ${allBacklogFeatures.length} backlog features with branchNames: ${allBacklogFeatures.map((f) => `${f.id}(${f.branchName ?? 'null'})`).join(', ')}` ); } } // Apply dependency-aware ordering const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures); // Remove missing dependencies from features and save them // This allows features to proceed when their dependencies have been deleted or don't exist if (missingDependencies.size > 0) { for (const [featureId, missingDepIds] of missingDependencies) { const feature = pendingFeatures.find((f) => f.id === featureId); if (feature && feature.dependencies) { // Filter out the missing dependency IDs const validDependencies = feature.dependencies.filter( (depId) => !missingDepIds.includes(depId) ); logger.warn( `[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.` ); // Update the feature in memory feature.dependencies = validDependencies.length > 0 ? validDependencies : undefined; // Save the updated feature to disk try { await this.featureLoader.update(projectPath, featureId, { dependencies: feature.dependencies, }); logger.info( `[loadPendingFeatures] Updated feature ${featureId} - removed missing dependencies` ); } catch (error) { logger.error( `[loadPendingFeatures] Failed to save feature ${featureId} after removing missing dependencies:`, error ); } } } } // Get skipVerificationInAutoMode setting const settings = await this.settingsService?.getGlobalSettings(); const skipVerification = settings?.skipVerificationInAutoMode ?? false; // Filter to only features with satisfied dependencies const readyFeatures: Feature[] = []; const blockedFeatures: Array<{ feature: Feature; reason: string }> = []; for (const feature of orderedFeatures) { const isSatisfied = areDependenciesSatisfied(feature, allFeatures, { skipVerification }); if (isSatisfied) { readyFeatures.push(feature); } else { // Find which dependencies are blocking const blockingDeps = feature.dependencies?.filter((depId) => { const dep = allFeatures.find((f) => f.id === depId); if (!dep) return true; // Missing dependency if (skipVerification) { return dep.status === 'running'; } return dep.status !== 'completed' && dep.status !== 'verified'; }) || []; blockedFeatures.push({ feature, reason: blockingDeps.length > 0 ? `Blocked by dependencies: ${blockingDeps.join(', ')}` : 'Unknown dependency issue', }); } } if (blockedFeatures.length > 0) { logger.info( `[loadPendingFeatures] ${blockedFeatures.length} features blocked by dependencies: ${blockedFeatures.map((b) => `${b.feature.id} (${b.reason})`).join('; ')}` ); } logger.info( `[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})` ); return readyFeatures; } catch (error) { logger.error(`[loadPendingFeatures] Error loading features:`, error); return []; } } /** * Extract a title from feature description (first line or truncated) */ private extractTitleFromDescription(description: string): string { if (!description || !description.trim()) { return 'Untitled Feature'; } // Get first line, or first 60 characters if no newline const firstLine = description.split('\n')[0].trim(); if (firstLine.length <= 60) { return firstLine; } // Truncate to 60 characters and add ellipsis return firstLine.substring(0, 57) + '...'; } /** * Get the planning prompt prefix based on feature's planning mode */ private async getPlanningPromptPrefix(feature: Feature): Promise { const mode = feature.planningMode || 'skip'; if (mode === 'skip') { return ''; // No planning phase } // Load prompts from settings (no caching - allows hot reload of custom prompts) const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); const planningPrompts: Record = { lite: prompts.autoMode.planningLite, lite_with_approval: prompts.autoMode.planningLiteWithApproval, spec: prompts.autoMode.planningSpec, full: prompts.autoMode.planningFull, }; // For lite mode, use the approval variant if requirePlanApproval is true let promptKey: string = mode; if (mode === 'lite' && feature.requirePlanApproval === true) { promptKey = 'lite_with_approval'; } const planningPrompt = planningPrompts[promptKey]; if (!planningPrompt) { return ''; } return planningPrompt + '\n\n---\n\n## Feature Request\n\n'; } private buildFeaturePrompt( feature: Feature, taskExecutionPrompts: { implementationInstructions: string; playwrightVerificationInstructions: string; } ): string { const title = this.extractTitleFromDescription(feature.description); let prompt = `## Feature Implementation Task **Feature ID:** ${feature.id} **Title:** ${title} **Description:** ${feature.description} `; if (feature.spec) { prompt += ` **Specification:** ${feature.spec} `; } // Add images note (like old implementation) if (feature.imagePaths && feature.imagePaths.length > 0) { const imagesList = feature.imagePaths .map((img, idx) => { const path = typeof img === 'string' ? img : img.path; const filename = typeof img === 'string' ? path.split('/').pop() : img.filename || path.split('/').pop(); const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*'; return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`; }) .join('\n'); prompt += ` **šŸ“Ž Context Images Attached:** The user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read: ${imagesList} You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing. `; } // Add verification instructions based on testing mode if (feature.skipTests) { // Manual verification - just implement the feature prompt += `\n${taskExecutionPrompts.implementationInstructions}`; } else { // Automated testing - implement and verify with Playwright prompt += `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`; } return prompt; } private async runAgent( workDir: string, featureId: string, prompt: string, abortController: AbortController, projectPath: string, imagePaths?: string[], model?: string, options?: { projectPath?: string; planningMode?: PlanningMode; requirePlanApproval?: boolean; previousContent?: string; systemPrompt?: string; autoLoadClaudeMd?: boolean; thinkingLevel?: ThinkingLevel; branchName?: string | null; } ): Promise { const finalProjectPath = options?.projectPath || projectPath; const branchName = options?.branchName ?? null; const planningMode = options?.planningMode || 'skip'; const previousContent = options?.previousContent; // Validate vision support before processing images const effectiveModel = model || 'claude-sonnet-4-20250514'; if (imagePaths && imagePaths.length > 0) { const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel); if (!supportsVision) { throw new Error( `This model (${effectiveModel}) does not support image input. ` + `Please switch to a model that supports vision (like Claude models), or remove the images and try again.` ); } } // Check if this planning mode can generate a spec/plan that needs approval // - spec and full always generate specs // - lite only generates approval-ready content when requirePlanApproval is true const planningModeRequiresApproval = planningMode === 'spec' || planningMode === 'full' || (planningMode === 'lite' && options?.requirePlanApproval === true); const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true; // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set // This prevents actual API calls during automated testing if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { logger.info(`MOCK MODE: Skipping real agent execution for feature ${featureId}`); // Simulate some work being done await this.sleep(500); // Emit mock progress events to simulate agent activity this.emitAutoModeEvent('auto_mode_progress', { featureId, content: 'Mock agent: Analyzing the codebase...', }); await this.sleep(300); this.emitAutoModeEvent('auto_mode_progress', { featureId, content: 'Mock agent: Implementing the feature...', }); await this.sleep(300); // Create a mock file with "yellow" content as requested in the test const mockFilePath = path.join(workDir, 'yellow.txt'); await secureFs.writeFile(mockFilePath, 'yellow'); this.emitAutoModeEvent('auto_mode_progress', { featureId, content: "Mock agent: Created yellow.txt file with content 'yellow'", }); await this.sleep(200); // Save mock agent output const featureDirForOutput = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDirForOutput, 'agent-output.md'); const mockOutput = `# Mock Agent Output ## Summary This is a mock agent response for CI/CD testing. ## Changes Made - Created \`yellow.txt\` with content "yellow" ## Notes This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. `; await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); await secureFs.writeFile(outputPath, mockOutput); logger.info(`MOCK MODE: Completed mock execution for feature ${featureId}`); return; } // Load autoLoadClaudeMd setting (project setting takes precedence over global) // Use provided value if available, otherwise load from settings const autoLoadClaudeMd = options?.autoLoadClaudeMd !== undefined ? options.autoLoadClaudeMd : await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]'); // Load MCP servers from settings (global setting only) const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]'); // Load MCP permission settings (global setting only) // Build SDK options using centralized configuration for feature implementation const sdkOptions = createAutoModeOptions({ cwd: workDir, model: model, abortController, autoLoadClaudeMd, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, thinkingLevel: options?.thinkingLevel, }); // Extract model, maxTurns, and allowedTools from SDK options const finalModel = sdkOptions.model!; const maxTurns = sdkOptions.maxTurns; const allowedTools = sdkOptions.allowedTools as string[] | undefined; logger.info( `runAgent called for feature ${featureId} with model: ${finalModel}, planningMode: ${planningMode}, requiresApproval: ${requiresApproval}` ); // Get provider for this model const provider = ProviderFactory.getProviderForModel(finalModel); // Strip provider prefix - providers should receive bare model IDs const bareModel = stripProviderPrefix(finalModel); logger.info( `Using provider "${provider.getName()}" for model "${finalModel}" (bare: ${bareModel})` ); // Build prompt content with images using utility const { content: promptContent } = await buildPromptWithImages( prompt, imagePaths, workDir, false // don't duplicate paths in text ); // Debug: Log if system prompt is provided if (options?.systemPrompt) { logger.info( `System prompt provided (${options.systemPrompt.length} chars), first 200 chars:\n${options.systemPrompt.substring(0, 200)}...` ); } // Get credentials for API calls (model comes from request, no phase model) const credentials = await this.settingsService?.getCredentials(); // Try to find a provider for the model (if it's a provider model like "GLM-4.7") // This allows users to select provider models in the Auto Mode / Feature execution let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; let providerResolvedModel: string | undefined; if (finalModel && this.settingsService) { const providerResult = await getProviderByModelId( finalModel, this.settingsService, '[AutoMode]' ); if (providerResult.provider) { claudeCompatibleProvider = providerResult.provider; providerResolvedModel = providerResult.resolvedModel; logger.info( `[AutoMode] Using provider "${providerResult.provider.name}" for model "${finalModel}"` + (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') ); } } // Use the resolved model if available (from mapsToClaudeModel), otherwise use bareModel const effectiveBareModel = providerResolvedModel ? stripProviderPrefix(providerResolvedModel) : bareModel; const executeOptions: ExecuteOptions = { prompt: promptContent, model: effectiveBareModel, maxTurns: maxTurns, cwd: workDir, allowedTools: allowedTools, abortController, systemPrompt: sdkOptions.systemPrompt, settingSources: sdkOptions.settingSources, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking credentials, // Pass credentials for resolving 'credentials' apiKeySource claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.) }; // Execute via provider logger.info(`Starting stream for feature ${featureId}...`); const stream = provider.executeQuery(executeOptions); logger.info(`Stream created, starting to iterate...`); // Initialize with previous content if this is a follow-up, with a separator let responseText = previousContent ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` : ''; let specDetected = false; // Agent output goes to .automaker directory // Note: We use projectPath here, not workDir, because workDir might be a worktree path const featureDirForOutput = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDirForOutput, 'agent-output.md'); const rawOutputPath = path.join(featureDirForOutput, 'raw-output.jsonl'); // Raw output logging is configurable via environment variable // Set AUTOMAKER_DEBUG_RAW_OUTPUT=true to enable raw stream event logging const enableRawOutput = process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' || process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1'; // Incremental file writing state let writeTimeout: ReturnType | null = null; const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms // Raw output accumulator for debugging (NDJSON format) let rawOutputLines: string[] = []; let rawWriteTimeout: ReturnType | null = null; // Helper to append raw stream event for debugging (only when enabled) const appendRawEvent = (event: unknown): void => { if (!enableRawOutput) return; try { const timestamp = new Date().toISOString(); const rawLine = JSON.stringify({ timestamp, event }, null, 4); // Pretty print for readability rawOutputLines.push(rawLine); // Debounced write of raw output if (rawWriteTimeout) { clearTimeout(rawWriteTimeout); } rawWriteTimeout = setTimeout(async () => { try { await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); rawOutputLines = []; // Clear after writing } catch (error) { logger.error(`Failed to write raw output for ${featureId}:`, error); } }, WRITE_DEBOUNCE_MS); } catch { // Ignore serialization errors } }; // Helper to write current responseText to file const writeToFile = async (): Promise => { try { await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); await secureFs.writeFile(outputPath, responseText); } catch (error) { // Log but don't crash - file write errors shouldn't stop execution logger.error(`Failed to write agent output for ${featureId}:`, error); } }; // Debounced write - schedules a write after WRITE_DEBOUNCE_MS const scheduleWrite = (): void => { if (writeTimeout) { clearTimeout(writeTimeout); } writeTimeout = setTimeout(() => { writeToFile(); }, WRITE_DEBOUNCE_MS); }; // Heartbeat logging so "silent" model calls are visible. // Some runs can take a while before the first streamed message arrives. const streamStartTime = Date.now(); let receivedAnyStreamMessage = false; const STREAM_HEARTBEAT_MS = 15_000; const streamHeartbeat = setInterval(() => { if (receivedAnyStreamMessage) return; const elapsedSeconds = Math.round((Date.now() - streamStartTime) / 1000); logger.info( `Waiting for first model response for feature ${featureId} (${elapsedSeconds}s elapsed)...` ); }, STREAM_HEARTBEAT_MS); // Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort try { streamLoop: for await (const msg of stream) { receivedAnyStreamMessage = true; // Log raw stream event for debugging appendRawEvent(msg); logger.info(`Stream message received:`, msg.type, msg.subtype || ''); if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { const newText = block.text || ''; // Skip empty text if (!newText) continue; // Note: Cursor-specific dedup (duplicate blocks, accumulated text) is now // handled in CursorProvider.deduplicateTextBlocks() for cleaner separation // Only add separator when we're at a natural paragraph break: // - Previous text ends with sentence terminator AND new text starts a new thought // - Don't add separators mid-word or mid-sentence (for streaming providers like Cursor) if (responseText.length > 0 && newText.length > 0) { const lastChar = responseText.slice(-1); const endsWithSentence = /[.!?:]\s*$/.test(responseText); const endsWithNewline = /\n\s*$/.test(responseText); const startsNewParagraph = /^[\n#\-*>]/.test(newText); // Add paragraph break only at natural boundaries if ( !endsWithNewline && (endsWithSentence || startsNewParagraph) && !/[a-zA-Z0-9]/.test(lastChar) // Not mid-word ) { responseText += '\n\n'; } } responseText += newText; // Check for authentication errors in the response if ( block.text && (block.text.includes('Invalid API key') || block.text.includes('authentication_failed') || block.text.includes('Fix external API key')) ) { throw new Error( 'Authentication failed: Invalid or expired API key. ' + "Please check your ANTHROPIC_API_KEY, or run 'claude login' to re-authenticate." ); } // Schedule incremental file write (debounced) scheduleWrite(); // Check for [SPEC_GENERATED] marker in planning modes (spec or full) if ( planningModeRequiresApproval && !specDetected && responseText.includes('[SPEC_GENERATED]') ) { specDetected = true; // Extract plan content (everything before the marker) const markerIndex = responseText.indexOf('[SPEC_GENERATED]'); const planContent = responseText.substring(0, markerIndex).trim(); // Parse tasks from the generated spec (for spec and full modes) // Use let since we may need to update this after plan revision let parsedTasks = parseTasksFromSpec(planContent); const tasksTotal = parsedTasks.length; logger.info(`Parsed ${tasksTotal} tasks from spec for feature ${featureId}`); if (parsedTasks.length > 0) { logger.info(`Tasks: ${parsedTasks.map((t) => t.id).join(', ')}`); } // Update planSpec status to 'generated' and save content with parsed tasks await this.updateFeaturePlanSpec(projectPath, featureId, { status: 'generated', content: planContent, version: 1, generatedAt: new Date().toISOString(), reviewedByUser: false, tasks: parsedTasks, tasksTotal, tasksCompleted: 0, }); let approvedPlanContent = planContent; let userFeedback: string | undefined; let currentPlanContent = planContent; let planVersion = 1; // Only pause for approval if requirePlanApproval is true if (requiresApproval) { // ======================================== // PLAN REVISION LOOP // Keep regenerating plan until user approves // ======================================== let planApproved = false; while (!planApproved) { logger.info( `Spec v${planVersion} generated for feature ${featureId}, waiting for approval` ); // CRITICAL: Register pending approval BEFORE emitting event const approvalPromise = this.waitForPlanApproval(featureId, projectPath); // Emit plan_approval_required event this.emitAutoModeEvent('plan_approval_required', { featureId, projectPath, branchName, planContent: currentPlanContent, planningMode, planVersion, }); // Wait for user response try { const approvalResult = await approvalPromise; if (approvalResult.approved) { // User approved the plan logger.info(`Plan v${planVersion} approved for feature ${featureId}`); planApproved = true; // If user provided edits, use the edited version if (approvalResult.editedPlan) { approvedPlanContent = approvalResult.editedPlan; await this.updateFeaturePlanSpec(projectPath, featureId, { content: approvalResult.editedPlan, }); } else { approvedPlanContent = currentPlanContent; } // Capture any additional feedback for implementation userFeedback = approvalResult.feedback; // Emit approval event this.emitAutoModeEvent('plan_approved', { featureId, projectPath, branchName, hasEdits: !!approvalResult.editedPlan, planVersion, }); } else { // User rejected - check if they provided feedback for revision const hasFeedback = approvalResult.feedback && approvalResult.feedback.trim().length > 0; const hasEdits = approvalResult.editedPlan && approvalResult.editedPlan.trim().length > 0; if (!hasFeedback && !hasEdits) { // No feedback or edits = explicit cancel logger.info( `Plan rejected without feedback for feature ${featureId}, cancelling` ); throw new Error('Plan cancelled by user'); } // User wants revisions - regenerate the plan logger.info( `Plan v${planVersion} rejected with feedback for feature ${featureId}, regenerating...` ); planVersion++; // Emit revision event this.emitAutoModeEvent('plan_revision_requested', { featureId, projectPath, branchName, feedback: approvalResult.feedback, hasEdits: !!hasEdits, planVersion, }); // Build revision prompt let revisionPrompt = `The user has requested revisions to the plan/specification. ## Previous Plan (v${planVersion - 1}) ${hasEdits ? approvalResult.editedPlan : currentPlanContent} ## User Feedback ${approvalResult.feedback || 'Please revise the plan based on the edits above.'} ## Instructions Please regenerate the specification incorporating the user's feedback. Keep the same format with the \`\`\`tasks block for task definitions. After generating the revised spec, output: "[SPEC_GENERATED] Please review the revised specification above." `; // Update status to regenerating await this.updateFeaturePlanSpec(projectPath, featureId, { status: 'generating', version: planVersion, }); // Make revision call const revisionStream = provider.executeQuery({ prompt: revisionPrompt, model: bareModel, maxTurns: maxTurns || 100, cwd: workDir, allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, credentials, // Pass credentials for resolving 'credentials' apiKeySource claudeCompatibleProvider, // Pass provider for alternative endpoint configuration }); let revisionText = ''; for await (const msg of revisionStream) { if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { revisionText += block.text || ''; this.emitAutoModeEvent('auto_mode_progress', { featureId, content: block.text, }); } } } else if (msg.type === 'error') { throw new Error(msg.error || 'Error during plan revision'); } else if (msg.type === 'result' && msg.subtype === 'success') { revisionText += msg.result || ''; } } // Extract new plan content const markerIndex = revisionText.indexOf('[SPEC_GENERATED]'); if (markerIndex > 0) { currentPlanContent = revisionText.substring(0, markerIndex).trim(); } else { currentPlanContent = revisionText.trim(); } // Re-parse tasks from revised plan const revisedTasks = parseTasksFromSpec(currentPlanContent); logger.info(`Revised plan has ${revisedTasks.length} tasks`); // Update planSpec with revised content await this.updateFeaturePlanSpec(projectPath, featureId, { status: 'generated', content: currentPlanContent, version: planVersion, tasks: revisedTasks, tasksTotal: revisedTasks.length, tasksCompleted: 0, }); // Update parsedTasks for implementation parsedTasks = revisedTasks; responseText += revisionText; } } catch (error) { if ((error as Error).message.includes('cancelled')) { throw error; } throw new Error(`Plan approval failed: ${(error as Error).message}`); } } } else { // Auto-approve: requirePlanApproval is false, just continue without pausing logger.info( `Spec generated for feature ${featureId}, auto-approving (requirePlanApproval=false)` ); // Emit info event for frontend this.emitAutoModeEvent('plan_auto_approved', { featureId, projectPath, branchName, planContent, planningMode, }); approvedPlanContent = planContent; } // CRITICAL: After approval, we need to make a second call to continue implementation // The agent is waiting for "approved" - we need to send it and continue logger.info( `Making continuation call after plan approval for feature ${featureId}` ); // Update planSpec status to approved (handles both manual and auto-approval paths) await this.updateFeaturePlanSpec(projectPath, featureId, { status: 'approved', approvedAt: new Date().toISOString(), reviewedByUser: requiresApproval, }); // ======================================== // MULTI-AGENT TASK EXECUTION // Each task gets its own focused agent call // ======================================== if (parsedTasks.length > 0) { logger.info( `Starting multi-agent execution: ${parsedTasks.length} tasks for feature ${featureId}` ); // Get customized prompts for task execution const taskPrompts = await getPromptCustomization( this.settingsService, '[AutoMode]' ); // Execute each task with a separate agent for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) { const task = parsedTasks[taskIndex]; // Check for abort if (abortController.signal.aborted) { throw new Error('Feature execution aborted'); } // Emit task started logger.info(`Starting task ${task.id}: ${task.description}`); this.emitAutoModeEvent('auto_mode_task_started', { featureId, projectPath, branchName, taskId: task.id, taskDescription: task.description, taskIndex, tasksTotal: parsedTasks.length, }); // Update planSpec with current task await this.updateFeaturePlanSpec(projectPath, featureId, { currentTaskId: task.id, }); // Build focused prompt for this specific task const taskPrompt = this.buildTaskPrompt( task, parsedTasks, taskIndex, approvedPlanContent, taskPrompts.taskExecution.taskPromptTemplate, userFeedback ); // Execute task with dedicated agent const taskStream = provider.executeQuery({ prompt: taskPrompt, model: bareModel, maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task cwd: workDir, allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, credentials, // Pass credentials for resolving 'credentials' apiKeySource claudeCompatibleProvider, // Pass provider for alternative endpoint configuration }); let taskOutput = ''; // Process task stream for await (const msg of taskStream) { if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { taskOutput += block.text || ''; responseText += block.text || ''; this.emitAutoModeEvent('auto_mode_progress', { featureId, branchName, content: block.text, }); } else if (block.type === 'tool_use') { this.emitAutoModeEvent('auto_mode_tool', { featureId, branchName, tool: block.name, input: block.input, }); } } } else if (msg.type === 'error') { throw new Error(msg.error || `Error during task ${task.id}`); } else if (msg.type === 'result' && msg.subtype === 'success') { taskOutput += msg.result || ''; responseText += msg.result || ''; } } // Emit task completed logger.info(`Task ${task.id} completed for feature ${featureId}`); this.emitAutoModeEvent('auto_mode_task_complete', { featureId, projectPath, branchName, taskId: task.id, tasksCompleted: taskIndex + 1, tasksTotal: parsedTasks.length, }); // Update planSpec with progress await this.updateFeaturePlanSpec(projectPath, featureId, { tasksCompleted: taskIndex + 1, }); // Check for phase completion (group tasks by phase) if (task.phase) { const nextTask = parsedTasks[taskIndex + 1]; if (!nextTask || nextTask.phase !== task.phase) { // Phase changed, emit phase complete const phaseMatch = task.phase.match(/Phase\s*(\d+)/i); if (phaseMatch) { this.emitAutoModeEvent('auto_mode_phase_complete', { featureId, projectPath, branchName, phaseNumber: parseInt(phaseMatch[1], 10), }); } } } } logger.info(`All ${parsedTasks.length} tasks completed for feature ${featureId}`); } else { // No parsed tasks - fall back to single-agent execution logger.info( `No parsed tasks, using single-agent execution for feature ${featureId}` ); // Get customized prompts for continuation const taskPrompts = await getPromptCustomization( this.settingsService, '[AutoMode]' ); let continuationPrompt = taskPrompts.taskExecution.continuationAfterApprovalTemplate; continuationPrompt = continuationPrompt.replace( /\{\{userFeedback\}\}/g, userFeedback || '' ); continuationPrompt = continuationPrompt.replace( /\{\{approvedPlan\}\}/g, approvedPlanContent ); const continuationStream = provider.executeQuery({ prompt: continuationPrompt, model: bareModel, maxTurns: maxTurns, cwd: workDir, allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, credentials, // Pass credentials for resolving 'credentials' apiKeySource claudeCompatibleProvider, // Pass provider for alternative endpoint configuration }); for await (const msg of continuationStream) { if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { responseText += block.text || ''; this.emitAutoModeEvent('auto_mode_progress', { featureId, branchName, content: block.text, }); } else if (block.type === 'tool_use') { this.emitAutoModeEvent('auto_mode_tool', { featureId, branchName, tool: block.name, input: block.input, }); } } } else if (msg.type === 'error') { throw new Error(msg.error || 'Unknown error during implementation'); } else if (msg.type === 'result' && msg.subtype === 'success') { responseText += msg.result || ''; } } } logger.info(`Implementation completed for feature ${featureId}`); // Exit the original stream loop since continuation is done break streamLoop; } // Only emit progress for non-marker text (marker was already handled above) if (!specDetected) { logger.info( `Emitting progress event for ${featureId}, content length: ${block.text?.length || 0}` ); this.emitAutoModeEvent('auto_mode_progress', { featureId, branchName, content: block.text, }); } } else if (block.type === 'tool_use') { // Emit event for real-time UI this.emitAutoModeEvent('auto_mode_tool', { featureId, branchName, tool: block.name, input: block.input, }); // Also add to file output for persistence if (responseText.length > 0 && !responseText.endsWith('\n')) { responseText += '\n'; } responseText += `\nšŸ”§ Tool: ${block.name}\n`; if (block.input) { responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; } scheduleWrite(); } } } else if (msg.type === 'error') { // Handle error messages throw new Error(msg.error || 'Unknown error'); } else if (msg.type === 'result' && msg.subtype === 'success') { // Don't replace responseText - the accumulated content is the full history // The msg.result is just a summary which would lose all tool use details // Just ensure final write happens scheduleWrite(); } } // Final write - ensure all accumulated content is saved (on success path) await writeToFile(); // Flush remaining raw output (only if enabled, on success path) if (enableRawOutput && rawOutputLines.length > 0) { try { await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); } catch (error) { logger.error(`Failed to write final raw output for ${featureId}:`, error); } } } finally { clearInterval(streamHeartbeat); // ALWAYS clear pending timeouts to prevent memory leaks // This runs on success, error, or abort if (writeTimeout) { clearTimeout(writeTimeout); writeTimeout = null; } if (rawWriteTimeout) { clearTimeout(rawWriteTimeout); rawWriteTimeout = null; } } } private async executeFeatureWithContext( projectPath: string, featureId: string, context: string, useWorktrees: boolean ): Promise { const feature = await this.loadFeature(projectPath, featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } // Get customized prompts from settings const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); // Build the feature prompt const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution); // Use the resume feature template with variable substitution let prompt = prompts.taskExecution.resumeFeatureTemplate; prompt = prompt.replace(/\{\{featurePrompt\}\}/g, featurePrompt); prompt = prompt.replace(/\{\{previousContext\}\}/g, context); return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { continuationPrompt: prompt, _calledInternally: true, }); } /** * Detect if a feature is stuck in a pipeline step and extract step information. * Parses the feature status to determine if it's a pipeline status (e.g., 'pipeline_step_xyz'), * loads the pipeline configuration, and validates that the step still exists. * * This method handles several scenarios: * - Non-pipeline status: Returns default PipelineStatusInfo with isPipeline=false * - Invalid pipeline status format: Returns isPipeline=true but null step info * - Step deleted from config: Returns stepIndex=-1 to signal missing step * - Valid pipeline step: Returns full step information and config * * @param {string} projectPath - Absolute path to the project directory * @param {string} featureId - Unique identifier of the feature * @param {FeatureStatusWithPipeline} currentStatus - Current feature status (may include pipeline step info) * @returns {Promise} Information about the pipeline status and step * @private */ private async detectPipelineStatus( projectPath: string, featureId: string, currentStatus: FeatureStatusWithPipeline ): Promise { // Check if status is pipeline format using PipelineService const isPipeline = pipelineService.isPipelineStatus(currentStatus); if (!isPipeline) { return { isPipeline: false, stepId: null, stepIndex: -1, totalSteps: 0, step: null, config: null, }; } // Extract step ID using PipelineService const stepId = pipelineService.getStepIdFromStatus(currentStatus); if (!stepId) { console.warn( `[AutoMode] Feature ${featureId} has invalid pipeline status format: ${currentStatus}` ); return { isPipeline: true, stepId: null, stepIndex: -1, totalSteps: 0, step: null, config: null, }; } // Load pipeline config const config = await pipelineService.getPipelineConfig(projectPath); if (!config || config.steps.length === 0) { // Pipeline config doesn't exist or empty - feature stuck with invalid pipeline status console.warn( `[AutoMode] Feature ${featureId} has pipeline status but no pipeline config exists` ); return { isPipeline: true, stepId, stepIndex: -1, totalSteps: 0, step: null, config: null, }; } // Find the step directly from config (already loaded, avoid redundant file read) const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order); const stepIndex = sortedSteps.findIndex((s) => s.id === stepId); const step = stepIndex === -1 ? null : sortedSteps[stepIndex]; if (!step) { // Step not found in current config - step was deleted/changed console.warn( `[AutoMode] Feature ${featureId} stuck in step ${stepId} which no longer exists in pipeline config` ); return { isPipeline: true, stepId, stepIndex: -1, totalSteps: sortedSteps.length, step: null, config, }; } console.log( `[AutoMode] Detected pipeline status for feature ${featureId}: step ${stepIndex + 1}/${sortedSteps.length} (${step.name})` ); return { isPipeline: true, stepId, stepIndex, totalSteps: sortedSteps.length, step, config, }; } /** * Build a focused prompt for executing a single task. * Each task gets minimal context to keep the agent focused. */ private buildTaskPrompt( task: ParsedTask, allTasks: ParsedTask[], taskIndex: number, planContent: string, taskPromptTemplate: string, userFeedback?: string ): string { const completedTasks = allTasks.slice(0, taskIndex); const remainingTasks = allTasks.slice(taskIndex + 1); // Build completed tasks string const completedTasksStr = completedTasks.length > 0 ? `### Already Completed (${completedTasks.length} tasks)\n${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')}\n` : ''; // Build remaining tasks string const remainingTasksStr = remainingTasks.length > 0 ? `### Coming Up Next (${remainingTasks.length} tasks remaining)\n${remainingTasks .slice(0, 3) .map((t) => `- [ ] ${t.id}: ${t.description}`) .join( '\n' )}${remainingTasks.length > 3 ? `\n... and ${remainingTasks.length - 3} more tasks` : ''}\n` : ''; // Build user feedback string const userFeedbackStr = userFeedback ? `### User Feedback\n${userFeedback}\n` : ''; // Use centralized template with variable substitution let prompt = taskPromptTemplate; prompt = prompt.replace(/\{\{taskId\}\}/g, task.id); prompt = prompt.replace(/\{\{taskDescription\}\}/g, task.description); prompt = prompt.replace(/\{\{taskFilePath\}\}/g, task.filePath || ''); prompt = prompt.replace(/\{\{taskPhase\}\}/g, task.phase || ''); prompt = prompt.replace(/\{\{completedTasks\}\}/g, completedTasksStr); prompt = prompt.replace(/\{\{remainingTasks\}\}/g, remainingTasksStr); prompt = prompt.replace(/\{\{userFeedback\}\}/g, userFeedbackStr); prompt = prompt.replace(/\{\{planContent\}\}/g, planContent); return prompt; } /** * Emit an auto-mode event wrapped in the correct format for the client. * All auto-mode events are sent as type "auto-mode:event" with the actual * event type and data in the payload. */ private emitAutoModeEvent(eventType: string, data: Record): void { // Wrap the event in auto-mode:event format expected by the client this.events.emit('auto-mode:event', { type: eventType, ...data, }); } private sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(resolve, ms); // If signal is provided and already aborted, reject immediately if (signal?.aborted) { clearTimeout(timeout); reject(new Error('Aborted')); return; } // Listen for abort signal if (signal) { signal.addEventListener( 'abort', () => { clearTimeout(timeout); reject(new Error('Aborted')); }, { once: true } ); } }); } // ============================================================================ // Execution State Persistence - For recovery after server restart // ============================================================================ /** * Save execution state to disk for recovery after server restart */ private async saveExecutionState(projectPath: string): Promise { try { await ensureAutomakerDir(projectPath); const statePath = getExecutionStatePath(projectPath); const state: ExecutionState = { version: 1, autoLoopWasRunning: this.autoLoopRunning, maxConcurrency: this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, projectPath, branchName: null, // Legacy global auto mode uses main worktree runningFeatureIds: Array.from(this.runningFeatures.keys()), savedAt: new Date().toISOString(), }; await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`); } catch (error) { logger.error('Failed to save execution state:', error); } } /** * Load execution state from disk */ private async loadExecutionState(projectPath: string): Promise { try { const statePath = getExecutionStatePath(projectPath); const content = (await secureFs.readFile(statePath, 'utf-8')) as string; const state = JSON.parse(content) as ExecutionState; return state; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { logger.error('Failed to load execution state:', error); } return DEFAULT_EXECUTION_STATE; } } /** * Clear execution state (called on successful shutdown or when auto-loop stops) */ private async clearExecutionState( projectPath: string, branchName: string | null = null ): Promise { try { const statePath = getExecutionStatePath(projectPath); await secureFs.unlink(statePath); const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info(`Cleared execution state for ${worktreeDesc}`); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { logger.error('Failed to clear execution state:', error); } } } /** * Check for and resume interrupted features after server restart * This should be called during server initialization */ async resumeInterruptedFeatures(projectPath: string): Promise { logger.info('Checking for interrupted features to resume...'); // Load all features and find those that were interrupted const featuresDir = getFeaturesDir(projectPath); try { const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); // Track features with and without context separately for better logging const featuresWithContext: Feature[] = []; const featuresWithoutContext: Feature[] = []; for (const entry of entries) { if (entry.isDirectory()) { const featurePath = path.join(featuresDir, entry.name, 'feature.json'); // Use recovery-enabled read for corrupted file handling const result = await readJsonWithRecovery(featurePath, null, { maxBackups: DEFAULT_BACKUP_COUNT, autoRestore: true, }); logRecoveryWarning(result, `Feature ${entry.name}`, logger); const feature = result.data; if (!feature) { // Skip features that couldn't be loaded or recovered continue; } // Check if feature was interrupted (in_progress or pipeline_*) if ( feature.status === 'in_progress' || (feature.status && feature.status.startsWith('pipeline_')) ) { // Check if context (agent-output.md) exists const featureDir = getFeatureDir(projectPath, feature.id); const contextPath = path.join(featureDir, 'agent-output.md'); try { await secureFs.access(contextPath); featuresWithContext.push(feature); logger.info( `Found interrupted feature with context: ${feature.id} (${feature.title}) - status: ${feature.status}` ); } catch { // No context file - feature was interrupted before any agent output // Still include it for resumption (will start fresh) featuresWithoutContext.push(feature); logger.info( `Found interrupted feature without context: ${feature.id} (${feature.title}) - status: ${feature.status} (will restart fresh)` ); } } } } // Combine all interrupted features (with and without context) const allInterruptedFeatures = [...featuresWithContext, ...featuresWithoutContext]; if (allInterruptedFeatures.length === 0) { logger.info('No interrupted features found'); return; } logger.info( `Found ${allInterruptedFeatures.length} interrupted feature(s) to resume ` + `(${featuresWithContext.length} with context, ${featuresWithoutContext.length} without context)` ); // Emit event to notify UI with context information this.emitAutoModeEvent('auto_mode_resuming_features', { message: `Resuming ${allInterruptedFeatures.length} interrupted feature(s) after server restart`, projectPath, featureIds: allInterruptedFeatures.map((f) => f.id), features: allInterruptedFeatures.map((f) => ({ id: f.id, title: f.title, status: f.status, branchName: f.branchName ?? null, hasContext: featuresWithContext.some((fc) => fc.id === f.id), })), }); // Resume each interrupted feature for (const feature of allInterruptedFeatures) { try { // Idempotent check: skip if feature is already being resumed (prevents race conditions) if (this.isFeatureRunning(feature.id)) { logger.info( `Feature ${feature.id} (${feature.title}) is already being resumed, skipping` ); continue; } const hasContext = featuresWithContext.some((fc) => fc.id === feature.id); logger.info( `Resuming feature: ${feature.id} (${feature.title}) - ${hasContext ? 'continuing from context' : 'starting fresh'}` ); // Use resumeFeature which will detect the existing context and continue, // or start fresh if no context exists await this.resumeFeature(projectPath, feature.id, true); } catch (error) { logger.error(`Failed to resume feature ${feature.id}:`, error); // Continue with other features } } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { logger.info('No features directory found, nothing to resume'); } else { logger.error('Error checking for interrupted features:', error); } } } /** * Extract and record learnings from a completed feature * Uses a quick Claude call to identify important decisions and patterns */ private async recordLearningsFromFeature( projectPath: string, feature: Feature, agentOutput: string ): Promise { if (!agentOutput || agentOutput.length < 100) { // Not enough output to extract learnings from console.log( `[AutoMode] Skipping learning extraction - output too short (${agentOutput?.length || 0} chars)` ); return; } console.log( `[AutoMode] Extracting learnings from feature "${feature.title}" (${agentOutput.length} chars)` ); // Limit output to avoid token limits const truncatedOutput = agentOutput.length > 10000 ? agentOutput.slice(-10000) : agentOutput; // Get customized prompts from settings const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); // Build user prompt using centralized template with variable substitution let userPrompt = prompts.taskExecution.learningExtractionUserPromptTemplate; userPrompt = userPrompt.replace(/\{\{featureTitle\}\}/g, feature.title || ''); userPrompt = userPrompt.replace(/\{\{implementationLog\}\}/g, truncatedOutput); try { // Get model from phase settings const settings = await this.settingsService?.getGlobalSettings(); const phaseModelEntry = settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel; const { model } = resolvePhaseModel(phaseModelEntry); const hasClaudeKey = Boolean(process.env.ANTHROPIC_API_KEY); let resolvedModel = model; if (isClaudeModel(model) && !hasClaudeKey) { const fallbackModel = feature.model ? resolveModelString(feature.model, DEFAULT_MODELS.claude) : null; if (fallbackModel && !isClaudeModel(fallbackModel)) { console.log( `[AutoMode] Claude not configured for memory extraction; using feature model "${fallbackModel}".` ); resolvedModel = fallbackModel; } else { console.log( '[AutoMode] Claude not configured for memory extraction; skipping learning extraction.' ); return; } } const result = await simpleQuery({ prompt: userPrompt, model: resolvedModel, cwd: projectPath, maxTurns: 1, allowedTools: [], systemPrompt: prompts.taskExecution.learningExtractionSystemPrompt, }); const responseText = result.text; console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`); console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`); // Parse the response - handle JSON in markdown code blocks or raw let jsonStr: string | null = null; // First try to find JSON in markdown code blocks const codeBlockMatch = responseText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/); if (codeBlockMatch) { console.log('[AutoMode] Found JSON in code block'); jsonStr = codeBlockMatch[1]; } else { // Fall back to finding balanced braces containing "learnings" // Use a more precise approach: find the opening brace before "learnings" const learningsIndex = responseText.indexOf('"learnings"'); if (learningsIndex !== -1) { // Find the opening brace before "learnings" let braceStart = responseText.lastIndexOf('{', learningsIndex); if (braceStart !== -1) { // Find matching closing brace let braceCount = 0; let braceEnd = -1; for (let i = braceStart; i < responseText.length; i++) { if (responseText[i] === '{') braceCount++; if (responseText[i] === '}') braceCount--; if (braceCount === 0) { braceEnd = i; break; } } if (braceEnd !== -1) { jsonStr = responseText.substring(braceStart, braceEnd + 1); } } } } if (!jsonStr) { console.log('[AutoMode] Could not extract JSON from response'); return; } console.log(`[AutoMode] Extracted JSON: ${jsonStr.substring(0, 200)}`); let parsed: { learnings?: unknown[] }; try { parsed = JSON.parse(jsonStr); } catch { console.warn('[AutoMode] Failed to parse learnings JSON:', jsonStr.substring(0, 200)); return; } if (!parsed.learnings || !Array.isArray(parsed.learnings)) { console.log('[AutoMode] No learnings array in parsed response'); return; } console.log(`[AutoMode] Found ${parsed.learnings.length} potential learnings`); // Valid learning types const validTypes = new Set(['decision', 'learning', 'pattern', 'gotcha']); // Record each learning for (const item of parsed.learnings) { // Validate required fields with proper type narrowing if (!item || typeof item !== 'object') continue; const learning = item as Record; if ( !learning.category || typeof learning.category !== 'string' || !learning.content || typeof learning.content !== 'string' || !learning.content.trim() ) { continue; } // Validate and normalize type const typeStr = typeof learning.type === 'string' ? learning.type : 'learning'; const learningType = validTypes.has(typeStr) ? (typeStr as 'decision' | 'learning' | 'pattern' | 'gotcha') : 'learning'; console.log( `[AutoMode] Appending learning: category=${learning.category}, type=${learningType}` ); await appendLearning( projectPath, { category: learning.category, type: learningType, content: learning.content.trim(), context: typeof learning.context === 'string' ? learning.context : undefined, why: typeof learning.why === 'string' ? learning.why : undefined, rejected: typeof learning.rejected === 'string' ? learning.rejected : undefined, tradeoffs: typeof learning.tradeoffs === 'string' ? learning.tradeoffs : undefined, breaking: typeof learning.breaking === 'string' ? learning.breaking : undefined, }, secureFs as Parameters[2] ); } const validLearnings = parsed.learnings.filter( (l) => l && typeof l === 'object' && (l as Record).content ); if (validLearnings.length > 0) { console.log( `[AutoMode] Recorded ${parsed.learnings.length} learning(s) from feature ${feature.id}` ); } } catch (error) { console.warn(`[AutoMode] Failed to extract learnings from feature ${feature.id}:`, error); } } /** * Detect orphaned features - features whose branchName points to a branch that no longer exists. * * Orphaned features can occur when: * - A feature branch is deleted after merge * - A worktree is manually removed * - A branch is force-deleted * * @param projectPath - Path to the project * @returns Array of orphaned features with their missing branch names */ async detectOrphanedFeatures( projectPath: string ): Promise> { const orphanedFeatures: Array<{ feature: Feature; missingBranch: string }> = []; try { // Get all features for this project const allFeatures = await this.featureLoader.getAll(projectPath); // Get features that have a branchName set (excludes main branch features) const featuresWithBranches = allFeatures.filter( (f) => f.branchName && f.branchName.trim() !== '' ); if (featuresWithBranches.length === 0) { logger.debug('[detectOrphanedFeatures] No features with branch names found'); return orphanedFeatures; } // Get all existing branches (local) const existingBranches = await this.getExistingBranches(projectPath); // Get current/primary branch (features with null branchName are implicitly on this) const primaryBranch = await getCurrentBranch(projectPath); // Check each feature with a branchName for (const feature of featuresWithBranches) { const branchName = feature.branchName!; // Skip if the branchName matches the primary branch (implicitly valid) if (primaryBranch && branchName === primaryBranch) { continue; } // Check if the branch exists if (!existingBranches.has(branchName)) { orphanedFeatures.push({ feature, missingBranch: branchName, }); logger.info( `[detectOrphanedFeatures] Found orphaned feature: ${feature.id} (${feature.title}) - branch "${branchName}" no longer exists` ); } } if (orphanedFeatures.length > 0) { logger.info( `[detectOrphanedFeatures] Found ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}` ); } else { logger.debug('[detectOrphanedFeatures] No orphaned features found'); } return orphanedFeatures; } catch (error) { logger.error('[detectOrphanedFeatures] Error detecting orphaned features:', error); return orphanedFeatures; } } /** * Get all existing local branches for a project * @param projectPath - Path to the git repository * @returns Set of branch names */ private async getExistingBranches(projectPath: string): Promise> { const branches = new Set(); try { // Use git for-each-ref to get all local branches const { stdout } = await execAsync( 'git for-each-ref --format="%(refname:short)" refs/heads/', { cwd: projectPath } ); const branchLines = stdout.trim().split('\n'); for (const branch of branchLines) { const trimmed = branch.trim(); if (trimmed) { branches.add(trimmed); } } logger.debug(`[getExistingBranches] Found ${branches.size} local branches`); } catch (error) { logger.error('[getExistingBranches] Failed to get branches:', error); } return branches; } }