| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import path from 'path'; |
| import { exec } from 'child_process'; |
| import { promisify } from 'util'; |
| import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types'; |
| import { |
| DEFAULT_MAX_CONCURRENCY, |
| DEFAULT_MODELS, |
| stripProviderPrefix, |
| isPipelineStatus, |
| } from '@automaker/types'; |
| import { resolveModelString } from '@automaker/model-resolver'; |
| import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; |
| import { getFeatureDir } from '@automaker/platform'; |
| import * as secureFs from '../../lib/secure-fs.js'; |
| import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js'; |
| import { |
| getPromptCustomization, |
| resolveProviderContext, |
| getMCPServersFromSettings, |
| getDefaultMaxTurnsSetting, |
| } from '../../lib/settings-helpers.js'; |
| import { execGitCommand } from '@automaker/git-utils'; |
| import { TypedEventBus } from '../typed-event-bus.js'; |
| import { ConcurrencyManager } from '../concurrency-manager.js'; |
| import { WorktreeResolver } from '../worktree-resolver.js'; |
| import { FeatureStateManager } from '../feature-state-manager.js'; |
| import { PlanApprovalService } from '../plan-approval-service.js'; |
| import { AutoLoopCoordinator, type AutoModeConfig } from '../auto-loop-coordinator.js'; |
| import { ExecutionService } from '../execution-service.js'; |
| import { RecoveryService } from '../recovery-service.js'; |
| import { PipelineOrchestrator } from '../pipeline-orchestrator.js'; |
| import { AgentExecutor } from '../agent-executor.js'; |
| import { TestRunnerService } from '../test-runner-service.js'; |
| import { ProviderFactory } from '../../providers/provider-factory.js'; |
| import { FeatureLoader } from '../feature-loader.js'; |
| import type { SettingsService } from '../settings-service.js'; |
| import type { EventEmitter } from '../../lib/events.js'; |
| import type { |
| FacadeOptions, |
| FacadeError, |
| AutoModeStatus, |
| ProjectAutoModeStatus, |
| WorktreeCapacityInfo, |
| RunningAgentInfo, |
| OrphanedFeatureInfo, |
| } from './types.js'; |
|
|
| const execAsync = promisify(exec); |
| const logger = createLogger('AutoModeServiceFacade'); |
|
|
| |
| |
| |
| |
| |
| |
| export class AutoModeServiceFacade { |
| private constructor( |
| private readonly projectPath: string, |
| private readonly events: EventEmitter, |
| private readonly eventBus: TypedEventBus, |
| private readonly concurrencyManager: ConcurrencyManager, |
| private readonly worktreeResolver: WorktreeResolver, |
| private readonly featureStateManager: FeatureStateManager, |
| private readonly featureLoader: FeatureLoader, |
| private readonly planApprovalService: PlanApprovalService, |
| private readonly autoLoopCoordinator: AutoLoopCoordinator, |
| private readonly executionService: ExecutionService, |
| private readonly recoveryService: RecoveryService, |
| private readonly pipelineOrchestrator: PipelineOrchestrator, |
| private readonly settingsService: SettingsService | null |
| ) {} |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| public static isFeatureEligibleForAutoMode( |
| feature: Feature, |
| branchName: string | null, |
| primaryBranch: string | null |
| ): boolean { |
| const isEligibleStatus = |
| feature.status === 'backlog' || |
| feature.status === 'ready' || |
| feature.status === 'interrupted' || |
| isPipelineStatus(feature.status); |
|
|
| if (!isEligibleStatus) return false; |
|
|
| |
| if (branchName === null) { |
| |
| return !feature.branchName || (primaryBranch != null && feature.branchName === primaryBranch); |
| } else { |
| |
| return feature.branchName === branchName; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private handleFacadeError(error: unknown, method: string, featureId?: string): FacadeError { |
| const errorInfo = classifyError(error); |
|
|
| |
| logger.error( |
| `[${method}] ${featureId ? `Feature ${featureId}: ` : ''}${errorInfo.message}`, |
| error |
| ); |
|
|
| |
| if (!errorInfo.isAbort && !errorInfo.isCancellation) { |
| this.eventBus.emitAutoModeEvent('auto_mode_error', { |
| featureId: featureId ?? null, |
| featureName: undefined, |
| branchName: null, |
| error: errorInfo.message, |
| errorType: errorInfo.type, |
| projectPath: this.projectPath, |
| }); |
| } |
|
|
| return { |
| method, |
| errorType: errorInfo.type, |
| message: errorInfo.message, |
| featureId, |
| projectPath: this.projectPath, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| static create(projectPath: string, options: FacadeOptions): AutoModeServiceFacade { |
| const { |
| events, |
| settingsService = null, |
| featureLoader = new FeatureLoader(), |
| sharedServices, |
| } = options; |
|
|
| |
| |
| const eventBus = sharedServices?.eventBus ?? new TypedEventBus(events); |
| const worktreeResolver = sharedServices?.worktreeResolver ?? new WorktreeResolver(); |
| const concurrencyManager = |
| sharedServices?.concurrencyManager ?? |
| new ConcurrencyManager((p) => worktreeResolver.getCurrentBranch(p)); |
| const featureStateManager = new FeatureStateManager(events, featureLoader); |
| const planApprovalService = new PlanApprovalService( |
| eventBus, |
| featureStateManager, |
| settingsService |
| ); |
| const agentExecutor = new AgentExecutor( |
| eventBus, |
| featureStateManager, |
| planApprovalService, |
| settingsService |
| ); |
| const testRunnerService = new TestRunnerService(); |
|
|
| |
| const buildFeaturePrompt = ( |
| feature: Feature, |
| prompts: { implementationInstructions: string; playwrightVerificationInstructions: string } |
| ): string => { |
| const title = |
| feature.title || feature.description?.split('\n')[0]?.substring(0, 60) || 'Untitled'; |
| let prompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${title}\n**Description:** ${feature.description}\n`; |
| if (feature.spec) { |
| prompt += `\n**Specification:**\n${feature.spec}\n`; |
| } |
| if (!feature.skipTests) { |
| prompt += `\n${prompts.implementationInstructions}\n\n${prompts.playwrightVerificationInstructions}`; |
| } else { |
| prompt += `\n${prompts.implementationInstructions}`; |
| } |
| return prompt; |
| }; |
|
|
| |
| |
| |
| |
| |
| let facadeInstance: AutoModeServiceFacade | null = null; |
| const getFacade = (): AutoModeServiceFacade => { |
| if (!facadeInstance) { |
| throw new Error( |
| 'AutoModeServiceFacade not yet initialized — callback invoked during construction' |
| ); |
| } |
| return facadeInstance; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const createRunAgentFn = |
| () => |
| async ( |
| workDir: string, |
| featureId: string, |
| prompt: string, |
| abortController: AbortController, |
| pPath: string, |
| imagePaths?: string[], |
| model?: string, |
| opts?: { |
| planningMode?: PlanningMode; |
| requirePlanApproval?: boolean; |
| previousContent?: string; |
| systemPrompt?: string; |
| autoLoadClaudeMd?: boolean; |
| useClaudeCodeSystemPrompt?: boolean; |
| thinkingLevel?: ThinkingLevel; |
| reasoningEffort?: ReasoningEffort; |
| branchName?: string | null; |
| status?: string; |
| [key: string]: unknown; |
| } |
| ): Promise<void> => { |
| const resolvedModel = resolveModelString(model, DEFAULT_MODELS.claude); |
| const provider = ProviderFactory.getProviderForModel(resolvedModel); |
| const effectiveBareModel = stripProviderPrefix(resolvedModel); |
|
|
| |
| let claudeCompatibleProvider: |
| | import('@automaker/types').ClaudeCompatibleProvider |
| | undefined; |
| let credentials: import('@automaker/types').Credentials | undefined; |
| let providerResolvedModel: string | undefined; |
|
|
| if (settingsService) { |
| const providerId = opts?.providerId as string | undefined; |
| const result = await resolveProviderContext( |
| settingsService, |
| resolvedModel, |
| providerId, |
| '[AutoModeFacade]' |
| ); |
| claudeCompatibleProvider = result.provider; |
| credentials = result.credentials; |
| providerResolvedModel = result.resolvedModel; |
| } |
|
|
| |
| |
| |
| |
| const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false; |
| const useClaudeCodeSystemPrompt = opts?.useClaudeCodeSystemPrompt ?? true; |
| let mcpServers: Record<string, unknown> | undefined; |
| try { |
| if (settingsService) { |
| const servers = await getMCPServersFromSettings(settingsService, '[AutoModeFacade]'); |
| if (Object.keys(servers).length > 0) { |
| mcpServers = servers; |
| } |
| } |
| } catch { |
| |
| } |
|
|
| |
| const userMaxTurns = await getDefaultMaxTurnsSetting(settingsService, '[AutoModeFacade]'); |
|
|
| const sdkOpts = createAutoModeOptions({ |
| cwd: workDir, |
| model: providerResolvedModel || resolvedModel, |
| systemPrompt: opts?.systemPrompt, |
| abortController, |
| autoLoadClaudeMd, |
| useClaudeCodeSystemPrompt, |
| thinkingLevel: opts?.thinkingLevel, |
| maxTurns: userMaxTurns, |
| mcpServers: mcpServers as |
| | Record<string, import('@automaker/types').McpServerConfig> |
| | undefined, |
| }); |
|
|
| if (!sdkOpts) { |
| logger.error( |
| `[createRunAgentFn] sdkOpts is UNDEFINED! createAutoModeOptions type: ${typeof createAutoModeOptions}` |
| ); |
| } |
|
|
| logger.info( |
| `[createRunAgentFn] Feature ${featureId}: model=${resolvedModel} (resolved=${providerResolvedModel || resolvedModel}), ` + |
| `maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` + |
| `provider=${provider.getName()}` |
| ); |
|
|
| await agentExecutor.execute( |
| { |
| workDir, |
| featureId, |
| prompt, |
| projectPath: pPath, |
| abortController, |
| imagePaths, |
| model: resolvedModel, |
| planningMode: opts?.planningMode as PlanningMode | undefined, |
| requirePlanApproval: opts?.requirePlanApproval as boolean | undefined, |
| previousContent: opts?.previousContent as string | undefined, |
| systemPrompt: opts?.systemPrompt as string | undefined, |
| autoLoadClaudeMd: opts?.autoLoadClaudeMd as boolean | undefined, |
| useClaudeCodeSystemPrompt, |
| thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined, |
| reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined, |
| branchName: opts?.branchName as string | null | undefined, |
| status: opts?.status as string | undefined, |
| provider, |
| effectiveBareModel, |
| credentials, |
| claudeCompatibleProvider, |
| mcpServers, |
| sdkOptions: { |
| maxTurns: sdkOpts.maxTurns, |
| allowedTools: sdkOpts.allowedTools as string[] | undefined, |
| systemPrompt: sdkOpts.systemPrompt, |
| settingSources: sdkOpts.settingSources as |
| | Array<'user' | 'project' | 'local'> |
| | undefined, |
| }, |
| }, |
| { |
| waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath), |
| saveFeatureSummary: (projPath, fId, summary) => |
| featureStateManager.saveFeatureSummary(projPath, fId, summary), |
| updateFeatureSummary: (projPath, fId, summary) => |
| featureStateManager.saveFeatureSummary(projPath, fId, summary), |
| buildTaskPrompt: (task, allTasks, taskIndex, _planContent, template, feedback) => { |
| let taskPrompt = template |
| .replace(/\{\{taskName\}\}/g, task.description || `Task ${task.id}`) |
| .replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1)) |
| .replace(/\{\{totalTasks\}\}/g, String(allTasks.length)) |
| .replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`); |
| if (feedback) { |
| taskPrompt = taskPrompt.replace(/\{\{userFeedback\}\}/g, feedback); |
| } |
| return taskPrompt; |
| }, |
| } |
| ); |
| }; |
|
|
| |
| const pipelineOrchestrator = new PipelineOrchestrator( |
| eventBus, |
| featureStateManager, |
| agentExecutor, |
| testRunnerService, |
| worktreeResolver, |
| concurrencyManager, |
| settingsService, |
| |
| (pPath, featureId, status) => |
| featureStateManager.updateFeatureStatus(pPath, featureId, status), |
| loadContextFiles, |
| buildFeaturePrompt, |
| (pPath, featureId, useWorktrees, _isAutoMode, _model, opts) => |
| getFacade().executeFeature(featureId, useWorktrees, false, undefined, opts), |
| createRunAgentFn() |
| ); |
|
|
| |
| |
| |
| |
| const autoLoopCoordinator = new AutoLoopCoordinator( |
| eventBus, |
| concurrencyManager, |
| settingsService, |
| |
| (pPath, featureId, useWorktrees, isAutoMode) => |
| getFacade().executeFeature(featureId, useWorktrees, isAutoMode), |
| async (pPath, branchName) => { |
| const features = await featureLoader.getAll(pPath); |
| |
| |
| let primaryBranch: string | null = null; |
| if (branchName === null) { |
| primaryBranch = await worktreeResolver.getCurrentBranch(pPath); |
| } |
| return features.filter((f) => |
| AutoModeServiceFacade.isFeatureEligibleForAutoMode(f, branchName, primaryBranch) |
| ); |
| }, |
| (pPath, branchName, maxConcurrency) => |
| getFacade().saveExecutionStateForProject(branchName, maxConcurrency), |
| (pPath, branchName) => getFacade().clearExecutionState(branchName), |
| (pPath) => featureStateManager.resetStuckFeatures(pPath), |
| (feature) => |
| feature.status === 'completed' || |
| feature.status === 'verified' || |
| feature.status === 'waiting_approval', |
| (featureId) => concurrencyManager.isRunning(featureId), |
| async (pPath) => featureLoader.getAll(pPath) |
| ); |
|
|
| |
| |
| |
| |
| const forEachProjectWorktree = (fn: (branchName: string | null) => void): void => { |
| const projectWorktrees = autoLoopCoordinator |
| .getActiveWorktrees() |
| .filter((w) => w.projectPath === projectPath); |
| if (projectWorktrees.length === 0) { |
| fn(null); |
| } else { |
| for (const w of projectWorktrees) { |
| fn(w.branchName); |
| } |
| } |
| }; |
|
|
| |
| const executionService = new ExecutionService( |
| eventBus, |
| concurrencyManager, |
| worktreeResolver, |
| settingsService, |
| createRunAgentFn(), |
| (context) => pipelineOrchestrator.executePipeline(context), |
| (pPath, featureId, status) => |
| featureStateManager.updateFeatureStatus(pPath, featureId, status), |
| (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), |
| async (feature) => { |
| |
| if (!feature.planningMode || feature.planningMode === 'skip') { |
| return ''; |
| } |
| const prompts = await getPromptCustomization(settingsService, '[PlanningPromptPrefix]'); |
| const autoModePrompts = prompts.autoMode; |
| switch (feature.planningMode) { |
| case 'lite': |
| return feature.requirePlanApproval |
| ? autoModePrompts.planningLiteWithApproval + '\n\n' |
| : autoModePrompts.planningLite + '\n\n'; |
| case 'spec': |
| return autoModePrompts.planningSpec + '\n\n'; |
| case 'full': |
| return autoModePrompts.planningFull + '\n\n'; |
| default: |
| return ''; |
| } |
| }, |
| (pPath, featureId, summary) => |
| featureStateManager.saveFeatureSummary(pPath, featureId, summary), |
| async () => { |
| |
| }, |
| (pPath, featureId) => getFacade().contextExists(featureId), |
| (pPath, featureId, useWorktrees, _calledInternally) => |
| getFacade().resumeFeature(featureId, useWorktrees, _calledInternally), |
| (errorInfo) => { |
| |
| |
| |
| |
| let shouldPause = false; |
| forEachProjectWorktree((branchName) => { |
| if ( |
| autoLoopCoordinator.trackFailureAndCheckPauseForProject( |
| projectPath, |
| branchName, |
| errorInfo |
| ) |
| ) { |
| shouldPause = true; |
| } |
| }); |
| return shouldPause; |
| }, |
| (errorInfo) => { |
| forEachProjectWorktree((branchName) => |
| autoLoopCoordinator.signalShouldPauseForProject(projectPath, branchName, errorInfo) |
| ); |
| }, |
| () => { |
| |
| |
| forEachProjectWorktree((branchName) => |
| autoLoopCoordinator.recordSuccessForProject(projectPath, branchName) |
| ); |
| }, |
| (_pPath) => getFacade().saveExecutionState(), |
| loadContextFiles |
| ); |
|
|
| |
| const recoveryService = new RecoveryService( |
| eventBus, |
| concurrencyManager, |
| settingsService, |
| |
| (pPath, featureId, useWorktrees, isAutoMode, providedWorktreePath, opts) => |
| getFacade().executeFeature(featureId, useWorktrees, isAutoMode, providedWorktreePath, opts), |
| (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), |
| (pPath, featureId, status) => |
| pipelineOrchestrator.detectPipelineStatus(pPath, featureId, status), |
| (pPath, feature, useWorktrees, pipelineInfo) => |
| pipelineOrchestrator.resumePipeline(pPath, feature, useWorktrees, pipelineInfo), |
| (featureId) => concurrencyManager.isRunning(featureId), |
| (opts) => concurrencyManager.acquire(opts), |
| (featureId) => concurrencyManager.release(featureId) |
| ); |
|
|
| |
| facadeInstance = new AutoModeServiceFacade( |
| projectPath, |
| events, |
| eventBus, |
| concurrencyManager, |
| worktreeResolver, |
| featureStateManager, |
| featureLoader, |
| planApprovalService, |
| autoLoopCoordinator, |
| executionService, |
| recoveryService, |
| pipelineOrchestrator, |
| settingsService |
| ); |
|
|
| return facadeInstance; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| async startAutoLoop(branchName: string | null = null, maxConcurrency?: number): Promise<number> { |
| try { |
| return await this.autoLoopCoordinator.startAutoLoopForProject( |
| this.projectPath, |
| branchName, |
| maxConcurrency |
| ); |
| } catch (error) { |
| this.handleFacadeError(error, 'startAutoLoop'); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| |
| async stopAutoLoop(branchName: string | null = null): Promise<number> { |
| try { |
| return await this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName); |
| } catch (error) { |
| this.handleFacadeError(error, 'stopAutoLoop'); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| |
| isAutoLoopRunning(branchName: string | null = null): boolean { |
| return this.autoLoopCoordinator.isAutoLoopRunningForProject(this.projectPath, branchName); |
| } |
|
|
| |
| |
| |
| |
| getAutoLoopConfig(branchName: string | null = null): AutoModeConfig | null { |
| return this.autoLoopCoordinator.getAutoLoopConfigForProject(this.projectPath, branchName); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async executeFeature( |
| featureId: string, |
| useWorktrees = false, |
| isAutoMode = false, |
| providedWorktreePath?: string, |
| options?: { |
| continuationPrompt?: string; |
| _calledInternally?: boolean; |
| } |
| ): Promise<void> { |
| try { |
| return await this.executionService.executeFeature( |
| this.projectPath, |
| featureId, |
| useWorktrees, |
| isAutoMode, |
| providedWorktreePath, |
| options |
| ); |
| } catch (error) { |
| this.handleFacadeError(error, 'executeFeature', featureId); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| |
| async stopFeature(featureId: string): Promise<boolean> { |
| try { |
| |
| this.cancelPlanApproval(featureId); |
| return await this.executionService.stopFeature(featureId); |
| } catch (error) { |
| this.handleFacadeError(error, 'stopFeature', featureId); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async resumeFeature( |
| featureId: string, |
| useWorktrees = false, |
| _calledInternally = false |
| ): Promise<void> { |
| |
| |
| |
| |
| |
| try { |
| return await this.recoveryService.resumeFeature( |
| this.projectPath, |
| featureId, |
| useWorktrees, |
| _calledInternally |
| ); |
| } catch (error) { |
| this.handleFacadeError(error, 'resumeFeature', featureId); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async followUpFeature( |
| featureId: string, |
| prompt: string, |
| imagePaths?: string[], |
| useWorktrees = true |
| ): Promise<void> { |
| validateWorkingDirectory(this.projectPath); |
|
|
| try { |
| |
| const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); |
| if (!feature) throw new Error(`Feature ${featureId} not found`); |
|
|
| |
| const featureDir = getFeatureDir(this.projectPath, featureId); |
| let previousContext = ''; |
| try { |
| previousContext = (await secureFs.readFile( |
| path.join(featureDir, 'agent-output.md'), |
| 'utf-8' |
| )) as string; |
| } catch { |
| |
| } |
|
|
| |
| const featurePrompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled Feature'}\n**Description:** ${feature.description}\n`; |
|
|
| |
| const prompts = await getPromptCustomization(this.settingsService, '[Facade]'); |
| let continuationPrompt = prompts.autoMode.followUpPromptTemplate; |
| continuationPrompt = continuationPrompt |
| .replace(/\{\{featurePrompt\}\}/g, featurePrompt) |
| .replace(/\{\{previousContext\}\}/g, previousContext) |
| .replace(/\{\{followUpInstructions\}\}/g, prompt); |
|
|
| |
| if (imagePaths && imagePaths.length > 0) { |
| feature.imagePaths = imagePaths.map((p) => ({ |
| path: p, |
| filename: p.split('/').pop() || p, |
| mimeType: 'image/*', |
| })); |
| await this.featureStateManager.updateFeatureStatus( |
| this.projectPath, |
| featureId, |
| feature.status || 'in_progress' |
| ); |
| } |
|
|
| |
| await this.executeFeature(featureId, useWorktrees, false, undefined, { |
| continuationPrompt, |
| }); |
| } catch (error) { |
| const errorInfo = classifyError(error); |
| if (!errorInfo.isAbort) { |
| this.eventBus.emitAutoModeEvent('auto_mode_error', { |
| featureId, |
| featureName: undefined, |
| branchName: null, |
| error: errorInfo.message, |
| errorType: errorInfo.type, |
| projectPath: this.projectPath, |
| }); |
| } |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| |
| async verifyFeature(featureId: string): Promise<boolean> { |
| const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); |
| let workDir = this.projectPath; |
|
|
| |
| const branchName = feature?.branchName; |
| if (branchName) { |
| const resolved = await this.worktreeResolver.findWorktreeForBranch( |
| this.projectPath, |
| branchName |
| ); |
| if (resolved) { |
| try { |
| await secureFs.access(resolved); |
| workDir = resolved; |
| } catch { |
| |
| } |
| } |
| } |
|
|
| const verificationChecks = [ |
| { cmd: 'npm run lint', name: 'Lint' }, |
| { cmd: 'npm run typecheck', name: 'Type check' }, |
| { cmd: 'npm test', name: 'Tests' }, |
| { cmd: 'npm run build', name: 'Build' }, |
| ]; |
|
|
| let allPassed = true; |
| const results: Array<{ check: string; passed: boolean; output?: string }> = []; |
|
|
| for (const check of verificationChecks) { |
| try { |
| const { stdout, stderr } = await execAsync(check.cmd, { cwd: workDir, timeout: 120000 }); |
| results.push({ check: check.name, passed: true, output: stdout || stderr }); |
| } catch (error) { |
| allPassed = false; |
| results.push({ check: check.name, passed: false, output: (error as Error).message }); |
| break; |
| } |
| } |
|
|
| const runningEntryForVerify = this.concurrencyManager.getRunningFeature(featureId); |
| if (runningEntryForVerify?.isAutoMode) { |
| this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { |
| featureId, |
| featureName: feature?.title, |
| branchName: feature?.branchName ?? null, |
| executionMode: 'auto', |
| passes: allPassed, |
| message: allPassed |
| ? 'All verification checks passed' |
| : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, |
| projectPath: this.projectPath, |
| }); |
| } |
|
|
| return allPassed; |
| } |
|
|
| |
| |
| |
| |
| |
| async commitFeature(featureId: string, providedWorktreePath?: string): Promise<string | null> { |
| let workDir = this.projectPath; |
|
|
| if (providedWorktreePath) { |
| try { |
| await secureFs.access(providedWorktreePath); |
| workDir = providedWorktreePath; |
| } catch { |
| |
| } |
| } else { |
| |
| const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); |
| const branchName = feature?.branchName; |
| if (branchName) { |
| const resolved = await this.worktreeResolver.findWorktreeForBranch( |
| this.projectPath, |
| branchName |
| ); |
| if (resolved) { |
| workDir = resolved; |
| } |
| } |
| } |
|
|
| try { |
| const status = await execGitCommand(['status', '--porcelain'], workDir); |
| if (!status.trim()) { |
| return null; |
| } |
|
|
| const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); |
| const title = |
| feature?.description?.split('\n')[0]?.substring(0, 60) || `Feature ${featureId}`; |
| const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`; |
|
|
| await execGitCommand(['add', '-A'], workDir); |
| await execGitCommand(['commit', '-m', commitMessage], workDir); |
| const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir); |
|
|
| const runningEntryForCommit = this.concurrencyManager.getRunningFeature(featureId); |
| if (runningEntryForCommit?.isAutoMode) { |
| this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { |
| featureId, |
| featureName: feature?.title, |
| branchName: feature?.branchName ?? null, |
| executionMode: 'auto', |
| passes: true, |
| message: `Changes committed: ${hash.trim().substring(0, 8)}`, |
| projectPath: this.projectPath, |
| }); |
| } |
|
|
| return hash.trim(); |
| } catch (error) { |
| logger.error(`Commit failed for ${featureId}:`, error); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| getStatus(): AutoModeStatus { |
| const allRunning = this.concurrencyManager.getAllRunning(); |
| return { |
| isRunning: allRunning.length > 0, |
| runningFeatures: allRunning.map((rf) => rf.featureId), |
| runningCount: allRunning.length, |
| }; |
| } |
|
|
| |
| |
| |
| |
| async getStatusForProject(branchName: string | null = null): Promise<ProjectAutoModeStatus> { |
| const isAutoLoopRunning = this.autoLoopCoordinator.isAutoLoopRunningForProject( |
| this.projectPath, |
| branchName |
| ); |
| const config = this.autoLoopCoordinator.getAutoLoopConfigForProject( |
| this.projectPath, |
| branchName |
| ); |
| |
| |
| const runningFeatures = await this.concurrencyManager.getRunningFeaturesForWorktree( |
| this.projectPath, |
| branchName |
| ); |
|
|
| return { |
| isAutoLoopRunning, |
| runningFeatures, |
| runningCount: runningFeatures.length, |
| maxConcurrency: config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, |
| branchName, |
| }; |
| } |
|
|
| |
| |
| |
| getActiveAutoLoopProjects(): string[] { |
| return this.autoLoopCoordinator.getActiveProjects(); |
| } |
|
|
| |
| |
| |
| getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { |
| return this.autoLoopCoordinator.getActiveWorktrees(); |
| } |
|
|
| |
| |
| |
| async getRunningAgents(): Promise<RunningAgentInfo[]> { |
| const agents = await Promise.all( |
| this.concurrencyManager.getAllRunning().map(async (rf) => { |
| let title: string | undefined; |
| let description: string | undefined; |
| let branchName: string | undefined; |
|
|
| try { |
| const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); |
| if (feature) { |
| title = feature.title; |
| description = feature.description; |
| branchName = feature.branchName ?? undefined; |
| } |
| } catch { |
| |
| } |
|
|
| return { |
| featureId: rf.featureId, |
| projectPath: rf.projectPath, |
| projectName: path.basename(rf.projectPath), |
| isAutoMode: rf.isAutoMode, |
| model: rf.model, |
| provider: rf.provider, |
| title, |
| description, |
| branchName, |
| }; |
| }) |
| ); |
| return agents; |
| } |
|
|
| |
| |
| |
| |
| async checkWorktreeCapacity(featureId: string): Promise<WorktreeCapacityInfo> { |
| const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); |
| const rawBranchName = feature?.branchName ?? null; |
| |
| const primaryBranch = await this.worktreeResolver.getCurrentBranch(this.projectPath); |
| const branchName = rawBranchName === primaryBranch ? null : rawBranchName; |
|
|
| const maxAgents = await this.autoLoopCoordinator.resolveMaxConcurrency( |
| this.projectPath, |
| branchName |
| ); |
| const currentAgents = await this.concurrencyManager.getRunningCountForWorktree( |
| this.projectPath, |
| branchName |
| ); |
|
|
| return { |
| hasCapacity: currentAgents < maxAgents, |
| currentAgents, |
| maxAgents, |
| branchName, |
| }; |
| } |
|
|
| |
| |
| |
| |
| async contextExists(featureId: string): Promise<boolean> { |
| return this.recoveryService.contextExists(this.projectPath, featureId); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| async resolvePlanApproval( |
| featureId: string, |
| approved: boolean, |
| editedPlan?: string, |
| feedback?: string |
| ): Promise<{ success: boolean; error?: string }> { |
| const result = await this.planApprovalService.resolveApproval(featureId, approved, { |
| editedPlan, |
| feedback, |
| projectPath: this.projectPath, |
| }); |
|
|
| |
| if (result.success && result.needsRecovery) { |
| const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); |
| if (feature) { |
| const prompts = await getPromptCustomization(this.settingsService, '[Facade]'); |
| const planContent = editedPlan || feature.planSpec?.content || ''; |
| let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; |
| continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, feedback || ''); |
| continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); |
|
|
| |
| this.executeFeature(featureId, true, false, undefined, { continuationPrompt }).catch( |
| (error) => { |
| logger.error(`Recovery execution failed for feature ${featureId}:`, error); |
| } |
| ); |
| } |
| } |
|
|
| return { success: result.success, error: result.error }; |
| } |
|
|
| |
| |
| |
| |
| waitForPlanApproval( |
| featureId: string |
| ): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> { |
| return this.planApprovalService.waitForApproval(featureId, this.projectPath); |
| } |
|
|
| |
| |
| |
| |
| hasPendingApproval(featureId: string): boolean { |
| return this.planApprovalService.hasPendingApproval(featureId, this.projectPath); |
| } |
|
|
| |
| |
| |
| |
| cancelPlanApproval(featureId: string): void { |
| this.planApprovalService.cancelApproval(featureId, this.projectPath); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| async analyzeProject(): Promise<void> { |
| |
| |
| throw new Error( |
| 'analyzeProject not fully implemented in facade - use AutoModeService.analyzeProject instead' |
| ); |
| } |
|
|
| |
| |
| |
| async resumeInterruptedFeatures(): Promise<void> { |
| return this.recoveryService.resumeInterruptedFeatures(this.projectPath); |
| } |
|
|
| |
| |
| |
| |
| async detectOrphanedFeatures(preloadedFeatures?: Feature[]): Promise<OrphanedFeatureInfo[]> { |
| const orphanedFeatures: OrphanedFeatureInfo[] = []; |
|
|
| try { |
| const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath)); |
| const featuresWithBranches = allFeatures.filter( |
| (f) => f.branchName && f.branchName.trim() !== '' |
| ); |
|
|
| if (featuresWithBranches.length === 0) { |
| return orphanedFeatures; |
| } |
|
|
| |
| const stdout = await execGitCommand( |
| ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], |
| this.projectPath |
| ); |
| const existingBranches = new Set( |
| stdout |
| .trim() |
| .split('\n') |
| .map((b) => b.trim()) |
| .filter(Boolean) |
| ); |
|
|
| const primaryBranch = await this.worktreeResolver.getCurrentBranch(this.projectPath); |
|
|
| for (const feature of featuresWithBranches) { |
| const branchName = feature.branchName!; |
| if (primaryBranch && branchName === primaryBranch) { |
| continue; |
| } |
| if (!existingBranches.has(branchName)) { |
| orphanedFeatures.push({ feature, missingBranch: branchName }); |
| } |
| } |
|
|
| return orphanedFeatures; |
| } catch (error) { |
| logger.error('[detectOrphanedFeatures] Error:', error); |
| return orphanedFeatures; |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| async markAllRunningFeaturesInterrupted(reason?: string): Promise<void> { |
| const allRunning = this.concurrencyManager.getAllRunning(); |
|
|
| for (const rf of allRunning) { |
| await this.featureStateManager.markFeatureInterrupted(rf.projectPath, rf.featureId, reason); |
| } |
|
|
| if (allRunning.length > 0) { |
| logger.info( |
| `Marked ${allRunning.length} running feature(s) as interrupted: ${reason || 'no reason provided'}` |
| ); |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| private async saveExecutionState(): Promise<void> { |
| const projectWorktrees = this.autoLoopCoordinator |
| .getActiveWorktrees() |
| .filter((w) => w.projectPath === this.projectPath); |
|
|
| if (projectWorktrees.length === 0) { |
| |
| return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY); |
| } |
|
|
| |
| for (const { branchName } of projectWorktrees) { |
| const config = this.autoLoopCoordinator.getAutoLoopConfigForProject( |
| this.projectPath, |
| branchName |
| ); |
| const maxConcurrency = config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY; |
| await this.saveExecutionStateForProject(branchName, maxConcurrency); |
| } |
| } |
|
|
| |
| |
| |
| private async saveExecutionStateForProject( |
| branchName: string | null, |
| maxConcurrency: number |
| ): Promise<void> { |
| return this.recoveryService.saveExecutionStateForProject( |
| this.projectPath, |
| branchName, |
| maxConcurrency |
| ); |
| } |
|
|
| |
| |
| |
| private async clearExecutionState(branchName: string | null = null): Promise<void> { |
| return this.recoveryService.clearExecutionState(this.projectPath, branchName); |
| } |
| } |
|
|