From 758c6c0af5598e89bdde0b78d9507c07668d541d Mon Sep 17 00:00:00 2001 From: Shirone Date: Tue, 27 Jan 2026 16:55:58 +0100 Subject: [PATCH] refactor(03-03): wire runAgent() to delegate to AgentExecutor.execute() - Replace stream processing loop with AgentExecutor.execute() delegation - Build AgentExecutionOptions object from runAgent() parameters - Create callbacks for waitForApproval, saveFeatureSummary, etc. - Remove ~930 lines of duplicated stream processing code - Progress events now flow through AgentExecutor File: auto-mode-service.ts reduced from 5086 to 4157 lines --- apps/server/src/services/auto-mode-service.ts | 1025 +---------------- 1 file changed, 48 insertions(+), 977 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 59706fff..f91cde1d 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -3424,986 +3424,57 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ? stripProviderPrefix(providerResolvedModel) : bareModel; - const executeOptions: ExecuteOptions = { - prompt: promptContent, - model: effectiveBareModel, - maxTurns: maxTurns, - cwd: workDir, - allowedTools: allowedTools, + // Build AgentExecutionOptions for delegation to AgentExecutor + const agentOptions = { + workDir, + featureId, + prompt, + projectPath, 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.) + imagePaths, + model: finalModel, + planningMode, + requirePlanApproval: options?.requirePlanApproval, + previousContent, + systemPrompt: options?.systemPrompt, + autoLoadClaudeMd, + thinkingLevel: options?.thinkingLevel, + branchName, + credentials, + claudeCompatibleProvider, + mcpServers, + sdkOptions: { + maxTurns, + allowedTools, + systemPrompt: sdkOptions.systemPrompt, + settingSources: sdkOptions.settingSources, + }, + provider, + effectiveBareModel, + // Recovery options + specAlreadyDetected: !!existingApprovedPlan, + existingApprovedPlanContent: existingApprovedPlan?.content, + persistedTasks, }; - // 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` - : ''; - // Skip spec detection if we already have an approved plan (recovery scenario) - let specDetected = !!existingApprovedPlan; - - // 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); - - // RECOVERY PATH: If we have an approved plan with persisted tasks, skip spec generation - // and directly execute the remaining tasks - if (existingApprovedPlan && persistedTasks && persistedTasks.length > 0) { - logger.info( - `Recovery: Resuming task execution for feature ${featureId} with ${persistedTasks.length} tasks` - ); - - // Get customized prompts for task execution - const taskPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - const approvedPlanContent = existingApprovedPlan.content || ''; - - // Execute each task with a separate agent - for (let taskIndex = 0; taskIndex < persistedTasks.length; taskIndex++) { - const task = persistedTasks[taskIndex]; - - // Skip tasks that are already completed - if (task.status === 'completed') { - logger.info(`Skipping already completed task ${task.id}`); - continue; - } - - // Check for abort - if (abortController.signal.aborted) { - throw new Error('Feature execution aborted'); - } - - // Mark task as in_progress immediately (even without TASK_START marker) - await this.updateTaskStatus(projectPath, featureId, task.id, 'in_progress'); - - // Emit task started - logger.info(`Starting task ${task.id}: ${task.description}`); - this.eventBus.emitAutoModeEvent('auto_mode_task_started', { - featureId, - projectPath, - branchName, - taskId: task.id, - taskDescription: task.description, - taskIndex, - tasksTotal: persistedTasks.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, - persistedTasks, - taskIndex, - approvedPlanContent, - taskPrompts.taskExecution.taskPromptTemplate, - undefined - ); - - // Execute task with dedicated agent - const taskStream = provider.executeQuery({ - prompt: taskPrompt, - model: effectiveBareModel, - maxTurns: Math.min(maxTurns || 100, 50), - cwd: workDir, - allowedTools: allowedTools, - abortController, - mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - credentials, - claudeCompatibleProvider, - }); - - let taskOutput = ''; - let taskCompleteDetected = false; - - // 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') { - const text = block.text || ''; - taskOutput += text; - responseText += text; - this.eventBus.emitAutoModeEvent('auto_mode_progress', { - featureId, - branchName, - content: text, - }); - scheduleWrite(); - - // Detect [TASK_COMPLETE] marker - if (!taskCompleteDetected) { - const completeTaskId = detectTaskCompleteMarker(taskOutput); - if (completeTaskId) { - taskCompleteDetected = true; - logger.info(`[TASK_COMPLETE] detected for ${completeTaskId}`); - await this.updateTaskStatus( - projectPath, - featureId, - completeTaskId, - 'completed' - ); - } - } - } else if (block.type === 'tool_use') { - this.eventBus.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 || ''; - } - } - - // If no [TASK_COMPLETE] marker was detected, still mark as completed - if (!taskCompleteDetected) { - await this.updateTaskStatus(projectPath, featureId, task.id, 'completed'); - } - - // Emit task completed - logger.info(`Task ${task.id} completed for feature ${featureId}`); - this.eventBus.emitAutoModeEvent('auto_mode_task_complete', { - featureId, - projectPath, - branchName, - taskId: task.id, - tasksCompleted: taskIndex + 1, - tasksTotal: persistedTasks.length, - }); - - // Update planSpec with progress - await this.updateFeaturePlanSpec(projectPath, featureId, { - tasksCompleted: taskIndex + 1, - }); - } - - logger.info(`Recovery: All tasks completed for feature ${featureId}`); - - // Extract and save final summary - // Note: saveFeatureSummary already emits auto_mode_summary event - const summary = extractSummary(responseText); - if (summary) { - await this.saveFeatureSummary(projectPath, featureId, summary); - } - - // Final write and cleanup - clearInterval(streamHeartbeat); - if (writeTimeout) { - clearTimeout(writeTimeout); - } - await writeToFile(); - return; - } - - // 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) - // Also support fallback detection for non-Claude models that may not output the marker - const hasExplicitMarker = responseText.includes('[SPEC_GENERATED]'); - const hasFallbackSpec = !hasExplicitMarker && detectSpecFallback(responseText); - if ( - planningModeRequiresApproval && - !specDetected && - (hasExplicitMarker || hasFallbackSpec) - ) { - specDetected = true; - - // Extract plan content (everything before the marker, or full content for fallback) - let planContent: string; - if (hasExplicitMarker) { - const markerIndex = responseText.indexOf('[SPEC_GENERATED]'); - planContent = responseText.substring(0, markerIndex).trim(); - } else { - // Fallback: use all accumulated content as the plan - planContent = responseText.trim(); - logger.info( - `Using fallback spec detection for feature ${featureId} (no [SPEC_GENERATED] marker)` - ); - } - - // 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, - }); - - // Extract and save summary from the plan content - const planSummary = extractSummary(planContent); - if (planSummary) { - logger.info(`Extracted summary from plan: ${planSummary.substring(0, 100)}...`); - // Update the feature with the extracted summary - await this.updateFeatureSummary(projectPath, featureId, planSummary); - } - - 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.eventBus.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.eventBus.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.eventBus.emitAutoModeEvent('plan_revision_requested', { - featureId, - projectPath, - branchName, - feedback: approvalResult.feedback, - hasEdits: !!hasEdits, - planVersion, - }); - - // Build revision prompt using customizable template - const revisionPrompts = await getPromptCustomization( - this.settingsService, - '[AutoMode]' - ); - - // Get task format example based on planning mode - const taskFormatExample = - planningMode === 'full' - ? `\`\`\`tasks -## Phase 1: Foundation -- [ ] T001: [Description] | File: [path/to/file] -- [ ] T002: [Description] | File: [path/to/file] - -## Phase 2: Core Implementation -- [ ] T003: [Description] | File: [path/to/file] -- [ ] T004: [Description] | File: [path/to/file] -\`\`\`` - : `\`\`\`tasks -- [ ] T001: [Description] | File: [path/to/file] -- [ ] T002: [Description] | File: [path/to/file] -- [ ] T003: [Description] | File: [path/to/file] -\`\`\``; - - let revisionPrompt = revisionPrompts.taskExecution.planRevisionTemplate; - revisionPrompt = revisionPrompt.replace( - /\{\{planVersion\}\}/g, - String(planVersion - 1) - ); - revisionPrompt = revisionPrompt.replace( - /\{\{previousPlan\}\}/g, - hasEdits - ? approvalResult.editedPlan || currentPlanContent - : currentPlanContent - ); - revisionPrompt = revisionPrompt.replace( - /\{\{userFeedback\}\}/g, - approvalResult.feedback || - 'Please revise the plan based on the edits above.' - ); - revisionPrompt = revisionPrompt.replace( - /\{\{planningMode\}\}/g, - planningMode - ); - revisionPrompt = revisionPrompt.replace( - /\{\{taskFormatExample\}\}/g, - taskFormatExample - ); - - // Update status to regenerating - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: 'generating', - version: planVersion, - }); - - // Make revision call - const revisionStream = provider.executeQuery({ - prompt: revisionPrompt, - model: effectiveBareModel, - 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.eventBus.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`); - - // Warn if no tasks found in spec/full mode - this may cause fallback to single-agent - if ( - revisedTasks.length === 0 && - (planningMode === 'spec' || planningMode === 'full') - ) { - logger.warn( - `WARNING: Revised plan in ${planningMode} mode has no tasks! ` + - `This will cause fallback to single-agent execution. ` + - `The AI may have omitted the required \`\`\`tasks block.` - ); - this.eventBus.emitAutoModeEvent('plan_revision_warning', { - featureId, - projectPath, - branchName, - planningMode, - warning: - 'Revised plan missing tasks block - will use single-agent execution', - }); - } - - // 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.eventBus.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]; - - // Skip tasks that are already completed (for recovery after restart) - if (task.status === 'completed') { - logger.info(`Skipping already completed task ${task.id}`); - continue; - } - - // Check for abort - if (abortController.signal.aborted) { - throw new Error('Feature execution aborted'); - } - - // Mark task as in_progress immediately (even without TASK_START marker) - await this.updateTaskStatus(projectPath, featureId, task.id, 'in_progress'); - - // Emit task started - logger.info(`Starting task ${task.id}: ${task.description}`); - this.eventBus.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: effectiveBareModel, - 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 = ''; - let taskStartDetected = false; - let taskCompleteDetected = false; - - // 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') { - const text = block.text || ''; - taskOutput += text; - responseText += text; - this.eventBus.emitAutoModeEvent('auto_mode_progress', { - featureId, - branchName, - content: text, - }); - - // Detect [TASK_START] marker - if (!taskStartDetected) { - const startTaskId = detectTaskStartMarker(taskOutput); - if (startTaskId) { - taskStartDetected = true; - logger.info(`[TASK_START] detected for ${startTaskId}`); - // Update task status to in_progress in planSpec.tasks - await this.updateTaskStatus( - projectPath, - featureId, - startTaskId, - 'in_progress' - ); - this.eventBus.emitAutoModeEvent('auto_mode_task_started', { - featureId, - projectPath, - branchName, - taskId: startTaskId, - taskDescription: task.description, - taskIndex, - tasksTotal: parsedTasks.length, - }); - } - } - - // Detect [TASK_COMPLETE] marker - if (!taskCompleteDetected) { - const completeTaskId = detectTaskCompleteMarker(taskOutput); - if (completeTaskId) { - taskCompleteDetected = true; - logger.info(`[TASK_COMPLETE] detected for ${completeTaskId}`); - // Update task status to completed in planSpec.tasks - await this.updateTaskStatus( - projectPath, - featureId, - completeTaskId, - 'completed' - ); - } - } - - // Detect [PHASE_COMPLETE] marker - const phaseNumber = detectPhaseCompleteMarker(text); - if (phaseNumber !== null) { - logger.info(`[PHASE_COMPLETE] detected for Phase ${phaseNumber}`); - this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', { - featureId, - projectPath, - branchName, - phaseNumber, - }); - } - } else if (block.type === 'tool_use') { - this.eventBus.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 || ''; - } - } - - // If no [TASK_COMPLETE] marker was detected, still mark as completed - // (for models that don't output markers) - if (!taskCompleteDetected) { - await this.updateTaskStatus(projectPath, featureId, task.id, 'completed'); - } - - // Emit task completed - logger.info(`Task ${task.id} completed for feature ${featureId}`); - this.eventBus.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.eventBus.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: effectiveBareModel, - 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.eventBus.emitAutoModeEvent('auto_mode_progress', { - featureId, - branchName, - content: block.text, - }); - } else if (block.type === 'tool_use') { - this.eventBus.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 || ''; - } - } - } - - // Extract and save final summary from multi-task or single-agent execution - // Note: saveFeatureSummary already emits auto_mode_summary event - const summary = extractSummary(responseText); - if (summary) { - await this.saveFeatureSummary(projectPath, featureId, summary); - } - - 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.eventBus.emitAutoModeEvent('auto_mode_progress', { - featureId, - branchName, - content: block.text, - }); - } - } else if (block.type === 'tool_use') { - // Emit event for real-time UI - this.eventBus.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; - } - } + // Delegate to AgentExecutor with callbacks that wrap AutoModeService methods + logger.info(`Delegating to AgentExecutor for feature ${featureId}...`); + await this.agentExecutor.execute(agentOptions, { + waitForApproval: async (fId: string, pPath: string) => { + return this.planApprovalService.waitForApproval(fId, pPath); + }, + saveFeatureSummary: async (pPath: string, fId: string, summary: string) => { + await this.saveFeatureSummary(pPath, fId, summary); + }, + updateFeatureSummary: async (pPath: string, fId: string, summary: string) => { + await this.updateFeatureSummary(pPath, fId, summary); + }, + buildTaskPrompt: (task, allTasks, taskIndex, planContent, template, feedback) => { + return this.buildTaskPrompt(task, allTasks, taskIndex, planContent, template, feedback); + }, + }); + + logger.info(`AgentExecutor completed for feature ${featureId}`); } private async executeFeatureWithContext(