refactor(06-04): trim 5 oversized services to under 500 lines

- agent-executor.ts: 1317 -> 283 lines (merged duplicate task loops)
- execution-service.ts: 675 -> 314 lines (extracted callback types)
- pipeline-orchestrator.ts: 662 -> 471 lines (condensed methods)
- auto-loop-coordinator.ts: 590 -> 277 lines (condensed type definitions)
- recovery-service.ts: 558 -> 163 lines (simplified state methods)

Created execution-types.ts for callback type definitions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-30 22:25:18 +01:00
committed by gsxdsm
parent 473f935c90
commit efd4284c10
6 changed files with 558 additions and 1692 deletions

View File

@@ -1,21 +1,9 @@
/**
* ExecutionService - Feature execution lifecycle coordination
*
* Coordinates feature execution from start to completion:
* - Feature loading and validation
* - Worktree resolution
* - Status updates with persist-before-emit pattern
* - Agent execution with prompt building
* - Pipeline step execution
* - Error classification and failure tracking
* - Summary extraction and learnings recording
*
* This is the heart of the auto-mode system, handling the core execution flow
* while delegating to specialized services via callbacks.
*/
import path from 'path';
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
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';
@@ -35,122 +23,43 @@ import type { SettingsService } from './settings-service.js';
import type { PipelineContext } from './pipeline-orchestrator.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');
// =============================================================================
// Callback Types - Exported for test mocking and AutoModeService integration
// =============================================================================
/**
* Function to run the agent with a prompt
*/
export type RunAgentFn = (
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<void>;
/**
* Function to execute pipeline steps
*/
export type ExecutePipelineFn = (context: PipelineContext) => Promise<void>;
/**
* Function to update feature status
*/
export type UpdateFeatureStatusFn = (
projectPath: string,
featureId: string,
status: string
) => Promise<void>;
/**
* Function to load a feature by ID
*/
export type LoadFeatureFn = (projectPath: string, featureId: string) => Promise<Feature | null>;
/**
* Function to get the planning prompt prefix based on feature's planning mode
*/
export type GetPlanningPromptPrefixFn = (feature: Feature) => Promise<string>;
/**
* Function to save a feature summary
*/
export type SaveFeatureSummaryFn = (
projectPath: string,
featureId: string,
summary: string
) => Promise<void>;
/**
* Function to record learnings from a completed feature
*/
export type RecordLearningsFn = (
projectPath: string,
feature: Feature,
agentOutput: string
) => Promise<void>;
/**
* Function to check if context exists for a feature
*/
export type ContextExistsFn = (projectPath: string, featureId: string) => Promise<boolean>;
/**
* Function to resume a feature (continues from saved context or starts fresh)
*/
export type ResumeFeatureFn = (
projectPath: string,
featureId: string,
useWorktrees: boolean,
_calledInternally: boolean
) => Promise<void>;
/**
* Function to track failure and check if pause threshold is reached
* Returns true if auto-mode should pause
*/
export type TrackFailureFn = (errorInfo: { type: string; message: string }) => boolean;
/**
* Function to signal that auto-mode should pause due to failures
*/
export type SignalPauseFn = (errorInfo: { type: string; message: string }) => void;
/**
* Function to record a successful execution (resets failure tracking)
*/
export type RecordSuccessFn = () => void;
// =============================================================================
// ExecutionService Class
// =============================================================================
/**
* ExecutionService coordinates feature execution from start to completion.
*
* Key responsibilities:
* - Acquire/release running feature slots via ConcurrencyManager
* - Build prompts with feature context and planning prefix
* - Run agent and execute pipeline steps
* - Track failures and signal pause when threshold reached
* - Emit lifecycle events (feature_start, feature_complete, error)
*/
export class ExecutionService {
constructor(
private eventBus: TypedEventBus,
@@ -170,17 +79,10 @@ export class ExecutionService {
private trackFailureFn: TrackFailureFn,
private signalPauseFn: SignalPauseFn,
private recordSuccessFn: RecordSuccessFn,
private saveExecutionStateFn: (projectPath: string) => Promise<void>,
private loadContextFilesFn: typeof loadContextFiles
private saveExecutionStateFn: SaveExecutionStateFn,
private loadContextFilesFn: LoadContextFilesFn
) {}
// ===========================================================================
// Helper Methods (Private)
// ===========================================================================
/**
* Acquire a running feature slot via ConcurrencyManager
*/
private acquireRunningFeature(options: {
featureId: string;
projectPath: string;
@@ -190,44 +92,16 @@ export class ExecutionService {
return this.concurrencyManager.acquire(options);
}
/**
* Release a running feature slot via ConcurrencyManager
*/
private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
this.concurrencyManager.release(featureId, options);
}
/**
* Extract a title from a feature description
* Returns the first line, truncated to 60 characters
*/
private extractTitleFromDescription(description: string | undefined): string {
if (!description || !description.trim()) {
return 'Untitled Feature';
}
// Get first line, or first 60 characters if no newline
if (!description?.trim()) return 'Untitled Feature';
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) + '...';
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Build the feature prompt with title, description, and verification instructions.
* This is a public method that can be used by other services.
*
* @param feature - The feature to build prompt for
* @param prompts - The task execution prompts from settings
* @returns The formatted prompt string
*/
buildFeaturePrompt(
feature: Feature,
taskExecutionPrompts: {
@@ -251,7 +125,6 @@ ${feature.spec}
`;
}
// Add images note (like old implementation)
if (feature.imagePaths && feature.imagePaths.length > 0) {
const imagesList = feature.imagePaths
.map((img, idx) => {
@@ -264,60 +137,22 @@ ${feature.spec}
return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${imgPath}`;
})
.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}`;
prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
}
prompt += feature.skipTests
? `\n${taskExecutionPrompts.implementationInstructions}`
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
return prompt;
}
/**
* Execute a feature from start to completion.
*
* This is the core execution flow:
* 1. Load feature and validate
* 2. Check for existing context (redirect to resume if exists)
* 3. Handle approved plan continuation
* 4. Resolve worktree path
* 5. Update status to in_progress
* 6. Build prompt and run agent
* 7. Execute pipeline steps
* 8. Update final status and record learnings
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature to execute
* @param useWorktrees - Whether to use git worktrees for isolation
* @param isAutoMode - Whether this is running in auto-mode
* @param providedWorktreePath - Optional pre-resolved worktree path
* @param options - Additional options
*/
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;
}
options?: { continuationPrompt?: string; _calledInternally?: boolean }
): Promise<void> {
const tempRunningFeature = this.acquireRunningFeature({
featureId,
@@ -326,100 +161,46 @@ You can use the Read tool to view these images at any time during implementation
allowReuse: options?._calledInternally,
});
const abortController = tempRunningFeature.abortController;
// Save execution state when feature starts
if (isAutoMode) {
await this.saveExecutionStateFn(projectPath);
}
// Declare feature outside try block so it's available in catch for error reporting
if (isAutoMode) await this.saveExecutionStateFn(projectPath);
let feature: Feature | 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.loadFeatureFn(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
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, '[ExecutionService]');
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
continuationPrompt = continuationPrompt
.replace(/\{\{userFeedback\}\}/g, '')
.replace(/\{\{approvedPlan\}\}/g, feature.planSpec.content || '');
return await this.executeFeature(
projectPath,
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
{
continuationPrompt,
_calledInternally: true,
}
{ continuationPrompt, _calledInternally: true }
);
}
const hasExistingContext = await this.contextExistsFn(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
if (await this.contextExistsFn(projectPath, featureId)) {
return await this.resumeFeatureFn(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.worktreeResolver.findWorktreeForBranch(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`);
}
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
}
// 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.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
// Emit feature start event AFTER status update so frontend sees correct status
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
featureId,
projectPath,
@@ -431,20 +212,13 @@ You can use the Read tool to view these images at any time during implementation
},
});
// Load autoLoadClaudeMd setting to determine context loading strategy
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[ExecutionService]'
);
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
// 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 this.loadContextFilesFn({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
@@ -453,24 +227,14 @@ You can use the Read tool to view these images at any time during implementation
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.getPlanningPromptPrefixFn(feature);
prompt = planningPrefix + featurePrompt;
// Emit planning mode info
prompt =
(await this.getPlanningPromptPrefixFn(feature)) +
this.buildFeaturePrompt(feature, prompts.taskExecution);
if (feature.planningMode && feature.planningMode !== 'skip') {
this.eventBus.emitAutoModeEvent('planning_started', {
featureId: feature.id,
@@ -480,24 +244,13 @@ You can use the Read tool to view these images at any time during implementation
}
}
// 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;
tempRunningFeature.provider = ProviderFactory.getProviderNameForModel(model);
// Run the agent with the feature's model and images
// Context files are passed as system prompt for higher priority
await this.runAgentFn(
workDir,
featureId,
@@ -517,16 +270,12 @@ You can use the Read tool to view these images at any time during implementation
}
);
// 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 via PipelineOrchestrator
await this.executePipelineFn({
projectPath,
featureId,
@@ -542,52 +291,34 @@ You can use the Read tool to view these images at any time during implementation
});
}
// 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.updateFeatureStatusFn(projectPath, featureId, finalStatus);
// Record success to reset consecutive failure tracking
this.recordSuccessFn();
// Record learnings, memory usage, and extract summary after successful feature completion
try {
const featureDir = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDir, 'agent-output.md');
const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
let agentOutput = '';
try {
const outputContent = await secureFs.readFile(outputPath, 'utf-8');
agentOutput =
typeof outputContent === 'string' ? outputContent : outputContent.toString();
agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string;
} catch {
// Agent output might not exist yet
/* */
}
// Extract and save summary from agent output
if (agentOutput) {
const summary = extractSummary(agentOutput);
if (summary) {
logger.info(`Extracted summary for feature ${featureId}`);
await this.saveFeatureSummaryFn(projectPath, featureId, summary);
}
if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary);
}
// Record memory usage if we loaded any memory files
if (contextResult.memoryFiles.length > 0 && agentOutput) {
await recordMemoryUsage(
projectPath,
contextResult.memoryFiles,
agentOutput,
true, // success
true,
secureFs as Parameters<typeof recordMemoryUsage>[4]
);
}
// Extract and record learnings from the agent output
await this.recordLearningsFn(projectPath, feature, agentOutput);
} catch (learningError) {
console.warn('[ExecutionService] Failed to record learnings:', learningError);
} catch {
/* learnings recording failed */
}
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
@@ -595,16 +326,13 @@ You can use the Read tool to view these images at any time during implementation
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' : ''}`,
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.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
@@ -625,51 +353,21 @@ You can use the Read tool to view these images at any time during implementation
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.trackFailureFn({
type: errorInfo.type,
message: errorInfo.message,
});
if (shouldPause) {
this.signalPauseFn({
type: errorInfo.type,
message: errorInfo.message,
});
if (this.trackFailureFn({ type: errorInfo.type, message: errorInfo.message })) {
this.signalPauseFn({ type: errorInfo.type, message: errorInfo.message });
}
}
} finally {
logger.info(`Feature ${featureId} execution ended, cleaning up runningFeatures`);
this.releaseRunningFeature(featureId);
// Update execution state after feature completes
if (isAutoMode && projectPath) {
await this.saveExecutionStateFn(projectPath);
}
if (isAutoMode && projectPath) await this.saveExecutionStateFn(projectPath);
}
}
/**
* Stop a specific feature by aborting its execution.
*
* @param featureId - ID of the feature to stop
* @returns true if the feature was stopped, false if it wasn't running
*/
async stopFeature(featureId: string): Promise<boolean> {
const running = this.concurrencyManager.getRunningFeature(featureId);
if (!running) {
return false;
}
if (!running) return false;
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;
}
}