/** * ExecutionService - Feature execution lifecycle coordination */ import path from 'path'; import type { Feature } from '@automaker/types'; import { createLogger, classifyError, loadContextFiles, recordMemoryUsage } from '@automaker/utils'; import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; import { getFeatureDir } from '@automaker/platform'; import { ProviderFactory } from '../providers/provider-factory.js'; import * as secureFs from '../lib/secure-fs.js'; import { getPromptCustomization, getAutoLoadClaudeMdSetting, getUseClaudeCodeSystemPromptSetting, filterClaudeMdFromContext, } from '../lib/settings-helpers.js'; import { validateWorkingDirectory } from '../lib/sdk-options.js'; import { extractSummary } from './spec-parser.js'; import type { TypedEventBus } from './typed-event-bus.js'; import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js'; import type { WorktreeResolver } from './worktree-resolver.js'; import type { SettingsService } from './settings-service.js'; import { pipelineService } from './pipeline-service.js'; // Re-export callback types from execution-types.ts for backward compatibility export type { RunAgentFn, ExecutePipelineFn, UpdateFeatureStatusFn, LoadFeatureFn, GetPlanningPromptPrefixFn, SaveFeatureSummaryFn, RecordLearningsFn, ContextExistsFn, ResumeFeatureFn, TrackFailureFn, SignalPauseFn, RecordSuccessFn, SaveExecutionStateFn, LoadContextFilesFn, } from './execution-types.js'; import type { RunAgentFn, ExecutePipelineFn, UpdateFeatureStatusFn, LoadFeatureFn, GetPlanningPromptPrefixFn, SaveFeatureSummaryFn, RecordLearningsFn, ContextExistsFn, ResumeFeatureFn, TrackFailureFn, SignalPauseFn, RecordSuccessFn, SaveExecutionStateFn, LoadContextFilesFn, } from './execution-types.js'; const logger = createLogger('ExecutionService'); /** Marker written by agent-executor for each tool invocation. */ const TOOL_USE_MARKER = '🔧 Tool:'; /** Minimum trimmed output length to consider agent work meaningful. */ const MIN_MEANINGFUL_OUTPUT_LENGTH = 200; export class ExecutionService { constructor( private eventBus: TypedEventBus, private concurrencyManager: ConcurrencyManager, private worktreeResolver: WorktreeResolver, private settingsService: SettingsService | null, // Callback dependencies for delegation private runAgentFn: RunAgentFn, private executePipelineFn: ExecutePipelineFn, private updateFeatureStatusFn: UpdateFeatureStatusFn, private loadFeatureFn: LoadFeatureFn, private getPlanningPromptPrefixFn: GetPlanningPromptPrefixFn, private saveFeatureSummaryFn: SaveFeatureSummaryFn, private recordLearningsFn: RecordLearningsFn, private contextExistsFn: ContextExistsFn, private resumeFeatureFn: ResumeFeatureFn, private trackFailureFn: TrackFailureFn, private signalPauseFn: SignalPauseFn, private recordSuccessFn: RecordSuccessFn, private saveExecutionStateFn: SaveExecutionStateFn, private loadContextFilesFn: LoadContextFilesFn ) {} private acquireRunningFeature(options: { featureId: string; projectPath: string; isAutoMode: boolean; allowReuse?: boolean; }): RunningFeature { return this.concurrencyManager.acquire(options); } private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void { this.concurrencyManager.release(featureId, options); } private extractTitleFromDescription(description: string | undefined): string { if (!description?.trim()) return 'Untitled Feature'; const firstLine = description.split('\n')[0].trim(); return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...'; } /** * Build feature description section (without implementation instructions). * Used when planning mode is active — the planning prompt provides its own instructions. */ buildFeatureDescription(feature: Feature): string { const title = this.extractTitleFromDescription(feature.description); let prompt = `## Feature Task **Feature ID:** ${feature.id} **Title:** ${title} **Description:** ${feature.description} `; if (feature.spec) { prompt += ` **Specification:** ${feature.spec} `; } if (feature.imagePaths && feature.imagePaths.length > 0) { const imagesList = feature.imagePaths .map((img, idx) => { const imgPath = typeof img === 'string' ? img : img.path; const filename = typeof img === 'string' ? imgPath.split('/').pop() : img.filename || imgPath.split('/').pop(); const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*'; return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${imgPath}`; }) .join('\n'); prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`; } return prompt; } buildFeaturePrompt( feature: Feature, taskExecutionPrompts: { implementationInstructions: string; playwrightVerificationInstructions: string; } ): string { let prompt = this.buildFeatureDescription(feature); prompt += feature.skipTests ? `\n${taskExecutionPrompts.implementationInstructions}` : `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`; return prompt; } async executeFeature( projectPath: string, featureId: string, useWorktrees = false, isAutoMode = false, providedWorktreePath?: string, options?: { continuationPrompt?: string; _calledInternally?: boolean } ): Promise { const tempRunningFeature = this.acquireRunningFeature({ featureId, projectPath, isAutoMode, allowReuse: options?._calledInternally, }); const abortController = tempRunningFeature.abortController; if (isAutoMode) await this.saveExecutionStateFn(projectPath); let feature: Feature | null = null; let pipelineCompleted = false; try { validateWorkingDirectory(projectPath); feature = await this.loadFeatureFn(projectPath, featureId); if (!feature) throw new Error(`Feature ${featureId} not found`); // Update status to in_progress immediately after acquiring the feature. // This prevents a race condition where the UI reloads features and sees the // feature still in 'backlog' status while it's actually being executed. // Only do this for the initial call (not internal/recursive calls which would // redundantly update the status). if ( !options?._calledInternally && (feature.status === 'backlog' || feature.status === 'ready' || feature.status === 'interrupted') ) { await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress'); } if (!options?.continuationPrompt) { if (feature.planSpec?.status === 'approved') { const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]'); let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; continuationPrompt = continuationPrompt .replace(/\{\{userFeedback\}\}/g, '') .replace(/\{\{approvedPlan\}\}/g, feature.planSpec.content || ''); return await this.executeFeature( projectPath, featureId, useWorktrees, isAutoMode, providedWorktreePath, { continuationPrompt, _calledInternally: true } ); } if (await this.contextExistsFn(projectPath, featureId)) { return await this.resumeFeatureFn(projectPath, featureId, useWorktrees, true); } } let worktreePath: string | null = providedWorktreePath ?? null; const branchName = feature.branchName; if (!worktreePath && useWorktrees && branchName) { worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName); if (!worktreePath) { throw new Error( `Worktree enabled but no worktree found for feature branch "${branchName}".` ); } logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); } const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); validateWorkingDirectory(workDir); tempRunningFeature.worktreePath = worktreePath; tempRunningFeature.branchName = branchName ?? null; // Ensure status is in_progress (may already be set from the early update above, // but internal/recursive calls skip the early update and need it here). // Mirror the external guard: only transition when the feature is still in // backlog, ready, or interrupted to avoid overwriting a concurrent terminal status. if ( options?._calledInternally && (feature.status === 'backlog' || feature.status === 'ready' || feature.status === 'interrupted') ) { await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress'); } this.eventBus.emitAutoModeEvent('auto_mode_feature_start', { featureId, projectPath, branchName: feature.branchName ?? null, feature: { id: featureId, title: feature.title || 'Loading...', description: feature.description || 'Feature is starting', }, }); const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( projectPath, this.settingsService, '[ExecutionService]' ); const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( projectPath, this.settingsService, '[ExecutionService]' ); const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]'); let prompt: string; const contextResult = await this.loadContextFilesFn({ projectPath, fsModule: secureFs as Parameters[0]['fsModule'], taskContext: { title: feature.title ?? '', description: feature.description ?? '', }, }); const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); if (options?.continuationPrompt) { prompt = options.continuationPrompt; } else { const planningPrefix = await this.getPlanningPromptPrefixFn(feature); if (planningPrefix) { // Planning mode active: use planning instructions + feature description only. // Do NOT include implementationInstructions — they conflict with the planning // prompt's "DO NOT proceed with implementation until approval" directive. prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature); } else { prompt = this.buildFeaturePrompt(feature, prompts.taskExecution); } if (feature.planningMode && feature.planningMode !== 'skip') { this.eventBus.emitAutoModeEvent('planning_started', { featureId: feature.id, mode: feature.planningMode, message: `Starting ${feature.planningMode} planning phase`, }); } } const imagePaths = feature.imagePaths?.map((img) => typeof img === 'string' ? img : img.path ); const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); tempRunningFeature.model = model; tempRunningFeature.provider = ProviderFactory.getProviderNameForModel(model); await this.runAgentFn( workDir, featureId, prompt, abortController, projectPath, imagePaths, model, { projectPath, planningMode: feature.planningMode, requirePlanApproval: feature.requirePlanApproval, systemPrompt: combinedSystemPrompt || undefined, autoLoadClaudeMd, useClaudeCodeSystemPrompt, thinkingLevel: feature.thinkingLevel, reasoningEffort: feature.reasoningEffort, providerId: feature.providerId, branchName: feature.branchName ?? null, } ); // Check for incomplete tasks after agent execution. // The agent may have finished early (hit max turns, decided it was done, etc.) // while tasks are still pending. If so, re-run the agent to complete remaining tasks. const MAX_TASK_RETRY_ATTEMPTS = 3; let taskRetryAttempts = 0; while (!abortController.signal.aborted && taskRetryAttempts < MAX_TASK_RETRY_ATTEMPTS) { const currentFeature = await this.loadFeatureFn(projectPath, featureId); if (!currentFeature?.planSpec?.tasks) break; const pendingTasks = currentFeature.planSpec.tasks.filter( (t) => t.status === 'pending' || t.status === 'in_progress' ); if (pendingTasks.length === 0) break; taskRetryAttempts++; const totalTasks = currentFeature.planSpec.tasks.length; const completedTasks = currentFeature.planSpec.tasks.filter( (t) => t.status === 'completed' ).length; logger.info( `[executeFeature] Feature ${featureId} has ${pendingTasks.length} incomplete tasks (${completedTasks}/${totalTasks} completed). Re-running agent (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})` ); this.eventBus.emitAutoModeEvent('auto_mode_progress', { featureId, branchName: feature.branchName ?? null, content: `Agent finished with ${pendingTasks.length} tasks remaining. Re-running to complete tasks (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})...`, projectPath, }); // Build a continuation prompt that tells the agent to finish remaining tasks const remainingTasksList = pendingTasks .map((t) => `- ${t.id}: ${t.description} (${t.status})`) .join('\n'); const continuationPrompt = `## Continue Implementation - Incomplete Tasks The previous agent session ended before all tasks were completed. Please continue implementing the remaining tasks. **Completed:** ${completedTasks}/${totalTasks} tasks **Remaining tasks:** ${remainingTasksList} Please continue from where you left off and complete all remaining tasks. Use the same [TASK_START:ID] and [TASK_COMPLETE:ID] markers for each task.`; await this.runAgentFn( workDir, featureId, continuationPrompt, abortController, projectPath, undefined, model, { projectPath, planningMode: 'skip', requirePlanApproval: false, systemPrompt: combinedSystemPrompt || undefined, autoLoadClaudeMd, useClaudeCodeSystemPrompt, thinkingLevel: feature.thinkingLevel, reasoningEffort: feature.reasoningEffort, providerId: feature.providerId, branchName: feature.branchName ?? null, } ); } // Log if tasks are still incomplete after retry attempts if (taskRetryAttempts >= MAX_TASK_RETRY_ATTEMPTS) { const finalFeature = await this.loadFeatureFn(projectPath, featureId); const stillPending = finalFeature?.planSpec?.tasks?.filter( (t) => t.status === 'pending' || t.status === 'in_progress' ); if (stillPending && stillPending.length > 0) { logger.warn( `[executeFeature] Feature ${featureId} still has ${stillPending.length} incomplete tasks after ${MAX_TASK_RETRY_ATTEMPTS} retry attempts. Moving to final status.` ); } } const pipelineConfig = await pipelineService.getPipelineConfig(projectPath); 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) { await this.executePipelineFn({ projectPath, featureId, feature, steps: sortedSteps, workDir, worktreePath, branchName: feature.branchName ?? null, abortController, autoLoadClaudeMd, useClaudeCodeSystemPrompt, testAttempts: 0, maxTestAttempts: 5, }); pipelineCompleted = true; // Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it const refreshed = await this.loadFeatureFn(projectPath, featureId); if (refreshed?.status === 'merge_conflict') { return; } } // Read agent output before determining final status. // CLI-based providers (Cursor, Codex, etc.) may exit quickly without doing // meaningful work. Check output to avoid prematurely marking as 'verified'. const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'); let agentOutput = ''; try { agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string; } catch { /* */ } // Determine if the agent did meaningful work by checking for tool usage // indicators in the output. The agent executor writes "🔧 Tool:" markers // each time a tool is invoked. No tool usage suggests the CLI exited // without performing implementation work. const hasToolUsage = agentOutput.includes(TOOL_USE_MARKER); const isOutputTooShort = agentOutput.trim().length < MIN_MEANINGFUL_OUTPUT_LENGTH; const agentDidWork = hasToolUsage && !isOutputTooShort; let finalStatus: 'verified' | 'waiting_approval'; if (feature.skipTests) { finalStatus = 'waiting_approval'; } else if (!agentDidWork) { // Agent didn't produce meaningful output (e.g., CLI exited quickly). // Route to waiting_approval so the user can review and re-run. finalStatus = 'waiting_approval'; logger.warn( `[executeFeature] Feature ${featureId}: agent produced insufficient output ` + `(${agentOutput.trim().length}/${MIN_MEANINGFUL_OUTPUT_LENGTH} chars, toolUsage=${hasToolUsage}). ` + `Setting status to waiting_approval instead of verified.` ); } else { finalStatus = 'verified'; } await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); this.recordSuccessFn(); // Check final task completion state for accurate reporting const completedFeature = await this.loadFeatureFn(projectPath, featureId); const totalTasks = completedFeature?.planSpec?.tasks?.length ?? 0; const completedTasks = completedFeature?.planSpec?.tasks?.filter((t) => t.status === 'completed').length ?? 0; const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks; try { // Only save summary if feature doesn't already have one (e.g., accumulated from pipeline steps) // This prevents overwriting accumulated summaries with just the last step's output // The agent-executor already extracts and saves summaries during execution if (agentOutput && !completedFeature?.summary) { const summary = extractSummary(agentOutput); if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary); } if (contextResult.memoryFiles.length > 0 && agentOutput) { await recordMemoryUsage( projectPath, contextResult.memoryFiles, agentOutput, true, secureFs as Parameters[4] ); } await this.recordLearningsFn(projectPath, feature, agentOutput); } catch { /* learnings recording failed */ } const elapsedSeconds = Math.round((Date.now() - tempRunningFeature.startTime) / 1000); let completionMessage = `Feature completed in ${elapsedSeconds}s`; if (finalStatus === 'verified') completionMessage += ' - auto-verified'; if (hasIncompleteTasks) completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`; if (isAutoMode) { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, branchName: feature.branchName ?? null, executionMode: 'auto', passes: true, message: completionMessage, projectPath, model: tempRunningFeature.model, provider: tempRunningFeature.provider, }); } } catch (error) { const errorInfo = classifyError(error); if (errorInfo.isAbort) { await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted'); if (isAutoMode) { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature?.title, branchName: feature?.branchName ?? null, executionMode: 'auto', passes: false, message: 'Feature stopped by user', projectPath, }); } } else { logger.error(`Feature ${featureId} failed:`, error); // If pipeline steps completed successfully, don't send the feature back to backlog. // The pipeline work is done — set to waiting_approval so the user can review. const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog'; if (pipelineCompleted) { logger.info( `[executeFeature] Feature ${featureId} failed after pipeline completed. ` + `Setting status to waiting_approval instead of backlog to preserve pipeline work.` ); } // Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution let currentStatus: string | undefined; try { const currentFeature = await this.loadFeatureFn(projectPath, featureId); currentStatus = currentFeature?.status; } catch (loadErr) { // If loading fails, log it and proceed with the status update anyway logger.warn( `[executeFeature] Failed to reload feature ${featureId} for status check:`, loadErr ); } if (currentStatus !== 'merge_conflict') { await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus); } this.eventBus.emitAutoModeEvent('auto_mode_error', { featureId, featureName: feature?.title, branchName: feature?.branchName ?? null, error: errorInfo.message, errorType: errorInfo.type, projectPath, }); if (this.trackFailureFn({ type: errorInfo.type, message: errorInfo.message })) { this.signalPauseFn({ type: errorInfo.type, message: errorInfo.message }); } } } finally { this.releaseRunningFeature(featureId); if (isAutoMode && projectPath) await this.saveExecutionStateFn(projectPath); } } async stopFeature(featureId: string): Promise { const running = this.concurrencyManager.getRunningFeature(featureId); if (!running) return false; const { projectPath } = running; // Immediately update feature status to 'interrupted' so the UI reflects // the stop right away. CLI-based providers can take seconds to terminate // their subprocess after the abort signal fires, leaving the feature stuck // in 'in_progress' on the Kanban board until the executeFeature catch block // eventually runs. By persisting and emitting the status change here, the // board updates immediately regardless of how long the subprocess takes to stop. try { await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted'); } catch (err) { // Non-fatal: the abort still proceeds and executeFeature's catch block // will attempt the same update once the subprocess terminates. logger.warn(`stopFeature: failed to immediately update status for ${featureId}:`, err); } running.abortController.abort(); this.releaseRunningFeature(featureId, { force: true }); return true; } }