Spaces:
Paused
Paused
| /** | |
| * AgentExecutor - Core agent execution engine with streaming support | |
| */ | |
| import path from 'path'; | |
| import type { ExecuteOptions, ParsedTask } from '@automaker/types'; | |
| import { isPipelineStatus } from '@automaker/types'; | |
| import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils'; | |
| import { getFeatureDir } from '@automaker/platform'; | |
| import * as secureFs from '../lib/secure-fs.js'; | |
| import { TypedEventBus } from './typed-event-bus.js'; | |
| import { FeatureStateManager } from './feature-state-manager.js'; | |
| import { PlanApprovalService } from './plan-approval-service.js'; | |
| import type { SettingsService } from './settings-service.js'; | |
| import { | |
| parseTasksFromSpec, | |
| detectTaskStartMarker, | |
| detectTaskCompleteMarker, | |
| detectPhaseCompleteMarker, | |
| detectSpecFallback, | |
| extractSummary, | |
| } from './spec-parser.js'; | |
| import { getPromptCustomization } from '../lib/settings-helpers.js'; | |
| import type { | |
| AgentExecutionOptions, | |
| AgentExecutionResult, | |
| AgentExecutorCallbacks, | |
| } from './agent-executor-types.js'; | |
| // Re-export types for backward compatibility | |
| export type { | |
| AgentExecutionOptions, | |
| AgentExecutionResult, | |
| WaitForApprovalFn, | |
| SaveFeatureSummaryFn, | |
| UpdateFeatureSummaryFn, | |
| BuildTaskPromptFn, | |
| } from './agent-executor-types.js'; | |
| const logger = createLogger('AgentExecutor'); | |
| const DEFAULT_MAX_TURNS = 10000; | |
| export class AgentExecutor { | |
| private static readonly WRITE_DEBOUNCE_MS = 500; | |
| private static readonly STREAM_HEARTBEAT_MS = 15_000; | |
| /** | |
| * Sanitize a provider error value into clean text. | |
| * Coalesces to string, removes ANSI codes, strips leading "Error:" prefix, | |
| * trims, and returns 'Unknown error' when empty. | |
| */ | |
| private static sanitizeProviderError(input: string | { error?: string } | undefined): string { | |
| let raw: string; | |
| if (typeof input === 'string') { | |
| raw = input; | |
| } else if (input && typeof input === 'object' && typeof input.error === 'string') { | |
| raw = input.error; | |
| } else { | |
| raw = ''; | |
| } | |
| const cleaned = raw | |
| .replace(/\x1b\[[0-9;]*m/g, '') | |
| .replace(/^Error:\s*/i, '') | |
| .trim(); | |
| return cleaned || 'Unknown error'; | |
| } | |
| constructor( | |
| private eventBus: TypedEventBus, | |
| private featureStateManager: FeatureStateManager, | |
| private planApprovalService: PlanApprovalService, | |
| private settingsService: SettingsService | null = null | |
| ) {} | |
| async execute( | |
| options: AgentExecutionOptions, | |
| callbacks: AgentExecutorCallbacks | |
| ): Promise<AgentExecutionResult> { | |
| const { | |
| workDir, | |
| featureId, | |
| projectPath, | |
| abortController, | |
| branchName = null, | |
| provider, | |
| effectiveBareModel, | |
| previousContent, | |
| planningMode = 'skip', | |
| requirePlanApproval = false, | |
| specAlreadyDetected = false, | |
| existingApprovedPlanContent, | |
| persistedTasks, | |
| credentials, | |
| status, // Feature status for pipeline summary check | |
| claudeCompatibleProvider, | |
| mcpServers, | |
| sdkSessionId, | |
| sdkOptions, | |
| } = options; | |
| const { content: promptContent } = await buildPromptWithImages( | |
| options.prompt, | |
| options.imagePaths, | |
| workDir, | |
| false | |
| ); | |
| const resolvedMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS; | |
| if (sdkOptions?.maxTurns == null) { | |
| logger.info( | |
| `[execute] Feature ${featureId}: sdkOptions.maxTurns is not set, defaulting to ${resolvedMaxTurns}. ` + | |
| `Model: ${effectiveBareModel}` | |
| ); | |
| } else { | |
| logger.info( | |
| `[execute] Feature ${featureId}: maxTurns=${resolvedMaxTurns}, model=${effectiveBareModel}` | |
| ); | |
| } | |
| const executeOptions: ExecuteOptions = { | |
| prompt: promptContent, | |
| model: effectiveBareModel, | |
| maxTurns: resolvedMaxTurns, | |
| cwd: workDir, | |
| allowedTools: sdkOptions?.allowedTools as string[] | undefined, | |
| abortController, | |
| systemPrompt: sdkOptions?.systemPrompt, | |
| settingSources: sdkOptions?.settingSources, | |
| mcpServers: | |
| mcpServers && Object.keys(mcpServers).length > 0 | |
| ? (mcpServers as Record<string, { command: string }>) | |
| : undefined, | |
| thinkingLevel: options.thinkingLevel, | |
| reasoningEffort: options.reasoningEffort, | |
| credentials, | |
| claudeCompatibleProvider, | |
| sdkSessionId, | |
| }; | |
| const featureDirForOutput = getFeatureDir(projectPath, featureId); | |
| const outputPath = path.join(featureDirForOutput, 'agent-output.md'); | |
| const rawOutputPath = path.join(featureDirForOutput, 'raw-output.jsonl'); | |
| const enableRawOutput = | |
| process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' || | |
| process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1'; | |
| let responseText = previousContent | |
| ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` | |
| : ''; | |
| let specDetected = specAlreadyDetected, | |
| tasksCompleted = 0, | |
| aborted = false; | |
| let writeTimeout: ReturnType<typeof setTimeout> | null = null, | |
| rawOutputLines: string[] = [], | |
| rawWriteTimeout: ReturnType<typeof setTimeout> | null = null; | |
| const writeToFile = async (): Promise<void> => { | |
| try { | |
| await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); | |
| await secureFs.writeFile(outputPath, responseText); | |
| } catch (error) { | |
| logger.error(`Failed to write agent output for ${featureId}:`, error); | |
| } | |
| }; | |
| const scheduleWrite = (): void => { | |
| if (writeTimeout) clearTimeout(writeTimeout); | |
| writeTimeout = setTimeout(() => writeToFile(), AgentExecutor.WRITE_DEBOUNCE_MS); | |
| }; | |
| const appendRawEvent = (event: unknown): void => { | |
| if (!enableRawOutput) return; | |
| try { | |
| rawOutputLines.push(JSON.stringify({ timestamp: new Date().toISOString(), event })); | |
| 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 = []; | |
| } catch { | |
| /* ignore */ | |
| } | |
| }, AgentExecutor.WRITE_DEBOUNCE_MS); | |
| } catch { | |
| /* ignore */ | |
| } | |
| }; | |
| const streamStartTime = Date.now(); | |
| let receivedAnyStreamMessage = false; | |
| const streamHeartbeat = setInterval(() => { | |
| if (!receivedAnyStreamMessage) | |
| logger.info( | |
| `Waiting for first model response for feature ${featureId} (${Math.round((Date.now() - streamStartTime) / 1000)}s elapsed)...` | |
| ); | |
| }, AgentExecutor.STREAM_HEARTBEAT_MS); | |
| const planningModeRequiresApproval = | |
| planningMode === 'spec' || | |
| planningMode === 'full' || | |
| (planningMode === 'lite' && requirePlanApproval); | |
| const requiresApproval = planningModeRequiresApproval && requirePlanApproval; | |
| if (existingApprovedPlanContent && persistedTasks && persistedTasks.length > 0) { | |
| const result = await this.executeTasksLoop( | |
| options, | |
| persistedTasks, | |
| existingApprovedPlanContent, | |
| responseText, | |
| scheduleWrite, | |
| callbacks | |
| ); | |
| clearInterval(streamHeartbeat); | |
| if (writeTimeout) clearTimeout(writeTimeout); | |
| if (rawWriteTimeout) clearTimeout(rawWriteTimeout); | |
| await writeToFile(); | |
| // Extract and save summary from the new content generated in this session | |
| await this.extractAndSaveSessionSummary( | |
| projectPath, | |
| featureId, | |
| result.responseText, | |
| previousContent, | |
| callbacks, | |
| status | |
| ); | |
| return { | |
| responseText: result.responseText, | |
| specDetected: true, | |
| tasksCompleted: result.tasksCompleted, | |
| aborted: result.aborted, | |
| }; | |
| } | |
| logger.info(`Starting stream for feature ${featureId}...`); | |
| try { | |
| const stream = provider.executeQuery(executeOptions); | |
| streamLoop: for await (const msg of stream) { | |
| if (msg.session_id && msg.session_id !== options.sdkSessionId) { | |
| options.sdkSessionId = msg.session_id; | |
| } | |
| receivedAnyStreamMessage = true; | |
| appendRawEvent(msg); | |
| if (abortController.signal.aborted) { | |
| aborted = true; | |
| throw new Error('Feature execution aborted'); | |
| } | |
| if (msg.type === 'assistant' && msg.message?.content) { | |
| for (const block of msg.message.content) { | |
| if (block.type === 'text') { | |
| const newText = block.text || ''; | |
| if (!newText) continue; | |
| if (responseText.length > 0 && newText.length > 0) { | |
| const endsWithSentence = /[.!?:]\s*$/.test(responseText), | |
| endsWithNewline = /\n\s*$/.test(responseText); | |
| if ( | |
| !endsWithNewline && | |
| (endsWithSentence || /^[\n#\-*>]/.test(newText)) && | |
| !/[a-zA-Z0-9]/.test(responseText.slice(-1)) | |
| ) | |
| responseText += '\n\n'; | |
| } | |
| responseText += newText; | |
| // Check for authentication errors using provider-agnostic utility | |
| if (block.text && isAuthenticationError(block.text)) | |
| throw new Error( | |
| 'Authentication failed: Invalid or expired API key. Please check your API key configuration or re-authenticate with your provider.' | |
| ); | |
| scheduleWrite(); | |
| const hasExplicitMarker = responseText.includes('[SPEC_GENERATED]'), | |
| hasFallbackSpec = !hasExplicitMarker && detectSpecFallback(responseText); | |
| if ( | |
| planningModeRequiresApproval && | |
| !specDetected && | |
| (hasExplicitMarker || hasFallbackSpec) | |
| ) { | |
| specDetected = true; | |
| const planContent = hasExplicitMarker | |
| ? responseText.substring(0, responseText.indexOf('[SPEC_GENERATED]')).trim() | |
| : responseText.trim(); | |
| if (!hasExplicitMarker) | |
| logger.info(`Using fallback spec detection for feature ${featureId}`); | |
| const result = await this.handleSpecGenerated( | |
| options, | |
| planContent, | |
| responseText, | |
| requiresApproval, | |
| scheduleWrite, | |
| callbacks | |
| ); | |
| responseText = result.responseText; | |
| tasksCompleted = result.tasksCompleted; | |
| break streamLoop; | |
| } | |
| if (!specDetected) | |
| 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, | |
| }); | |
| 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') { | |
| const sanitized = AgentExecutor.sanitizeProviderError(msg.error); | |
| logger.error( | |
| `[execute] Feature ${featureId} received error from provider. ` + | |
| `raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}` | |
| ); | |
| throw new Error(sanitized); | |
| } else if (msg.type === 'result') { | |
| if (msg.subtype === 'success') { | |
| scheduleWrite(); | |
| } else if (msg.subtype?.startsWith('error')) { | |
| // Non-success result subtypes from the SDK (error_max_turns, error_during_execution, etc.) | |
| logger.error( | |
| `[execute] Feature ${featureId} ended with error subtype: ${msg.subtype}. ` + | |
| `session_id=${msg.session_id ?? 'none'}` | |
| ); | |
| throw new Error(`Agent execution ended with: ${msg.subtype}`); | |
| } else { | |
| logger.warn( | |
| `[execute] Feature ${featureId} received unhandled result subtype: ${msg.subtype}` | |
| ); | |
| } | |
| } | |
| } | |
| } finally { | |
| clearInterval(streamHeartbeat); | |
| if (writeTimeout) clearTimeout(writeTimeout); | |
| if (rawWriteTimeout) clearTimeout(rawWriteTimeout); | |
| const streamElapsedMs = Date.now() - streamStartTime; | |
| logger.info( | |
| `[execute] Stream ended for feature ${featureId} after ${Math.round(streamElapsedMs / 1000)}s. ` + | |
| `aborted=${aborted}, specDetected=${specDetected}, responseLength=${responseText.length}` | |
| ); | |
| await writeToFile(); | |
| if (enableRawOutput && rawOutputLines.length > 0) { | |
| try { | |
| await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); | |
| await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); | |
| } catch { | |
| /* ignore */ | |
| } | |
| } | |
| } | |
| // Capture summary if it hasn't been captured by handleSpecGenerated or executeTasksLoop | |
| // or if we're in a simple execution mode (planningMode='skip') | |
| await this.extractAndSaveSessionSummary( | |
| projectPath, | |
| featureId, | |
| responseText, | |
| previousContent, | |
| callbacks, | |
| status | |
| ); | |
| return { responseText, specDetected, tasksCompleted, aborted }; | |
| } | |
| /** | |
| * Strip the follow-up session scaffold marker from content. | |
| * The scaffold is added when resuming a session with previous content: | |
| * "\n\n---\n\n## Follow-up Session\n\n" | |
| * This ensures fallback summaries don't include the scaffold header. | |
| * | |
| * The regex pattern handles variations in whitespace while matching the | |
| * scaffold structure: dashes followed by "## Follow-up Session" at the | |
| * start of the content. | |
| */ | |
| private static stripFollowUpScaffold(content: string): string { | |
| // Pattern matches: ^\s*---\s*##\s*Follow-up Session\s* | |
| // - ^ = start of content (scaffold is always at the beginning of sessionContent) | |
| // - \s* = any whitespace (handles \n\n before ---, spaces/tabs between markers) | |
| // - --- = literal dashes | |
| // - \s* = whitespace between dashes and heading | |
| // - ## = heading marker | |
| // - \s* = whitespace before "Follow-up" | |
| // - Follow-up Session = literal heading text | |
| // - \s* = trailing whitespace/newlines after heading | |
| const scaffoldPattern = /^\s*---\s*##\s*Follow-up Session\s*/; | |
| return content.replace(scaffoldPattern, ''); | |
| } | |
| /** | |
| * Extract summary ONLY from the new content generated in this session | |
| * and save it via the provided callback. | |
| */ | |
| private async extractAndSaveSessionSummary( | |
| projectPath: string, | |
| featureId: string, | |
| responseText: string, | |
| previousContent: string | undefined, | |
| callbacks: AgentExecutorCallbacks, | |
| status?: string | |
| ): Promise<void> { | |
| const sessionContent = responseText.substring(previousContent ? previousContent.length : 0); | |
| const summary = extractSummary(sessionContent); | |
| if (summary) { | |
| await callbacks.saveFeatureSummary(projectPath, featureId, summary); | |
| return; | |
| } | |
| // If we're in a pipeline step, a summary is expected. Use a fallback if extraction fails. | |
| if (isPipelineStatus(status)) { | |
| // Strip any follow-up session scaffold before using as fallback | |
| const cleanSessionContent = AgentExecutor.stripFollowUpScaffold(sessionContent); | |
| const fallback = cleanSessionContent.trim(); | |
| if (fallback) { | |
| await callbacks.saveFeatureSummary(projectPath, featureId, fallback); | |
| } | |
| logger.warn( | |
| `[AgentExecutor] Mandatory summary extraction failed for pipeline feature ${featureId} (status="${status}")` | |
| ); | |
| } | |
| } | |
| private async executeTasksLoop( | |
| options: AgentExecutionOptions, | |
| tasks: ParsedTask[], | |
| planContent: string, | |
| initialResponseText: string, | |
| scheduleWrite: () => void, | |
| callbacks: AgentExecutorCallbacks, | |
| userFeedback?: string | |
| ): Promise<{ responseText: string; tasksCompleted: number; aborted: boolean }> { | |
| const { | |
| featureId, | |
| projectPath, | |
| abortController, | |
| branchName = null, | |
| provider, | |
| sdkOptions, | |
| } = options; | |
| logger.info(`Starting task execution for feature ${featureId} with ${tasks.length} tasks`); | |
| const taskPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); | |
| let responseText = initialResponseText, | |
| tasksCompleted = 0; | |
| for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) { | |
| const task = tasks[taskIndex]; | |
| if (task.status === 'completed') { | |
| tasksCompleted++; | |
| continue; | |
| } | |
| if (abortController.signal.aborted) return { responseText, tasksCompleted, aborted: true }; | |
| await this.featureStateManager.updateTaskStatus( | |
| projectPath, | |
| featureId, | |
| task.id, | |
| 'in_progress' | |
| ); | |
| this.eventBus.emitAutoModeEvent('auto_mode_task_started', { | |
| featureId, | |
| projectPath, | |
| branchName, | |
| taskId: task.id, | |
| taskDescription: task.description, | |
| taskIndex, | |
| tasksTotal: tasks.length, | |
| }); | |
| await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { | |
| currentTaskId: task.id, | |
| }); | |
| const taskPrompt = callbacks.buildTaskPrompt( | |
| task, | |
| tasks, | |
| taskIndex, | |
| planContent, | |
| taskPrompts.taskExecution.taskPromptTemplate, | |
| userFeedback | |
| ); | |
| const taskMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS; | |
| logger.info( | |
| `[executeTasksLoop] Feature ${featureId}, task ${task.id} (${taskIndex + 1}/${tasks.length}): ` + | |
| `maxTurns=${taskMaxTurns} (sdkOptions.maxTurns=${sdkOptions?.maxTurns ?? 'undefined'})` | |
| ); | |
| const taskStream = provider.executeQuery( | |
| this.buildExecOpts(options, taskPrompt, taskMaxTurns) | |
| ); | |
| let taskOutput = '', | |
| taskStartDetected = false, | |
| taskCompleteDetected = false; | |
| for await (const msg of taskStream) { | |
| if (msg.session_id && msg.session_id !== options.sdkSessionId) { | |
| options.sdkSessionId = msg.session_id; | |
| } | |
| if (msg.type === 'assistant' && msg.message?.content) { | |
| for (const b of msg.message.content) { | |
| if (b.type === 'text') { | |
| const text = b.text || ''; | |
| taskOutput += text; | |
| responseText += text; | |
| this.eventBus.emitAutoModeEvent('auto_mode_progress', { | |
| featureId, | |
| branchName, | |
| content: text, | |
| }); | |
| scheduleWrite(); | |
| if (!taskStartDetected) { | |
| const sid = detectTaskStartMarker(taskOutput); | |
| if (sid) { | |
| taskStartDetected = true; | |
| await this.featureStateManager.updateTaskStatus( | |
| projectPath, | |
| featureId, | |
| sid, | |
| 'in_progress' | |
| ); | |
| } | |
| } | |
| if (!taskCompleteDetected) { | |
| const completeMarker = detectTaskCompleteMarker(taskOutput); | |
| if (completeMarker) { | |
| taskCompleteDetected = true; | |
| await this.featureStateManager.updateTaskStatus( | |
| projectPath, | |
| featureId, | |
| completeMarker.id, | |
| 'completed', | |
| completeMarker.summary | |
| ); | |
| } | |
| } | |
| const pn = detectPhaseCompleteMarker(text); | |
| if (pn !== null) | |
| this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', { | |
| featureId, | |
| projectPath, | |
| branchName, | |
| phaseNumber: pn, | |
| }); | |
| } else if (b.type === 'tool_use') | |
| this.eventBus.emitAutoModeEvent('auto_mode_tool', { | |
| featureId, | |
| branchName, | |
| tool: b.name, | |
| input: b.input, | |
| }); | |
| } | |
| } else if (msg.type === 'error') { | |
| const fallback = `Error during task ${task.id}`; | |
| const sanitized = AgentExecutor.sanitizeProviderError(msg.error || fallback); | |
| logger.error( | |
| `[executeTasksLoop] Feature ${featureId} task ${task.id} received error from provider. ` + | |
| `raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}` | |
| ); | |
| throw new Error(sanitized); | |
| } else if (msg.type === 'result') { | |
| if (msg.subtype === 'success') { | |
| taskOutput += msg.result || ''; | |
| responseText += msg.result || ''; | |
| } else if (msg.subtype?.startsWith('error')) { | |
| logger.error( | |
| `[executeTasksLoop] Feature ${featureId} task ${task.id} ended with error subtype: ${msg.subtype}. ` + | |
| `session_id=${msg.session_id ?? 'none'}` | |
| ); | |
| throw new Error(`Agent execution ended with: ${msg.subtype}`); | |
| } else { | |
| logger.warn( | |
| `[executeTasksLoop] Feature ${featureId} task ${task.id} received unhandled result subtype: ${msg.subtype}` | |
| ); | |
| } | |
| } | |
| } | |
| if (!taskCompleteDetected) | |
| await this.featureStateManager.updateTaskStatus( | |
| projectPath, | |
| featureId, | |
| task.id, | |
| 'completed' | |
| ); | |
| tasksCompleted = taskIndex + 1; | |
| this.eventBus.emitAutoModeEvent('auto_mode_task_complete', { | |
| featureId, | |
| projectPath, | |
| branchName, | |
| taskId: task.id, | |
| tasksCompleted, | |
| tasksTotal: tasks.length, | |
| }); | |
| await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { | |
| tasksCompleted, | |
| }); | |
| if (task.phase) { | |
| const next = tasks[taskIndex + 1]; | |
| if (!next || next.phase !== task.phase) { | |
| const m = task.phase.match(/Phase\s*(\d+)/i); | |
| if (m) | |
| this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', { | |
| featureId, | |
| projectPath, | |
| branchName, | |
| phaseNumber: parseInt(m[1], 10), | |
| }); | |
| } | |
| } | |
| } | |
| return { responseText, tasksCompleted, aborted: false }; | |
| } | |
| private async handleSpecGenerated( | |
| options: AgentExecutionOptions, | |
| planContent: string, | |
| initialResponseText: string, | |
| requiresApproval: boolean, | |
| scheduleWrite: () => void, | |
| callbacks: AgentExecutorCallbacks | |
| ): Promise<{ responseText: string; tasksCompleted: number }> { | |
| const { | |
| featureId, | |
| projectPath, | |
| branchName = null, | |
| planningMode = 'skip', | |
| provider, | |
| sdkOptions, | |
| } = options; | |
| let responseText = initialResponseText, | |
| parsedTasks = parseTasksFromSpec(planContent); | |
| logger.info(`Parsed ${parsedTasks.length} tasks from spec for feature ${featureId}`); | |
| await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { | |
| status: 'generated', | |
| content: planContent, | |
| version: 1, | |
| generatedAt: new Date().toISOString(), | |
| reviewedByUser: false, | |
| tasks: parsedTasks, | |
| tasksTotal: parsedTasks.length, | |
| tasksCompleted: 0, | |
| }); | |
| const planSummary = extractSummary(planContent); | |
| if (planSummary) await callbacks.updateFeatureSummary(projectPath, featureId, planSummary); | |
| let approvedPlanContent = planContent, | |
| userFeedback: string | undefined, | |
| currentPlanContent = planContent, | |
| planVersion = 1; | |
| if (requiresApproval) { | |
| let planApproved = false; | |
| while (!planApproved) { | |
| logger.info( | |
| `Spec v${planVersion} generated for feature ${featureId}, waiting for approval` | |
| ); | |
| this.eventBus.emitAutoModeEvent('plan_approval_required', { | |
| featureId, | |
| projectPath, | |
| branchName, | |
| planContent: currentPlanContent, | |
| planningMode, | |
| planVersion, | |
| }); | |
| const approvalResult = await callbacks.waitForApproval(featureId, projectPath); | |
| if (approvalResult.approved) { | |
| planApproved = true; | |
| userFeedback = approvalResult.feedback; | |
| approvedPlanContent = approvalResult.editedPlan || currentPlanContent; | |
| if (approvalResult.editedPlan) { | |
| // Re-parse tasks from edited plan to ensure we execute the updated tasks | |
| const editedTasks = parseTasksFromSpec(approvalResult.editedPlan); | |
| parsedTasks = editedTasks; | |
| await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { | |
| content: approvalResult.editedPlan, | |
| tasks: editedTasks, | |
| tasksTotal: editedTasks.length, | |
| tasksCompleted: 0, | |
| }); | |
| } | |
| this.eventBus.emitAutoModeEvent('plan_approved', { | |
| featureId, | |
| projectPath, | |
| branchName, | |
| hasEdits: !!approvalResult.editedPlan, | |
| planVersion, | |
| }); | |
| } else { | |
| const hasFeedback = approvalResult.feedback?.trim().length, | |
| hasEdits = approvalResult.editedPlan?.trim().length; | |
| if (!hasFeedback && !hasEdits) throw new Error('Plan cancelled by user'); | |
| planVersion++; | |
| this.eventBus.emitAutoModeEvent('plan_revision_requested', { | |
| featureId, | |
| projectPath, | |
| branchName, | |
| feedback: approvalResult.feedback, | |
| hasEdits: !!hasEdits, | |
| planVersion, | |
| }); | |
| const revPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); | |
| const taskEx = | |
| planningMode === 'full' | |
| ? '```tasks\n## Phase 1: Foundation\n- [ ] T001: [Description] | File: [path/to/file]\n```' | |
| : '```tasks\n- [ ] T001: [Description] | File: [path/to/file]\n```'; | |
| let revPrompt = revPrompts.taskExecution.planRevisionTemplate | |
| .replace(/\{\{planVersion\}\}/g, String(planVersion - 1)) | |
| .replace( | |
| /\{\{previousPlan\}\}/g, | |
| hasEdits ? approvalResult.editedPlan || currentPlanContent : currentPlanContent | |
| ) | |
| .replace( | |
| /\{\{userFeedback\}\}/g, | |
| approvalResult.feedback || 'Please revise the plan based on the edits above.' | |
| ) | |
| .replace(/\{\{planningMode\}\}/g, planningMode) | |
| .replace(/\{\{taskFormatExample\}\}/g, taskEx); | |
| await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { | |
| status: 'generating', | |
| version: planVersion, | |
| }); | |
| let revText = ''; | |
| for await (const msg of provider.executeQuery( | |
| this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) | |
| )) { | |
| if (msg.session_id && msg.session_id !== options.sdkSessionId) { | |
| options.sdkSessionId = msg.session_id; | |
| } | |
| if (msg.type === 'assistant' && msg.message?.content) | |
| for (const b of msg.message.content) | |
| if (b.type === 'text') { | |
| revText += b.text || ''; | |
| this.eventBus.emitAutoModeEvent('auto_mode_progress', { | |
| featureId, | |
| branchName, | |
| content: b.text, | |
| }); | |
| } | |
| if (msg.type === 'error') { | |
| const cleanedError = | |
| (msg.error || 'Error during plan revision') | |
| .replace(/\x1b\[[0-9;]*m/g, '') | |
| .replace(/^Error:\s*/i, '') | |
| .trim() || 'Error during plan revision'; | |
| throw new Error(cleanedError); | |
| } | |
| if (msg.type === 'result' && msg.subtype === 'success') revText += msg.result || ''; | |
| } | |
| const mi = revText.indexOf('[SPEC_GENERATED]'); | |
| currentPlanContent = mi > 0 ? revText.substring(0, mi).trim() : revText.trim(); | |
| const revisedTasks = parseTasksFromSpec(currentPlanContent); | |
| if (revisedTasks.length === 0 && (planningMode === 'spec' || planningMode === 'full')) | |
| this.eventBus.emitAutoModeEvent('plan_revision_warning', { | |
| featureId, | |
| projectPath, | |
| branchName, | |
| planningMode, | |
| warning: 'Revised plan missing tasks block', | |
| }); | |
| await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { | |
| status: 'generated', | |
| content: currentPlanContent, | |
| version: planVersion, | |
| tasks: revisedTasks, | |
| tasksTotal: revisedTasks.length, | |
| tasksCompleted: 0, | |
| }); | |
| parsedTasks = revisedTasks; | |
| responseText += revText; | |
| } | |
| } | |
| } else { | |
| this.eventBus.emitAutoModeEvent('plan_auto_approved', { | |
| featureId, | |
| projectPath, | |
| branchName, | |
| planContent, | |
| planningMode, | |
| }); | |
| } | |
| await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { | |
| status: 'approved', | |
| approvedAt: new Date().toISOString(), | |
| reviewedByUser: requiresApproval, | |
| }); | |
| let tasksCompleted = 0; | |
| if (parsedTasks.length > 0) { | |
| const r = await this.executeTasksLoop( | |
| options, | |
| parsedTasks, | |
| approvedPlanContent, | |
| responseText, | |
| scheduleWrite, | |
| callbacks, | |
| userFeedback | |
| ); | |
| responseText = r.responseText; | |
| tasksCompleted = r.tasksCompleted; | |
| } else { | |
| const r = await this.executeSingleAgentContinuation( | |
| options, | |
| approvedPlanContent, | |
| userFeedback, | |
| responseText | |
| ); | |
| responseText = r.responseText; | |
| } | |
| return { responseText, tasksCompleted }; | |
| } | |
| private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns: number) { | |
| return { | |
| prompt, | |
| model: o.effectiveBareModel, | |
| maxTurns, | |
| cwd: o.workDir, | |
| allowedTools: o.sdkOptions?.allowedTools as string[] | undefined, | |
| abortController: o.abortController, | |
| thinkingLevel: o.thinkingLevel, | |
| reasoningEffort: o.reasoningEffort, | |
| mcpServers: | |
| o.mcpServers && Object.keys(o.mcpServers).length > 0 | |
| ? (o.mcpServers as Record<string, { command: string }>) | |
| : undefined, | |
| credentials: o.credentials, | |
| claudeCompatibleProvider: o.claudeCompatibleProvider, | |
| sdkSessionId: o.sdkSessionId, | |
| }; | |
| } | |
| private async executeSingleAgentContinuation( | |
| options: AgentExecutionOptions, | |
| planContent: string, | |
| userFeedback: string | undefined, | |
| initialResponseText: string | |
| ): Promise<{ responseText: string }> { | |
| const { featureId, branchName = null, provider } = options; | |
| logger.info(`No parsed tasks, using single-agent execution for feature ${featureId}`); | |
| const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); | |
| const contPrompt = prompts.taskExecution.continuationAfterApprovalTemplate | |
| .replace(/\{\{userFeedback\}\}/g, userFeedback || '') | |
| .replace(/\{\{approvedPlan\}\}/g, planContent); | |
| let responseText = initialResponseText; | |
| for await (const msg of provider.executeQuery( | |
| this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) | |
| )) { | |
| if (msg.session_id && msg.session_id !== options.sdkSessionId) { | |
| options.sdkSessionId = msg.session_id; | |
| } | |
| if (msg.type === 'assistant' && msg.message?.content) | |
| for (const b of msg.message.content) { | |
| if (b.type === 'text') { | |
| responseText += b.text || ''; | |
| this.eventBus.emitAutoModeEvent('auto_mode_progress', { | |
| featureId, | |
| branchName, | |
| content: b.text, | |
| }); | |
| } else if (b.type === 'tool_use') | |
| this.eventBus.emitAutoModeEvent('auto_mode_tool', { | |
| featureId, | |
| branchName, | |
| tool: b.name, | |
| input: b.input, | |
| }); | |
| } | |
| else if (msg.type === 'error') { | |
| const cleanedError = | |
| (msg.error || 'Unknown error during implementation') | |
| .replace(/\x1b\[[0-9;]*m/g, '') | |
| .replace(/^Error:\s*/i, '') | |
| .trim() || 'Unknown error during implementation'; | |
| throw new Error(cleanedError); | |
| } else if (msg.type === 'result' && msg.subtype === 'success') | |
| responseText += msg.result || ''; | |
| } | |
| return { responseText }; | |
| } | |
| } | |