diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4bd496bc..dcb9685d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -56,7 +56,7 @@ import { import { createSettingsRoutes } from './routes/settings/index.js'; import { AgentService } from './services/agent-service.js'; import { FeatureLoader } from './services/feature-loader.js'; -import { AutoModeService } from './services/auto-mode-service.js'; +import { AutoModeServiceCompat } from './services/auto-mode/index.js'; import { getTerminalService } from './services/terminal-service.js'; import { SettingsService } from './services/settings-service.js'; import { createSpecRegenerationRoutes } from './routes/app-spec/index.js'; @@ -258,7 +258,9 @@ const events: EventEmitter = createEventEmitter(); const settingsService = new SettingsService(DATA_DIR); const agentService = new AgentService(DATA_DIR, events, settingsService); const featureLoader = new FeatureLoader(); -const autoModeService = new AutoModeService(events, settingsService); + +// Auto-mode services: compatibility layer provides old interface while using new architecture +const autoModeService = new AutoModeServiceCompat(events, settingsService, featureLoader); const claudeUsageService = new ClaudeUsageService(); const codexAppServerService = new CodexAppServerService(); const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService); diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index de376b7d..a0c998d6 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -1,13 +1,12 @@ /** * Auto Mode routes - HTTP API for autonomous feature implementation * - * Uses the AutoModeService for real feature execution with Claude Agent SDK. - * Supports optional facadeFactory for per-project facade creation during migration. + * Uses AutoModeServiceCompat which provides the old interface while + * delegating to GlobalAutoModeService and per-project facades. */ import { Router } from 'express'; -import type { AutoModeService } from '../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js'; import { validatePathParams } from '../../middleware/validate-paths.js'; import { createStopFeatureHandler } from './routes/stop-feature.js'; import { createStatusHandler } from './routes/status.js'; @@ -24,81 +23,63 @@ import { createApprovePlanHandler } from './routes/approve-plan.js'; import { createResumeInterruptedHandler } from './routes/resume-interrupted.js'; /** - * Create auto-mode routes with optional facade factory. + * Create auto-mode routes. * - * @param autoModeService - The AutoModeService instance (for backward compatibility) - * @param facadeFactory - Optional factory for creating per-project facades + * @param autoModeService - AutoModeServiceCompat instance */ -export function createAutoModeRoutes( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -): Router { +export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Router { const router = Router(); // Auto loop control routes - router.post( - '/start', - validatePathParams('projectPath'), - createStartHandler(autoModeService, facadeFactory) - ); - router.post( - '/stop', - validatePathParams('projectPath'), - createStopHandler(autoModeService, facadeFactory) - ); + router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService)); + router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService)); - // Note: stop-feature doesn't have projectPath, so we pass undefined for facade. - // When we fully migrate, we can update stop-feature to use a different approach. router.post('/stop-feature', createStopFeatureHandler(autoModeService)); - router.post( - '/status', - validatePathParams('projectPath?'), - createStatusHandler(autoModeService, facadeFactory) - ); + router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService)); router.post( '/run-feature', validatePathParams('projectPath'), - createRunFeatureHandler(autoModeService, facadeFactory) + createRunFeatureHandler(autoModeService) ); router.post( '/verify-feature', validatePathParams('projectPath'), - createVerifyFeatureHandler(autoModeService, facadeFactory) + createVerifyFeatureHandler(autoModeService) ); router.post( '/resume-feature', validatePathParams('projectPath'), - createResumeFeatureHandler(autoModeService, facadeFactory) + createResumeFeatureHandler(autoModeService) ); router.post( '/context-exists', validatePathParams('projectPath'), - createContextExistsHandler(autoModeService, facadeFactory) + createContextExistsHandler(autoModeService) ); router.post( '/analyze-project', validatePathParams('projectPath'), - createAnalyzeProjectHandler(autoModeService, facadeFactory) + createAnalyzeProjectHandler(autoModeService) ); router.post( '/follow-up-feature', validatePathParams('projectPath', 'imagePaths[]'), - createFollowUpFeatureHandler(autoModeService, facadeFactory) + createFollowUpFeatureHandler(autoModeService) ); router.post( '/commit-feature', validatePathParams('projectPath', 'worktreePath?'), - createCommitFeatureHandler(autoModeService, facadeFactory) + createCommitFeatureHandler(autoModeService) ); router.post( '/approve-plan', validatePathParams('projectPath'), - createApprovePlanHandler(autoModeService, facadeFactory) + createApprovePlanHandler(autoModeService) ); router.post( '/resume-interrupted', validatePathParams('projectPath'), - createResumeInterruptedHandler(autoModeService, facadeFactory) + createResumeInterruptedHandler(autoModeService) ); return router; diff --git a/apps/server/src/routes/auto-mode/routes/analyze-project.ts b/apps/server/src/routes/auto-mode/routes/analyze-project.ts index 1903940a..9ba22c50 100644 --- a/apps/server/src/routes/auto-mode/routes/analyze-project.ts +++ b/apps/server/src/routes/auto-mode/routes/analyze-project.ts @@ -3,17 +3,13 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; const logger = createLogger('AutoMode'); -export function createAnalyzeProjectHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { projectPath } = req.body as { projectPath: string }; @@ -23,19 +19,6 @@ export function createAnalyzeProjectHandler( return; } - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - // Start analysis in background - facade.analyzeProject().catch((error) => { - logger.error(`[AutoMode] Project analysis error:`, error); - }); - - res.json({ success: true, message: 'Project analysis started' }); - return; - } - - // Legacy path: use autoModeService directly // Start analysis in background autoModeService.analyzeProject(projectPath).catch((error) => { logger.error(`[AutoMode] Project analysis error:`, error); diff --git a/apps/server/src/routes/auto-mode/routes/approve-plan.ts b/apps/server/src/routes/auto-mode/routes/approve-plan.ts index bc989099..277b50e2 100644 --- a/apps/server/src/routes/auto-mode/routes/approve-plan.ts +++ b/apps/server/src/routes/auto-mode/routes/approve-plan.ts @@ -3,17 +3,13 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; const logger = createLogger('AutoMode'); -export function createApprovePlanHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { featureId, approved, editedPlan, feedback, projectPath } = req.body as { @@ -50,37 +46,13 @@ export function createApprovePlanHandler( }${feedback ? ` - Feedback: ${feedback}` : ''}` ); - // Use facade if factory is provided and projectPath is available - if (facadeFactory && projectPath) { - const facade = facadeFactory(projectPath); - const result = await facade.resolvePlanApproval(featureId, approved, editedPlan, feedback); - - if (!result.success) { - res.status(500).json({ - success: false, - error: result.error, - }); - return; - } - - res.json({ - success: true, - approved, - message: approved - ? 'Plan approved - implementation will continue' - : 'Plan rejected - feature execution stopped', - }); - return; - } - - // Legacy path: use autoModeService directly // Resolve the pending approval (with recovery support) const result = await autoModeService.resolvePlanApproval( + projectPath || '', featureId, approved, editedPlan, - feedback, - projectPath + feedback ); if (!result.success) { diff --git a/apps/server/src/routes/auto-mode/routes/commit-feature.ts b/apps/server/src/routes/auto-mode/routes/commit-feature.ts index 982a66c4..16b9000d 100644 --- a/apps/server/src/routes/auto-mode/routes/commit-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/commit-feature.ts @@ -3,18 +3,10 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { getErrorMessage, logError } from '../common.js'; -/** - * Create commit feature handler with transition compatibility. - * Accepts either autoModeService (legacy) or facadeFactory (new). - */ -export function createCommitFeatureHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createCommitFeatureHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { projectPath, featureId, worktreePath } = req.body as { @@ -31,15 +23,6 @@ export function createCommitFeatureHandler( return; } - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - const commitHash = await facade.commitFeature(featureId, worktreePath); - res.json({ success: true, commitHash }); - return; - } - - // Legacy path: use autoModeService directly const commitHash = await autoModeService.commitFeature(projectPath, featureId, worktreePath); res.json({ success: true, commitHash }); } catch (error) { diff --git a/apps/server/src/routes/auto-mode/routes/context-exists.ts b/apps/server/src/routes/auto-mode/routes/context-exists.ts index 8e761094..8c85c2ab 100644 --- a/apps/server/src/routes/auto-mode/routes/context-exists.ts +++ b/apps/server/src/routes/auto-mode/routes/context-exists.ts @@ -3,18 +3,10 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { getErrorMessage, logError } from '../common.js'; -/** - * Create context exists handler with transition compatibility. - * Accepts either autoModeService (legacy) or facadeFactory (new). - */ -export function createContextExistsHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createContextExistsHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { projectPath, featureId } = req.body as { @@ -30,15 +22,6 @@ export function createContextExistsHandler( return; } - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - const exists = await facade.contextExists(featureId); - res.json({ success: true, exists }); - return; - } - - // Legacy path: use autoModeService directly const exists = await autoModeService.contextExists(projectPath, featureId); res.json({ success: true, exists }); } catch (error) { diff --git a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts index ffeb8d60..312edcde 100644 --- a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts @@ -3,17 +3,13 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; const logger = createLogger('AutoMode'); -export function createFollowUpFeatureHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { projectPath, featureId, prompt, imagePaths, useWorktrees } = req.body as { @@ -32,40 +28,14 @@ export function createFollowUpFeatureHandler( return; } - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - // Start follow-up in background - // followUpFeature derives workDir from feature.branchName - facade - // Default to false to match run-feature/resume-feature behavior. - // Worktrees should only be used when explicitly enabled by the user. - .followUpFeature(featureId, prompt, imagePaths, useWorktrees ?? false) - .catch((error) => { - logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error); - }) - .finally(() => { - // Release the starting slot when follow-up completes (success or error) - // Note: The feature should be in runningFeatures by this point - }); - - res.json({ success: true }); - return; - } - - // Legacy path: use autoModeService directly // Start follow-up in background // followUpFeature derives workDir from feature.branchName + // Default to false to match run-feature/resume-feature behavior. + // Worktrees should only be used when explicitly enabled by the user. autoModeService - // Default to false to match run-feature/resume-feature behavior. - // Worktrees should only be used when explicitly enabled by the user. .followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false) .catch((error) => { logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error); - }) - .finally(() => { - // Release the starting slot when follow-up completes (success or error) - // Note: The feature should be in runningFeatures by this point }); res.json({ success: true }); diff --git a/apps/server/src/routes/auto-mode/routes/resume-feature.ts b/apps/server/src/routes/auto-mode/routes/resume-feature.ts index 9fae28ce..d9f5de32 100644 --- a/apps/server/src/routes/auto-mode/routes/resume-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/resume-feature.ts @@ -3,17 +3,13 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; const logger = createLogger('AutoMode'); -export function createResumeFeatureHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createResumeFeatureHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { projectPath, featureId, useWorktrees } = req.body as { @@ -30,20 +26,6 @@ export function createResumeFeatureHandler( return; } - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - // Start resume in background - // Default to false - worktrees should only be used when explicitly enabled - facade.resumeFeature(featureId, useWorktrees ?? false).catch((error) => { - logger.error(`Resume feature ${featureId} error:`, error); - }); - - res.json({ success: true }); - return; - } - - // Legacy path: use autoModeService directly // Start resume in background // Default to false - worktrees should only be used when explicitly enabled autoModeService diff --git a/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts b/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts index f492a6bc..314bc067 100644 --- a/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts +++ b/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts @@ -7,8 +7,7 @@ import type { Request, Response } from 'express'; import { createLogger } from '@automaker/utils'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; const logger = createLogger('ResumeInterrupted'); @@ -16,10 +15,7 @@ interface ResumeInterruptedRequest { projectPath: string; } -export function createResumeInterruptedHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createResumeInterruptedHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { const { projectPath } = req.body as ResumeInterruptedRequest; @@ -31,13 +27,7 @@ export function createResumeInterruptedHandler( logger.info(`Checking for interrupted features in ${projectPath}`); try { - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - await facade.resumeInterruptedFeatures(); - } else { - await autoModeService.resumeInterruptedFeatures(projectPath); - } + await autoModeService.resumeInterruptedFeatures(projectPath); res.json({ success: true, diff --git a/apps/server/src/routes/auto-mode/routes/run-feature.ts b/apps/server/src/routes/auto-mode/routes/run-feature.ts index b789a73c..c59ed7ca 100644 --- a/apps/server/src/routes/auto-mode/routes/run-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts @@ -3,17 +3,13 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; const logger = createLogger('AutoMode'); -export function createRunFeatureHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { projectPath, featureId, useWorktrees } = req.body as { @@ -30,45 +26,6 @@ export function createRunFeatureHandler( return; } - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - - // Check per-worktree capacity before starting - const capacity = await facade.checkWorktreeCapacity(featureId); - if (!capacity.hasCapacity) { - const worktreeDesc = capacity.branchName - ? `worktree "${capacity.branchName}"` - : 'main worktree'; - res.status(429).json({ - success: false, - error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`, - details: { - currentAgents: capacity.currentAgents, - maxAgents: capacity.maxAgents, - branchName: capacity.branchName, - }, - }); - return; - } - - // Start execution in background - // executeFeature derives workDir from feature.branchName - facade - .executeFeature(featureId, useWorktrees ?? false, false) - .catch((error) => { - logger.error(`Feature ${featureId} error:`, error); - }) - .finally(() => { - // Release the starting slot when execution completes (success or error) - // Note: The feature should be in runningFeatures by this point - }); - - res.json({ success: true }); - return; - } - - // Legacy path: use autoModeService directly // Check per-worktree capacity before starting const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId); if (!capacity.hasCapacity) { @@ -93,10 +50,6 @@ export function createRunFeatureHandler( .executeFeature(projectPath, featureId, useWorktrees ?? false, false) .catch((error) => { logger.error(`Feature ${featureId} error:`, error); - }) - .finally(() => { - // Release the starting slot when execution completes (success or error) - // Note: The feature should be in runningFeatures by this point }); res.json({ success: true }); diff --git a/apps/server/src/routes/auto-mode/routes/start.ts b/apps/server/src/routes/auto-mode/routes/start.ts index 5a5e0c6e..c8cc8bff 100644 --- a/apps/server/src/routes/auto-mode/routes/start.ts +++ b/apps/server/src/routes/auto-mode/routes/start.ts @@ -3,17 +3,13 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; const logger = createLogger('AutoMode'); -export function createStartHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createStartHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { projectPath, branchName, maxConcurrency } = req.body as { @@ -36,40 +32,6 @@ export function createStartHandler( ? `worktree ${normalizedBranchName}` : 'main worktree'; - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - - // Check if already running - if (facade.isAutoLoopRunning(normalizedBranchName)) { - res.json({ - success: true, - message: `Auto mode is already running for ${worktreeDesc}`, - alreadyRunning: true, - branchName: normalizedBranchName, - }); - return; - } - - // Start the auto loop for this project/worktree - const resolvedMaxConcurrency = await facade.startAutoLoop( - normalizedBranchName, - maxConcurrency - ); - - logger.info( - `Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}` - ); - - res.json({ - success: true, - message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, - branchName: normalizedBranchName, - }); - return; - } - - // Legacy path: use autoModeService directly // Check if already running if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) { res.json({ diff --git a/apps/server/src/routes/auto-mode/routes/status.ts b/apps/server/src/routes/auto-mode/routes/status.ts index 8d519d38..aad4b248 100644 --- a/apps/server/src/routes/auto-mode/routes/status.ts +++ b/apps/server/src/routes/auto-mode/routes/status.ts @@ -6,19 +6,13 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { getErrorMessage, logError } from '../common.js'; /** - * Create status handler with transition compatibility. - * Accepts either autoModeService (legacy) or facade (new). - * When facade is provided, creates a per-project facade for the request. + * Create status handler. */ -export function createStatusHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createStatusHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { projectPath, branchName } = req.body as { @@ -31,24 +25,6 @@ export function createStatusHandler( // Normalize branchName: undefined becomes null const normalizedBranchName = branchName ?? null; - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - const projectStatus = facade.getStatusForProject(normalizedBranchName); - res.json({ - success: true, - isRunning: projectStatus.runningCount > 0, - isAutoLoopRunning: projectStatus.isAutoLoopRunning, - runningFeatures: projectStatus.runningFeatures, - runningCount: projectStatus.runningCount, - maxConcurrency: projectStatus.maxConcurrency, - projectPath, - branchName: normalizedBranchName, - }); - return; - } - - // Legacy path: use autoModeService directly const projectStatus = autoModeService.getStatusForProject( projectPath, normalizedBranchName @@ -66,8 +42,7 @@ export function createStatusHandler( return; } - // Fall back to global status for backward compatibility - // Global status uses autoModeService (facade is per-project) + // Global status for backward compatibility const status = autoModeService.getStatus(); const activeProjects = autoModeService.getActiveAutoLoopProjects(); const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees(); diff --git a/apps/server/src/routes/auto-mode/routes/stop-feature.ts b/apps/server/src/routes/auto-mode/routes/stop-feature.ts index d2df729d..2e3e69eb 100644 --- a/apps/server/src/routes/auto-mode/routes/stop-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/stop-feature.ts @@ -3,19 +3,10 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { getErrorMessage, logError } from '../common.js'; -/** - * Create stop feature handler with transition compatibility. - * Accepts either autoModeService (legacy) or facade (new). - * Note: stopFeature is feature-scoped (not project-scoped), so a single facade can be used. - */ -export function createStopFeatureHandler( - autoModeService: AutoModeService, - facade?: AutoModeServiceFacade -) { +export function createStopFeatureHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { featureId } = req.body as { featureId: string }; @@ -25,10 +16,7 @@ export function createStopFeatureHandler( return; } - // Use facade if provided, otherwise fall back to autoModeService - const stopped = facade - ? await facade.stopFeature(featureId) - : await autoModeService.stopFeature(featureId); + const stopped = await autoModeService.stopFeature(featureId); res.json({ success: true, stopped }); } catch (error) { logError(error, 'Stop feature failed'); diff --git a/apps/server/src/routes/auto-mode/routes/stop.ts b/apps/server/src/routes/auto-mode/routes/stop.ts index c934c500..224b0daf 100644 --- a/apps/server/src/routes/auto-mode/routes/stop.ts +++ b/apps/server/src/routes/auto-mode/routes/stop.ts @@ -3,21 +3,13 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; const logger = createLogger('AutoMode'); -/** - * Create stop handler with transition compatibility. - * Accepts either autoModeService (legacy) or facadeFactory (new). - */ -export function createStopHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createStopHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { projectPath, branchName } = req.body as { @@ -39,38 +31,6 @@ export function createStopHandler( ? `worktree ${normalizedBranchName}` : 'main worktree'; - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - - // Check if running - if (!facade.isAutoLoopRunning(normalizedBranchName)) { - res.json({ - success: true, - message: `Auto mode is not running for ${worktreeDesc}`, - wasRunning: false, - branchName: normalizedBranchName, - }); - return; - } - - // Stop the auto loop for this project/worktree - const runningCount = await facade.stopAutoLoop(normalizedBranchName); - - logger.info( - `Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running` - ); - - res.json({ - success: true, - message: 'Auto mode stopped', - runningFeaturesCount: runningCount, - branchName: normalizedBranchName, - }); - return; - } - - // Legacy path: use autoModeService directly // Check if running if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) { res.json({ diff --git a/apps/server/src/routes/auto-mode/routes/verify-feature.ts b/apps/server/src/routes/auto-mode/routes/verify-feature.ts index 5ca87c15..7c036812 100644 --- a/apps/server/src/routes/auto-mode/routes/verify-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/verify-feature.ts @@ -3,18 +3,10 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { getErrorMessage, logError } from '../common.js'; -/** - * Create verify feature handler with transition compatibility. - * Accepts either autoModeService (legacy) or facadeFactory (new). - */ -export function createVerifyFeatureHandler( - autoModeService: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade -) { +export function createVerifyFeatureHandler(autoModeService: AutoModeServiceCompat) { return async (req: Request, res: Response): Promise => { try { const { projectPath, featureId } = req.body as { @@ -30,15 +22,6 @@ export function createVerifyFeatureHandler( return; } - // Use facade if factory is provided, otherwise fall back to autoModeService - if (facadeFactory) { - const facade = facadeFactory(projectPath); - const passes = await facade.verifyFeature(featureId); - res.json({ success: true, passes }); - return; - } - - // Legacy path: use autoModeService directly const passes = await autoModeService.verifyFeature(projectPath, featureId); res.json({ success: true, passes }); } catch (error) { diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index bc0f0b52..3952e480 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -5,8 +5,7 @@ import { Router } from 'express'; import { FeatureLoader } from '../../services/feature-loader.js'; import type { SettingsService } from '../../services/settings-service.js'; -import type { AutoModeService } from '../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js'; import type { EventEmitter } from '../../lib/events.js'; import { validatePathParams } from '../../middleware/validate-paths.js'; import { createListHandler } from './routes/list.js'; @@ -25,15 +24,14 @@ export function createFeaturesRoutes( featureLoader: FeatureLoader, settingsService?: SettingsService, events?: EventEmitter, - autoModeService?: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade + autoModeService?: AutoModeServiceCompat ): Router { const router = Router(); router.post( '/list', validatePathParams('projectPath'), - createListHandler(featureLoader, autoModeService, facadeFactory) + createListHandler(featureLoader, autoModeService) ); router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader)); router.post( diff --git a/apps/server/src/routes/features/routes/list.ts b/apps/server/src/routes/features/routes/list.ts index 1dc8f29f..766e625c 100644 --- a/apps/server/src/routes/features/routes/list.ts +++ b/apps/server/src/routes/features/routes/list.ts @@ -7,8 +7,7 @@ import type { Request, Response } from 'express'; import { FeatureLoader } from '../../../services/feature-loader.js'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { getErrorMessage, logError } from '../common.js'; import { createLogger } from '@automaker/utils'; @@ -16,8 +15,7 @@ const logger = createLogger('FeaturesListRoute'); export function createListHandler( featureLoader: FeatureLoader, - autoModeService?: AutoModeService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade + autoModeService?: AutoModeServiceCompat ) { return async (req: Request, res: Response): Promise => { try { @@ -34,21 +32,7 @@ export function createListHandler( // This detects features whose branches no longer exist (e.g., after merge/delete) // We don't await this to keep the list response fast // Note: detectOrphanedFeatures handles errors internally and always resolves - if (facadeFactory) { - const facade = facadeFactory(projectPath); - facade.detectOrphanedFeatures().then((orphanedFeatures) => { - if (orphanedFeatures.length > 0) { - logger.info( - `[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}` - ); - for (const { feature, missingBranch } of orphanedFeatures) { - logger.info( - `[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists` - ); - } - } - }); - } else if (autoModeService) { + if (autoModeService) { autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => { if (orphanedFeatures.length > 0) { logger.info( diff --git a/apps/server/src/routes/projects/index.ts b/apps/server/src/routes/projects/index.ts index f698e76a..ff58167d 100644 --- a/apps/server/src/routes/projects/index.ts +++ b/apps/server/src/routes/projects/index.ts @@ -4,31 +4,23 @@ import { Router } from 'express'; import type { FeatureLoader } from '../../services/feature-loader.js'; -import type { AutoModeService } from '../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js'; import type { SettingsService } from '../../services/settings-service.js'; import type { NotificationService } from '../../services/notification-service.js'; import { createOverviewHandler } from './routes/overview.js'; export function createProjectsRoutes( featureLoader: FeatureLoader, - autoModeService: AutoModeService, + autoModeService: AutoModeServiceCompat, settingsService: SettingsService, - notificationService: NotificationService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade + notificationService: NotificationService ): Router { const router = Router(); // GET /overview - Get aggregate status for all projects router.get( '/overview', - createOverviewHandler( - featureLoader, - autoModeService, - settingsService, - notificationService, - facadeFactory - ) + createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService) ); return router; diff --git a/apps/server/src/routes/projects/routes/overview.ts b/apps/server/src/routes/projects/routes/overview.ts index a5cf0819..3ace44cf 100644 --- a/apps/server/src/routes/projects/routes/overview.ts +++ b/apps/server/src/routes/projects/routes/overview.ts @@ -9,9 +9,8 @@ import type { Request, Response } from 'express'; import type { FeatureLoader } from '../../../services/feature-loader.js'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; import type { - AutoModeServiceFacade, + AutoModeServiceCompat, RunningAgentInfo, ProjectAutoModeStatus, } from '../../../services/auto-mode/index.js'; @@ -152,10 +151,9 @@ function getLastActivityAt(features: Feature[]): string | undefined { export function createOverviewHandler( featureLoader: FeatureLoader, - autoModeService: AutoModeService, + autoModeService: AutoModeServiceCompat, settingsService: SettingsService, - notificationService: NotificationService, - facadeFactory?: (projectPath: string) => AutoModeServiceFacade + notificationService: NotificationService ) { return async (_req: Request, res: Response): Promise => { try { @@ -164,15 +162,7 @@ export function createOverviewHandler( const projectRefs: ProjectRef[] = settings.projects || []; // Get all running agents once to count live running features per project - // Use facade if available, otherwise fall back to autoModeService - let allRunningAgents: RunningAgentInfo[]; - if (facadeFactory && projectRefs.length > 0) { - // For running agents, we can use any project's facade since it's a global query - const facade = facadeFactory(projectRefs[0].path); - allRunningAgents = await facade.getRunningAgents(); - } else { - allRunningAgents = await autoModeService.getRunningAgents(); - } + const allRunningAgents: RunningAgentInfo[] = await autoModeService.getRunningAgents(); // Collect project statuses in parallel const projectStatusPromises = projectRefs.map(async (projectRef): Promise => { @@ -183,13 +173,10 @@ export function createOverviewHandler( const totalFeatures = features.length; // Get auto-mode status for this project (main worktree, branchName = null) - let autoModeStatus: ProjectAutoModeStatus; - if (facadeFactory) { - const facade = facadeFactory(projectRef.path); - autoModeStatus = facade.getStatusForProject(null); - } else { - autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null); - } + const autoModeStatus: ProjectAutoModeStatus = autoModeService.getStatusForProject( + projectRef.path, + null + ); const isAutoModeRunning = autoModeStatus.isAutoLoopRunning; // Count live running features for this project (across all branches) diff --git a/apps/server/src/routes/running-agents/index.ts b/apps/server/src/routes/running-agents/index.ts index a1dbffcd..b94e54f3 100644 --- a/apps/server/src/routes/running-agents/index.ts +++ b/apps/server/src/routes/running-agents/index.ts @@ -3,10 +3,10 @@ */ import { Router } from 'express'; -import type { AutoModeService } from '../../services/auto-mode-service.js'; +import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js'; import { createIndexHandler } from './routes/index.js'; -export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router { +export function createRunningAgentsRoutes(autoModeService: AutoModeServiceCompat): Router { const router = Router(); router.get('/', createIndexHandler(autoModeService)); diff --git a/apps/server/src/routes/running-agents/routes/index.ts b/apps/server/src/routes/running-agents/routes/index.ts index d5b394f7..c18be55b 100644 --- a/apps/server/src/routes/running-agents/routes/index.ts +++ b/apps/server/src/routes/running-agents/routes/index.ts @@ -3,29 +3,16 @@ */ import type { Request, Response } from 'express'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; -import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js'; import { getAllRunningGenerations } from '../../app-spec/common.js'; import path from 'path'; import { getErrorMessage, logError } from '../common.js'; -/** - * Create index handler with transition compatibility. - * Accepts either autoModeService (legacy) or facade (new). - * Note: getRunningAgents is global (not per-project), so facade is created - * with an empty path for global queries. - */ -export function createIndexHandler( - autoModeService: AutoModeService, - facade?: AutoModeServiceFacade -) { +export function createIndexHandler(autoModeService: AutoModeServiceCompat) { return async (_req: Request, res: Response): Promise => { try { - // Use facade if provided, otherwise fall back to autoModeService - const runningAgents = facade - ? [...(await facade.getRunningAgents())] - : [...(await autoModeService.getRunningAgents())]; + const runningAgents = [...(await autoModeService.getRunningAgents())]; const backlogPlanStatus = getBacklogPlanStatus(); const backlogPlanDetails = getRunningDetails(); diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts index 5a2c1129..2971d230 100644 --- a/apps/server/src/services/auto-loop-coordinator.ts +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -363,6 +363,36 @@ export class AutoLoopCoordinator { return projectState?.config ?? null; } + /** + * Get all active auto loop worktrees with their project paths and branch names + */ + getActiveWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = []; + for (const [, state] of this.autoLoopsByProject) { + if (state.isRunning) { + activeWorktrees.push({ + projectPath: state.config.projectPath, + branchName: state.branchName, + }); + } + } + return activeWorktrees; + } + + /** + * Get all projects that have auto mode running (returns unique project paths) + * @deprecated Use getActiveWorktrees instead for full worktree information + */ + getActiveProjects(): string[] { + const activeProjects = new Set(); + for (const [, state] of this.autoLoopsByProject) { + if (state.isRunning) { + activeProjects.add(state.config.projectPath); + } + } + return Array.from(activeProjects); + } + /** * Get count of running features for a specific worktree * Delegates to ConcurrencyManager. diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts deleted file mode 100644 index 92d93993..00000000 --- a/apps/server/src/services/auto-mode-service.ts +++ /dev/null @@ -1,2705 +0,0 @@ -/** - * Auto Mode Service - Autonomous feature implementation using Claude Agent SDK - * - * Manages: - * - Worktree creation for isolated development - * - Feature execution with Claude - * - Concurrent execution with max concurrency limits - * - Progress streaming via events - * - Verification and merge workflows - */ - -import { ProviderFactory } from '../providers/provider-factory.js'; -import { simpleQuery } from '../providers/simple-query-service.js'; -import type { - ExecuteOptions, - Feature, - ModelProvider, - PipelineStep, - FeatureStatusWithPipeline, - PipelineConfig, - ThinkingLevel, - PlanningMode, - ParsedTask, - PlanSpec, -} from '@automaker/types'; -import { - DEFAULT_PHASE_MODELS, - DEFAULT_MAX_CONCURRENCY, - isClaudeModel, - stripProviderPrefix, -} from '@automaker/types'; -import { - buildPromptWithImages, - classifyError, - loadContextFiles, - appendLearning, - recordMemoryUsage, - createLogger, - atomicWriteJson, - readJsonWithRecovery, - logRecoveryWarning, - DEFAULT_BACKUP_COUNT, -} from '@automaker/utils'; - -const logger = createLogger('AutoMode'); -import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver'; -import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; -import { - getFeatureDir, - getAutomakerDir, - getFeaturesDir, - getExecutionStatePath, - ensureAutomakerDir, -} from '@automaker/platform'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import path from 'path'; -import * as secureFs from '../lib/secure-fs.js'; -import type { EventEmitter } from '../lib/events.js'; -import { - createAutoModeOptions, - createCustomOptions, - validateWorkingDirectory, -} from '../lib/sdk-options.js'; -import { FeatureLoader } from './feature-loader.js'; -import { - ConcurrencyManager, - type RunningFeature, - type GetCurrentBranchFn, -} from './concurrency-manager.js'; -import { TypedEventBus } from './typed-event-bus.js'; -import { WorktreeResolver } from './worktree-resolver.js'; -import { FeatureStateManager } from './feature-state-manager.js'; -import { PlanApprovalService } from './plan-approval-service.js'; -import type { SettingsService } from './settings-service.js'; -import { pipelineService, PipelineService } from './pipeline-service.js'; -import { - getAutoLoadClaudeMdSetting, - filterClaudeMdFromContext, - getMCPServersFromSettings, - getPromptCustomization, - getProviderByModelId, - getPhaseModelWithOverrides, -} from '../lib/settings-helpers.js'; -import { getNotificationService } from './notification-service.js'; -import { extractSummary } from './spec-parser.js'; -import { AgentExecutor } from './agent-executor.js'; -import { PipelineOrchestrator } from './pipeline-orchestrator.js'; -import { TestRunnerService } from './test-runner-service.js'; -import { - AutoLoopCoordinator, - getWorktreeAutoLoopKey as getCoordinatorWorktreeKey, -} from './auto-loop-coordinator.js'; -import { ExecutionService } from './execution-service.js'; -import { RecoveryService } from './recovery-service.js'; - -const execAsync = promisify(exec); - -// ParsedTask and PlanSpec types are imported from @automaker/types - -// Spec parsing functions are imported from spec-parser.js - -// Feature type is imported from feature-loader.js -// Extended type with planning fields for local use -interface FeatureWithPlanning extends Feature { - planningMode?: PlanningMode; - planSpec?: PlanSpec; - requirePlanApproval?: boolean; -} - -interface AutoLoopState { - projectPath: string; - maxConcurrency: number; - abortController: AbortController; - isRunning: boolean; -} - -// PendingApproval interface moved to PlanApprovalService - -interface AutoModeConfig { - maxConcurrency: number; - useWorktrees: boolean; - projectPath: string; - branchName: string | null; // null = main worktree -} - -/** - * Generate a unique key for worktree-scoped auto loop state - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree - */ -function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { - const normalizedBranch = branchName === 'main' ? null : branchName; - return `${projectPath}::${normalizedBranch ?? '__main__'}`; -} - -/** - * Per-worktree autoloop state for multi-project/worktree support - */ -interface ProjectAutoLoopState { - abortController: AbortController; - config: AutoModeConfig; - isRunning: boolean; - consecutiveFailures: { timestamp: number; error: string }[]; - pausedDueToFailures: boolean; - hasEmittedIdleEvent: boolean; - branchName: string | null; // null = main worktree -} - -/** - * Execution state for recovery after server restart - * Tracks which features were running and auto-loop configuration - */ -interface ExecutionState { - version: 1; - autoLoopWasRunning: boolean; - maxConcurrency: number; - projectPath: string; - branchName: string | null; // null = main worktree - runningFeatureIds: string[]; - savedAt: string; -} - -// Default empty execution state -const DEFAULT_EXECUTION_STATE: ExecutionState = { - version: 1, - autoLoopWasRunning: false, - maxConcurrency: DEFAULT_MAX_CONCURRENCY, - projectPath: '', - branchName: null, - runningFeatureIds: [], - savedAt: '', -}; - -// Constants for consecutive failure tracking -const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures -const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive - -export class AutoModeService { - private events: EventEmitter; - private eventBus: TypedEventBus; - private concurrencyManager: ConcurrencyManager; - private worktreeResolver: WorktreeResolver; - private featureStateManager: FeatureStateManager; - private autoLoop: AutoLoopState | null = null; - private featureLoader = new FeatureLoader(); - // Per-project autoloop state (supports multiple concurrent projects) - private autoLoopsByProject = new Map(); - // Legacy single-project properties (kept for backward compatibility during transition) - private autoLoopRunning = false; - private autoLoopAbortController: AbortController | null = null; - private config: AutoModeConfig | null = null; - private planApprovalService: PlanApprovalService; - private agentExecutor: AgentExecutor; - private pipelineOrchestrator: PipelineOrchestrator; - private autoLoopCoordinator: AutoLoopCoordinator; - private executionService: ExecutionService; - private recoveryService: RecoveryService; - private settingsService: SettingsService | null = null; - // Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject) - private consecutiveFailures: { timestamp: number; error: string }[] = []; - private pausedDueToFailures = false; - // Track if idle event has been emitted (legacy, now per-project in autoLoopsByProject) - private hasEmittedIdleEvent = false; - - constructor( - events: EventEmitter, - settingsService?: SettingsService, - concurrencyManager?: ConcurrencyManager, - eventBus?: TypedEventBus, - worktreeResolver?: WorktreeResolver, - featureStateManager?: FeatureStateManager, - planApprovalService?: PlanApprovalService, - agentExecutor?: AgentExecutor, - pipelineOrchestrator?: PipelineOrchestrator, - autoLoopCoordinator?: AutoLoopCoordinator, - executionService?: ExecutionService, - recoveryService?: RecoveryService - ) { - this.events = events; - this.eventBus = eventBus ?? new TypedEventBus(events); - this.settingsService = settingsService ?? null; - this.worktreeResolver = worktreeResolver ?? new WorktreeResolver(); - this.featureStateManager = - featureStateManager ?? new FeatureStateManager(events, this.featureLoader); - // Pass the WorktreeResolver's getCurrentBranch to ConcurrencyManager for worktree counting - this.concurrencyManager = - concurrencyManager ?? - new ConcurrencyManager((projectPath) => this.worktreeResolver.getCurrentBranch(projectPath)); - this.planApprovalService = - planApprovalService ?? - new PlanApprovalService(this.eventBus, this.featureStateManager, this.settingsService); - // AgentExecutor encapsulates the core agent execution pipeline - this.agentExecutor = - agentExecutor ?? - new AgentExecutor( - this.eventBus, - this.featureStateManager, - this.planApprovalService, - this.settingsService - ); - // PipelineOrchestrator encapsulates pipeline step execution - this.pipelineOrchestrator = - pipelineOrchestrator ?? - new PipelineOrchestrator( - this.eventBus, - this.featureStateManager, - this.agentExecutor, - new TestRunnerService(), - this.worktreeResolver, - this.concurrencyManager, - this.settingsService, - // Callbacks wrapping AutoModeService methods - (projectPath, featureId, status) => - this.updateFeatureStatus(projectPath, featureId, status), - loadContextFiles, - (feature, prompts) => this.buildFeaturePrompt(feature, prompts), - (projectPath, featureId, useWorktrees, useScreenshots, model, options) => - this.executeFeature(projectPath, featureId, useWorktrees, useScreenshots, model, options), - (workDir, featureId, prompt, abortController, projectPath, imagePaths, model, options) => - this.runAgent( - workDir, - featureId, - prompt, - abortController, - projectPath, - imagePaths, - model, - options - ) - ); - - // AutoLoopCoordinator manages loop lifecycle, failure tracking, start/stop - this.autoLoopCoordinator = - autoLoopCoordinator ?? - new AutoLoopCoordinator( - this.eventBus, - this.concurrencyManager, - this.settingsService, - // Callbacks wrapping AutoModeService methods - (projectPath, featureId, useWorktrees, isAutoMode) => - this.executeFeature(projectPath, featureId, useWorktrees, isAutoMode), - (projectPath, branchName) => this.loadPendingFeatures(projectPath, branchName), - (projectPath, branchName, maxConcurrency) => - this.saveExecutionStateForProject(projectPath, branchName, maxConcurrency), - (projectPath, branchName) => this.clearExecutionState(projectPath, branchName), - (projectPath) => this.resetStuckFeatures(projectPath), - (feature) => this.isFeatureFinished(feature), - (featureId) => this.isFeatureRunning(featureId) - ); - - // ExecutionService coordinates feature execution lifecycle - this.executionService = - executionService ?? - new ExecutionService( - this.eventBus, - this.concurrencyManager, - this.worktreeResolver, - this.settingsService, - // Callbacks wrapping AutoModeService methods - (workDir, featureId, prompt, abortController, projectPath, imagePaths, model, options) => - this.runAgent( - workDir, - featureId, - prompt, - abortController, - projectPath, - imagePaths, - model, - options - ), - (context) => this.pipelineOrchestrator.executePipeline(context), - (projectPath, featureId, status) => - this.updateFeatureStatus(projectPath, featureId, status), - (projectPath, featureId) => this.loadFeature(projectPath, featureId), - (feature) => this.getPlanningPromptPrefix(feature), - (projectPath, featureId, summary) => - this.saveFeatureSummary(projectPath, featureId, summary), - (projectPath, feature, agentOutput) => - this.recordLearningsFromFeature(projectPath, feature, agentOutput), - (projectPath, featureId) => this.contextExists(projectPath, featureId), - (projectPath, featureId, useWorktrees, _calledInternally) => - this.resumeFeature(projectPath, featureId, useWorktrees, _calledInternally), - (errorInfo) => - this.autoLoopCoordinator.trackFailureAndCheckPauseForProject( - '', // projectPath resolved at call site - errorInfo - ), - (errorInfo) => - this.autoLoopCoordinator.signalShouldPauseForProject( - '', // projectPath resolved at call site - errorInfo - ), - () => { - /* No-op: success recording handled by autoLoopCoordinator */ - }, - (projectPath) => this.saveExecutionState(projectPath), - loadContextFiles - ); - - // RecoveryService handles crash recovery and feature resumption - this.recoveryService = - recoveryService ?? - new RecoveryService( - this.eventBus, - this.concurrencyManager, - this.settingsService, - // Callbacks wrapping AutoModeService methods - (projectPath, featureId, useWorktrees, isAutoMode, providedWorktreePath, options) => - this.executeFeature( - projectPath, - featureId, - useWorktrees, - isAutoMode, - providedWorktreePath, - options - ), - (projectPath, featureId) => this.loadFeature(projectPath, featureId), - (projectPath, featureId, status) => - this.pipelineOrchestrator.detectPipelineStatus(projectPath, featureId, status), - (projectPath, feature, useWorktrees, pipelineInfo) => - this.pipelineOrchestrator.resumePipeline( - projectPath, - feature, - useWorktrees, - pipelineInfo - ), - (featureId) => this.isFeatureRunning(featureId), - (options) => this.acquireRunningFeature(options), - (featureId) => this.releaseRunningFeature(featureId) - ); - } - - /** - * Acquire a slot in the runningFeatures map for a feature. - * Delegates to ConcurrencyManager for lease-based reference counting. - * - * @param params.featureId - ID of the feature to track - * @param params.projectPath - Path to the project - * @param params.isAutoMode - Whether this is an auto-mode execution - * @param params.allowReuse - If true, allows incrementing leaseCount for already-running features - * @param params.abortController - Optional abort controller to use - * @returns The RunningFeature entry (existing or newly created) - * @throws Error if feature is already running and allowReuse is false - */ - private acquireRunningFeature(params: { - featureId: string; - projectPath: string; - isAutoMode: boolean; - allowReuse?: boolean; - abortController?: AbortController; - }): RunningFeature { - return this.concurrencyManager.acquire(params); - } - - /** - * Release a slot in the runningFeatures map for a feature. - * Delegates to ConcurrencyManager for lease-based reference counting. - * - * @param featureId - ID of the feature to release - * @param options.force - If true, immediately removes the entry regardless of leaseCount - */ - private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void { - this.concurrencyManager.release(featureId, options); - } - - /** - * Reset features that were stuck in transient states due to server crash - * Called when auto mode is enabled to clean up from previous session - * @param projectPath - The project path to reset features for - */ - async resetStuckFeatures(projectPath: string): Promise { - await this.featureStateManager.resetStuckFeatures(projectPath); - } - - /** - * Start the auto mode loop for a specific project/worktree (supports multiple concurrent projects and worktrees) - * @param projectPath - The project to start auto mode for - * @param branchName - The branch name for worktree scoping, null for main worktree - * @param maxConcurrency - Maximum concurrent features (default: DEFAULT_MAX_CONCURRENCY) - */ - async startAutoLoopForProject( - projectPath: string, - branchName: string | null = null, - maxConcurrency?: number - ): Promise { - return this.autoLoopCoordinator.startAutoLoopForProject( - projectPath, - branchName, - maxConcurrency - ); - } - - /** - * Stop the auto mode loop for a specific project/worktree - * @param projectPath - The project to stop auto mode for - * @param branchName - The branch name, or null for main worktree - */ - async stopAutoLoopForProject( - projectPath: string, - branchName: string | null = null - ): Promise { - return this.autoLoopCoordinator.stopAutoLoopForProject(projectPath, branchName); - } - - /** - * Check if auto mode is running for a specific project/worktree - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree - */ - isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean { - return this.autoLoopCoordinator.isAutoLoopRunningForProject(projectPath, branchName); - } - - /** - * Get auto loop config for a specific project/worktree - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree - */ - getAutoLoopConfigForProject( - projectPath: string, - branchName: string | null = null - ): AutoModeConfig | null { - return this.autoLoopCoordinator.getAutoLoopConfigForProject(projectPath, branchName); - } - - /** - * Save execution state for a specific project/worktree - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree - * @param maxConcurrency - Maximum concurrent features - */ - private async saveExecutionStateForProject( - projectPath: string, - branchName: string | null, - maxConcurrency: number - ): Promise { - try { - await ensureAutomakerDir(projectPath); - const statePath = getExecutionStatePath(projectPath); - const runningFeatureIds = this.concurrencyManager - .getAllRunning() - .filter((f) => f.projectPath === projectPath) - .map((f) => f.featureId); - - const state: ExecutionState = { - version: 1, - autoLoopWasRunning: true, - maxConcurrency, - projectPath, - branchName, - runningFeatureIds, - savedAt: new Date().toISOString(), - }; - await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.info( - `Saved execution state for ${worktreeDesc} in ${projectPath}: ${runningFeatureIds.length} running features` - ); - } catch (error) { - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.error(`Failed to save execution state for ${worktreeDesc} in ${projectPath}:`, error); - } - } - - /** - * Start the auto mode loop - continuously picks and executes pending features - * @deprecated Use startAutoLoopForProject instead for multi-project support - */ - async startAutoLoop( - projectPath: string, - maxConcurrency = DEFAULT_MAX_CONCURRENCY - ): Promise { - // Delegate to the new per-project method - await this.startAutoLoopForProject(projectPath, null, maxConcurrency); - // Maintain legacy state for existing code that might check it - this.autoLoopRunning = true; - this.autoLoopAbortController = new AbortController(); - this.config = { - maxConcurrency, - useWorktrees: true, - projectPath, - branchName: null, - }; - } - - /** - * @deprecated Use runAutoLoopForProject instead - */ - private async runAutoLoop(): Promise { - while ( - this.autoLoopRunning && - this.autoLoopAbortController && - !this.autoLoopAbortController.signal.aborted - ) { - try { - // Check if we have capacity - const totalRunning = this.concurrencyManager.getAllRunning().length; - if (totalRunning >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) { - await this.sleep(5000); - continue; - } - - // Load pending features - const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); - - if (pendingFeatures.length === 0) { - // Emit idle event only once when backlog is empty AND no features are running - const runningCount = this.concurrencyManager.getAllRunning().length; - if (runningCount === 0 && !this.hasEmittedIdleEvent) { - this.eventBus.emitAutoModeEvent('auto_mode_idle', { - message: 'No pending features - auto mode idle', - projectPath: this.config!.projectPath, - }); - this.hasEmittedIdleEvent = true; - logger.info(`[AutoLoop] Backlog complete, auto mode now idle`); - } else if (runningCount > 0) { - logger.debug( - `[AutoLoop] No pending features, ${runningCount} still running, waiting...` - ); - } else { - logger.debug(`[AutoLoop] No pending features, waiting for new items...`); - } - await this.sleep(10000); - continue; - } - - // Find a feature not currently running - const nextFeature = pendingFeatures.find((f) => !this.concurrencyManager.isRunning(f.id)); - - if (nextFeature) { - // Reset idle event flag since we're doing work again - this.hasEmittedIdleEvent = false; - // Start feature execution in background - this.executeFeature( - this.config!.projectPath, - nextFeature.id, - this.config!.useWorktrees, - true - ).catch((error) => { - logger.error(`Feature ${nextFeature.id} error:`, error); - }); - } - - await this.sleep(2000); - } catch (error) { - logger.error('Loop iteration error:', error); - await this.sleep(5000); - } - } - - this.autoLoopRunning = false; - } - - /** - * Stop the auto mode loop - * @deprecated Use stopAutoLoopForProject instead for multi-project support - */ - async stopAutoLoop(): Promise { - const wasRunning = this.autoLoopRunning; - const projectPath = this.config?.projectPath; - this.autoLoopRunning = false; - if (this.autoLoopAbortController) { - this.autoLoopAbortController.abort(); - this.autoLoopAbortController = null; - } - - // Clear execution state when auto-loop is explicitly stopped - if (projectPath) { - await this.clearExecutionState(projectPath); - } - - // Emit stop event immediately when user explicitly stops - if (wasRunning) { - this.eventBus.emitAutoModeEvent('auto_mode_stopped', { - message: 'Auto mode stopped', - projectPath, - }); - } - - return this.concurrencyManager.getAllRunning().length; - } - - /** - * Check if there's capacity to start a feature on a worktree. - * This respects per-worktree agent limits from autoModeByWorktree settings. - * - * @param projectPath - The main project path - * @param featureId - The feature ID to check capacity for - * @returns Object with hasCapacity boolean and details about current/max agents - */ - async checkWorktreeCapacity( - projectPath: string, - featureId: string - ): Promise<{ - hasCapacity: boolean; - currentAgents: number; - maxAgents: number; - branchName: string | null; - }> { - // Load feature to get branchName - const feature = await this.loadFeature(projectPath, featureId); - const rawBranchName = feature?.branchName ?? null; - // Normalize "main" to null to match UI convention for main worktree - const branchName = rawBranchName === 'main' ? null : rawBranchName; - - // Get per-worktree limit from AutoLoopCoordinator - const maxAgents = await this.autoLoopCoordinator.resolveMaxConcurrency(projectPath, branchName); - - // Get current running count for this worktree - const currentAgents = await this.concurrencyManager.getRunningCountForWorktree( - projectPath, - branchName - ); - - return { - hasCapacity: currentAgents < maxAgents, - currentAgents, - maxAgents, - branchName, - }; - } - - /** - * Execute a single feature - * - * Delegates to ExecutionService for the actual execution lifecycle. - * - * @param projectPath - The main project path - * @param featureId - The feature ID to execute - * @param useWorktrees - Whether to use worktrees for isolation - * @param isAutoMode - Whether this is running in auto mode - */ - 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; - } - ): Promise { - return this.executionService.executeFeature( - projectPath, - featureId, - useWorktrees, - isAutoMode, - providedWorktreePath, - options - ); - } - - /** - * Stop a specific feature - * - * Delegates to ExecutionService for stopping the feature. - * Additionally cancels any pending plan approval. - */ - async stopFeature(featureId: string): Promise { - // Cancel any pending plan approval for this feature - this.cancelPlanApproval(featureId); - - return this.executionService.stopFeature(featureId); - } - - /** - * Resume a feature (continues from saved context or starts fresh if no context) - * - * This method handles interrupted features regardless of whether they have saved context: - * - With context: Continues from where the agent left off using the saved agent-output.md - * - Without context: Starts fresh execution (feature was interrupted before any agent output) - * - Pipeline features: Delegates to PipelineOrchestrator for specialized handling - * - * @param projectPath - Path to the project - * @param featureId - ID of the feature to resume - * @param useWorktrees - Whether to use git worktrees for isolation - * @param _calledInternally - Internal flag to prevent double-tracking when called from other methods - */ - async resumeFeature( - projectPath: string, - featureId: string, - useWorktrees = false, - /** Internal flag: set to true when called from a method that already tracks the feature */ - _calledInternally = false - ): Promise { - return this.recoveryService.resumeFeature( - projectPath, - featureId, - useWorktrees, - _calledInternally - ); - } - - /** - * Follow up on a feature with additional instructions - */ - async followUpFeature( - projectPath: string, - featureId: string, - prompt: string, - imagePaths?: string[], - useWorktrees = true - ): Promise { - // Validate project path early for fast failure - validateWorkingDirectory(projectPath); - - const runningEntry = this.acquireRunningFeature({ - featureId, - projectPath, - isAutoMode: false, - }); - const abortController = runningEntry.abortController; - - // Load feature info for context FIRST to get branchName - const feature = await this.loadFeature(projectPath, featureId); - - // Derive workDir from feature.branchName - // If no branchName, derive from feature ID: feature/{featureId} - let workDir = path.resolve(projectPath); - let worktreePath: string | null = null; - const branchName = feature?.branchName || `feature/${featureId}`; - - if (useWorktrees && branchName) { - // Try to find existing worktree for this branch - worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName); - - if (worktreePath) { - workDir = worktreePath; - logger.info(`Follow-up using worktree for branch "${branchName}": ${workDir}`); - } - } - - // Load previous agent output if it exists - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - let previousContext = ''; - try { - previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; - } catch { - // No previous context - } - - // Load autoLoadClaudeMd setting to determine context loading strategy - const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( - projectPath, - this.settingsService, - '[AutoMode]' - ); - - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - - // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt - const contextResult = await loadContextFiles({ - projectPath, - fsModule: secureFs as Parameters[0]['fsModule'], - taskContext: { - title: feature?.title ?? prompt.substring(0, 200), - description: feature?.description ?? prompt, - }, - }); - - // 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 - const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); - - // Build complete prompt with feature info, previous context, and follow-up instructions - let fullPrompt = `## Follow-up on Feature Implementation - -${feature ? this.buildFeaturePrompt(feature, prompts.taskExecution) : `**Feature ID:** ${featureId}`} -`; - - if (previousContext) { - fullPrompt += ` -## Previous Agent Work -The following is the output from the previous implementation attempt: - -${previousContext} -`; - } - - fullPrompt += ` -## Follow-up Instructions -${prompt} - -## Task -Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`; - - // Get model from feature and determine provider early for tracking - const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude); - const provider = ProviderFactory.getProviderNameForModel(model); - logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`); - - runningEntry.worktreePath = worktreePath; - runningEntry.branchName = branchName; - runningEntry.model = model; - runningEntry.provider = provider; - - try { - // Update feature status to in_progress BEFORE emitting event - // This ensures the frontend sees the updated status when it reloads features - await this.updateFeatureStatus(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, - branchName, - feature: feature || { - id: featureId, - title: 'Follow-up', - description: prompt.substring(0, 100), - }, - model, - provider, - }); - - // Copy follow-up images to feature folder - const copiedImagePaths: string[] = []; - if (imagePaths && imagePaths.length > 0) { - const featureDirForImages = getFeatureDir(projectPath, featureId); - const featureImagesDir = path.join(featureDirForImages, 'images'); - - await secureFs.mkdir(featureImagesDir, { recursive: true }); - - for (const imagePath of imagePaths) { - try { - // Get the filename from the path - const filename = path.basename(imagePath); - const destPath = path.join(featureImagesDir, filename); - - // Copy the image - await secureFs.copyFile(imagePath, destPath); - - // Store the absolute path (external storage uses absolute paths) - copiedImagePaths.push(destPath); - } catch (error) { - logger.error(`Failed to copy follow-up image ${imagePath}:`, error); - } - } - } - - // Update feature object with new follow-up images BEFORE building prompt - if (copiedImagePaths.length > 0 && feature) { - const currentImagePaths = feature.imagePaths || []; - const newImagePaths = copiedImagePaths.map((p) => ({ - path: p, - filename: path.basename(p), - mimeType: 'image/png', // Default, could be improved - })); - - feature.imagePaths = [...currentImagePaths, ...newImagePaths]; - } - - // Combine original feature images with new follow-up images - const allImagePaths: string[] = []; - - // Add all images from feature (now includes both original and new) - if (feature?.imagePaths) { - const allPaths = feature.imagePaths.map((img) => - typeof img === 'string' ? img : img.path - ); - allImagePaths.push(...allPaths); - } - - // Save updated feature.json with new images (atomic write with backup) - if (copiedImagePaths.length > 0 && feature) { - const featureDirForSave = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDirForSave, 'feature.json'); - - try { - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - } catch (error) { - logger.error(`Failed to save feature.json:`, error); - } - } - - // Use fullPrompt (already built above) with model and all images - // Note: Follow-ups skip planning mode - they continue from previous work - // Pass previousContext so the history is preserved in the output file - // Context files are passed as system prompt for higher priority - await this.runAgent( - workDir, - featureId, - fullPrompt, - abortController, - projectPath, - allImagePaths.length > 0 ? allImagePaths : imagePaths, - model, - { - projectPath, - planningMode: 'skip', // Follow-ups don't require approval - previousContent: previousContext || undefined, - systemPrompt: contextFilesPrompt || undefined, - autoLoadClaudeMd, - thinkingLevel: feature?.thinkingLevel, - } - ); - - // 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.updateFeatureStatus(projectPath, featureId, finalStatus); - - this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature?.title, - branchName: branchName ?? null, - passes: true, - message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`, - projectPath, - model, - provider, - }); - } catch (error) { - const errorInfo = classifyError(error); - if (!errorInfo.isCancellation) { - this.eventBus.emitAutoModeEvent('auto_mode_error', { - featureId, - featureName: feature?.title, - branchName: branchName ?? null, - error: errorInfo.message, - errorType: errorInfo.type, - projectPath, - }); - // Note: Follow-ups are manual operations, not part of auto-loop - // Failure tracking is handled by AutoLoopCoordinator for auto-mode - } - } finally { - this.releaseRunningFeature(featureId); - } - } - - /** - * Verify a feature's implementation - */ - async verifyFeature(projectPath: string, featureId: string): Promise { - // Load feature to get the name for event reporting - const feature = await this.loadFeature(projectPath, featureId); - - // Worktrees are in project dir - // Sanitize featureId the same way it's sanitized when creating worktrees - const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); - const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); - let workDir = projectPath; - - try { - await secureFs.access(worktreePath); - workDir = worktreePath; - } catch { - // No worktree - } - - // Run verification - check if tests pass, build works, etc. - 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; // Stop on first failure - } - } - - this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature?.title, - branchName: feature?.branchName ?? null, - passes: allPassed, - message: allPassed - ? 'All verification checks passed' - : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, - projectPath, - }); - - return allPassed; - } - - /** - * Commit feature changes - * @param projectPath - The main project path - * @param featureId - The feature ID to commit - * @param providedWorktreePath - Optional: the worktree path where the feature's changes are located - */ - async commitFeature( - projectPath: string, - featureId: string, - providedWorktreePath?: string - ): Promise { - let workDir = projectPath; - - // Use the provided worktree path if given - if (providedWorktreePath) { - try { - await secureFs.access(providedWorktreePath); - workDir = providedWorktreePath; - logger.info(`Committing in provided worktree: ${workDir}`); - } catch { - logger.info( - `Provided worktree path doesn't exist: ${providedWorktreePath}, using project path` - ); - } - } else { - // Fallback: try to find worktree at legacy location - // Sanitize featureId the same way it's sanitized when creating worktrees - const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); - const legacyWorktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); - try { - await secureFs.access(legacyWorktreePath); - workDir = legacyWorktreePath; - logger.info(`Committing in legacy worktree: ${workDir}`); - } catch { - logger.info(`No worktree found, committing in project path: ${workDir}`); - } - } - - try { - // Check for changes - const { stdout: status } = await execAsync('git status --porcelain', { - cwd: workDir, - }); - if (!status.trim()) { - return null; // No changes - } - - // Load feature for commit message - const feature = await this.loadFeature(projectPath, featureId); - const commitMessage = feature - ? `feat: ${this.extractTitleFromDescription( - feature.description - )}\n\nImplemented by Automaker auto-mode` - : `feat: Feature ${featureId}`; - - // Stage and commit - await execAsync('git add -A', { cwd: workDir }); - await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { - cwd: workDir, - }); - - // Get commit hash - const { stdout: hash } = await execAsync('git rev-parse HEAD', { - cwd: workDir, - }); - - this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature?.title, - branchName: feature?.branchName ?? null, - passes: true, - message: `Changes committed: ${hash.trim().substring(0, 8)}`, - projectPath, - }); - - return hash.trim(); - } catch (error) { - logger.error(`Commit failed for ${featureId}:`, error); - return null; - } - } - - /** - * Check if context exists for a feature - */ - async contextExists(projectPath: string, featureId: string): Promise { - // Context is stored in .automaker directory - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - - try { - await secureFs.access(contextPath); - return true; - } catch { - return false; - } - } - - /** - * Analyze project to gather context - */ - async analyzeProject(projectPath: string): Promise { - const abortController = new AbortController(); - - const analysisFeatureId = `analysis-${Date.now()}`; - this.eventBus.emitAutoModeEvent('auto_mode_feature_start', { - featureId: analysisFeatureId, - projectPath, - branchName: null, // Project analysis is not worktree-specific - feature: { - id: analysisFeatureId, - title: 'Project Analysis', - description: 'Analyzing project structure', - }, - }); - - const prompt = `Analyze this project and provide a summary of: -1. Project structure and architecture -2. Main technologies and frameworks used -3. Key components and their responsibilities -4. Build and test commands -5. Any existing conventions or patterns - -Format your response as a structured markdown document.`; - - try { - // Get model from phase settings with provider info - const { - phaseModel: phaseModelEntry, - provider: analysisClaudeProvider, - credentials, - } = await getPhaseModelWithOverrides( - 'projectAnalysisModel', - this.settingsService, - projectPath, - '[AutoMode]' - ); - const { model: analysisModel, thinkingLevel: analysisThinkingLevel } = - resolvePhaseModel(phaseModelEntry); - logger.info( - 'Using model for project analysis:', - analysisModel, - analysisClaudeProvider ? `via provider: ${analysisClaudeProvider.name}` : 'direct API' - ); - - const provider = ProviderFactory.getProviderForModel(analysisModel); - - // Load autoLoadClaudeMd setting - const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( - projectPath, - this.settingsService, - '[AutoMode]' - ); - - // Use createCustomOptions for centralized SDK configuration with CLAUDE.md support - const sdkOptions = createCustomOptions({ - cwd: projectPath, - model: analysisModel, - maxTurns: 5, - allowedTools: ['Read', 'Glob', 'Grep'], - abortController, - autoLoadClaudeMd, - thinkingLevel: analysisThinkingLevel, - }); - - const options: ExecuteOptions = { - prompt, - model: sdkOptions.model ?? analysisModel, - cwd: sdkOptions.cwd ?? projectPath, - maxTurns: sdkOptions.maxTurns, - allowedTools: sdkOptions.allowedTools as string[], - abortController, - settingSources: sdkOptions.settingSources, - thinkingLevel: analysisThinkingLevel, // Pass thinking level - credentials, // Pass credentials for resolving 'credentials' apiKeySource - claudeCompatibleProvider: analysisClaudeProvider, // Pass provider for alternative endpoint configuration - }; - - const stream = provider.executeQuery(options); - let analysisResult = ''; - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - analysisResult = block.text || ''; - this.eventBus.emitAutoModeEvent('auto_mode_progress', { - featureId: analysisFeatureId, - content: block.text, - projectPath, - }); - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - analysisResult = msg.result || analysisResult; - } - } - - // Save analysis to .automaker directory - const automakerDir = getAutomakerDir(projectPath); - const analysisPath = path.join(automakerDir, 'project-analysis.md'); - await secureFs.mkdir(automakerDir, { recursive: true }); - await secureFs.writeFile(analysisPath, analysisResult); - - this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { - featureId: analysisFeatureId, - featureName: 'Project Analysis', - branchName: null, // Project analysis is not worktree-specific - passes: true, - message: 'Project analysis completed', - projectPath, - }); - } catch (error) { - const errorInfo = classifyError(error); - this.eventBus.emitAutoModeEvent('auto_mode_error', { - featureId: analysisFeatureId, - featureName: 'Project Analysis', - branchName: null, // Project analysis is not worktree-specific - error: errorInfo.message, - errorType: errorInfo.type, - projectPath, - }); - } - } - - /** - * Get current status - */ - getStatus(): { - isRunning: boolean; - runningFeatures: string[]; - runningCount: number; - } { - const allRunning = this.concurrencyManager.getAllRunning(); - return { - isRunning: allRunning.length > 0, - runningFeatures: allRunning.map((rf) => rf.featureId), - runningCount: allRunning.length, - }; - } - - /** - * Get status for a specific project/worktree - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree - */ - getStatusForProject( - projectPath: string, - branchName: string | null = null - ): { - isAutoLoopRunning: boolean; - runningFeatures: string[]; - runningCount: number; - maxConcurrency: number; - branchName: string | null; - } { - const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); - const projectState = this.autoLoopsByProject.get(worktreeKey); - const runningFeatures = this.concurrencyManager - .getAllRunning() - .filter((f) => f.projectPath === projectPath && f.branchName === branchName) - .map((f) => f.featureId); - - return { - isAutoLoopRunning: projectState?.isRunning ?? false, - runningFeatures, - runningCount: runningFeatures.length, - maxConcurrency: projectState?.config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, - branchName, - }; - } - - /** - * Get all active auto loop worktrees with their project paths and branch names - */ - getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { - const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = []; - for (const [, state] of this.autoLoopsByProject) { - if (state.isRunning) { - activeWorktrees.push({ - projectPath: state.config.projectPath, - branchName: state.branchName, - }); - } - } - return activeWorktrees; - } - - /** - * Get all projects that have auto mode running (legacy, returns unique project paths) - * @deprecated Use getActiveAutoLoopWorktrees instead for full worktree information - */ - getActiveAutoLoopProjects(): string[] { - const activeProjects = new Set(); - for (const [, state] of this.autoLoopsByProject) { - if (state.isRunning) { - activeProjects.add(state.config.projectPath); - } - } - return Array.from(activeProjects); - } - - /** - * Get detailed info about all running agents - */ - async getRunningAgents(): Promise< - Array<{ - featureId: string; - projectPath: string; - projectName: string; - isAutoMode: boolean; - model?: string; - provider?: ModelProvider; - title?: string; - description?: string; - branchName?: string; - }> - > { - const agents = await Promise.all( - this.concurrencyManager.getAllRunning().map(async (rf) => { - // Try to fetch feature data to get title, description, and branchName - 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; - } - } catch (error) { - // Silently ignore errors - title/description/branchName are optional - } - - 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; - } - - /** - * Wait for plan approval from the user. - * Delegates to PlanApprovalService. - */ - waitForPlanApproval( - featureId: string, - projectPath: string - ): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> { - return this.planApprovalService.waitForApproval(featureId, projectPath); - } - - /** - * Resolve a pending plan approval. - * Delegates to PlanApprovalService, handles recovery execution when needsRecovery=true. - */ - async resolvePlanApproval( - featureId: string, - approved: boolean, - editedPlan?: string, - feedback?: string, - projectPathFromClient?: string - ): Promise<{ success: boolean; error?: string }> { - const result = await this.planApprovalService.resolveApproval(featureId, approved, { - editedPlan, - feedback, - projectPath: projectPathFromClient, - }); - - // Handle recovery case - PlanApprovalService returns flag, AutoModeService executes - if (result.success && result.needsRecovery && projectPathFromClient) { - const feature = await this.loadFeature(projectPathFromClient, featureId); - if (feature) { - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - - // Build continuation prompt using centralized template - const planContent = editedPlan || feature.planSpec?.content || ''; - let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; - continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, feedback || ''); - continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); - - logger.info(`Starting recovery execution for feature ${featureId}`); - - // Start feature execution with the continuation prompt (async, don't await) - this.executeFeature(projectPathFromClient, featureId, true, false, undefined, { - continuationPrompt, - }).catch((error) => { - logger.error(`Recovery execution failed for feature ${featureId}:`, error); - }); - } - } - - return { success: result.success, error: result.error }; - } - - /** - * Cancel a pending plan approval (e.g., when feature is stopped). - * Delegates to PlanApprovalService. - */ - cancelPlanApproval(featureId: string): void { - this.planApprovalService.cancelApproval(featureId); - } - - /** - * Check if a feature has a pending plan approval. - * Delegates to PlanApprovalService. - */ - hasPendingApproval(featureId: string): boolean { - return this.planApprovalService.hasPendingApproval(featureId); - } - - // Private helpers - delegate to extracted services - - private async loadFeature(projectPath: string, featureId: string): Promise { - return this.featureStateManager.loadFeature(projectPath, featureId); - } - - private async updateFeatureStatus( - projectPath: string, - featureId: string, - status: string - ): Promise { - await this.featureStateManager.updateFeatureStatus(projectPath, featureId, status); - } - - /** - * Mark a feature as interrupted due to server restart or other interruption. - * - * This is a convenience helper that updates the feature status to 'interrupted', - * indicating the feature was in progress but execution was disrupted (e.g., server - * restart, process crash, or manual stop). Features with this status can be - * resumed later using the resume functionality. - * - * Note: Features with pipeline_* statuses are preserved rather than overwritten - * to 'interrupted'. This ensures that pipeline resume can pick up from - * the correct pipeline step after a restart. - * - * @param projectPath - Path to the project - * @param featureId - ID of the feature to mark as interrupted - * @param reason - Optional reason for the interruption (logged for debugging) - */ - async markFeatureInterrupted( - projectPath: string, - featureId: string, - reason?: string - ): Promise { - await this.featureStateManager.markFeatureInterrupted(projectPath, featureId, reason); - } - - /** - * Mark all currently running features as interrupted. - * - * This method is called during graceful server shutdown to ensure that all - * features currently being executed are properly marked as 'interrupted'. - * This allows them to be detected and resumed when the server restarts. - * - * @param reason - Optional reason for the interruption (logged for debugging) - * @returns Promise that resolves when all features have been marked as interrupted - */ - async markAllRunningFeaturesInterrupted(reason?: string): Promise { - const allRunning = this.concurrencyManager.getAllRunning(); - const runningCount = allRunning.length; - - if (runningCount === 0) { - logger.info('No running features to mark as interrupted'); - return; - } - - const logReason = reason || 'server shutdown'; - logger.info(`Marking ${runningCount} running feature(s) as interrupted due to: ${logReason}`); - - const markPromises: Promise[] = []; - - for (const runningFeature of allRunning) { - markPromises.push( - this.markFeatureInterrupted( - runningFeature.projectPath, - runningFeature.featureId, - logReason - ).catch((error) => { - logger.error(`Failed to mark feature ${runningFeature.featureId} as interrupted:`, error); - }) - ); - } - - await Promise.all(markPromises); - - logger.info(`Finished marking ${runningCount} feature(s) as interrupted`); - } - - private isFeatureFinished(feature: Feature): boolean { - const isCompleted = feature.status === 'completed' || feature.status === 'verified'; - - // Even if marked as completed, if it has an approved plan with pending tasks, it's not finished - if (feature.planSpec?.status === 'approved') { - const tasksCompleted = feature.planSpec.tasksCompleted ?? 0; - const tasksTotal = feature.planSpec.tasksTotal ?? 0; - if (tasksCompleted < tasksTotal) { - return false; - } - } - - return isCompleted; - } - - /** - * Check if a feature is currently running (being executed or resumed). - * This is used for idempotent checks to prevent race conditions when - * multiple callers try to resume the same feature simultaneously. - * - * @param featureId - The ID of the feature to check - * @returns true if the feature is currently running, false otherwise - */ - isFeatureRunning(featureId: string): boolean { - return this.concurrencyManager.isRunning(featureId); - } - - /** - * Update the planSpec of a feature - */ - private async updateFeaturePlanSpec( - projectPath: string, - featureId: string, - updates: Partial - ): Promise { - await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, updates); - } - - /** - * Save the extracted summary to a feature's summary field. - * This is called after agent execution completes to save a summary - * extracted from the agent's output using tags. - * - * Note: This is different from updateFeatureSummary which updates - * the description field during plan generation. - * - * @param projectPath - The project path - * @param featureId - The feature ID - * @param summary - The summary text to save - */ - private async saveFeatureSummary( - projectPath: string, - featureId: string, - summary: string - ): Promise { - await this.featureStateManager.saveFeatureSummary(projectPath, featureId, summary); - } - - /** - * Update the status of a specific task within planSpec.tasks - */ - private async updateTaskStatus( - projectPath: string, - featureId: string, - taskId: string, - status: ParsedTask['status'] - ): Promise { - await this.featureStateManager.updateTaskStatus(projectPath, featureId, taskId, status); - } - - /** - * Update the description of a feature based on extracted summary from plan content. - * This is called when a plan is generated during spec/full planning modes. - * - * Only updates the description if it's short (<50 chars), same as title, - * or starts with generic verbs like "implement/add/create/fix/update". - * - * Note: This is different from saveFeatureSummary which saves to the - * separate summary field after agent execution. - * - * @param projectPath - The project path - * @param featureId - The feature ID - * @param summary - The summary text extracted from the plan - */ - private async updateFeatureSummary( - projectPath: string, - featureId: string, - summary: string - ): Promise { - const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, 'feature.json'); - - try { - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - logRecoveryWarning(result, `Feature ${featureId}`, logger); - - const feature = result.data; - if (!feature) { - logger.warn(`Feature ${featureId} not found`); - return; - } - - // Only update if the feature doesn't already have a detailed description - // (Don't overwrite user-provided descriptions with extracted summaries) - const currentDesc = feature.description || ''; - const isShortOrGeneric = - currentDesc.length < 50 || - currentDesc === feature.title || - /^(implement|add|create|fix|update)\s/i.test(currentDesc); - - if (isShortOrGeneric) { - feature.description = summary; - feature.updatedAt = new Date().toISOString(); - - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - logger.info(`Updated feature ${featureId} description with extracted summary`); - } - } catch (error) { - logger.error(`Failed to update summary for ${featureId}:`, error); - } - } - - /** - * Load pending features for a specific project/worktree - * @param projectPath - The project path - * @param branchName - The branch name to filter by, or null for main worktree (features without branchName) - */ - private async loadPendingFeatures( - projectPath: string, - branchName: string | null = null - ): Promise { - // Features are stored in .automaker directory - const featuresDir = getFeaturesDir(projectPath); - - // Get the actual primary branch name for the project (e.g., "main", "master", "develop") - // This is needed to correctly match features when branchName is null (main worktree) - const primaryBranch = await this.worktreeResolver.getCurrentBranch(projectPath); - - try { - const entries = await secureFs.readdir(featuresDir, { - withFileTypes: true, - }); - const allFeatures: Feature[] = []; - const pendingFeatures: Feature[] = []; - - // Load all features (for dependency checking) with recovery support - for (const entry of entries) { - if (entry.isDirectory()) { - const featurePath = path.join(featuresDir, entry.name, 'feature.json'); - - // Use recovery-enabled read for corrupted file handling - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - logRecoveryWarning(result, `Feature ${entry.name}`, logger); - - const feature = result.data; - if (!feature) { - // Skip features that couldn't be loaded or recovered - continue; - } - - allFeatures.push(feature); - - // Track pending features separately, filtered by worktree/branch - // Note: waiting_approval is NOT included - those features have completed execution - // and are waiting for user review, they should not be picked up again - // - // Recovery cases: - // 1. Standard pending/ready/backlog statuses - // 2. Features with approved plans that have incomplete tasks (crash recovery) - // 3. Features stuck in 'in_progress' status (crash recovery) - // 4. Features with 'generating' planSpec status (spec generation was interrupted) - const needsRecovery = - feature.status === 'pending' || - feature.status === 'ready' || - feature.status === 'backlog' || - feature.status === 'in_progress' || // Recover features that were in progress when server crashed - (feature.planSpec?.status === 'approved' && - (feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) || - feature.planSpec?.status === 'generating'; // Recover interrupted spec generation - - if (needsRecovery) { - // Filter by branchName: - // - If branchName is null (main worktree), include features with: - // - branchName === null, OR - // - branchName === primaryBranch (e.g., "main", "master", "develop") - // - If branchName is set, only include features with matching branchName - const featureBranch = feature.branchName ?? null; - if (branchName === null) { - // Main worktree: include features without branchName OR with branchName matching primary branch - // This handles repos where the primary branch is named something other than "main" - const isPrimaryBranch = - featureBranch === null || (primaryBranch && featureBranch === primaryBranch); - if (isPrimaryBranch) { - pendingFeatures.push(feature); - } else { - logger.debug( - `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, primaryBranch: ${primaryBranch}) for main worktree` - ); - } - } else { - // Feature worktree: include features with matching branchName - if (featureBranch === branchName) { - pendingFeatures.push(feature); - } else { - logger.debug( - `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, expected: ${branchName}) for worktree ${branchName}` - ); - } - } - } - } - } - - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.info( - `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/approved_with_pending_tasks/generating) for ${worktreeDesc}` - ); - - if (pendingFeatures.length === 0) { - logger.warn( - `[loadPendingFeatures] No pending features found for ${worktreeDesc}. Check branchName matching - looking for branchName: ${branchName === null ? 'null (main)' : branchName}` - ); - // Log all backlog features to help debug branchName matching - const allBacklogFeatures = allFeatures.filter( - (f) => - f.status === 'backlog' || - f.status === 'pending' || - f.status === 'ready' || - (f.planSpec?.status === 'approved' && - (f.planSpec.tasksCompleted ?? 0) < (f.planSpec.tasksTotal ?? 0)) - ); - if (allBacklogFeatures.length > 0) { - logger.info( - `[loadPendingFeatures] Found ${allBacklogFeatures.length} backlog features with branchNames: ${allBacklogFeatures.map((f) => `${f.id}(${f.branchName ?? 'null'})`).join(', ')}` - ); - } - } - - // Apply dependency-aware ordering - const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures); - - // Remove missing dependencies from features and save them - // This allows features to proceed when their dependencies have been deleted or don't exist - if (missingDependencies.size > 0) { - for (const [featureId, missingDepIds] of missingDependencies) { - const feature = pendingFeatures.find((f) => f.id === featureId); - if (feature && feature.dependencies) { - // Filter out the missing dependency IDs - const validDependencies = feature.dependencies.filter( - (depId) => !missingDepIds.includes(depId) - ); - - logger.warn( - `[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.` - ); - - // Update the feature in memory - feature.dependencies = validDependencies.length > 0 ? validDependencies : undefined; - - // Save the updated feature to disk - try { - await this.featureLoader.update(projectPath, featureId, { - dependencies: feature.dependencies, - }); - logger.info( - `[loadPendingFeatures] Updated feature ${featureId} - removed missing dependencies` - ); - } catch (error) { - logger.error( - `[loadPendingFeatures] Failed to save feature ${featureId} after removing missing dependencies:`, - error - ); - } - } - } - } - - // Get skipVerificationInAutoMode setting - const settings = await this.settingsService?.getGlobalSettings(); - const skipVerification = settings?.skipVerificationInAutoMode ?? false; - - // Filter to only features with satisfied dependencies - const readyFeatures: Feature[] = []; - const blockedFeatures: Array<{ feature: Feature; reason: string }> = []; - - for (const feature of orderedFeatures) { - const isSatisfied = areDependenciesSatisfied(feature, allFeatures, { skipVerification }); - if (isSatisfied) { - readyFeatures.push(feature); - } else { - // Find which dependencies are blocking - const blockingDeps = - feature.dependencies?.filter((depId) => { - const dep = allFeatures.find((f) => f.id === depId); - if (!dep) return true; // Missing dependency - if (skipVerification) { - return dep.status === 'running'; - } - return dep.status !== 'completed' && dep.status !== 'verified'; - }) || []; - blockedFeatures.push({ - feature, - reason: - blockingDeps.length > 0 - ? `Blocked by dependencies: ${blockingDeps.join(', ')}` - : 'Unknown dependency issue', - }); - } - } - - if (blockedFeatures.length > 0) { - logger.info( - `[loadPendingFeatures] ${blockedFeatures.length} features blocked by dependencies: ${blockedFeatures.map((b) => `${b.feature.id} (${b.reason})`).join('; ')}` - ); - } - - logger.info( - `[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})` - ); - - return readyFeatures; - } catch (error) { - logger.error(`[loadPendingFeatures] Error loading features:`, error); - return []; - } - } - - /** - * Extract a title from feature description (first line or truncated) - */ - private extractTitleFromDescription(description: string): string { - if (!description || !description.trim()) { - return 'Untitled Feature'; - } - - // Get first line, or first 60 characters if no newline - 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) + '...'; - } - - /** - * Get the planning prompt prefix based on feature's planning mode - */ - private async getPlanningPromptPrefix(feature: Feature): Promise { - const mode = feature.planningMode || 'skip'; - - if (mode === 'skip') { - return ''; // No planning phase - } - - // Load prompts from settings (no caching - allows hot reload of custom prompts) - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - const planningPrompts: Record = { - lite: prompts.autoMode.planningLite, - lite_with_approval: prompts.autoMode.planningLiteWithApproval, - spec: prompts.autoMode.planningSpec, - full: prompts.autoMode.planningFull, - }; - - // For lite mode, use the approval variant if requirePlanApproval is true - let promptKey: string = mode; - if (mode === 'lite' && feature.requirePlanApproval === true) { - promptKey = 'lite_with_approval'; - } - - const planningPrompt = planningPrompts[promptKey]; - if (!planningPrompt) { - return ''; - } - - return planningPrompt + '\n\n---\n\n## Feature Request\n\n'; - } - - private buildFeaturePrompt( - feature: Feature, - taskExecutionPrompts: { - implementationInstructions: string; - playwrightVerificationInstructions: string; - } - ): string { - const title = this.extractTitleFromDescription(feature.description); - - let prompt = `## Feature Implementation Task - -**Feature ID:** ${feature.id} -**Title:** ${title} -**Description:** ${feature.description} -`; - - if (feature.spec) { - prompt += ` -**Specification:** -${feature.spec} -`; - } - - // Add images note (like old implementation) - if (feature.imagePaths && feature.imagePaths.length > 0) { - const imagesList = feature.imagePaths - .map((img, idx) => { - const path = typeof img === 'string' ? img : img.path; - const filename = - typeof img === 'string' ? path.split('/').pop() : img.filename || path.split('/').pop(); - const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*'; - return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`; - }) - .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}`; - } - - return prompt; - } - - private async runAgent( - 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 { - const finalProjectPath = options?.projectPath || projectPath; - const branchName = options?.branchName ?? null; - const planningMode = options?.planningMode || 'skip'; - const previousContent = options?.previousContent; - - // Validate vision support before processing images - const effectiveModel = model || 'claude-sonnet-4-20250514'; - if (imagePaths && imagePaths.length > 0) { - const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel); - if (!supportsVision) { - throw new Error( - `This model (${effectiveModel}) does not support image input. ` + - `Please switch to a model that supports vision (like Claude models), or remove the images and try again.` - ); - } - } - - // Check if this planning mode can generate a spec/plan that needs approval - // - spec and full always generate specs - // - lite only generates approval-ready content when requirePlanApproval is true - const planningModeRequiresApproval = - planningMode === 'spec' || - planningMode === 'full' || - (planningMode === 'lite' && options?.requirePlanApproval === true); - const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true; - - // Check if feature already has an approved plan with tasks (recovery scenario) - // If so, we should skip spec detection and use persisted task status - let existingApprovedPlan: Feature['planSpec'] | undefined; - let persistedTasks: ParsedTask[] | undefined; - if (planningModeRequiresApproval) { - const feature = await this.loadFeature(projectPath, featureId); - if (feature?.planSpec?.status === 'approved' && feature.planSpec.tasks) { - existingApprovedPlan = feature.planSpec; - persistedTasks = feature.planSpec.tasks; - logger.info( - `Recovery: Using persisted tasks for feature ${featureId} (${persistedTasks.length} tasks, ${persistedTasks.filter((t) => t.status === 'completed').length} completed)` - ); - } - } - - // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set - // This prevents actual API calls during automated testing - if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { - logger.info(`MOCK MODE: Skipping real agent execution for feature ${featureId}`); - - // Simulate some work being done - await this.sleep(500); - - // Emit mock progress events to simulate agent activity - this.eventBus.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: 'Mock agent: Analyzing the codebase...', - }); - - await this.sleep(300); - - this.eventBus.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: 'Mock agent: Implementing the feature...', - }); - - await this.sleep(300); - - // Create a mock file with "yellow" content as requested in the test - const mockFilePath = path.join(workDir, 'yellow.txt'); - await secureFs.writeFile(mockFilePath, 'yellow'); - - this.eventBus.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: "Mock agent: Created yellow.txt file with content 'yellow'", - }); - - await this.sleep(200); - - // Save mock agent output - const featureDirForOutput = getFeatureDir(projectPath, featureId); - const outputPath = path.join(featureDirForOutput, 'agent-output.md'); - - const mockOutput = `# Mock Agent Output - -## Summary -This is a mock agent response for CI/CD testing. - -## Changes Made -- Created \`yellow.txt\` with content "yellow" - -## Notes -This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. -`; - - await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); - await secureFs.writeFile(outputPath, mockOutput); - - logger.info(`MOCK MODE: Completed mock execution for feature ${featureId}`); - return; - } - - // Load autoLoadClaudeMd setting (project setting takes precedence over global) - // Use provided value if available, otherwise load from settings - const autoLoadClaudeMd = - options?.autoLoadClaudeMd !== undefined - ? options.autoLoadClaudeMd - : await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]'); - - // Load MCP servers from settings (global setting only) - const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]'); - - // Load MCP permission settings (global setting only) - - // Build SDK options using centralized configuration for feature implementation - const sdkOptions = createAutoModeOptions({ - cwd: workDir, - model: model, - abortController, - autoLoadClaudeMd, - mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - thinkingLevel: options?.thinkingLevel, - }); - - // Extract model, maxTurns, and allowedTools from SDK options - const finalModel = sdkOptions.model!; - const maxTurns = sdkOptions.maxTurns; - const allowedTools = sdkOptions.allowedTools as string[] | undefined; - - logger.info( - `runAgent called for feature ${featureId} with model: ${finalModel}, planningMode: ${planningMode}, requiresApproval: ${requiresApproval}` - ); - - // Get provider for this model - const provider = ProviderFactory.getProviderForModel(finalModel); - - // Strip provider prefix - providers should receive bare model IDs - const bareModel = stripProviderPrefix(finalModel); - - logger.info( - `Using provider "${provider.getName()}" for model "${finalModel}" (bare: ${bareModel})` - ); - - // Build prompt content with images using utility - const { content: promptContent } = await buildPromptWithImages( - prompt, - imagePaths, - workDir, - false // don't duplicate paths in text - ); - - // Debug: Log if system prompt is provided - if (options?.systemPrompt) { - logger.info( - `System prompt provided (${options.systemPrompt.length} chars), first 200 chars:\n${options.systemPrompt.substring(0, 200)}...` - ); - } - - // Get credentials for API calls (model comes from request, no phase model) - const credentials = await this.settingsService?.getCredentials(); - - // Try to find a provider for the model (if it's a provider model like "GLM-4.7") - // This allows users to select provider models in the Auto Mode / Feature execution - let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; - let providerResolvedModel: string | undefined; - if (finalModel && this.settingsService) { - const providerResult = await getProviderByModelId( - finalModel, - this.settingsService, - '[AutoMode]' - ); - if (providerResult.provider) { - claudeCompatibleProvider = providerResult.provider; - providerResolvedModel = providerResult.resolvedModel; - logger.info( - `[AutoMode] Using provider "${providerResult.provider.name}" for model "${finalModel}"` + - (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') - ); - } - } - - // Use the resolved model if available (from mapsToClaudeModel), otherwise use bareModel - const effectiveBareModel = providerResolvedModel - ? stripProviderPrefix(providerResolvedModel) - : bareModel; - - // Build AgentExecutionOptions for delegation to AgentExecutor - const agentOptions = { - workDir, - featureId, - prompt, - projectPath, - abortController, - imagePaths, - model: finalModel, - planningMode, - requirePlanApproval: options?.requirePlanApproval, - previousContent, - systemPrompt: options?.systemPrompt, - autoLoadClaudeMd, - thinkingLevel: options?.thinkingLevel, - branchName, - credentials, - claudeCompatibleProvider, - mcpServers, - sdkOptions: { - maxTurns, - allowedTools, - systemPrompt: sdkOptions.systemPrompt, - settingSources: sdkOptions.settingSources, - }, - provider, - effectiveBareModel, - // Recovery options - specAlreadyDetected: !!existingApprovedPlan, - existingApprovedPlanContent: existingApprovedPlan?.content, - persistedTasks, - }; - - // Delegate to AgentExecutor with callbacks that wrap AutoModeService methods - logger.info(`Delegating to AgentExecutor for feature ${featureId}...`); - await this.agentExecutor.execute(agentOptions, { - waitForApproval: async (fId: string, pPath: string) => { - return this.planApprovalService.waitForApproval(fId, pPath); - }, - saveFeatureSummary: async (pPath: string, fId: string, summary: string) => { - await this.saveFeatureSummary(pPath, fId, summary); - }, - updateFeatureSummary: async (pPath: string, fId: string, summary: string) => { - await this.updateFeatureSummary(pPath, fId, summary); - }, - buildTaskPrompt: (task, allTasks, taskIndex, planContent, template, feedback) => { - return this.buildTaskPrompt(task, allTasks, taskIndex, planContent, template, feedback); - }, - }); - - logger.info(`AgentExecutor completed for feature ${featureId}`); - } - - private async executeFeatureWithContext( - projectPath: string, - featureId: string, - context: string, - useWorktrees: boolean - ): Promise { - const feature = await this.loadFeature(projectPath, featureId); - if (!feature) { - throw new Error(`Feature ${featureId} not found`); - } - - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - - // Build the feature prompt - const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution); - - // Use the resume feature template with variable substitution - let prompt = prompts.taskExecution.resumeFeatureTemplate; - prompt = prompt.replace(/\{\{featurePrompt\}\}/g, featurePrompt); - prompt = prompt.replace(/\{\{previousContext\}\}/g, context); - - return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { - continuationPrompt: prompt, - _calledInternally: true, - }); - } - - /** - * Build a focused prompt for executing a single task. - * Each task gets minimal context to keep the agent focused. - */ - private buildTaskPrompt( - task: ParsedTask, - allTasks: ParsedTask[], - taskIndex: number, - planContent: string, - taskPromptTemplate: string, - userFeedback?: string - ): string { - const completedTasks = allTasks.slice(0, taskIndex); - const remainingTasks = allTasks.slice(taskIndex + 1); - - // Build completed tasks string - const completedTasksStr = - completedTasks.length > 0 - ? `### Already Completed (${completedTasks.length} tasks)\n${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')}\n` - : ''; - - // Build remaining tasks string - const remainingTasksStr = - remainingTasks.length > 0 - ? `### Coming Up Next (${remainingTasks.length} tasks remaining)\n${remainingTasks - .slice(0, 3) - .map((t) => `- [ ] ${t.id}: ${t.description}`) - .join( - '\n' - )}${remainingTasks.length > 3 ? `\n... and ${remainingTasks.length - 3} more tasks` : ''}\n` - : ''; - - // Build user feedback string - const userFeedbackStr = userFeedback ? `### User Feedback\n${userFeedback}\n` : ''; - - // Use centralized template with variable substitution - let prompt = taskPromptTemplate; - prompt = prompt.replace(/\{\{taskId\}\}/g, task.id); - prompt = prompt.replace(/\{\{taskDescription\}\}/g, task.description); - prompt = prompt.replace(/\{\{taskFilePath\}\}/g, task.filePath || ''); - prompt = prompt.replace(/\{\{taskPhase\}\}/g, task.phase || ''); - prompt = prompt.replace(/\{\{completedTasks\}\}/g, completedTasksStr); - prompt = prompt.replace(/\{\{remainingTasks\}\}/g, remainingTasksStr); - prompt = prompt.replace(/\{\{userFeedback\}\}/g, userFeedbackStr); - prompt = prompt.replace(/\{\{planContent\}\}/g, planContent); - - return prompt; - } - - private sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, ms); - - // If signal is provided and already aborted, reject immediately - if (signal?.aborted) { - clearTimeout(timeout); - reject(new Error('Aborted')); - return; - } - - // Listen for abort signal - if (signal) { - signal.addEventListener( - 'abort', - () => { - clearTimeout(timeout); - reject(new Error('Aborted')); - }, - { once: true } - ); - } - }); - } - - // ============================================================================ - // Execution State Persistence - For recovery after server restart - // ============================================================================ - - /** - * Save execution state to disk for recovery after server restart - */ - private async saveExecutionState(projectPath: string): Promise { - try { - await ensureAutomakerDir(projectPath); - const statePath = getExecutionStatePath(projectPath); - const runningFeatureIds = this.concurrencyManager.getAllRunning().map((rf) => rf.featureId); - const state: ExecutionState = { - version: 1, - autoLoopWasRunning: this.autoLoopRunning, - maxConcurrency: this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, - projectPath, - branchName: null, // Legacy global auto mode uses main worktree - runningFeatureIds, - savedAt: new Date().toISOString(), - }; - await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); - logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`); - } catch (error) { - logger.error('Failed to save execution state:', error); - } - } - - /** - * Load execution state from disk - */ - private async loadExecutionState(projectPath: string): Promise { - try { - const statePath = getExecutionStatePath(projectPath); - const content = (await secureFs.readFile(statePath, 'utf-8')) as string; - const state = JSON.parse(content) as ExecutionState; - return state; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.error('Failed to load execution state:', error); - } - return DEFAULT_EXECUTION_STATE; - } - } - - /** - * Clear execution state (called on successful shutdown or when auto-loop stops) - */ - private async clearExecutionState( - projectPath: string, - branchName: string | null = null - ): Promise { - try { - const statePath = getExecutionStatePath(projectPath); - await secureFs.unlink(statePath); - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.info(`Cleared execution state for ${worktreeDesc}`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.error('Failed to clear execution state:', error); - } - } - } - - /** - * Check for and resume interrupted features after server restart - * This should be called during server initialization - */ - async resumeInterruptedFeatures(projectPath: string): Promise { - return this.recoveryService.resumeInterruptedFeatures(projectPath); - } - - /** - * Extract and record learnings from a completed feature - * Uses a quick Claude call to identify important decisions and patterns - */ - private async recordLearningsFromFeature( - projectPath: string, - feature: Feature, - agentOutput: string - ): Promise { - if (!agentOutput || agentOutput.length < 100) { - // Not enough output to extract learnings from - console.log( - `[AutoMode] Skipping learning extraction - output too short (${agentOutput?.length || 0} chars)` - ); - return; - } - - console.log( - `[AutoMode] Extracting learnings from feature "${feature.title}" (${agentOutput.length} chars)` - ); - - // Limit output to avoid token limits - const truncatedOutput = agentOutput.length > 10000 ? agentOutput.slice(-10000) : agentOutput; - - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - - // Build user prompt using centralized template with variable substitution - let userPrompt = prompts.taskExecution.learningExtractionUserPromptTemplate; - userPrompt = userPrompt.replace(/\{\{featureTitle\}\}/g, feature.title || ''); - userPrompt = userPrompt.replace(/\{\{implementationLog\}\}/g, truncatedOutput); - - try { - // Get model from phase settings - const settings = await this.settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel; - const { model } = resolvePhaseModel(phaseModelEntry); - const hasClaudeKey = Boolean(process.env.ANTHROPIC_API_KEY); - let resolvedModel = model; - - if (isClaudeModel(model) && !hasClaudeKey) { - const fallbackModel = feature.model - ? resolveModelString(feature.model, DEFAULT_MODELS.claude) - : null; - if (fallbackModel && !isClaudeModel(fallbackModel)) { - console.log( - `[AutoMode] Claude not configured for memory extraction; using feature model "${fallbackModel}".` - ); - resolvedModel = fallbackModel; - } else { - console.log( - '[AutoMode] Claude not configured for memory extraction; skipping learning extraction.' - ); - return; - } - } - - const result = await simpleQuery({ - prompt: userPrompt, - model: resolvedModel, - cwd: projectPath, - maxTurns: 1, - allowedTools: [], - systemPrompt: prompts.taskExecution.learningExtractionSystemPrompt, - }); - - const responseText = result.text; - - console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`); - console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`); - - // Parse the response - handle JSON in markdown code blocks or raw - let jsonStr: string | null = null; - - // First try to find JSON in markdown code blocks - const codeBlockMatch = responseText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/); - if (codeBlockMatch) { - console.log('[AutoMode] Found JSON in code block'); - jsonStr = codeBlockMatch[1]; - } else { - // Fall back to finding balanced braces containing "learnings" - // Use a more precise approach: find the opening brace before "learnings" - const learningsIndex = responseText.indexOf('"learnings"'); - if (learningsIndex !== -1) { - // Find the opening brace before "learnings" - let braceStart = responseText.lastIndexOf('{', learningsIndex); - if (braceStart !== -1) { - // Find matching closing brace - let braceCount = 0; - let braceEnd = -1; - for (let i = braceStart; i < responseText.length; i++) { - if (responseText[i] === '{') braceCount++; - if (responseText[i] === '}') braceCount--; - if (braceCount === 0) { - braceEnd = i; - break; - } - } - if (braceEnd !== -1) { - jsonStr = responseText.substring(braceStart, braceEnd + 1); - } - } - } - } - - if (!jsonStr) { - console.log('[AutoMode] Could not extract JSON from response'); - return; - } - - console.log(`[AutoMode] Extracted JSON: ${jsonStr.substring(0, 200)}`); - - let parsed: { learnings?: unknown[] }; - try { - parsed = JSON.parse(jsonStr); - } catch { - console.warn('[AutoMode] Failed to parse learnings JSON:', jsonStr.substring(0, 200)); - return; - } - - if (!parsed.learnings || !Array.isArray(parsed.learnings)) { - console.log('[AutoMode] No learnings array in parsed response'); - return; - } - - console.log(`[AutoMode] Found ${parsed.learnings.length} potential learnings`); - - // Valid learning types - const validTypes = new Set(['decision', 'learning', 'pattern', 'gotcha']); - - // Record each learning - for (const item of parsed.learnings) { - // Validate required fields with proper type narrowing - if (!item || typeof item !== 'object') continue; - - const learning = item as Record; - if ( - !learning.category || - typeof learning.category !== 'string' || - !learning.content || - typeof learning.content !== 'string' || - !learning.content.trim() - ) { - continue; - } - - // Validate and normalize type - const typeStr = typeof learning.type === 'string' ? learning.type : 'learning'; - const learningType = validTypes.has(typeStr) - ? (typeStr as 'decision' | 'learning' | 'pattern' | 'gotcha') - : 'learning'; - - console.log( - `[AutoMode] Appending learning: category=${learning.category}, type=${learningType}` - ); - await appendLearning( - projectPath, - { - category: learning.category, - type: learningType, - content: learning.content.trim(), - context: typeof learning.context === 'string' ? learning.context : undefined, - why: typeof learning.why === 'string' ? learning.why : undefined, - rejected: typeof learning.rejected === 'string' ? learning.rejected : undefined, - tradeoffs: typeof learning.tradeoffs === 'string' ? learning.tradeoffs : undefined, - breaking: typeof learning.breaking === 'string' ? learning.breaking : undefined, - }, - secureFs as Parameters[2] - ); - } - - const validLearnings = parsed.learnings.filter( - (l) => l && typeof l === 'object' && (l as Record).content - ); - if (validLearnings.length > 0) { - console.log( - `[AutoMode] Recorded ${parsed.learnings.length} learning(s) from feature ${feature.id}` - ); - } - } catch (error) { - console.warn(`[AutoMode] Failed to extract learnings from feature ${feature.id}:`, error); - } - } - - /** - * Detect orphaned features - features whose branchName points to a branch that no longer exists. - * - * Orphaned features can occur when: - * - A feature branch is deleted after merge - * - A worktree is manually removed - * - A branch is force-deleted - * - * @param projectPath - Path to the project - * @returns Array of orphaned features with their missing branch names - */ - async detectOrphanedFeatures( - projectPath: string - ): Promise> { - const orphanedFeatures: Array<{ feature: Feature; missingBranch: string }> = []; - - try { - // Get all features for this project - const allFeatures = await this.featureLoader.getAll(projectPath); - - // Get features that have a branchName set (excludes main branch features) - const featuresWithBranches = allFeatures.filter( - (f) => f.branchName && f.branchName.trim() !== '' - ); - - if (featuresWithBranches.length === 0) { - logger.debug('[detectOrphanedFeatures] No features with branch names found'); - return orphanedFeatures; - } - - // Get all existing branches (local) - const existingBranches = await this.getExistingBranches(projectPath); - - // Get current/primary branch (features with null branchName are implicitly on this) - const primaryBranch = await this.worktreeResolver.getCurrentBranch(projectPath); - - // Check each feature with a branchName - for (const feature of featuresWithBranches) { - const branchName = feature.branchName!; - - // Skip if the branchName matches the primary branch (implicitly valid) - if (primaryBranch && branchName === primaryBranch) { - continue; - } - - // Check if the branch exists - if (!existingBranches.has(branchName)) { - orphanedFeatures.push({ - feature, - missingBranch: branchName, - }); - logger.info( - `[detectOrphanedFeatures] Found orphaned feature: ${feature.id} (${feature.title}) - branch "${branchName}" no longer exists` - ); - } - } - - if (orphanedFeatures.length > 0) { - logger.info( - `[detectOrphanedFeatures] Found ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}` - ); - } else { - logger.debug('[detectOrphanedFeatures] No orphaned features found'); - } - - return orphanedFeatures; - } catch (error) { - logger.error('[detectOrphanedFeatures] Error detecting orphaned features:', error); - return orphanedFeatures; - } - } - - /** - * Get all existing local branches for a project - * @param projectPath - Path to the git repository - * @returns Set of branch names - */ - private async getExistingBranches(projectPath: string): Promise> { - const branches = new Set(); - - try { - // Use git for-each-ref to get all local branches - const { stdout } = await execAsync( - 'git for-each-ref --format="%(refname:short)" refs/heads/', - { cwd: projectPath } - ); - - const branchLines = stdout.trim().split('\n'); - for (const branch of branchLines) { - const trimmed = branch.trim(); - if (trimmed) { - branches.add(trimmed); - } - } - - logger.debug(`[getExistingBranches] Found ${branches.size} local branches`); - } catch (error) { - logger.error('[getExistingBranches] Failed to get branches:', error); - } - - return branches; - } -} diff --git a/apps/server/src/services/auto-mode/compat.ts b/apps/server/src/services/auto-mode/compat.ts new file mode 100644 index 00000000..1e37ecaf --- /dev/null +++ b/apps/server/src/services/auto-mode/compat.ts @@ -0,0 +1,225 @@ +/** + * Compatibility Shim - Provides AutoModeService-like interface using the new architecture + * + * This allows existing routes to work without major changes during the transition. + * Routes receive this shim which delegates to GlobalAutoModeService and facades. + * + * This is a TEMPORARY shim - routes should be updated to use the new interface directly. + */ + +import type { Feature } from '@automaker/types'; +import type { EventEmitter } from '../../lib/events.js'; +import { GlobalAutoModeService } from './global-service.js'; +import { AutoModeServiceFacade } from './facade.js'; +import type { SettingsService } from '../settings-service.js'; +import type { FeatureLoader } from '../feature-loader.js'; +import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js'; + +/** + * AutoModeServiceCompat wraps GlobalAutoModeService and facades to provide + * the old AutoModeService interface that routes expect. + */ +export class AutoModeServiceCompat { + private readonly globalService: GlobalAutoModeService; + private readonly facadeOptions: FacadeOptions; + + constructor( + events: EventEmitter, + settingsService: SettingsService | null, + featureLoader: FeatureLoader + ) { + this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader); + const sharedServices = this.globalService.getSharedServices(); + + this.facadeOptions = { + events, + settingsService, + featureLoader, + sharedServices, + }; + } + + /** + * Get the global service for direct access + */ + getGlobalService(): GlobalAutoModeService { + return this.globalService; + } + + /** + * Create a facade for a specific project + */ + createFacade(projectPath: string): AutoModeServiceFacade { + return AutoModeServiceFacade.create(projectPath, this.facadeOptions); + } + + // =========================================================================== + // GLOBAL OPERATIONS (delegated to GlobalAutoModeService) + // =========================================================================== + + getStatus(): AutoModeStatus { + return this.globalService.getStatus(); + } + + getActiveAutoLoopProjects(): string[] { + return this.globalService.getActiveAutoLoopProjects(); + } + + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + return this.globalService.getActiveAutoLoopWorktrees(); + } + + async getRunningAgents(): Promise { + return this.globalService.getRunningAgents(); + } + + async markAllRunningFeaturesInterrupted(reason?: string): Promise { + return this.globalService.markAllRunningFeaturesInterrupted(reason); + } + + // =========================================================================== + // PER-PROJECT OPERATIONS (delegated to facades) + // =========================================================================== + + getStatusForProject( + projectPath: string, + branchName: string | null = null + ): { + isAutoLoopRunning: boolean; + runningFeatures: string[]; + runningCount: number; + maxConcurrency: number; + branchName: string | null; + } { + const facade = this.createFacade(projectPath); + return facade.getStatusForProject(branchName); + } + + isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean { + const facade = this.createFacade(projectPath); + return facade.isAutoLoopRunning(branchName); + } + + async startAutoLoopForProject( + projectPath: string, + branchName: string | null = null, + maxConcurrency?: number + ): Promise { + const facade = this.createFacade(projectPath); + return facade.startAutoLoop(branchName, maxConcurrency); + } + + async stopAutoLoopForProject( + projectPath: string, + branchName: string | null = null + ): Promise { + const facade = this.createFacade(projectPath); + return facade.stopAutoLoop(branchName); + } + + async executeFeature( + projectPath: string, + featureId: string, + useWorktrees = false, + isAutoMode = false, + providedWorktreePath?: string, + options?: { continuationPrompt?: string; _calledInternally?: boolean } + ): Promise { + const facade = this.createFacade(projectPath); + return facade.executeFeature( + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + options + ); + } + + async stopFeature(featureId: string): Promise { + // Stop feature is tricky - we need to find which project the feature is running in + // The concurrency manager tracks this + const runningAgents = await this.getRunningAgents(); + const agent = runningAgents.find((a) => a.featureId === featureId); + if (agent) { + const facade = this.createFacade(agent.projectPath); + return facade.stopFeature(featureId); + } + return false; + } + + async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise { + const facade = this.createFacade(projectPath); + return facade.resumeFeature(featureId, useWorktrees); + } + + async followUpFeature( + projectPath: string, + featureId: string, + prompt: string, + imagePaths?: string[], + useWorktrees = true + ): Promise { + const facade = this.createFacade(projectPath); + return facade.followUpFeature(featureId, prompt, imagePaths, useWorktrees); + } + + async verifyFeature(projectPath: string, featureId: string): Promise { + const facade = this.createFacade(projectPath); + return facade.verifyFeature(featureId); + } + + async commitFeature( + projectPath: string, + featureId: string, + providedWorktreePath?: string + ): Promise { + const facade = this.createFacade(projectPath); + return facade.commitFeature(featureId, providedWorktreePath); + } + + async contextExists(projectPath: string, featureId: string): Promise { + const facade = this.createFacade(projectPath); + return facade.contextExists(featureId); + } + + async analyzeProject(projectPath: string): Promise { + const facade = this.createFacade(projectPath); + return facade.analyzeProject(); + } + + async resolvePlanApproval( + projectPath: string, + featureId: string, + approved: boolean, + editedPlan?: string, + feedback?: string + ): Promise<{ success: boolean; error?: string }> { + const facade = this.createFacade(projectPath); + return facade.resolvePlanApproval(featureId, approved, editedPlan, feedback); + } + + async resumeInterruptedFeatures(projectPath: string): Promise { + const facade = this.createFacade(projectPath); + return facade.resumeInterruptedFeatures(); + } + + async checkWorktreeCapacity( + projectPath: string, + featureId: string + ): Promise<{ + hasCapacity: boolean; + currentAgents: number; + maxAgents: number; + branchName: string | null; + }> { + const facade = this.createFacade(projectPath); + return facade.checkWorktreeCapacity(featureId); + } + + async detectOrphanedFeatures( + projectPath: string + ): Promise> { + const facade = this.createFacade(projectPath); + return facade.detectOrphanedFeatures(); + } +} diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 03eb044a..b29fc15f 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -86,12 +86,20 @@ export class AutoModeServiceFacade { * @param options - Configuration options including events, settingsService, featureLoader */ static create(projectPath: string, options: FacadeOptions): AutoModeServiceFacade { - const { events, settingsService = null, featureLoader = new FeatureLoader() } = options; + const { + events, + settingsService = null, + featureLoader = new FeatureLoader(), + sharedServices, + } = options; - // Create core services - const eventBus = new TypedEventBus(events); - const worktreeResolver = new WorktreeResolver(); - const concurrencyManager = new ConcurrencyManager((p) => worktreeResolver.getCurrentBranch(p)); + // Use shared services if provided, otherwise create new ones + // Shared services allow multiple facades to share state (e.g., running features, auto loops) + 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, @@ -151,36 +159,39 @@ export class AutoModeServiceFacade { } ); - // AutoLoopCoordinator - const autoLoopCoordinator = new AutoLoopCoordinator( - eventBus, - concurrencyManager, - settingsService, - // Callbacks - (pPath, featureId, useWorktrees, isAutoMode) => - facadeInstance!.executeFeature(featureId, useWorktrees, isAutoMode), - (pPath, branchName) => - featureLoader - .getAll(pPath) - .then((features) => - features.filter( - (f) => - (f.status === 'backlog' || f.status === 'ready') && - (branchName === null - ? !f.branchName || f.branchName === 'main' - : f.branchName === branchName) - ) - ), - (pPath, branchName, maxConcurrency) => - facadeInstance!.saveExecutionStateForProject(branchName, maxConcurrency), - (pPath, branchName) => facadeInstance!.clearExecutionState(branchName), - (pPath) => featureStateManager.resetStuckFeatures(pPath), - (feature) => - feature.status === 'completed' || - feature.status === 'verified' || - feature.status === 'waiting_approval', - (featureId) => concurrencyManager.isRunning(featureId) - ); + // AutoLoopCoordinator - use shared if provided, otherwise create new + // Note: When using shared autoLoopCoordinator, callbacks are already set up by the global service + const autoLoopCoordinator = + sharedServices?.autoLoopCoordinator ?? + new AutoLoopCoordinator( + eventBus, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, useWorktrees, isAutoMode) => + facadeInstance!.executeFeature(featureId, useWorktrees, isAutoMode), + (pPath, branchName) => + featureLoader + .getAll(pPath) + .then((features) => + features.filter( + (f) => + (f.status === 'backlog' || f.status === 'ready') && + (branchName === null + ? !f.branchName || f.branchName === 'main' + : f.branchName === branchName) + ) + ), + (pPath, branchName, maxConcurrency) => + facadeInstance!.saveExecutionStateForProject(branchName, maxConcurrency), + (pPath, branchName) => facadeInstance!.clearExecutionState(branchName), + (pPath) => featureStateManager.resetStuckFeatures(pPath), + (feature) => + feature.status === 'completed' || + feature.status === 'verified' || + feature.status === 'waiting_approval', + (featureId) => concurrencyManager.isRunning(featureId) + ); // ExecutionService - runAgentFn is a stub const executionService = new ExecutionService( diff --git a/apps/server/src/services/auto-mode/global-service.ts b/apps/server/src/services/auto-mode/global-service.ts new file mode 100644 index 00000000..a371272f --- /dev/null +++ b/apps/server/src/services/auto-mode/global-service.ts @@ -0,0 +1,200 @@ +/** + * GlobalAutoModeService - Global operations for auto-mode that span across all projects + * + * This service manages global state and operations that are not project-specific: + * - Overall status (all running features across all projects) + * - Active auto loop projects and worktrees + * - Graceful shutdown (mark all features as interrupted) + * + * Per-project operations should use AutoModeServiceFacade instead. + */ + +import path from 'path'; +import type { Feature } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../../lib/events.js'; +import { TypedEventBus } from '../typed-event-bus.js'; +import { ConcurrencyManager } from '../concurrency-manager.js'; +import { WorktreeResolver } from '../worktree-resolver.js'; +import { AutoLoopCoordinator } from '../auto-loop-coordinator.js'; +import { FeatureStateManager } from '../feature-state-manager.js'; +import { FeatureLoader } from '../feature-loader.js'; +import type { SettingsService } from '../settings-service.js'; +import type { SharedServices, AutoModeStatus, RunningAgentInfo } from './types.js'; + +const logger = createLogger('GlobalAutoModeService'); + +/** + * GlobalAutoModeService provides global operations for auto-mode. + * + * Created once at server startup, shared across all facades. + */ +export class GlobalAutoModeService { + private readonly eventBus: TypedEventBus; + private readonly concurrencyManager: ConcurrencyManager; + private readonly autoLoopCoordinator: AutoLoopCoordinator; + private readonly worktreeResolver: WorktreeResolver; + private readonly featureStateManager: FeatureStateManager; + private readonly featureLoader: FeatureLoader; + + constructor( + events: EventEmitter, + settingsService: SettingsService | null, + featureLoader: FeatureLoader = new FeatureLoader() + ) { + this.featureLoader = featureLoader; + this.eventBus = new TypedEventBus(events); + this.worktreeResolver = new WorktreeResolver(); + this.concurrencyManager = new ConcurrencyManager((p) => + this.worktreeResolver.getCurrentBranch(p) + ); + this.featureStateManager = new FeatureStateManager(events, featureLoader); + + // Create AutoLoopCoordinator with callbacks + // These callbacks use placeholders since GlobalAutoModeService doesn't execute features + // Feature execution is done via facades + this.autoLoopCoordinator = new AutoLoopCoordinator( + this.eventBus, + this.concurrencyManager, + settingsService, + // executeFeatureFn - not used by global service, routes handle execution + async () => { + throw new Error('executeFeatureFn not available in GlobalAutoModeService'); + }, + // getBacklogFeaturesFn + (pPath, branchName) => + featureLoader + .getAll(pPath) + .then((features) => + features.filter( + (f) => + (f.status === 'backlog' || f.status === 'ready') && + (branchName === null + ? !f.branchName || f.branchName === 'main' + : f.branchName === branchName) + ) + ), + // saveExecutionStateFn - placeholder + async () => {}, + // clearExecutionStateFn - placeholder + async () => {}, + // resetStuckFeaturesFn + (pPath) => this.featureStateManager.resetStuckFeatures(pPath), + // isFeatureDoneFn + (feature) => + feature.status === 'completed' || + feature.status === 'verified' || + feature.status === 'waiting_approval', + // isFeatureRunningFn + (featureId) => this.concurrencyManager.isRunning(featureId) + ); + } + + /** + * Get the shared services for use by facades. + * This allows facades to share state with the global service. + */ + getSharedServices(): SharedServices { + return { + eventBus: this.eventBus, + concurrencyManager: this.concurrencyManager, + autoLoopCoordinator: this.autoLoopCoordinator, + worktreeResolver: this.worktreeResolver, + }; + } + + // =========================================================================== + // GLOBAL STATUS (3 methods) + // =========================================================================== + + /** + * Get global status (all projects combined) + */ + getStatus(): AutoModeStatus { + const allRunning = this.concurrencyManager.getAllRunning(); + return { + isRunning: allRunning.length > 0, + runningFeatures: allRunning.map((rf) => rf.featureId), + runningCount: allRunning.length, + }; + } + + /** + * Get all active auto loop projects (unique project paths) + */ + getActiveAutoLoopProjects(): string[] { + return this.autoLoopCoordinator.getActiveProjects(); + } + + /** + * Get all active auto loop worktrees + */ + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + return this.autoLoopCoordinator.getActiveWorktrees(); + } + + // =========================================================================== + // RUNNING AGENTS (1 method) + // =========================================================================== + + /** + * Get detailed info about all running agents + */ + async getRunningAgents(): Promise { + 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; + } + } catch { + // Silently ignore + } + + 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; + } + + // =========================================================================== + // LIFECYCLE (1 method) + // =========================================================================== + + /** + * Mark all running features as interrupted. + * Called during graceful shutdown. + * + * @param reason - Optional reason for the interruption + */ + async markAllRunningFeaturesInterrupted(reason?: string): Promise { + 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'}` + ); + } + } +} diff --git a/apps/server/src/services/auto-mode/index.ts b/apps/server/src/services/auto-mode/index.ts index 7dbfc93e..9e150ad2 100644 --- a/apps/server/src/services/auto-mode/index.ts +++ b/apps/server/src/services/auto-mode/index.ts @@ -2,13 +2,16 @@ * Auto Mode Service Module * * Entry point for auto-mode functionality. Exports: - * - AutoModeServiceFacade: Clean facade for auto-mode operations + * - GlobalAutoModeService: Global operations that span all projects + * - AutoModeServiceFacade: Per-project facade for auto-mode operations * - createAutoModeFacade: Convenience factory function * - Types for route consumption */ -// Main facade export +// Main exports +export { GlobalAutoModeService } from './global-service.js'; export { AutoModeServiceFacade } from './facade.js'; +export { AutoModeServiceCompat } from './compat.js'; // Convenience factory function import { AutoModeServiceFacade } from './facade.js'; @@ -49,11 +52,13 @@ export function createAutoModeFacade( // Type exports from types.ts export type { FacadeOptions, + SharedServices, AutoModeStatus, ProjectAutoModeStatus, WorktreeCapacityInfo, RunningAgentInfo, OrphanedFeatureInfo, + GlobalAutoModeOperations, } from './types.js'; // Re-export types from extracted services for route convenience diff --git a/apps/server/src/services/auto-mode/types.ts b/apps/server/src/services/auto-mode/types.ts index 18df4f7d..b831daba 100644 --- a/apps/server/src/services/auto-mode/types.ts +++ b/apps/server/src/services/auto-mode/types.ts @@ -11,6 +11,10 @@ import type { EventEmitter } from '../../lib/events.js'; import type { Feature, ModelProvider } from '@automaker/types'; import type { SettingsService } from '../settings-service.js'; import type { FeatureLoader } from '../feature-loader.js'; +import type { ConcurrencyManager } from '../concurrency-manager.js'; +import type { AutoLoopCoordinator } from '../auto-loop-coordinator.js'; +import type { WorktreeResolver } from '../worktree-resolver.js'; +import type { TypedEventBus } from '../typed-event-bus.js'; // Re-export types from extracted services for route consumption export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js'; @@ -25,6 +29,20 @@ export type { PlanApprovalResult, ResolveApprovalResult } from '../plan-approval export type { ExecutionState } from '../recovery-service.js'; +/** + * Shared services that can be passed to facades to enable state sharing + */ +export interface SharedServices { + /** TypedEventBus for typed event emission */ + eventBus: TypedEventBus; + /** ConcurrencyManager for tracking running features across all projects */ + concurrencyManager: ConcurrencyManager; + /** AutoLoopCoordinator for managing auto loop state across all projects */ + autoLoopCoordinator: AutoLoopCoordinator; + /** WorktreeResolver for git worktree operations */ + worktreeResolver: WorktreeResolver; +} + /** * Options for creating an AutoModeServiceFacade instance */ @@ -35,6 +53,8 @@ export interface FacadeOptions { settingsService?: SettingsService | null; /** FeatureLoader for loading feature data (optional, defaults to new FeatureLoader()) */ featureLoader?: FeatureLoader; + /** Shared services for state sharing across facades (optional) */ + sharedServices?: SharedServices; } /** @@ -89,3 +109,20 @@ export interface OrphanedFeatureInfo { feature: Feature; missingBranch: string; } + +/** + * Interface describing global auto-mode operations (not project-specific). + * Used by routes that need global state access. + */ +export interface GlobalAutoModeOperations { + /** Get global status (all projects combined) */ + getStatus(): AutoModeStatus; + /** Get all active auto loop projects (unique project paths) */ + getActiveAutoLoopProjects(): string[]; + /** Get all active auto loop worktrees */ + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }>; + /** Get detailed info about all running agents */ + getRunningAgents(): Promise; + /** Mark all running features as interrupted (for graceful shutdown) */ + markAllRunningFeaturesInterrupted(reason?: string): Promise; +} diff --git a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts deleted file mode 100644 index e0ab4c4d..00000000 --- a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts +++ /dev/null @@ -1,694 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AutoModeService } from '@/services/auto-mode-service.js'; -import { ProviderFactory } from '@/providers/provider-factory.js'; -import { FeatureLoader } from '@/services/feature-loader.js'; -import { - createTestGitRepo, - createTestFeature, - listBranches, - listWorktrees, - branchExists, - worktreeExists, - type TestRepo, -} from '../helpers/git-test-repo.js'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { exec } from 'child_process'; -import { promisify } from 'util'; - -const execAsync = promisify(exec); - -vi.mock('@/providers/provider-factory.js'); - -describe('auto-mode-service.ts (integration)', () => { - let service: AutoModeService; - let testRepo: TestRepo; - let featureLoader: FeatureLoader; - const mockEvents = { - subscribe: vi.fn(), - emit: vi.fn(), - }; - - beforeEach(async () => { - vi.clearAllMocks(); - service = new AutoModeService(mockEvents as any); - featureLoader = new FeatureLoader(); - testRepo = await createTestGitRepo(); - }); - - afterEach(async () => { - // Stop any running auto loops - await service.stopAutoLoop(); - - // Cleanup test repo - if (testRepo) { - await testRepo.cleanup(); - } - }); - - describe('worktree operations', () => { - it('should use existing git worktree for feature', async () => { - const branchName = 'feature/test-feature-1'; - - // Create a test feature with branchName set - await createTestFeature(testRepo.path, 'test-feature-1', { - id: 'test-feature-1', - category: 'test', - description: 'Test feature', - status: 'pending', - branchName: branchName, - }); - - // Create worktree before executing (worktrees are now created when features are added/edited) - const worktreesDir = path.join(testRepo.path, '.worktrees'); - const worktreePath = path.join(worktreesDir, 'test-feature-1'); - await fs.mkdir(worktreesDir, { recursive: true }); - await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, { - cwd: testRepo.path, - }); - - // Mock provider to complete quickly - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Feature implemented' }], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Execute feature with worktrees enabled - await service.executeFeature( - testRepo.path, - 'test-feature-1', - true, // useWorktrees - false // isAutoMode - ); - - // Verify branch exists (was created when worktree was created) - const branches = await listBranches(testRepo.path); - expect(branches).toContain(branchName); - - // Verify worktree exists and is being used - // The service should have found and used the worktree (check via logs) - // We can verify the worktree exists by checking git worktree list - const worktrees = await listWorktrees(testRepo.path); - expect(worktrees.length).toBeGreaterThan(0); - // Verify that at least one worktree path contains our feature ID - const worktreePathsMatch = worktrees.some( - (wt) => wt.includes('test-feature-1') || wt.includes('.worktrees') - ); - expect(worktreePathsMatch).toBe(true); - - // Note: Worktrees are not automatically cleaned up by the service - // This is expected behavior - manual cleanup is required - }, 30000); - - it('should handle error gracefully', async () => { - await createTestFeature(testRepo.path, 'test-feature-error', { - id: 'test-feature-error', - category: 'test', - description: 'Test feature that errors', - status: 'pending', - }); - - // Mock provider that throws error - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - throw new Error('Provider error'); - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Execute feature (should handle error) - await service.executeFeature(testRepo.path, 'test-feature-error', true, false); - - // Verify feature status was updated to backlog (error status) - const feature = await featureLoader.get(testRepo.path, 'test-feature-error'); - expect(feature?.status).toBe('backlog'); - }, 30000); - - it('should work without worktrees', async () => { - await createTestFeature(testRepo.path, 'test-no-worktree', { - id: 'test-no-worktree', - category: 'test', - description: 'Test without worktree', - status: 'pending', - skipTests: true, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Execute without worktrees - await service.executeFeature( - testRepo.path, - 'test-no-worktree', - false, // useWorktrees = false - false - ); - - // Feature should be updated successfully - const feature = await featureLoader.get(testRepo.path, 'test-no-worktree'); - expect(feature?.status).toBe('waiting_approval'); - }, 30000); - }); - - describe('feature execution', () => { - it('should execute feature and update status', async () => { - await createTestFeature(testRepo.path, 'feature-exec-1', { - id: 'feature-exec-1', - category: 'ui', - description: 'Execute this feature', - status: 'pending', - skipTests: true, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Implemented the feature' }], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature( - testRepo.path, - 'feature-exec-1', - false, // Don't use worktrees so agent output is saved to main project - false - ); - - // Check feature status was updated - const feature = await featureLoader.get(testRepo.path, 'feature-exec-1'); - expect(feature?.status).toBe('waiting_approval'); - - // Check agent output was saved - const agentOutput = await featureLoader.getAgentOutput(testRepo.path, 'feature-exec-1'); - expect(agentOutput).toBeTruthy(); - expect(agentOutput).toContain('Implemented the feature'); - }, 30000); - - it('should handle feature not found', async () => { - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Try to execute non-existent feature - await service.executeFeature(testRepo.path, 'nonexistent-feature', true, false); - - // Should emit error event - expect(mockEvents.emit).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - featureId: 'nonexistent-feature', - error: expect.stringContaining('not found'), - }) - ); - }, 30000); - - it('should prevent duplicate feature execution', async () => { - await createTestFeature(testRepo.path, 'feature-dup', { - id: 'feature-dup', - category: 'test', - description: 'Duplicate test', - status: 'pending', - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - // Simulate slow execution - await new Promise((resolve) => setTimeout(resolve, 500)); - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Start first execution - const promise1 = service.executeFeature(testRepo.path, 'feature-dup', false, false); - - // Try to start second execution (should throw) - await expect( - service.executeFeature(testRepo.path, 'feature-dup', false, false) - ).rejects.toThrow('already running'); - - await promise1; - }, 30000); - - it('should use feature-specific model', async () => { - await createTestFeature(testRepo.path, 'feature-model', { - id: 'feature-model', - category: 'test', - description: 'Model test', - status: 'pending', - model: 'claude-sonnet-4-20250514', - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature(testRepo.path, 'feature-model', false, false); - - // Should have used claude-sonnet-4-20250514 - expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-20250514'); - }, 30000); - }); - - describe('auto loop', () => { - it('should start and stop auto loop', async () => { - const startPromise = service.startAutoLoop(testRepo.path, 2); - - // Give it time to start - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Stop the loop - const runningCount = await service.stopAutoLoop(); - - expect(runningCount).toBe(0); - await startPromise.catch(() => {}); // Cleanup - }, 10000); - - it('should process pending features in auto loop', async () => { - // Create multiple pending features - await createTestFeature(testRepo.path, 'auto-1', { - id: 'auto-1', - category: 'test', - description: 'Auto feature 1', - status: 'pending', - skipTests: true, - }); - - await createTestFeature(testRepo.path, 'auto-2', { - id: 'auto-2', - category: 'test', - description: 'Auto feature 2', - status: 'pending', - skipTests: true, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Start auto loop - const startPromise = service.startAutoLoop(testRepo.path, 2); - - // Wait for features to be processed - await new Promise((resolve) => setTimeout(resolve, 3000)); - - // Stop the loop - await service.stopAutoLoop(); - await startPromise.catch(() => {}); - - // Check that features were updated - const feature1 = await featureLoader.get(testRepo.path, 'auto-1'); - const feature2 = await featureLoader.get(testRepo.path, 'auto-2'); - - // At least one should have been processed - const processedCount = [feature1, feature2].filter( - (f) => f?.status === 'waiting_approval' || f?.status === 'in_progress' - ).length; - - expect(processedCount).toBeGreaterThan(0); - }, 15000); - - it('should respect max concurrency', async () => { - // Create 5 features - for (let i = 1; i <= 5; i++) { - await createTestFeature(testRepo.path, `concurrent-${i}`, { - id: `concurrent-${i}`, - category: 'test', - description: `Concurrent feature ${i}`, - status: 'pending', - }); - } - - let concurrentCount = 0; - let maxConcurrent = 0; - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - concurrentCount++; - maxConcurrent = Math.max(maxConcurrent, concurrentCount); - - // Simulate work - await new Promise((resolve) => setTimeout(resolve, 500)); - - concurrentCount--; - - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Start with max concurrency of 2 - const startPromise = service.startAutoLoop(testRepo.path, 2); - - // Wait for some features to be processed - await new Promise((resolve) => setTimeout(resolve, 3000)); - - await service.stopAutoLoop(); - await startPromise.catch(() => {}); - - // Max concurrent should not exceed 2 - expect(maxConcurrent).toBeLessThanOrEqual(2); - }, 15000); - - it('should emit auto mode events', async () => { - const startPromise = service.startAutoLoop(testRepo.path, 1); - - // Wait for start event - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Check start event was emitted - const startEvent = mockEvents.emit.mock.calls.find((call) => - call[1]?.message?.includes('Auto mode started') - ); - expect(startEvent).toBeTruthy(); - - await service.stopAutoLoop(); - await startPromise.catch(() => {}); - - // Check stop event was emitted (emitted immediately by stopAutoLoop) - const stopEvent = mockEvents.emit.mock.calls.find( - (call) => - call[1]?.type === 'auto_mode_stopped' || call[1]?.message?.includes('Auto mode stopped') - ); - expect(stopEvent).toBeTruthy(); - }, 10000); - }); - - describe('error handling', () => { - it('should handle provider errors gracefully', async () => { - await createTestFeature(testRepo.path, 'error-feature', { - id: 'error-feature', - category: 'test', - description: 'Error test', - status: 'pending', - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - throw new Error('Provider execution failed'); - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Should not throw - await service.executeFeature(testRepo.path, 'error-feature', true, false); - - // Feature should be marked as backlog (error status) - const feature = await featureLoader.get(testRepo.path, 'error-feature'); - expect(feature?.status).toBe('backlog'); - }, 30000); - - it('should continue auto loop after feature error', async () => { - await createTestFeature(testRepo.path, 'fail-1', { - id: 'fail-1', - category: 'test', - description: 'Will fail', - status: 'pending', - }); - - await createTestFeature(testRepo.path, 'success-1', { - id: 'success-1', - category: 'test', - description: 'Will succeed', - status: 'pending', - }); - - let callCount = 0; - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - callCount++; - if (callCount === 1) { - throw new Error('First feature fails'); - } - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - const startPromise = service.startAutoLoop(testRepo.path, 1); - - // Wait for both features to be attempted - await new Promise((resolve) => setTimeout(resolve, 5000)); - - await service.stopAutoLoop(); - await startPromise.catch(() => {}); - - // Both features should have been attempted - expect(callCount).toBeGreaterThanOrEqual(1); - }, 15000); - }); - - describe('planning mode', () => { - it('should execute feature with skip planning mode', async () => { - await createTestFeature(testRepo.path, 'skip-plan-feature', { - id: 'skip-plan-feature', - category: 'test', - description: 'Feature with skip planning', - status: 'pending', - planningMode: 'skip', - skipTests: true, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Feature implemented' }], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature(testRepo.path, 'skip-plan-feature', false, false); - - const feature = await featureLoader.get(testRepo.path, 'skip-plan-feature'); - expect(feature?.status).toBe('waiting_approval'); - }, 30000); - - it('should execute feature with lite planning mode without approval', async () => { - await createTestFeature(testRepo.path, 'lite-plan-feature', { - id: 'lite-plan-feature', - category: 'test', - description: 'Feature with lite planning', - status: 'pending', - planningMode: 'lite', - requirePlanApproval: false, - skipTests: true, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'text', - text: '[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented', - }, - ], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature(testRepo.path, 'lite-plan-feature', false, false); - - const feature = await featureLoader.get(testRepo.path, 'lite-plan-feature'); - expect(feature?.status).toBe('waiting_approval'); - }, 30000); - - it('should emit planning_started event for spec mode', async () => { - await createTestFeature(testRepo.path, 'spec-plan-feature', { - id: 'spec-plan-feature', - category: 'test', - description: 'Feature with spec planning', - status: 'pending', - planningMode: 'spec', - requirePlanApproval: false, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { type: 'text', text: 'Spec generated\n\n[SPEC_GENERATED] Review the spec.' }, - ], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature(testRepo.path, 'spec-plan-feature', false, false); - - // Check planning_started event was emitted - const planningEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.mode === 'spec'); - expect(planningEvent).toBeTruthy(); - }, 30000); - - it('should handle feature with full planning mode', async () => { - await createTestFeature(testRepo.path, 'full-plan-feature', { - id: 'full-plan-feature', - category: 'test', - description: 'Feature with full planning', - status: 'pending', - planningMode: 'full', - requirePlanApproval: false, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { type: 'text', text: 'Full spec with phases\n\n[SPEC_GENERATED] Review.' }, - ], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature(testRepo.path, 'full-plan-feature', false, false); - - // Check planning_started event was emitted with full mode - const planningEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.mode === 'full'); - expect(planningEvent).toBeTruthy(); - }, 30000); - - it('should track pending approval correctly', async () => { - // Initially no pending approvals - expect(service.hasPendingApproval('non-existent')).toBe(false); - }); - - it('should cancel pending approval gracefully', () => { - // Should not throw when cancelling non-existent approval - expect(() => service.cancelPlanApproval('non-existent')).not.toThrow(); - }); - - it('should resolve approval with error for non-existent feature', async () => { - const result = await service.resolvePlanApproval( - 'non-existent', - true, - undefined, - undefined, - undefined - ); - expect(result.success).toBe(false); - expect(result.error).toContain('No pending approval'); - }); - }); -}); diff --git a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts deleted file mode 100644 index 7c3f908a..00000000 --- a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AutoModeService } from '@/services/auto-mode-service.js'; - -describe('auto-mode-service.ts - Planning Mode', () => { - let service: AutoModeService; - const mockEvents = { - subscribe: vi.fn(), - emit: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - service = new AutoModeService(mockEvents as any); - }); - - afterEach(async () => { - // Clean up any running processes - await service.stopAutoLoop().catch(() => {}); - }); - - describe('getPlanningPromptPrefix', () => { - // Access private method through any cast for testing - const getPlanningPromptPrefix = (svc: any, feature: any) => { - return svc.getPlanningPromptPrefix(feature); - }; - - it('should return empty string for skip mode', async () => { - const feature = { id: 'test', planningMode: 'skip' as const }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toBe(''); - }); - - it('should return empty string when planningMode is undefined', async () => { - const feature = { id: 'test' }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toBe(''); - }); - - it('should return lite prompt for lite mode without approval', async () => { - const feature = { - id: 'test', - planningMode: 'lite' as const, - requirePlanApproval: false, - }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('Planning Phase (Lite Mode)'); - expect(result).toContain('[PLAN_GENERATED]'); - expect(result).toContain('Feature Request'); - }); - - it('should return lite_with_approval prompt for lite mode with approval', async () => { - const feature = { - id: 'test', - planningMode: 'lite' as const, - requirePlanApproval: true, - }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('## Planning Phase (Lite Mode)'); - expect(result).toContain('[SPEC_GENERATED]'); - expect(result).toContain( - 'DO NOT proceed with implementation until you receive explicit approval' - ); - }); - - it('should return spec prompt for spec mode', async () => { - const feature = { - id: 'test', - planningMode: 'spec' as const, - }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('## Specification Phase (Spec Mode)'); - expect(result).toContain('```tasks'); - expect(result).toContain('T001'); - expect(result).toContain('[TASK_START]'); - expect(result).toContain('[TASK_COMPLETE]'); - }); - - it('should return full prompt for full mode', async () => { - const feature = { - id: 'test', - planningMode: 'full' as const, - }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('## Full Specification Phase (Full SDD Mode)'); - expect(result).toContain('Phase 1: Foundation'); - expect(result).toContain('Phase 2: Core Implementation'); - expect(result).toContain('Phase 3: Integration & Testing'); - }); - - it('should include the separator and Feature Request header', async () => { - const feature = { - id: 'test', - planningMode: 'spec' as const, - }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('---'); - expect(result).toContain('## Feature Request'); - }); - - it('should instruct agent to NOT output exploration text', async () => { - const modes = ['lite', 'spec', 'full'] as const; - for (const mode of modes) { - const feature = { id: 'test', planningMode: mode }; - const result = await getPlanningPromptPrefix(service, feature); - // All modes should have the IMPORTANT instruction about not outputting exploration text - expect(result).toContain('IMPORTANT: Do NOT output exploration text'); - expect(result).toContain('Silently analyze the codebase first'); - } - }); - }); - - describe('parseTasksFromSpec (via module)', () => { - // We need to test the module-level function - // Import it directly for testing - it('should parse tasks from a valid tasks block', async () => { - // This tests the internal logic through integration - // The function is module-level, so we verify behavior through the service - const specContent = ` -## Specification - -\`\`\`tasks -- [ ] T001: Create user model | File: src/models/user.ts -- [ ] T002: Add API endpoint | File: src/routes/users.ts -- [ ] T003: Write unit tests | File: tests/user.test.ts -\`\`\` -`; - // Since parseTasksFromSpec is a module-level function, - // we verify its behavior indirectly through plan parsing - expect(specContent).toContain('T001'); - expect(specContent).toContain('T002'); - expect(specContent).toContain('T003'); - }); - - it('should handle tasks block with phases', () => { - const specContent = ` -\`\`\`tasks -## Phase 1: Setup -- [ ] T001: Initialize project | File: package.json -- [ ] T002: Configure TypeScript | File: tsconfig.json - -## Phase 2: Implementation -- [ ] T003: Create main module | File: src/index.ts -\`\`\` -`; - expect(specContent).toContain('Phase 1'); - expect(specContent).toContain('Phase 2'); - expect(specContent).toContain('T001'); - expect(specContent).toContain('T003'); - }); - }); - - describe('plan approval flow', () => { - it('should track pending approvals correctly', () => { - expect(service.hasPendingApproval('test-feature')).toBe(false); - }); - - it('should allow cancelling non-existent approval without error', () => { - expect(() => service.cancelPlanApproval('non-existent')).not.toThrow(); - }); - - it('should return running features count after stop', async () => { - const count = await service.stopAutoLoop(); - expect(count).toBe(0); - }); - }); - - describe('resolvePlanApproval', () => { - it('should return error when no pending approval exists', async () => { - const result = await service.resolvePlanApproval( - 'non-existent-feature', - true, - undefined, - undefined, - undefined - ); - expect(result.success).toBe(false); - expect(result.error).toContain('No pending approval'); - }); - - it('should handle approval with edited plan', async () => { - // Without a pending approval, this should fail gracefully - const result = await service.resolvePlanApproval( - 'test-feature', - true, - 'Edited plan content', - undefined, - undefined - ); - expect(result.success).toBe(false); - }); - - it('should handle rejection with feedback', async () => { - const result = await service.resolvePlanApproval( - 'test-feature', - false, - undefined, - 'Please add more details', - undefined - ); - expect(result.success).toBe(false); - }); - }); - - describe('buildFeaturePrompt', () => { - const defaultTaskExecutionPrompts = { - implementationInstructions: 'Test implementation instructions', - playwrightVerificationInstructions: 'Test playwright instructions', - }; - - const buildFeaturePrompt = ( - svc: any, - feature: any, - taskExecutionPrompts = defaultTaskExecutionPrompts - ) => { - return svc.buildFeaturePrompt(feature, taskExecutionPrompts); - }; - - it('should include feature ID and description', () => { - const feature = { - id: 'feat-123', - description: 'Add user authentication', - }; - const result = buildFeaturePrompt(service, feature); - expect(result).toContain('feat-123'); - expect(result).toContain('Add user authentication'); - }); - - it('should include specification when present', () => { - const feature = { - id: 'feat-123', - description: 'Test feature', - spec: 'Detailed specification here', - }; - const result = buildFeaturePrompt(service, feature); - expect(result).toContain('Specification:'); - expect(result).toContain('Detailed specification here'); - }); - - it('should include image paths when present', () => { - const feature = { - id: 'feat-123', - description: 'Test feature', - imagePaths: [ - { path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' }, - '/tmp/image2.jpg', - ], - }; - const result = buildFeaturePrompt(service, feature); - expect(result).toContain('Context Images Attached'); - expect(result).toContain('image1.png'); - expect(result).toContain('/tmp/image2.jpg'); - }); - - it('should include implementation instructions', () => { - const feature = { - id: 'feat-123', - description: 'Test feature', - }; - const result = buildFeaturePrompt(service, feature); - // The prompt should include the implementation instructions passed to it - expect(result).toContain('Test implementation instructions'); - expect(result).toContain('Test playwright instructions'); - }); - }); - - describe('extractTitleFromDescription', () => { - const extractTitle = (svc: any, description: string) => { - return svc.extractTitleFromDescription(description); - }; - - it("should return 'Untitled Feature' for empty description", () => { - expect(extractTitle(service, '')).toBe('Untitled Feature'); - expect(extractTitle(service, ' ')).toBe('Untitled Feature'); - }); - - it('should return first line if under 60 characters', () => { - const description = 'Add user login\nWith email validation'; - expect(extractTitle(service, description)).toBe('Add user login'); - }); - - it('should truncate long first lines to 60 characters', () => { - const description = - 'This is a very long feature description that exceeds the sixty character limit significantly'; - const result = extractTitle(service, description); - expect(result.length).toBe(60); - expect(result).toContain('...'); - }); - }); - - describe('PLANNING_PROMPTS structure', () => { - const getPlanningPromptPrefix = (svc: any, feature: any) => { - return svc.getPlanningPromptPrefix(feature); - }; - - it('should have all required planning modes', async () => { - const modes = ['lite', 'spec', 'full'] as const; - for (const mode of modes) { - const feature = { id: 'test', planningMode: mode }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result.length).toBeGreaterThan(100); - } - }); - - it('lite prompt should include correct structure', async () => { - const feature = { id: 'test', planningMode: 'lite' as const }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('Goal'); - expect(result).toContain('Approach'); - expect(result).toContain('Files to Touch'); - expect(result).toContain('Tasks'); - expect(result).toContain('Risks'); - }); - - it('spec prompt should include task format instructions', async () => { - const feature = { id: 'test', planningMode: 'spec' as const }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('Problem'); - expect(result).toContain('Solution'); - expect(result).toContain('Acceptance Criteria'); - expect(result).toContain('GIVEN-WHEN-THEN'); - expect(result).toContain('Implementation Tasks'); - expect(result).toContain('Verification'); - }); - - it('full prompt should include phases', async () => { - const feature = { id: 'test', planningMode: 'full' as const }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('1. **Problem Statement**'); - expect(result).toContain('2. **User Story**'); - expect(result).toContain('4. **Technical Context**'); - expect(result).toContain('5. **Non-Goals**'); - expect(result).toContain('Phase 1'); - expect(result).toContain('Phase 2'); - expect(result).toContain('Phase 3'); - }); - }); - - describe('status management', () => { - it('should report correct status', () => { - const status = service.getStatus(); - expect(status.runningFeatures).toEqual([]); - expect(status.isRunning).toBe(false); - expect(status.runningCount).toBe(0); - }); - }); -}); diff --git a/apps/server/tests/unit/services/auto-mode-service.test.ts b/apps/server/tests/unit/services/auto-mode-service.test.ts deleted file mode 100644 index b6d73ffc..00000000 --- a/apps/server/tests/unit/services/auto-mode-service.test.ts +++ /dev/null @@ -1,752 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { AutoModeService } from '@/services/auto-mode-service.js'; -import type { Feature } from '@automaker/types'; - -describe('auto-mode-service.ts', () => { - let service: AutoModeService; - const mockEvents = { - subscribe: vi.fn(), - emit: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - service = new AutoModeService(mockEvents as any); - }); - - describe('constructor', () => { - it('should initialize with event emitter', () => { - expect(service).toBeDefined(); - }); - }); - - describe('startAutoLoop', () => { - it('should throw if auto mode is already running', async () => { - // Start first loop - const promise1 = service.startAutoLoop('/test/project', 3); - - // Try to start second loop - await expect(service.startAutoLoop('/test/project', 3)).rejects.toThrow('already running'); - - // Cleanup - await service.stopAutoLoop(); - await promise1.catch(() => {}); - }); - - it('should emit auto mode start event', async () => { - const promise = service.startAutoLoop('/test/project', 3); - - // Give it time to emit the event - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(mockEvents.emit).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - message: expect.stringContaining('Auto mode started'), - }) - ); - - // Cleanup - await service.stopAutoLoop(); - await promise.catch(() => {}); - }); - }); - - describe('stopAutoLoop', () => { - it('should stop the auto loop', async () => { - const promise = service.startAutoLoop('/test/project', 3); - - const runningCount = await service.stopAutoLoop(); - - expect(runningCount).toBe(0); - await promise.catch(() => {}); - }); - - it('should return 0 when not running', async () => { - const runningCount = await service.stopAutoLoop(); - expect(runningCount).toBe(0); - }); - }); - - describe('getRunningAgents', () => { - // Helper to access private concurrencyManager - const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager; - - // Helper to add a running feature via concurrencyManager - const addRunningFeature = ( - svc: AutoModeService, - feature: { featureId: string; projectPath: string; isAutoMode: boolean } - ) => { - getConcurrencyManager(svc).acquire(feature); - }; - - // Helper to get the featureLoader and mock its get method - const mockFeatureLoaderGet = (svc: AutoModeService, mockFn: ReturnType) => { - (svc as any).featureLoader = { get: mockFn }; - }; - - it('should return empty array when no agents are running', async () => { - const result = await service.getRunningAgents(); - - expect(result).toEqual([]); - }); - - it('should return running agents with basic info when feature data is not available', async () => { - // Arrange: Add a running feature via concurrencyManager - addRunningFeature(service, { - featureId: 'feature-123', - projectPath: '/test/project/path', - isAutoMode: true, - }); - - // Mock featureLoader.get to return null (feature not found) - const getMock = vi.fn().mockResolvedValue(null); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - featureId: 'feature-123', - projectPath: '/test/project/path', - projectName: 'path', - isAutoMode: true, - title: undefined, - description: undefined, - }); - }); - - it('should return running agents with title and description when feature data is available', async () => { - // Arrange - addRunningFeature(service, { - featureId: 'feature-456', - projectPath: '/home/user/my-project', - isAutoMode: false, - }); - - const mockFeature: Partial = { - id: 'feature-456', - title: 'Implement user authentication', - description: 'Add login and signup functionality', - category: 'auth', - }; - - const getMock = vi.fn().mockResolvedValue(mockFeature); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - featureId: 'feature-456', - projectPath: '/home/user/my-project', - projectName: 'my-project', - isAutoMode: false, - title: 'Implement user authentication', - description: 'Add login and signup functionality', - }); - expect(getMock).toHaveBeenCalledWith('/home/user/my-project', 'feature-456'); - }); - - it('should handle multiple running agents', async () => { - // Arrange - addRunningFeature(service, { - featureId: 'feature-1', - projectPath: '/project-a', - isAutoMode: true, - }); - addRunningFeature(service, { - featureId: 'feature-2', - projectPath: '/project-b', - isAutoMode: false, - }); - - const getMock = vi - .fn() - .mockResolvedValueOnce({ - id: 'feature-1', - title: 'Feature One', - description: 'Description one', - }) - .mockResolvedValueOnce({ - id: 'feature-2', - title: 'Feature Two', - description: 'Description two', - }); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result).toHaveLength(2); - expect(getMock).toHaveBeenCalledTimes(2); - }); - - it('should silently handle errors when fetching feature data', async () => { - // Arrange - addRunningFeature(service, { - featureId: 'feature-error', - projectPath: '/project-error', - isAutoMode: true, - }); - - const getMock = vi.fn().mockRejectedValue(new Error('Database connection failed')); - mockFeatureLoaderGet(service, getMock); - - // Act - should not throw - const result = await service.getRunningAgents(); - - // Assert - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - featureId: 'feature-error', - projectPath: '/project-error', - projectName: 'project-error', - isAutoMode: true, - title: undefined, - description: undefined, - }); - }); - - it('should handle feature with title but no description', async () => { - // Arrange - addRunningFeature(service, { - featureId: 'feature-title-only', - projectPath: '/project', - isAutoMode: false, - }); - - const getMock = vi.fn().mockResolvedValue({ - id: 'feature-title-only', - title: 'Only Title', - // description is undefined - }); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result[0].title).toBe('Only Title'); - expect(result[0].description).toBeUndefined(); - }); - - it('should handle feature with description but no title', async () => { - // Arrange - addRunningFeature(service, { - featureId: 'feature-desc-only', - projectPath: '/project', - isAutoMode: false, - }); - - const getMock = vi.fn().mockResolvedValue({ - id: 'feature-desc-only', - description: 'Only description, no title', - // title is undefined - }); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result[0].title).toBeUndefined(); - expect(result[0].description).toBe('Only description, no title'); - }); - - it('should extract projectName from nested paths correctly', async () => { - // Arrange - addRunningFeature(service, { - featureId: 'feature-nested', - projectPath: '/home/user/workspace/projects/my-awesome-project', - isAutoMode: true, - }); - - const getMock = vi.fn().mockResolvedValue(null); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result[0].projectName).toBe('my-awesome-project'); - }); - - it('should fetch feature data in parallel for multiple agents', async () => { - // Arrange: Add multiple running features - for (let i = 1; i <= 5; i++) { - addRunningFeature(service, { - featureId: `feature-${i}`, - projectPath: `/project-${i}`, - isAutoMode: i % 2 === 0, - }); - } - - // Track call order - const callOrder: string[] = []; - const getMock = vi.fn().mockImplementation(async (projectPath: string, featureId: string) => { - callOrder.push(featureId); - // Simulate async delay to verify parallel execution - await new Promise((resolve) => setTimeout(resolve, 10)); - return { id: featureId, title: `Title for ${featureId}` }; - }); - mockFeatureLoaderGet(service, getMock); - - // Act - const startTime = Date.now(); - const result = await service.getRunningAgents(); - const duration = Date.now() - startTime; - - // Assert - expect(result).toHaveLength(5); - expect(getMock).toHaveBeenCalledTimes(5); - // If executed in parallel, total time should be ~10ms (one batch) - // If sequential, it would be ~50ms (5 * 10ms) - // Allow some buffer for execution overhead - expect(duration).toBeLessThan(40); - }); - }); - - describe('detectOrphanedFeatures', () => { - // Helper to mock featureLoader.getAll - const mockFeatureLoaderGetAll = (svc: AutoModeService, mockFn: ReturnType) => { - (svc as any).featureLoader = { getAll: mockFn }; - }; - - // Helper to mock getExistingBranches - const mockGetExistingBranches = (svc: AutoModeService, branches: string[]) => { - (svc as any).getExistingBranches = vi.fn().mockResolvedValue(new Set(branches)); - }; - - it('should return empty array when no features have branch names', async () => { - const getAllMock = vi.fn().mockResolvedValue([ - { id: 'f1', title: 'Feature 1', description: 'desc', category: 'test' }, - { id: 'f2', title: 'Feature 2', description: 'desc', category: 'test' }, - ] satisfies Feature[]); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main', 'develop']); - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toEqual([]); - }); - - it('should return empty array when all feature branches exist', async () => { - const getAllMock = vi.fn().mockResolvedValue([ - { - id: 'f1', - title: 'Feature 1', - description: 'desc', - category: 'test', - branchName: 'feature-1', - }, - { - id: 'f2', - title: 'Feature 2', - description: 'desc', - category: 'test', - branchName: 'feature-2', - }, - ] satisfies Feature[]); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main', 'feature-1', 'feature-2']); - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toEqual([]); - }); - - it('should detect orphaned features with missing branches', async () => { - const features: Feature[] = [ - { - id: 'f1', - title: 'Feature 1', - description: 'desc', - category: 'test', - branchName: 'feature-1', - }, - { - id: 'f2', - title: 'Feature 2', - description: 'desc', - category: 'test', - branchName: 'deleted-branch', - }, - { id: 'f3', title: 'Feature 3', description: 'desc', category: 'test' }, // No branch - ]; - const getAllMock = vi.fn().mockResolvedValue(features); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main', 'feature-1']); // deleted-branch not in list - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toHaveLength(1); - expect(result[0].feature.id).toBe('f2'); - expect(result[0].missingBranch).toBe('deleted-branch'); - }); - - it('should detect multiple orphaned features', async () => { - const features: Feature[] = [ - { - id: 'f1', - title: 'Feature 1', - description: 'desc', - category: 'test', - branchName: 'orphan-1', - }, - { - id: 'f2', - title: 'Feature 2', - description: 'desc', - category: 'test', - branchName: 'orphan-2', - }, - { - id: 'f3', - title: 'Feature 3', - description: 'desc', - category: 'test', - branchName: 'valid-branch', - }, - ]; - const getAllMock = vi.fn().mockResolvedValue(features); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main', 'valid-branch']); - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toHaveLength(2); - expect(result.map((r) => r.feature.id)).toContain('f1'); - expect(result.map((r) => r.feature.id)).toContain('f2'); - }); - - it('should return empty array when getAll throws error', async () => { - const getAllMock = vi.fn().mockRejectedValue(new Error('Failed to load features')); - mockFeatureLoaderGetAll(service, getAllMock); - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toEqual([]); - }); - - it('should ignore empty branchName strings', async () => { - const features: Feature[] = [ - { id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: '' }, - { id: 'f2', title: 'Feature 2', description: 'desc', category: 'test', branchName: ' ' }, - ]; - const getAllMock = vi.fn().mockResolvedValue(features); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main']); - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toEqual([]); - }); - - it('should skip features whose branchName matches the primary branch', async () => { - const features: Feature[] = [ - { id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: 'main' }, - { - id: 'f2', - title: 'Feature 2', - description: 'desc', - category: 'test', - branchName: 'orphaned', - }, - ]; - const getAllMock = vi.fn().mockResolvedValue(features); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main', 'develop']); - // Mock getCurrentBranch to return 'main' - (service as any).getCurrentBranch = vi.fn().mockResolvedValue('main'); - - const result = await service.detectOrphanedFeatures('/test/project'); - - // Only f2 should be orphaned (orphaned branch doesn't exist) - expect(result).toHaveLength(1); - expect(result[0].feature.id).toBe('f2'); - }); - }); - - describe('markFeatureInterrupted', () => { - // Helper to mock featureStateManager.markFeatureInterrupted - const mockFeatureStateManagerMarkInterrupted = ( - svc: AutoModeService, - mockFn: ReturnType - ) => { - (svc as any).featureStateManager.markFeatureInterrupted = mockFn; - }; - - it('should delegate to featureStateManager.markFeatureInterrupted', async () => { - const markMock = vi.fn().mockResolvedValue(undefined); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - await service.markFeatureInterrupted('/test/project', 'feature-123'); - - expect(markMock).toHaveBeenCalledWith('/test/project', 'feature-123', undefined); - }); - - it('should pass reason to featureStateManager.markFeatureInterrupted', async () => { - const markMock = vi.fn().mockResolvedValue(undefined); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown'); - - expect(markMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'server shutdown'); - }); - - it('should propagate errors from featureStateManager', async () => { - const markMock = vi.fn().mockRejectedValue(new Error('Update failed')); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - await expect(service.markFeatureInterrupted('/test/project', 'feature-123')).rejects.toThrow( - 'Update failed' - ); - }); - }); - - describe('markAllRunningFeaturesInterrupted', () => { - // Helper to access private concurrencyManager - const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager; - - // Helper to add a running feature via concurrencyManager - const addRunningFeatureForInterrupt = ( - svc: AutoModeService, - feature: { featureId: string; projectPath: string; isAutoMode: boolean } - ) => { - getConcurrencyManager(svc).acquire(feature); - }; - - // Helper to mock featureStateManager.markFeatureInterrupted - const mockFeatureStateManagerMarkInterrupted = ( - svc: AutoModeService, - mockFn: ReturnType - ) => { - (svc as any).featureStateManager.markFeatureInterrupted = mockFn; - }; - - it('should do nothing when no features are running', async () => { - const markMock = vi.fn().mockResolvedValue(undefined); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - await service.markAllRunningFeaturesInterrupted(); - - expect(markMock).not.toHaveBeenCalled(); - }); - - it('should mark a single running feature as interrupted', async () => { - addRunningFeatureForInterrupt(service, { - featureId: 'feature-1', - projectPath: '/project/path', - isAutoMode: true, - }); - - const markMock = vi.fn().mockResolvedValue(undefined); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - await service.markAllRunningFeaturesInterrupted(); - - expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'server shutdown'); - }); - - it('should mark multiple running features as interrupted', async () => { - addRunningFeatureForInterrupt(service, { - featureId: 'feature-1', - projectPath: '/project-a', - isAutoMode: true, - }); - addRunningFeatureForInterrupt(service, { - featureId: 'feature-2', - projectPath: '/project-b', - isAutoMode: false, - }); - addRunningFeatureForInterrupt(service, { - featureId: 'feature-3', - projectPath: '/project-a', - isAutoMode: true, - }); - - const markMock = vi.fn().mockResolvedValue(undefined); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - await service.markAllRunningFeaturesInterrupted(); - - expect(markMock).toHaveBeenCalledTimes(3); - expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'server shutdown'); - expect(markMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'server shutdown'); - expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-3', 'server shutdown'); - }); - - it('should mark features in parallel', async () => { - for (let i = 1; i <= 5; i++) { - addRunningFeatureForInterrupt(service, { - featureId: `feature-${i}`, - projectPath: `/project-${i}`, - isAutoMode: true, - }); - } - - const callOrder: string[] = []; - const markMock = vi - .fn() - .mockImplementation(async (_path: string, featureId: string, _reason?: string) => { - callOrder.push(featureId); - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - const startTime = Date.now(); - await service.markAllRunningFeaturesInterrupted(); - const duration = Date.now() - startTime; - - expect(markMock).toHaveBeenCalledTimes(5); - // If executed in parallel, total time should be ~10ms - // If sequential, it would be ~50ms (5 * 10ms) - expect(duration).toBeLessThan(40); - }); - - it('should continue marking other features when one fails', async () => { - addRunningFeatureForInterrupt(service, { - featureId: 'feature-1', - projectPath: '/project-a', - isAutoMode: true, - }); - addRunningFeatureForInterrupt(service, { - featureId: 'feature-2', - projectPath: '/project-b', - isAutoMode: false, - }); - - const markMock = vi - .fn() - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('Failed to update')); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - // Should not throw even though one feature failed - await expect(service.markAllRunningFeaturesInterrupted()).resolves.not.toThrow(); - - expect(markMock).toHaveBeenCalledTimes(2); - }); - - it('should use provided reason', async () => { - addRunningFeatureForInterrupt(service, { - featureId: 'feature-1', - projectPath: '/project/path', - isAutoMode: true, - }); - - const markMock = vi.fn().mockResolvedValue(undefined); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - await service.markAllRunningFeaturesInterrupted('manual stop'); - - expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'manual stop'); - }); - - it('should use default reason when none provided', async () => { - addRunningFeatureForInterrupt(service, { - featureId: 'feature-1', - projectPath: '/project/path', - isAutoMode: true, - }); - - const markMock = vi.fn().mockResolvedValue(undefined); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - await service.markAllRunningFeaturesInterrupted(); - - expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'server shutdown'); - }); - - it('should call markFeatureInterrupted for all running features (pipeline status handling delegated to FeatureStateManager)', async () => { - addRunningFeatureForInterrupt(service, { - featureId: 'feature-1', - projectPath: '/project-a', - isAutoMode: true, - }); - addRunningFeatureForInterrupt(service, { - featureId: 'feature-2', - projectPath: '/project-b', - isAutoMode: false, - }); - addRunningFeatureForInterrupt(service, { - featureId: 'feature-3', - projectPath: '/project-c', - isAutoMode: true, - }); - - // FeatureStateManager handles pipeline status preservation internally - const markMock = vi.fn().mockResolvedValue(undefined); - mockFeatureStateManagerMarkInterrupted(service, markMock); - - await service.markAllRunningFeaturesInterrupted(); - - // All running features should have markFeatureInterrupted called - // (FeatureStateManager internally preserves pipeline statuses) - expect(markMock).toHaveBeenCalledTimes(3); - expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'server shutdown'); - expect(markMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'server shutdown'); - expect(markMock).toHaveBeenCalledWith('/project-c', 'feature-3', 'server shutdown'); - }); - }); - - describe('isFeatureRunning', () => { - // Helper to access private concurrencyManager - const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager; - - // Helper to add a running feature via concurrencyManager - const addRunningFeatureForIsRunning = ( - svc: AutoModeService, - feature: { featureId: string; projectPath: string; isAutoMode: boolean } - ) => { - getConcurrencyManager(svc).acquire(feature); - }; - - it('should return false when no features are running', () => { - expect(service.isFeatureRunning('feature-123')).toBe(false); - }); - - it('should return true when the feature is running', () => { - addRunningFeatureForIsRunning(service, { - featureId: 'feature-123', - projectPath: '/project/path', - isAutoMode: true, - }); - - expect(service.isFeatureRunning('feature-123')).toBe(true); - }); - - it('should return false for non-running feature when others are running', () => { - addRunningFeatureForIsRunning(service, { - featureId: 'feature-other', - projectPath: '/project/path', - isAutoMode: true, - }); - - expect(service.isFeatureRunning('feature-123')).toBe(false); - }); - - it('should correctly track multiple running features', () => { - addRunningFeatureForIsRunning(service, { - featureId: 'feature-1', - projectPath: '/project-a', - isAutoMode: true, - }); - addRunningFeatureForIsRunning(service, { - featureId: 'feature-2', - projectPath: '/project-b', - isAutoMode: false, - }); - - expect(service.isFeatureRunning('feature-1')).toBe(true); - expect(service.isFeatureRunning('feature-2')).toBe(true); - expect(service.isFeatureRunning('feature-3')).toBe(false); - }); - }); -});