mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
Merge remote-tracking branch 'upstream/v0.15.0rc' into feat/duplicate-festure
# Conflicts: # apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx # apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx # apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts
This commit is contained in:
@@ -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';
|
||||
@@ -321,7 +321,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);
|
||||
|
||||
@@ -204,7 +204,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
model,
|
||||
cwd,
|
||||
systemPrompt,
|
||||
maxTurns = 20,
|
||||
maxTurns = 100,
|
||||
allowedTools,
|
||||
abortController,
|
||||
conversationHistory,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Auto Mode routes - HTTP API for autonomous feature implementation
|
||||
*
|
||||
* Uses the AutoModeService for real feature execution with Claude Agent SDK
|
||||
* 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 { 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';
|
||||
@@ -21,7 +22,12 @@ import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
||||
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
||||
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
/**
|
||||
* Create auto-mode routes.
|
||||
*
|
||||
* @param autoModeService - AutoModeServiceCompat instance
|
||||
*/
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Router {
|
||||
const router = Router();
|
||||
|
||||
// Auto loop control routes
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.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) {
|
||||
export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.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) {
|
||||
export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { featureId, approved, editedPlan, feedback, projectPath } = req.body as {
|
||||
@@ -17,7 +17,7 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
approved: boolean;
|
||||
editedPlan?: string;
|
||||
feedback?: string;
|
||||
projectPath?: string;
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
if (!featureId) {
|
||||
@@ -36,6 +36,14 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: We no longer check hasPendingApproval here because resolvePlanApproval
|
||||
// can handle recovery when pending approval is not in Map but feature has planSpec.status='generated'
|
||||
// This supports cases where the server restarted while waiting for approval
|
||||
@@ -48,11 +56,11 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
|
||||
// Resolve the pending approval (with recovery support)
|
||||
const result = await autoModeService.resolvePlanApproval(
|
||||
projectPath,
|
||||
featureId,
|
||||
approved,
|
||||
editedPlan,
|
||||
feedback,
|
||||
projectPath
|
||||
feedback
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
|
||||
export function createCommitFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, worktreePath } = req.body as {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createContextExistsHandler(autoModeService: AutoModeService) {
|
||||
export function createContextExistsHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.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) {
|
||||
export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, prompt, imagePaths, useWorktrees } = req.body as {
|
||||
@@ -30,16 +30,12 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
|
||||
// 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 });
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.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) {
|
||||
export function createResumeFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
|
||||
const logger = createLogger('ResumeInterrupted');
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ResumeInterruptedRequest {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function createResumeInterruptedHandler(autoModeService: AutoModeService) {
|
||||
export function createResumeInterruptedHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
const { projectPath } = req.body as ResumeInterruptedRequest;
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createResumeInterruptedHandler(autoModeService: AutoModeService)
|
||||
|
||||
try {
|
||||
await autoModeService.resumeInterruptedFeatures(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Resume check completed',
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.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) {
|
||||
export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||
@@ -50,10 +50,6 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
.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 });
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.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) {
|
||||
export function createStartHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName, maxConcurrency } = req.body as {
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
/**
|
||||
* Create status handler.
|
||||
*/
|
||||
export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName } = req.body as {
|
||||
@@ -21,7 +24,8 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
if (projectPath) {
|
||||
// Normalize branchName: undefined becomes null
|
||||
const normalizedBranchName = branchName ?? null;
|
||||
const projectStatus = autoModeService.getStatusForProject(
|
||||
|
||||
const projectStatus = await autoModeService.getStatusForProject(
|
||||
projectPath,
|
||||
normalizedBranchName
|
||||
);
|
||||
@@ -38,7 +42,7 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to global status for backward compatibility
|
||||
// Global status for backward compatibility
|
||||
const status = autoModeService.getStatus();
|
||||
const activeProjects = autoModeService.getActiveAutoLoopProjects();
|
||||
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopFeatureHandler(autoModeService: AutoModeService) {
|
||||
export function createStopFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { featureId } = req.body as { featureId: string };
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.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 createStopHandler(autoModeService: AutoModeService) {
|
||||
export function createStopHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName } = req.body as {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createVerifyFeatureHandler(autoModeService: AutoModeService) {
|
||||
export function createVerifyFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
|
||||
@@ -219,18 +219,21 @@ export function createEnhanceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the model - use provider resolved model, passed model, or default to sonnet
|
||||
const resolvedModel =
|
||||
providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
// Resolve the model for API call.
|
||||
// CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7")
|
||||
// to the API, NOT the resolved Claude model - otherwise we get "model not found"
|
||||
const modelForApi = claudeCompatibleProvider
|
||||
? model
|
||||
: providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
|
||||
logger.debug(`Using model: ${resolvedModel}`);
|
||||
logger.debug(`Using model: ${modelForApi}`);
|
||||
|
||||
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||
// The system prompt is combined with user prompt since some providers
|
||||
// don't have a separate system prompt concept
|
||||
const result = await simpleQuery({
|
||||
prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
|
||||
model: resolvedModel,
|
||||
model: modelForApi,
|
||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
|
||||
@@ -5,7 +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 { 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';
|
||||
@@ -24,7 +24,7 @@ export function createFeaturesRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
settingsService?: SettingsService,
|
||||
events?: EventEmitter,
|
||||
autoModeService?: AutoModeService
|
||||
autoModeService?: AutoModeServiceCompat
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('FeaturesListRoute');
|
||||
|
||||
export function createListHandler(featureLoader: FeatureLoader, autoModeService?: AutoModeService) {
|
||||
export function createListHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService?: AutoModeServiceCompat
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
@@ -30,18 +33,23 @@ export function createListHandler(featureLoader: FeatureLoader, autoModeService?
|
||||
// We don't await this to keep the list response fast
|
||||
// Note: detectOrphanedFeatures handles errors internally and always resolves
|
||||
if (autoModeService) {
|
||||
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
|
||||
if (orphanedFeatures.length > 0) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
|
||||
);
|
||||
for (const { feature, missingBranch } of orphanedFeatures) {
|
||||
autoModeService
|
||||
.detectOrphanedFeatures(projectPath)
|
||||
.then((orphanedFeatures) => {
|
||||
if (orphanedFeatures.length > 0) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
|
||||
`[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`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(`[ProjectLoad] Orphan detection failed for ${projectPath}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, features });
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
isOpencodeModel,
|
||||
supportsStructuredOutput,
|
||||
} from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { resolvePhaseModel, resolveModelString } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../../lib/json-extractor.js';
|
||||
import { writeValidation } from '../../../lib/validation-storage.js';
|
||||
import { streamingQuery } from '../../../providers/simple-query-service.js';
|
||||
@@ -188,8 +188,12 @@ ${basePrompt}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Use provider resolved model if available, otherwise use original model
|
||||
const effectiveModel = providerResolvedModel || (model as string);
|
||||
// CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7")
|
||||
// to the API, NOT the resolved Claude model - otherwise we get "model not found"
|
||||
// For standard Claude models, resolve aliases (e.g., 'opus' -> 'claude-opus-4-20250514')
|
||||
const effectiveModel = claudeCompatibleProvider
|
||||
? (model as string)
|
||||
: providerResolvedModel || resolveModelString(model as string);
|
||||
logger.info(`Using model: ${effectiveModel}`);
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.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
|
||||
): Router {
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
|
||||
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 {
|
||||
AutoModeServiceCompat,
|
||||
RunningAgentInfo,
|
||||
ProjectAutoModeStatus,
|
||||
} from '../../../services/auto-mode/index.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import type {
|
||||
@@ -147,7 +151,7 @@ function getLastActivityAt(features: Feature[]): string | undefined {
|
||||
|
||||
export function createOverviewHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
autoModeService: AutoModeServiceCompat,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
) {
|
||||
@@ -158,7 +162,7 @@ export function createOverviewHandler(
|
||||
const projectRefs: ProjectRef[] = settings.projects || [];
|
||||
|
||||
// Get all running agents once to count live running features per project
|
||||
const allRunningAgents = await autoModeService.getRunningAgents();
|
||||
const allRunningAgents: RunningAgentInfo[] = await autoModeService.getRunningAgents();
|
||||
|
||||
// Collect project statuses in parallel
|
||||
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<ProjectStatus> => {
|
||||
@@ -169,7 +173,10 @@ export function createOverviewHandler(
|
||||
const totalFeatures = features.length;
|
||||
|
||||
// Get auto-mode status for this project (main worktree, branchName = null)
|
||||
const autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null);
|
||||
const autoModeStatus: ProjectAutoModeStatus = await autoModeService.getStatusForProject(
|
||||
projectRef.path,
|
||||
null
|
||||
);
|
||||
const isAutoModeRunning = autoModeStatus.isAutoLoopRunning;
|
||||
|
||||
// Count live running features for this project (across all branches)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.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';
|
||||
|
||||
export function createIndexHandler(autoModeService: AutoModeService) {
|
||||
export function createIndexHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const runningAgents = [...(await autoModeService.getRunningAgents())];
|
||||
|
||||
const backlogPlanStatus = getBacklogPlanStatus();
|
||||
const backlogPlanDetails = getRunningDetails();
|
||||
|
||||
|
||||
83
apps/server/src/services/agent-executor-types.ts
Normal file
83
apps/server/src/services/agent-executor-types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* AgentExecutor Types - Type definitions for agent execution
|
||||
*/
|
||||
|
||||
import type {
|
||||
PlanningMode,
|
||||
ThinkingLevel,
|
||||
ParsedTask,
|
||||
ClaudeCompatibleProvider,
|
||||
Credentials,
|
||||
} from '@automaker/types';
|
||||
import type { BaseProvider } from '../providers/base-provider.js';
|
||||
|
||||
export interface AgentExecutionOptions {
|
||||
workDir: string;
|
||||
featureId: string;
|
||||
prompt: string;
|
||||
projectPath: string;
|
||||
abortController: AbortController;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
previousContent?: string;
|
||||
systemPrompt?: string;
|
||||
autoLoadClaudeMd?: boolean;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
branchName?: string | null;
|
||||
credentials?: Credentials;
|
||||
claudeCompatibleProvider?: ClaudeCompatibleProvider;
|
||||
mcpServers?: Record<string, unknown>;
|
||||
sdkOptions?: {
|
||||
maxTurns?: number;
|
||||
allowedTools?: string[];
|
||||
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string };
|
||||
settingSources?: Array<'user' | 'project' | 'local'>;
|
||||
};
|
||||
provider: BaseProvider;
|
||||
effectiveBareModel: string;
|
||||
specAlreadyDetected?: boolean;
|
||||
existingApprovedPlanContent?: string;
|
||||
persistedTasks?: ParsedTask[];
|
||||
}
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
responseText: string;
|
||||
specDetected: boolean;
|
||||
tasksCompleted: number;
|
||||
aborted: boolean;
|
||||
}
|
||||
|
||||
export type WaitForApprovalFn = (
|
||||
featureId: string,
|
||||
projectPath: string
|
||||
) => Promise<{ approved: boolean; feedback?: string; editedPlan?: string }>;
|
||||
|
||||
export type SaveFeatureSummaryFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
summary: string
|
||||
) => Promise<void>;
|
||||
|
||||
export type UpdateFeatureSummaryFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
summary: string
|
||||
) => Promise<void>;
|
||||
|
||||
export type BuildTaskPromptFn = (
|
||||
task: ParsedTask,
|
||||
allTasks: ParsedTask[],
|
||||
taskIndex: number,
|
||||
planContent: string,
|
||||
taskPromptTemplate: string,
|
||||
userFeedback?: string
|
||||
) => string;
|
||||
|
||||
export interface AgentExecutorCallbacks {
|
||||
waitForApproval: WaitForApprovalFn;
|
||||
saveFeatureSummary: SaveFeatureSummaryFn;
|
||||
updateFeatureSummary: UpdateFeatureSummaryFn;
|
||||
buildTaskPrompt: BuildTaskPromptFn;
|
||||
}
|
||||
689
apps/server/src/services/agent-executor.ts
Normal file
689
apps/server/src/services/agent-executor.ts
Normal file
@@ -0,0 +1,689 @@
|
||||
/**
|
||||
* AgentExecutor - Core agent execution engine with streaming support
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { ExecuteOptions, ParsedTask } from '@automaker/types';
|
||||
import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { TypedEventBus } from './typed-event-bus.js';
|
||||
import { FeatureStateManager } from './feature-state-manager.js';
|
||||
import { PlanApprovalService } from './plan-approval-service.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import {
|
||||
parseTasksFromSpec,
|
||||
detectTaskStartMarker,
|
||||
detectTaskCompleteMarker,
|
||||
detectPhaseCompleteMarker,
|
||||
detectSpecFallback,
|
||||
extractSummary,
|
||||
} from './spec-parser.js';
|
||||
import { getPromptCustomization } from '../lib/settings-helpers.js';
|
||||
import type {
|
||||
AgentExecutionOptions,
|
||||
AgentExecutionResult,
|
||||
AgentExecutorCallbacks,
|
||||
} from './agent-executor-types.js';
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
export type {
|
||||
AgentExecutionOptions,
|
||||
AgentExecutionResult,
|
||||
WaitForApprovalFn,
|
||||
SaveFeatureSummaryFn,
|
||||
UpdateFeatureSummaryFn,
|
||||
BuildTaskPromptFn,
|
||||
} from './agent-executor-types.js';
|
||||
|
||||
const logger = createLogger('AgentExecutor');
|
||||
|
||||
export class AgentExecutor {
|
||||
private static readonly WRITE_DEBOUNCE_MS = 500;
|
||||
private static readonly STREAM_HEARTBEAT_MS = 15_000;
|
||||
|
||||
constructor(
|
||||
private eventBus: TypedEventBus,
|
||||
private featureStateManager: FeatureStateManager,
|
||||
private planApprovalService: PlanApprovalService,
|
||||
private settingsService: SettingsService | null = null
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
options: AgentExecutionOptions,
|
||||
callbacks: AgentExecutorCallbacks
|
||||
): Promise<AgentExecutionResult> {
|
||||
const {
|
||||
workDir,
|
||||
featureId,
|
||||
projectPath,
|
||||
abortController,
|
||||
branchName = null,
|
||||
provider,
|
||||
effectiveBareModel,
|
||||
previousContent,
|
||||
planningMode = 'skip',
|
||||
requirePlanApproval = false,
|
||||
specAlreadyDetected = false,
|
||||
existingApprovedPlanContent,
|
||||
persistedTasks,
|
||||
credentials,
|
||||
claudeCompatibleProvider,
|
||||
mcpServers,
|
||||
sdkOptions,
|
||||
} = options;
|
||||
const { content: promptContent } = await buildPromptWithImages(
|
||||
options.prompt,
|
||||
options.imagePaths,
|
||||
workDir,
|
||||
false
|
||||
);
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: promptContent,
|
||||
model: effectiveBareModel,
|
||||
maxTurns: sdkOptions?.maxTurns,
|
||||
cwd: workDir,
|
||||
allowedTools: sdkOptions?.allowedTools as string[] | undefined,
|
||||
abortController,
|
||||
systemPrompt: sdkOptions?.systemPrompt,
|
||||
settingSources: sdkOptions?.settingSources,
|
||||
mcpServers:
|
||||
mcpServers && Object.keys(mcpServers).length > 0
|
||||
? (mcpServers as Record<string, { command: string }>)
|
||||
: undefined,
|
||||
thinkingLevel: options.thinkingLevel,
|
||||
credentials,
|
||||
claudeCompatibleProvider,
|
||||
};
|
||||
const featureDirForOutput = getFeatureDir(projectPath, featureId);
|
||||
const outputPath = path.join(featureDirForOutput, 'agent-output.md');
|
||||
const rawOutputPath = path.join(featureDirForOutput, 'raw-output.jsonl');
|
||||
const enableRawOutput =
|
||||
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
|
||||
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1';
|
||||
let responseText = previousContent
|
||||
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
|
||||
: '';
|
||||
let specDetected = specAlreadyDetected,
|
||||
tasksCompleted = 0,
|
||||
aborted = false;
|
||||
let writeTimeout: ReturnType<typeof setTimeout> | null = null,
|
||||
rawOutputLines: string[] = [],
|
||||
rawWriteTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const writeToFile = async (): Promise<void> => {
|
||||
try {
|
||||
await secureFs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await secureFs.writeFile(outputPath, responseText);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to write agent output for ${featureId}:`, error);
|
||||
}
|
||||
};
|
||||
const scheduleWrite = (): void => {
|
||||
if (writeTimeout) clearTimeout(writeTimeout);
|
||||
writeTimeout = setTimeout(() => writeToFile(), AgentExecutor.WRITE_DEBOUNCE_MS);
|
||||
};
|
||||
const appendRawEvent = (event: unknown): void => {
|
||||
if (!enableRawOutput) return;
|
||||
try {
|
||||
rawOutputLines.push(JSON.stringify({ timestamp: new Date().toISOString(), event }));
|
||||
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
|
||||
rawWriteTimeout = setTimeout(async () => {
|
||||
try {
|
||||
await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true });
|
||||
await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n');
|
||||
rawOutputLines = [];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, AgentExecutor.WRITE_DEBOUNCE_MS);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const streamStartTime = Date.now();
|
||||
let receivedAnyStreamMessage = false;
|
||||
const streamHeartbeat = setInterval(() => {
|
||||
if (!receivedAnyStreamMessage)
|
||||
logger.info(
|
||||
`Waiting for first model response for feature ${featureId} (${Math.round((Date.now() - streamStartTime) / 1000)}s elapsed)...`
|
||||
);
|
||||
}, AgentExecutor.STREAM_HEARTBEAT_MS);
|
||||
const planningModeRequiresApproval =
|
||||
planningMode === 'spec' ||
|
||||
planningMode === 'full' ||
|
||||
(planningMode === 'lite' && requirePlanApproval);
|
||||
const requiresApproval = planningModeRequiresApproval && requirePlanApproval;
|
||||
|
||||
if (existingApprovedPlanContent && persistedTasks && persistedTasks.length > 0) {
|
||||
const result = await this.executeTasksLoop(
|
||||
options,
|
||||
persistedTasks,
|
||||
existingApprovedPlanContent,
|
||||
responseText,
|
||||
scheduleWrite,
|
||||
callbacks
|
||||
);
|
||||
clearInterval(streamHeartbeat);
|
||||
if (writeTimeout) clearTimeout(writeTimeout);
|
||||
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
|
||||
await writeToFile();
|
||||
return {
|
||||
responseText: result.responseText,
|
||||
specDetected: true,
|
||||
tasksCompleted: result.tasksCompleted,
|
||||
aborted: result.aborted,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`Starting stream for feature ${featureId}...`);
|
||||
|
||||
try {
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
streamLoop: for await (const msg of stream) {
|
||||
receivedAnyStreamMessage = true;
|
||||
appendRawEvent(msg);
|
||||
if (abortController.signal.aborted) {
|
||||
aborted = true;
|
||||
throw new Error('Feature execution aborted');
|
||||
}
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
const newText = block.text || '';
|
||||
if (!newText) continue;
|
||||
if (responseText.length > 0 && newText.length > 0) {
|
||||
const endsWithSentence = /[.!?:]\s*$/.test(responseText),
|
||||
endsWithNewline = /\n\s*$/.test(responseText);
|
||||
if (
|
||||
!endsWithNewline &&
|
||||
(endsWithSentence || /^[\n#\-*>]/.test(newText)) &&
|
||||
!/[a-zA-Z0-9]/.test(responseText.slice(-1))
|
||||
)
|
||||
responseText += '\n\n';
|
||||
}
|
||||
responseText += newText;
|
||||
// Check for authentication errors using provider-agnostic utility
|
||||
if (block.text && isAuthenticationError(block.text))
|
||||
throw new Error(
|
||||
'Authentication failed: Invalid or expired API key. Please check your API key configuration or re-authenticate with your provider.'
|
||||
);
|
||||
scheduleWrite();
|
||||
const hasExplicitMarker = responseText.includes('[SPEC_GENERATED]'),
|
||||
hasFallbackSpec = !hasExplicitMarker && detectSpecFallback(responseText);
|
||||
if (
|
||||
planningModeRequiresApproval &&
|
||||
!specDetected &&
|
||||
(hasExplicitMarker || hasFallbackSpec)
|
||||
) {
|
||||
specDetected = true;
|
||||
const planContent = hasExplicitMarker
|
||||
? responseText.substring(0, responseText.indexOf('[SPEC_GENERATED]')).trim()
|
||||
: responseText.trim();
|
||||
if (!hasExplicitMarker)
|
||||
logger.info(`Using fallback spec detection for feature ${featureId}`);
|
||||
const result = await this.handleSpecGenerated(
|
||||
options,
|
||||
planContent,
|
||||
responseText,
|
||||
requiresApproval,
|
||||
scheduleWrite,
|
||||
callbacks
|
||||
);
|
||||
responseText = result.responseText;
|
||||
tasksCompleted = result.tasksCompleted;
|
||||
break streamLoop;
|
||||
}
|
||||
if (!specDetected)
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
branchName,
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_tool', {
|
||||
featureId,
|
||||
branchName,
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
if (responseText.length > 0 && !responseText.endsWith('\n')) responseText += '\n';
|
||||
responseText += `\n🔧 Tool: ${block.name}\n`;
|
||||
if (block.input) responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`;
|
||||
scheduleWrite();
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'error') {
|
||||
throw new Error(msg.error || 'Unknown error');
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
|
||||
}
|
||||
await writeToFile();
|
||||
if (enableRawOutput && rawOutputLines.length > 0) {
|
||||
try {
|
||||
await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true });
|
||||
await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearInterval(streamHeartbeat);
|
||||
if (writeTimeout) clearTimeout(writeTimeout);
|
||||
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
|
||||
}
|
||||
return { responseText, specDetected, tasksCompleted, aborted };
|
||||
}
|
||||
|
||||
private async executeTasksLoop(
|
||||
options: AgentExecutionOptions,
|
||||
tasks: ParsedTask[],
|
||||
planContent: string,
|
||||
initialResponseText: string,
|
||||
scheduleWrite: () => void,
|
||||
callbacks: AgentExecutorCallbacks,
|
||||
userFeedback?: string
|
||||
): Promise<{ responseText: string; tasksCompleted: number; aborted: boolean }> {
|
||||
const {
|
||||
featureId,
|
||||
projectPath,
|
||||
abortController,
|
||||
branchName = null,
|
||||
provider,
|
||||
sdkOptions,
|
||||
} = options;
|
||||
logger.info(`Starting task execution for feature ${featureId} with ${tasks.length} tasks`);
|
||||
const taskPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
||||
let responseText = initialResponseText,
|
||||
tasksCompleted = 0;
|
||||
|
||||
for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) {
|
||||
const task = tasks[taskIndex];
|
||||
if (task.status === 'completed') {
|
||||
tasksCompleted++;
|
||||
continue;
|
||||
}
|
||||
if (abortController.signal.aborted) return { responseText, tasksCompleted, aborted: true };
|
||||
await this.featureStateManager.updateTaskStatus(
|
||||
projectPath,
|
||||
featureId,
|
||||
task.id,
|
||||
'in_progress'
|
||||
);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_task_started', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName,
|
||||
taskId: task.id,
|
||||
taskDescription: task.description,
|
||||
taskIndex,
|
||||
tasksTotal: tasks.length,
|
||||
});
|
||||
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
|
||||
currentTaskId: task.id,
|
||||
});
|
||||
const taskPrompt = callbacks.buildTaskPrompt(
|
||||
task,
|
||||
tasks,
|
||||
taskIndex,
|
||||
planContent,
|
||||
taskPrompts.taskExecution.taskPromptTemplate,
|
||||
userFeedback
|
||||
);
|
||||
const taskStream = provider.executeQuery(
|
||||
this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 100, 100))
|
||||
);
|
||||
let taskOutput = '',
|
||||
taskStartDetected = false,
|
||||
taskCompleteDetected = false;
|
||||
|
||||
for await (const msg of taskStream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const b of msg.message.content) {
|
||||
if (b.type === 'text') {
|
||||
const text = b.text || '';
|
||||
taskOutput += text;
|
||||
responseText += text;
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
branchName,
|
||||
content: text,
|
||||
});
|
||||
scheduleWrite();
|
||||
if (!taskStartDetected) {
|
||||
const sid = detectTaskStartMarker(taskOutput);
|
||||
if (sid) {
|
||||
taskStartDetected = true;
|
||||
await this.featureStateManager.updateTaskStatus(
|
||||
projectPath,
|
||||
featureId,
|
||||
sid,
|
||||
'in_progress'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!taskCompleteDetected) {
|
||||
const cid = detectTaskCompleteMarker(taskOutput);
|
||||
if (cid) {
|
||||
taskCompleteDetected = true;
|
||||
await this.featureStateManager.updateTaskStatus(
|
||||
projectPath,
|
||||
featureId,
|
||||
cid,
|
||||
'completed'
|
||||
);
|
||||
}
|
||||
}
|
||||
const pn = detectPhaseCompleteMarker(text);
|
||||
if (pn !== null)
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName,
|
||||
phaseNumber: pn,
|
||||
});
|
||||
} else if (b.type === 'tool_use')
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_tool', {
|
||||
featureId,
|
||||
branchName,
|
||||
tool: b.name,
|
||||
input: b.input,
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'error')
|
||||
throw new Error(msg.error || `Error during task ${task.id}`);
|
||||
else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
taskOutput += msg.result || '';
|
||||
responseText += msg.result || '';
|
||||
}
|
||||
}
|
||||
if (!taskCompleteDetected)
|
||||
await this.featureStateManager.updateTaskStatus(
|
||||
projectPath,
|
||||
featureId,
|
||||
task.id,
|
||||
'completed'
|
||||
);
|
||||
tasksCompleted = taskIndex + 1;
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_task_complete', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName,
|
||||
taskId: task.id,
|
||||
tasksCompleted,
|
||||
tasksTotal: tasks.length,
|
||||
});
|
||||
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
|
||||
tasksCompleted,
|
||||
});
|
||||
if (task.phase) {
|
||||
const next = tasks[taskIndex + 1];
|
||||
if (!next || next.phase !== task.phase) {
|
||||
const m = task.phase.match(/Phase\s*(\d+)/i);
|
||||
if (m)
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName,
|
||||
phaseNumber: parseInt(m[1], 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const summary = extractSummary(responseText);
|
||||
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
|
||||
return { responseText, tasksCompleted, aborted: false };
|
||||
}
|
||||
|
||||
private async handleSpecGenerated(
|
||||
options: AgentExecutionOptions,
|
||||
planContent: string,
|
||||
initialResponseText: string,
|
||||
requiresApproval: boolean,
|
||||
scheduleWrite: () => void,
|
||||
callbacks: AgentExecutorCallbacks
|
||||
): Promise<{ responseText: string; tasksCompleted: number }> {
|
||||
const {
|
||||
workDir,
|
||||
featureId,
|
||||
projectPath,
|
||||
abortController,
|
||||
branchName = null,
|
||||
planningMode = 'skip',
|
||||
provider,
|
||||
effectiveBareModel,
|
||||
credentials,
|
||||
claudeCompatibleProvider,
|
||||
mcpServers,
|
||||
sdkOptions,
|
||||
} = options;
|
||||
let responseText = initialResponseText,
|
||||
parsedTasks = parseTasksFromSpec(planContent);
|
||||
logger.info(`Parsed ${parsedTasks.length} tasks from spec for feature ${featureId}`);
|
||||
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
|
||||
status: 'generated',
|
||||
content: planContent,
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
reviewedByUser: false,
|
||||
tasks: parsedTasks,
|
||||
tasksTotal: parsedTasks.length,
|
||||
tasksCompleted: 0,
|
||||
});
|
||||
const planSummary = extractSummary(planContent);
|
||||
if (planSummary) await callbacks.updateFeatureSummary(projectPath, featureId, planSummary);
|
||||
let approvedPlanContent = planContent,
|
||||
userFeedback: string | undefined,
|
||||
currentPlanContent = planContent,
|
||||
planVersion = 1;
|
||||
|
||||
if (requiresApproval) {
|
||||
let planApproved = false;
|
||||
while (!planApproved) {
|
||||
logger.info(
|
||||
`Spec v${planVersion} generated for feature ${featureId}, waiting for approval`
|
||||
);
|
||||
this.eventBus.emitAutoModeEvent('plan_approval_required', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName,
|
||||
planContent: currentPlanContent,
|
||||
planningMode,
|
||||
planVersion,
|
||||
});
|
||||
const approvalResult = await callbacks.waitForApproval(featureId, projectPath);
|
||||
if (approvalResult.approved) {
|
||||
planApproved = true;
|
||||
userFeedback = approvalResult.feedback;
|
||||
approvedPlanContent = approvalResult.editedPlan || currentPlanContent;
|
||||
if (approvalResult.editedPlan) {
|
||||
// Re-parse tasks from edited plan to ensure we execute the updated tasks
|
||||
const editedTasks = parseTasksFromSpec(approvalResult.editedPlan);
|
||||
parsedTasks = editedTasks;
|
||||
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
|
||||
content: approvalResult.editedPlan,
|
||||
tasks: editedTasks,
|
||||
tasksTotal: editedTasks.length,
|
||||
tasksCompleted: 0,
|
||||
});
|
||||
}
|
||||
this.eventBus.emitAutoModeEvent('plan_approved', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName,
|
||||
hasEdits: !!approvalResult.editedPlan,
|
||||
planVersion,
|
||||
});
|
||||
} else {
|
||||
const hasFeedback = approvalResult.feedback?.trim().length,
|
||||
hasEdits = approvalResult.editedPlan?.trim().length;
|
||||
if (!hasFeedback && !hasEdits) throw new Error('Plan cancelled by user');
|
||||
planVersion++;
|
||||
this.eventBus.emitAutoModeEvent('plan_revision_requested', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName,
|
||||
feedback: approvalResult.feedback,
|
||||
hasEdits: !!hasEdits,
|
||||
planVersion,
|
||||
});
|
||||
const revPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
||||
const taskEx =
|
||||
planningMode === 'full'
|
||||
? '```tasks\n## Phase 1: Foundation\n- [ ] T001: [Description] | File: [path/to/file]\n```'
|
||||
: '```tasks\n- [ ] T001: [Description] | File: [path/to/file]\n```';
|
||||
let revPrompt = revPrompts.taskExecution.planRevisionTemplate
|
||||
.replace(/\{\{planVersion\}\}/g, String(planVersion - 1))
|
||||
.replace(
|
||||
/\{\{previousPlan\}\}/g,
|
||||
hasEdits ? approvalResult.editedPlan || currentPlanContent : currentPlanContent
|
||||
)
|
||||
.replace(
|
||||
/\{\{userFeedback\}\}/g,
|
||||
approvalResult.feedback || 'Please revise the plan based on the edits above.'
|
||||
)
|
||||
.replace(/\{\{planningMode\}\}/g, planningMode)
|
||||
.replace(/\{\{taskFormatExample\}\}/g, taskEx);
|
||||
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
|
||||
status: 'generating',
|
||||
version: planVersion,
|
||||
});
|
||||
let revText = '';
|
||||
for await (const msg of provider.executeQuery(
|
||||
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? 100)
|
||||
)) {
|
||||
if (msg.type === 'assistant' && msg.message?.content)
|
||||
for (const b of msg.message.content)
|
||||
if (b.type === 'text') {
|
||||
revText += b.text || '';
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
branchName,
|
||||
content: b.text,
|
||||
});
|
||||
}
|
||||
if (msg.type === 'error') throw new Error(msg.error || 'Error during plan revision');
|
||||
if (msg.type === 'result' && msg.subtype === 'success') revText += msg.result || '';
|
||||
}
|
||||
const mi = revText.indexOf('[SPEC_GENERATED]');
|
||||
currentPlanContent = mi > 0 ? revText.substring(0, mi).trim() : revText.trim();
|
||||
const revisedTasks = parseTasksFromSpec(currentPlanContent);
|
||||
if (revisedTasks.length === 0 && (planningMode === 'spec' || planningMode === 'full'))
|
||||
this.eventBus.emitAutoModeEvent('plan_revision_warning', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName,
|
||||
planningMode,
|
||||
warning: 'Revised plan missing tasks block',
|
||||
});
|
||||
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
|
||||
status: 'generated',
|
||||
content: currentPlanContent,
|
||||
version: planVersion,
|
||||
tasks: revisedTasks,
|
||||
tasksTotal: revisedTasks.length,
|
||||
tasksCompleted: 0,
|
||||
});
|
||||
parsedTasks = revisedTasks;
|
||||
responseText += revText;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.eventBus.emitAutoModeEvent('plan_auto_approved', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName,
|
||||
planContent,
|
||||
planningMode,
|
||||
});
|
||||
}
|
||||
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
|
||||
status: 'approved',
|
||||
approvedAt: new Date().toISOString(),
|
||||
reviewedByUser: requiresApproval,
|
||||
});
|
||||
let tasksCompleted = 0;
|
||||
if (parsedTasks.length > 0) {
|
||||
const r = await this.executeTasksLoop(
|
||||
options,
|
||||
parsedTasks,
|
||||
approvedPlanContent,
|
||||
responseText,
|
||||
scheduleWrite,
|
||||
callbacks,
|
||||
userFeedback
|
||||
);
|
||||
responseText = r.responseText;
|
||||
tasksCompleted = r.tasksCompleted;
|
||||
} else {
|
||||
const r = await this.executeSingleAgentContinuation(
|
||||
options,
|
||||
approvedPlanContent,
|
||||
userFeedback,
|
||||
responseText
|
||||
);
|
||||
responseText = r.responseText;
|
||||
}
|
||||
const summary = extractSummary(responseText);
|
||||
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
|
||||
return { responseText, tasksCompleted };
|
||||
}
|
||||
|
||||
private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns?: number) {
|
||||
return {
|
||||
prompt,
|
||||
model: o.effectiveBareModel,
|
||||
maxTurns,
|
||||
cwd: o.workDir,
|
||||
allowedTools: o.sdkOptions?.allowedTools as string[] | undefined,
|
||||
abortController: o.abortController,
|
||||
thinkingLevel: o.thinkingLevel,
|
||||
mcpServers:
|
||||
o.mcpServers && Object.keys(o.mcpServers).length > 0
|
||||
? (o.mcpServers as Record<string, { command: string }>)
|
||||
: undefined,
|
||||
credentials: o.credentials,
|
||||
claudeCompatibleProvider: o.claudeCompatibleProvider,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeSingleAgentContinuation(
|
||||
options: AgentExecutionOptions,
|
||||
planContent: string,
|
||||
userFeedback: string | undefined,
|
||||
initialResponseText: string
|
||||
): Promise<{ responseText: string }> {
|
||||
const { featureId, branchName = null, provider } = options;
|
||||
logger.info(`No parsed tasks, using single-agent execution for feature ${featureId}`);
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
||||
const contPrompt = prompts.taskExecution.continuationAfterApprovalTemplate
|
||||
.replace(/\{\{userFeedback\}\}/g, userFeedback || '')
|
||||
.replace(/\{\{approvedPlan\}\}/g, planContent);
|
||||
let responseText = initialResponseText;
|
||||
for await (const msg of provider.executeQuery(
|
||||
this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns)
|
||||
)) {
|
||||
if (msg.type === 'assistant' && msg.message?.content)
|
||||
for (const b of msg.message.content) {
|
||||
if (b.type === 'text') {
|
||||
responseText += b.text || '';
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
branchName,
|
||||
content: b.text,
|
||||
});
|
||||
} else if (b.type === 'tool_use')
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_tool', {
|
||||
featureId,
|
||||
branchName,
|
||||
tool: b.name,
|
||||
input: b.input,
|
||||
});
|
||||
}
|
||||
else if (msg.type === 'error')
|
||||
throw new Error(msg.error || 'Unknown error during implementation');
|
||||
else if (msg.type === 'result' && msg.subtype === 'success') responseText += msg.result || '';
|
||||
}
|
||||
return { responseText };
|
||||
}
|
||||
}
|
||||
@@ -325,8 +325,9 @@ export class AgentService {
|
||||
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
|
||||
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
|
||||
|
||||
// When using a provider model, use the resolved Claude model (from mapsToClaudeModel)
|
||||
// e.g., "GLM-4.5-Air" -> "claude-haiku-4-5"
|
||||
// When using a custom provider (GLM, MiniMax), use resolved Claude model for SDK config
|
||||
// (thinking level budgets, allowedTools) but we MUST pass the provider's model ID
|
||||
// (e.g. "GLM-4.7") to the API - not "claude-sonnet-4-20250514" which causes "model not found"
|
||||
const modelForSdk = providerResolvedModel || model;
|
||||
const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
|
||||
|
||||
@@ -387,10 +388,18 @@ export class AgentService {
|
||||
}
|
||||
|
||||
// Get provider for this model (with prefix)
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
// When using custom provider (GLM, MiniMax), requestedModel routes to Claude provider
|
||||
const modelForProvider = claudeCompatibleProvider
|
||||
? (requestedModel ?? effectiveModel)
|
||||
: effectiveModel;
|
||||
const provider = ProviderFactory.getProviderForModel(modelForProvider);
|
||||
|
||||
// Strip provider prefix - providers should receive bare model IDs
|
||||
const bareModel = stripProviderPrefix(effectiveModel);
|
||||
// CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7")
|
||||
// to the API, NOT the resolved Claude model - otherwise we get "model not found"
|
||||
const bareModel: string = claudeCompatibleProvider
|
||||
? (requestedModel ?? effectiveModel)
|
||||
: stripProviderPrefix(effectiveModel);
|
||||
|
||||
// Build options for provider
|
||||
const options: ExecuteOptions = {
|
||||
|
||||
426
apps/server/src/services/auto-loop-coordinator.ts
Normal file
426
apps/server/src/services/auto-loop-coordinator.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* AutoLoopCoordinator - Manages the auto-mode loop lifecycle and failure tracking
|
||||
*/
|
||||
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { createLogger, classifyError } from '@automaker/utils';
|
||||
import type { TypedEventBus } from './typed-event-bus.js';
|
||||
import type { ConcurrencyManager } from './concurrency-manager.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AutoLoopCoordinator');
|
||||
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
|
||||
const FAILURE_WINDOW_MS = 60000;
|
||||
|
||||
export interface AutoModeConfig {
|
||||
maxConcurrency: number;
|
||||
useWorktrees: boolean;
|
||||
projectPath: string;
|
||||
branchName: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectAutoLoopState {
|
||||
abortController: AbortController;
|
||||
config: AutoModeConfig;
|
||||
isRunning: boolean;
|
||||
consecutiveFailures: { timestamp: number; error: string }[];
|
||||
pausedDueToFailures: boolean;
|
||||
hasEmittedIdleEvent: boolean;
|
||||
branchName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for a worktree auto-loop instance.
|
||||
*
|
||||
* When branchName is null, this represents the main worktree (uses '__main__' sentinel).
|
||||
* The string 'main' is also normalized to '__main__' for consistency.
|
||||
* Named branches always use their exact name.
|
||||
*/
|
||||
export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
|
||||
const normalizedBranch = branchName === 'main' ? null : branchName;
|
||||
return `${projectPath}::${normalizedBranch ?? '__main__'}`;
|
||||
}
|
||||
|
||||
export type ExecuteFeatureFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees: boolean,
|
||||
isAutoMode: boolean
|
||||
) => Promise<void>;
|
||||
export type LoadPendingFeaturesFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
) => Promise<Feature[]>;
|
||||
export type SaveExecutionStateFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null,
|
||||
maxConcurrency: number
|
||||
) => Promise<void>;
|
||||
export type ClearExecutionStateFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
) => Promise<void>;
|
||||
export type ResetStuckFeaturesFn = (projectPath: string) => Promise<void>;
|
||||
export type IsFeatureFinishedFn = (feature: Feature) => boolean;
|
||||
|
||||
export class AutoLoopCoordinator {
|
||||
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
|
||||
|
||||
constructor(
|
||||
private eventBus: TypedEventBus,
|
||||
private concurrencyManager: ConcurrencyManager,
|
||||
private settingsService: SettingsService | null,
|
||||
private executeFeatureFn: ExecuteFeatureFn,
|
||||
private loadPendingFeaturesFn: LoadPendingFeaturesFn,
|
||||
private saveExecutionStateFn: SaveExecutionStateFn,
|
||||
private clearExecutionStateFn: ClearExecutionStateFn,
|
||||
private resetStuckFeaturesFn: ResetStuckFeaturesFn,
|
||||
private isFeatureFinishedFn: IsFeatureFinishedFn,
|
||||
private isFeatureRunningFn: (featureId: string) => boolean
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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<number> {
|
||||
const resolvedMaxConcurrency = await this.resolveMaxConcurrency(
|
||||
projectPath,
|
||||
branchName,
|
||||
maxConcurrency
|
||||
);
|
||||
|
||||
// Use worktree-scoped key
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
|
||||
|
||||
// Check if this project/worktree already has an active autoloop
|
||||
const existingState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (existingState?.isRunning) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
throw new Error(
|
||||
`Auto mode is already running for ${worktreeDesc} in project: ${projectPath}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create new project/worktree autoloop state
|
||||
const abortController = new AbortController();
|
||||
const config: AutoModeConfig = {
|
||||
maxConcurrency: resolvedMaxConcurrency,
|
||||
useWorktrees: true,
|
||||
projectPath,
|
||||
branchName,
|
||||
};
|
||||
|
||||
const projectState: ProjectAutoLoopState = {
|
||||
abortController,
|
||||
config,
|
||||
isRunning: true,
|
||||
consecutiveFailures: [],
|
||||
pausedDueToFailures: false,
|
||||
hasEmittedIdleEvent: false,
|
||||
branchName,
|
||||
};
|
||||
|
||||
this.autoLoopsByProject.set(worktreeKey, projectState);
|
||||
try {
|
||||
await this.resetStuckFeaturesFn(projectPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_started', {
|
||||
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
|
||||
projectPath,
|
||||
branchName,
|
||||
maxConcurrency: resolvedMaxConcurrency,
|
||||
});
|
||||
await this.saveExecutionStateFn(projectPath, branchName, resolvedMaxConcurrency);
|
||||
this.runAutoLoopForProject(worktreeKey).catch((error) => {
|
||||
const errorInfo = classifyError(error);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
||||
error: errorInfo.message,
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
});
|
||||
return resolvedMaxConcurrency;
|
||||
}
|
||||
|
||||
private async runAutoLoopForProject(worktreeKey: string): Promise<void> {
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (!projectState) return;
|
||||
const { projectPath, branchName } = projectState.config;
|
||||
let iterationCount = 0;
|
||||
|
||||
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
||||
iterationCount++;
|
||||
try {
|
||||
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
if (runningCount >= projectState.config.maxConcurrency) {
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
|
||||
if (pendingFeatures.length === 0) {
|
||||
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
projectState.hasEmittedIdleEvent = true;
|
||||
}
|
||||
await this.sleep(10000, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
const nextFeature = pendingFeatures.find(
|
||||
(f) => !this.isFeatureRunningFn(f.id) && !this.isFeatureFinishedFn(f)
|
||||
);
|
||||
if (nextFeature) {
|
||||
projectState.hasEmittedIdleEvent = false;
|
||||
this.executeFeatureFn(
|
||||
projectPath,
|
||||
nextFeature.id,
|
||||
projectState.config.useWorktrees,
|
||||
true
|
||||
).catch((error) => {
|
||||
const errorInfo = classifyError(error);
|
||||
logger.error(`Auto-loop feature ${nextFeature.id} failed:`, errorInfo.message);
|
||||
if (this.trackFailureAndCheckPauseForProject(projectPath, branchName, errorInfo)) {
|
||||
this.signalShouldPauseForProject(projectPath, branchName, errorInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.sleep(2000, projectState.abortController.signal);
|
||||
} catch {
|
||||
if (projectState.abortController.signal.aborted) break;
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
}
|
||||
}
|
||||
projectState.isRunning = false;
|
||||
}
|
||||
|
||||
async stopAutoLoopForProject(
|
||||
projectPath: string,
|
||||
branchName: string | null = null
|
||||
): Promise<number> {
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (!projectState) return 0;
|
||||
const wasRunning = projectState.isRunning;
|
||||
projectState.isRunning = false;
|
||||
projectState.abortController.abort();
|
||||
await this.clearExecutionStateFn(projectPath, branchName);
|
||||
if (wasRunning)
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_stopped', {
|
||||
message: 'Auto mode stopped',
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
this.autoLoopsByProject.delete(worktreeKey);
|
||||
return await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
}
|
||||
|
||||
isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean {
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
return projectState?.isRunning ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
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;
|
||||
}
|
||||
|
||||
getActiveProjects(): string[] {
|
||||
const activeProjects = new Set<string>();
|
||||
for (const [, state] of this.autoLoopsByProject) {
|
||||
if (state.isRunning) activeProjects.add(state.config.projectPath);
|
||||
}
|
||||
return Array.from(activeProjects);
|
||||
}
|
||||
|
||||
async getRunningCountForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<number> {
|
||||
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName);
|
||||
}
|
||||
|
||||
trackFailureAndCheckPauseForProject(
|
||||
projectPath: string,
|
||||
branchNameOrError: string | null | { type: string; message: string },
|
||||
errorInfo?: { type: string; message: string }
|
||||
): boolean {
|
||||
// Support both old (projectPath, errorInfo) and new (projectPath, branchName, errorInfo) signatures
|
||||
let branchName: string | null;
|
||||
let actualErrorInfo: { type: string; message: string };
|
||||
if (
|
||||
typeof branchNameOrError === 'object' &&
|
||||
branchNameOrError !== null &&
|
||||
'type' in branchNameOrError
|
||||
) {
|
||||
// Old signature: (projectPath, errorInfo)
|
||||
branchName = null;
|
||||
actualErrorInfo = branchNameOrError;
|
||||
} else {
|
||||
// New signature: (projectPath, branchName, errorInfo)
|
||||
branchName = branchNameOrError;
|
||||
actualErrorInfo = errorInfo!;
|
||||
}
|
||||
const projectState = this.autoLoopsByProject.get(
|
||||
getWorktreeAutoLoopKey(projectPath, branchName)
|
||||
);
|
||||
if (!projectState) return false;
|
||||
const now = Date.now();
|
||||
projectState.consecutiveFailures.push({ timestamp: now, error: actualErrorInfo.message });
|
||||
projectState.consecutiveFailures = projectState.consecutiveFailures.filter(
|
||||
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
||||
);
|
||||
return (
|
||||
projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD ||
|
||||
actualErrorInfo.type === 'quota_exhausted' ||
|
||||
actualErrorInfo.type === 'rate_limit'
|
||||
);
|
||||
}
|
||||
|
||||
signalShouldPauseForProject(
|
||||
projectPath: string,
|
||||
branchNameOrError: string | null | { type: string; message: string },
|
||||
errorInfo?: { type: string; message: string }
|
||||
): void {
|
||||
// Support both old (projectPath, errorInfo) and new (projectPath, branchName, errorInfo) signatures
|
||||
let branchName: string | null;
|
||||
let actualErrorInfo: { type: string; message: string };
|
||||
if (
|
||||
typeof branchNameOrError === 'object' &&
|
||||
branchNameOrError !== null &&
|
||||
'type' in branchNameOrError
|
||||
) {
|
||||
branchName = null;
|
||||
actualErrorInfo = branchNameOrError;
|
||||
} else {
|
||||
branchName = branchNameOrError;
|
||||
actualErrorInfo = errorInfo!;
|
||||
}
|
||||
|
||||
const projectState = this.autoLoopsByProject.get(
|
||||
getWorktreeAutoLoopKey(projectPath, branchName)
|
||||
);
|
||||
if (!projectState || projectState.pausedDueToFailures) return;
|
||||
projectState.pausedDueToFailures = true;
|
||||
const failureCount = projectState.consecutiveFailures.length;
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_paused_failures', {
|
||||
message:
|
||||
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
||||
? `Auto Mode paused: ${failureCount} consecutive failures detected.`
|
||||
: 'Auto Mode paused: Usage limit or API error detected.',
|
||||
errorType: actualErrorInfo.type,
|
||||
originalError: actualErrorInfo.message,
|
||||
failureCount,
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
this.stopAutoLoopForProject(projectPath, branchName);
|
||||
}
|
||||
|
||||
resetFailureTrackingForProject(projectPath: string, branchName: string | null = null): void {
|
||||
const projectState = this.autoLoopsByProject.get(
|
||||
getWorktreeAutoLoopKey(projectPath, branchName)
|
||||
);
|
||||
if (projectState) {
|
||||
projectState.consecutiveFailures = [];
|
||||
projectState.pausedDueToFailures = false;
|
||||
}
|
||||
}
|
||||
|
||||
recordSuccessForProject(projectPath: string, branchName: string | null = null): void {
|
||||
const projectState = this.autoLoopsByProject.get(
|
||||
getWorktreeAutoLoopKey(projectPath, branchName)
|
||||
);
|
||||
if (projectState) projectState.consecutiveFailures = [];
|
||||
}
|
||||
|
||||
async resolveMaxConcurrency(
|
||||
projectPath: string,
|
||||
branchName: string | null,
|
||||
provided?: number
|
||||
): Promise<number> {
|
||||
if (typeof provided === 'number' && Number.isFinite(provided)) return provided;
|
||||
if (!this.settingsService) return DEFAULT_MAX_CONCURRENCY;
|
||||
try {
|
||||
const settings = await this.settingsService.getGlobalSettings();
|
||||
const globalMax =
|
||||
typeof settings.maxConcurrency === 'number'
|
||||
? settings.maxConcurrency
|
||||
: DEFAULT_MAX_CONCURRENCY;
|
||||
const projectId = settings.projects?.find((p) => p.path === projectPath)?.id;
|
||||
const autoModeByWorktree = settings.autoModeByWorktree;
|
||||
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
|
||||
const normalizedBranch =
|
||||
branchName === null || branchName === 'main' ? '__main__' : branchName;
|
||||
const worktreeId = `${projectId}::${normalizedBranch}`;
|
||||
if (
|
||||
worktreeId in autoModeByWorktree &&
|
||||
typeof autoModeByWorktree[worktreeId]?.maxConcurrency === 'number'
|
||||
) {
|
||||
return autoModeByWorktree[worktreeId].maxConcurrency;
|
||||
}
|
||||
}
|
||||
return globalMax;
|
||||
} catch {
|
||||
return DEFAULT_MAX_CONCURRENCY;
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error('Aborted'));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Aborted'));
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
236
apps/server/src/services/auto-mode/compat.ts
Normal file
236
apps/server/src/services/auto-mode/compat.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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 { ClaudeUsageService } from '../claude-usage-service.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;
|
||||
private readonly facadeCache = new Map<string, AutoModeServiceFacade>();
|
||||
|
||||
constructor(
|
||||
events: EventEmitter,
|
||||
settingsService: SettingsService | null,
|
||||
featureLoader: FeatureLoader,
|
||||
claudeUsageService?: ClaudeUsageService | null
|
||||
) {
|
||||
this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader);
|
||||
const sharedServices = this.globalService.getSharedServices();
|
||||
|
||||
this.facadeOptions = {
|
||||
events,
|
||||
settingsService,
|
||||
featureLoader,
|
||||
sharedServices,
|
||||
claudeUsageService: claudeUsageService ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global service for direct access
|
||||
*/
|
||||
getGlobalService(): GlobalAutoModeService {
|
||||
return this.globalService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a facade for a specific project.
|
||||
* Facades are cached by project path so that auto loop state
|
||||
* (stored in the facade's AutoLoopCoordinator) persists across API calls.
|
||||
*/
|
||||
createFacade(projectPath: string): AutoModeServiceFacade {
|
||||
let facade = this.facadeCache.get(projectPath);
|
||||
if (!facade) {
|
||||
facade = AutoModeServiceFacade.create(projectPath, this.facadeOptions);
|
||||
this.facadeCache.set(projectPath, facade);
|
||||
}
|
||||
return facade;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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<RunningAgentInfo[]> {
|
||||
return this.globalService.getRunningAgents();
|
||||
}
|
||||
|
||||
async markAllRunningFeaturesInterrupted(reason?: string): Promise<void> {
|
||||
return this.globalService.markAllRunningFeaturesInterrupted(reason);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PER-PROJECT OPERATIONS (delegated to facades)
|
||||
// ===========================================================================
|
||||
|
||||
async getStatusForProject(
|
||||
projectPath: string,
|
||||
branchName: string | null = null
|
||||
): Promise<{
|
||||
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<number> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.startAutoLoop(branchName, maxConcurrency);
|
||||
}
|
||||
|
||||
async stopAutoLoopForProject(
|
||||
projectPath: string,
|
||||
branchName: string | null = null
|
||||
): Promise<number> {
|
||||
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<void> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.executeFeature(
|
||||
featureId,
|
||||
useWorktrees,
|
||||
isAutoMode,
|
||||
providedWorktreePath,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
async stopFeature(featureId: string): Promise<boolean> {
|
||||
// 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<void> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.resumeFeature(featureId, useWorktrees);
|
||||
}
|
||||
|
||||
async followUpFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
imagePaths?: string[],
|
||||
useWorktrees = true
|
||||
): Promise<void> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.followUpFeature(featureId, prompt, imagePaths, useWorktrees);
|
||||
}
|
||||
|
||||
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.verifyFeature(featureId);
|
||||
}
|
||||
|
||||
async commitFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
providedWorktreePath?: string
|
||||
): Promise<string | null> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.commitFeature(featureId, providedWorktreePath);
|
||||
}
|
||||
|
||||
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.contextExists(featureId);
|
||||
}
|
||||
|
||||
async analyzeProject(projectPath: string): Promise<void> {
|
||||
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<void> {
|
||||
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<Array<{ feature: Feature; missingBranch: string }>> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.detectOrphanedFeatures();
|
||||
}
|
||||
}
|
||||
1130
apps/server/src/services/auto-mode/facade.ts
Normal file
1130
apps/server/src/services/auto-mode/facade.ts
Normal file
File diff suppressed because it is too large
Load Diff
208
apps/server/src/services/auto-mode/global-service.ts
Normal file
208
apps/server/src/services/auto-mode/global-service.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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
|
||||
// IMPORTANT: This coordinator is for MONITORING ONLY (getActiveProjects, getActiveWorktrees).
|
||||
// Facades MUST create their own AutoLoopCoordinator for actual execution.
|
||||
// The executeFeatureFn here is a safety guard - it should never be called.
|
||||
this.autoLoopCoordinator = new AutoLoopCoordinator(
|
||||
this.eventBus,
|
||||
this.concurrencyManager,
|
||||
settingsService,
|
||||
// executeFeatureFn - throws because facades must use their own coordinator for execution
|
||||
async () => {
|
||||
throw new Error(
|
||||
'executeFeatureFn not available in GlobalAutoModeService. ' +
|
||||
'Facades must create their own AutoLoopCoordinator for execution.'
|
||||
);
|
||||
},
|
||||
// getBacklogFeaturesFn
|
||||
async (pPath, branchName) => {
|
||||
const features = await featureLoader.getAll(pPath);
|
||||
// For main worktree (branchName === null), resolve the actual primary branch name
|
||||
// so features with branchName matching the primary branch are included
|
||||
let primaryBranch: string | null = null;
|
||||
if (branchName === null) {
|
||||
primaryBranch = await this.worktreeResolver.getCurrentBranch(pPath);
|
||||
}
|
||||
return features.filter(
|
||||
(f) =>
|
||||
(f.status === 'backlog' || f.status === 'ready') &&
|
||||
(branchName === null
|
||||
? !f.branchName || (primaryBranch && f.branchName === primaryBranch)
|
||||
: 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<RunningAgentInfo[]> {
|
||||
const agents = await Promise.all(
|
||||
this.concurrencyManager.getAllRunning().map(async (rf) => {
|
||||
let title: string | undefined;
|
||||
let description: string | undefined;
|
||||
let branchName: string | undefined;
|
||||
|
||||
try {
|
||||
const feature = await this.featureLoader.get(rf.projectPath, rf.featureId);
|
||||
if (feature) {
|
||||
title = feature.title;
|
||||
description = feature.description;
|
||||
branchName = feature.branchName;
|
||||
}
|
||||
} 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<void> {
|
||||
const allRunning = this.concurrencyManager.getAllRunning();
|
||||
|
||||
for (const rf of allRunning) {
|
||||
await this.featureStateManager.markFeatureInterrupted(rf.projectPath, rf.featureId, reason);
|
||||
}
|
||||
|
||||
if (allRunning.length > 0) {
|
||||
logger.info(
|
||||
`Marked ${allRunning.length} running feature(s) as interrupted: ${reason || 'no reason provided'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
77
apps/server/src/services/auto-mode/index.ts
Normal file
77
apps/server/src/services/auto-mode/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Auto Mode Service Module
|
||||
*
|
||||
* Entry point for auto-mode functionality. Exports:
|
||||
* - GlobalAutoModeService: Global operations that span all projects
|
||||
* - AutoModeServiceFacade: Per-project facade for auto-mode operations
|
||||
* - createAutoModeFacade: Convenience factory function
|
||||
* - Types for route consumption
|
||||
*/
|
||||
|
||||
// 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';
|
||||
import type { FacadeOptions } from './types.js';
|
||||
|
||||
/**
|
||||
* Create an AutoModeServiceFacade instance for a specific project.
|
||||
*
|
||||
* This is a convenience wrapper around AutoModeServiceFacade.create().
|
||||
*
|
||||
* @param projectPath - The project path this facade operates on
|
||||
* @param options - Configuration options including events, settingsService, featureLoader
|
||||
* @returns A new AutoModeServiceFacade instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createAutoModeFacade } from './services/auto-mode';
|
||||
*
|
||||
* const facade = createAutoModeFacade('/path/to/project', {
|
||||
* events: eventEmitter,
|
||||
* settingsService,
|
||||
* });
|
||||
*
|
||||
* // Start auto mode
|
||||
* await facade.startAutoLoop(null, 3);
|
||||
*
|
||||
* // Check status
|
||||
* const status = facade.getStatusForProject();
|
||||
* ```
|
||||
*/
|
||||
export function createAutoModeFacade(
|
||||
projectPath: string,
|
||||
options: FacadeOptions
|
||||
): AutoModeServiceFacade {
|
||||
return AutoModeServiceFacade.create(projectPath, options);
|
||||
}
|
||||
|
||||
// Type exports from types.ts
|
||||
export type {
|
||||
FacadeOptions,
|
||||
SharedServices,
|
||||
AutoModeStatus,
|
||||
ProjectAutoModeStatus,
|
||||
WorktreeCapacityInfo,
|
||||
RunningAgentInfo,
|
||||
OrphanedFeatureInfo,
|
||||
FacadeError,
|
||||
GlobalAutoModeOperations,
|
||||
} from './types.js';
|
||||
|
||||
// Re-export types from extracted services for route convenience
|
||||
export type {
|
||||
AutoModeConfig,
|
||||
ProjectAutoLoopState,
|
||||
RunningFeature,
|
||||
AcquireParams,
|
||||
WorktreeInfo,
|
||||
PipelineContext,
|
||||
PipelineStatusInfo,
|
||||
PlanApprovalResult,
|
||||
ResolveApprovalResult,
|
||||
ExecutionState,
|
||||
} from './types.js';
|
||||
148
apps/server/src/services/auto-mode/types.ts
Normal file
148
apps/server/src/services/auto-mode/types.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Facade Types - Type definitions for AutoModeServiceFacade
|
||||
*
|
||||
* Contains:
|
||||
* - FacadeOptions interface for factory configuration
|
||||
* - Re-exports of types from extracted services that routes might need
|
||||
* - Additional types for facade method signatures
|
||||
*/
|
||||
|
||||
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';
|
||||
import type { ClaudeUsageService } from '../claude-usage-service.js';
|
||||
|
||||
// Re-export types from extracted services for route consumption
|
||||
export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js';
|
||||
|
||||
export type { RunningFeature, AcquireParams } from '../concurrency-manager.js';
|
||||
|
||||
export type { WorktreeInfo } from '../worktree-resolver.js';
|
||||
|
||||
export type { PipelineContext, PipelineStatusInfo } from '../pipeline-orchestrator.js';
|
||||
|
||||
export type { PlanApprovalResult, ResolveApprovalResult } from '../plan-approval-service.js';
|
||||
|
||||
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
|
||||
*/
|
||||
export interface FacadeOptions {
|
||||
/** EventEmitter for broadcasting events to clients */
|
||||
events: EventEmitter;
|
||||
/** SettingsService for reading project/global settings (optional) */
|
||||
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;
|
||||
/** ClaudeUsageService for checking usage limits before picking up features (optional) */
|
||||
claudeUsageService?: ClaudeUsageService | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status returned by getStatus()
|
||||
*/
|
||||
export interface AutoModeStatus {
|
||||
isRunning: boolean;
|
||||
runningFeatures: string[];
|
||||
runningCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status returned by getStatusForProject()
|
||||
*/
|
||||
export interface ProjectAutoModeStatus {
|
||||
isAutoLoopRunning: boolean;
|
||||
runningFeatures: string[];
|
||||
runningCount: number;
|
||||
maxConcurrency: number;
|
||||
branchName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capacity info returned by checkWorktreeCapacity()
|
||||
*/
|
||||
export interface WorktreeCapacityInfo {
|
||||
hasCapacity: boolean;
|
||||
currentAgents: number;
|
||||
maxAgents: number;
|
||||
branchName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Running agent info returned by getRunningAgents()
|
||||
*/
|
||||
export interface RunningAgentInfo {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
model?: string;
|
||||
provider?: ModelProvider;
|
||||
title?: string;
|
||||
description?: string;
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orphaned feature info returned by detectOrphanedFeatures()
|
||||
*/
|
||||
export interface OrphanedFeatureInfo {
|
||||
feature: Feature;
|
||||
missingBranch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured error object returned/emitted by facade methods.
|
||||
* Provides consistent error information for callers and UI consumers.
|
||||
*/
|
||||
export interface FacadeError {
|
||||
/** The facade method where the error originated */
|
||||
method: string;
|
||||
/** Classified error type from the error handler */
|
||||
errorType: import('@automaker/types').ErrorType;
|
||||
/** Human-readable error message */
|
||||
message: string;
|
||||
/** Feature ID if the error is associated with a specific feature */
|
||||
featureId?: string;
|
||||
/** Project path where the error occurred */
|
||||
projectPath: 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<RunningAgentInfo[]>;
|
||||
/** Mark all running features as interrupted (for graceful shutdown) */
|
||||
markAllRunningFeaturesInterrupted(reason?: string): Promise<void>;
|
||||
}
|
||||
@@ -294,7 +294,16 @@ export class ClaudeUsageService {
|
||||
this.killPtyProcess(ptyProcess);
|
||||
}
|
||||
// Don't fail if we have data - return it instead
|
||||
if (output.includes('Current session')) {
|
||||
// Check cleaned output since raw output has ANSI codes between words
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanedForCheck = output
|
||||
.replace(/\x1B\[(\d+)C/g, (_m: string, n: string) => ' '.repeat(parseInt(n, 10)))
|
||||
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '');
|
||||
if (
|
||||
cleanedForCheck.includes('Current session') ||
|
||||
cleanedForCheck.includes('% used') ||
|
||||
cleanedForCheck.includes('% left')
|
||||
) {
|
||||
resolve(output);
|
||||
} else if (hasSeenTrustPrompt) {
|
||||
// Trust prompt was shown but we couldn't auto-approve it
|
||||
@@ -320,8 +329,13 @@ export class ClaudeUsageService {
|
||||
output += data;
|
||||
|
||||
// Strip ANSI codes for easier matching
|
||||
// Convert cursor forward (ESC[nC) to spaces first to preserve word boundaries,
|
||||
// then strip remaining ANSI sequences. Without this, the Claude CLI TUI output
|
||||
// like "Current week (all models)" becomes "Currentweek(allmodels)".
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||
const cleanOutput = output
|
||||
.replace(/\x1B\[(\d+)C/g, (_match: string, n: string) => ' '.repeat(parseInt(n, 10)))
|
||||
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '');
|
||||
|
||||
// Check for specific authentication/permission errors
|
||||
// Must be very specific to avoid false positives from garbled terminal encoding
|
||||
@@ -356,7 +370,8 @@ export class ClaudeUsageService {
|
||||
const hasUsageIndicators =
|
||||
cleanOutput.includes('Current session') ||
|
||||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')) ||
|
||||
// Additional patterns for winpty - look for percentage patterns
|
||||
// Look for percentage patterns - allow optional whitespace between % and left/used
|
||||
// since cursor movement codes may or may not create spaces after stripping
|
||||
/\d+%\s*(left|used|remaining)/i.test(cleanOutput) ||
|
||||
cleanOutput.includes('Resets in') ||
|
||||
cleanOutput.includes('Current week');
|
||||
@@ -382,12 +397,15 @@ export class ClaudeUsageService {
|
||||
// Handle Trust Dialog - multiple variants:
|
||||
// - "Do you want to work in this folder?"
|
||||
// - "Ready to code here?" / "I'll need permission to work with your files"
|
||||
// - "Quick safety check" / "Yes, I trust this folder"
|
||||
// Since we are running in cwd (project dir), it is safe to approve.
|
||||
if (
|
||||
!hasApprovedTrust &&
|
||||
(cleanOutput.includes('Do you want to work in this folder?') ||
|
||||
cleanOutput.includes('Ready to code here') ||
|
||||
cleanOutput.includes('permission to work with your files'))
|
||||
cleanOutput.includes('permission to work with your files') ||
|
||||
cleanOutput.includes('trust this folder') ||
|
||||
cleanOutput.includes('safety check'))
|
||||
) {
|
||||
hasApprovedTrust = true;
|
||||
hasSeenTrustPrompt = true;
|
||||
@@ -471,10 +489,17 @@ export class ClaudeUsageService {
|
||||
* Handles CSI, OSC, and other common ANSI sequences
|
||||
*/
|
||||
private stripAnsiCodes(text: string): string {
|
||||
// First strip ANSI sequences (colors, etc) and handle CR
|
||||
// First, convert cursor movement sequences to whitespace to preserve word boundaries.
|
||||
// The Claude CLI TUI uses ESC[nC (cursor forward) instead of actual spaces between words.
|
||||
// Without this, "Current week (all models)" becomes "Currentweek(allmodels)" after stripping.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
let clean = text
|
||||
// CSI sequences: ESC [ ... (letter or @)
|
||||
// Cursor forward (CSI n C): replace with n spaces to preserve word separation
|
||||
.replace(/\x1B\[(\d+)C/g, (_match, n) => ' '.repeat(parseInt(n, 10)))
|
||||
// Cursor movement (up/down/back/position): replace with newline or nothing
|
||||
.replace(/\x1B\[\d*[ABD]/g, '') // cursor up (A), down (B), back (D)
|
||||
.replace(/\x1B\[\d+;\d+[Hf]/g, '\n') // cursor position (H/f)
|
||||
// Now strip remaining CSI sequences (colors, modes, etc.)
|
||||
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '')
|
||||
// OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC
|
||||
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '')
|
||||
|
||||
261
apps/server/src/services/concurrency-manager.ts
Normal file
261
apps/server/src/services/concurrency-manager.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* ConcurrencyManager - Manages running feature slots with lease-based reference counting
|
||||
*
|
||||
* Extracted from AutoModeService to provide a standalone service for tracking
|
||||
* running feature execution with proper lease counting to support nested calls
|
||||
* (e.g., resumeFeature -> executeFeature).
|
||||
*
|
||||
* Key behaviors:
|
||||
* - acquire() with existing entry + allowReuse: increment leaseCount, return existing
|
||||
* - acquire() with existing entry + no allowReuse: throw Error('already running')
|
||||
* - release() decrements leaseCount, only deletes at 0
|
||||
* - release() with force:true bypasses leaseCount check
|
||||
*/
|
||||
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Function type for getting the current branch of a project.
|
||||
* Injected to allow for testing and decoupling from git operations.
|
||||
*/
|
||||
export type GetCurrentBranchFn = (projectPath: string) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Represents a running feature execution with all tracking metadata
|
||||
*/
|
||||
export interface RunningFeature {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
worktreePath: string | null;
|
||||
branchName: string | null;
|
||||
abortController: AbortController;
|
||||
isAutoMode: boolean;
|
||||
startTime: number;
|
||||
leaseCount: number;
|
||||
model?: string;
|
||||
provider?: ModelProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for acquiring a running feature slot
|
||||
*/
|
||||
export interface AcquireParams {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
isAutoMode: boolean;
|
||||
allowReuse?: boolean;
|
||||
abortController?: AbortController;
|
||||
}
|
||||
|
||||
/**
|
||||
* ConcurrencyManager manages the running features Map with lease-based reference counting.
|
||||
*
|
||||
* This supports nested execution patterns where a feature may be acquired multiple times
|
||||
* (e.g., during resume operations) and should only be released when all references are done.
|
||||
*/
|
||||
export class ConcurrencyManager {
|
||||
private runningFeatures = new Map<string, RunningFeature>();
|
||||
private getCurrentBranch: GetCurrentBranchFn;
|
||||
|
||||
/**
|
||||
* @param getCurrentBranch - Function to get the current branch for a project.
|
||||
* If not provided, defaults to returning 'main'.
|
||||
*/
|
||||
constructor(getCurrentBranch?: GetCurrentBranchFn) {
|
||||
this.getCurrentBranch = getCurrentBranch ?? (() => Promise.resolve('main'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a slot in the runningFeatures map for a feature.
|
||||
* Implements reference counting via leaseCount to support nested calls
|
||||
* (e.g., resumeFeature -> executeFeature).
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
acquire(params: AcquireParams): RunningFeature {
|
||||
const existing = this.runningFeatures.get(params.featureId);
|
||||
if (existing) {
|
||||
if (!params.allowReuse) {
|
||||
throw new Error('already running');
|
||||
}
|
||||
existing.leaseCount += 1;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const abortController = params.abortController ?? new AbortController();
|
||||
const entry: RunningFeature = {
|
||||
featureId: params.featureId,
|
||||
projectPath: params.projectPath,
|
||||
worktreePath: null,
|
||||
branchName: null,
|
||||
abortController,
|
||||
isAutoMode: params.isAutoMode,
|
||||
startTime: Date.now(),
|
||||
leaseCount: 1,
|
||||
};
|
||||
this.runningFeatures.set(params.featureId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a slot in the runningFeatures map for a feature.
|
||||
* Decrements leaseCount and only removes the entry when it reaches zero,
|
||||
* unless force option is used.
|
||||
*
|
||||
* @param featureId - ID of the feature to release
|
||||
* @param options.force - If true, immediately removes the entry regardless of leaseCount
|
||||
*/
|
||||
release(featureId: string, options?: { force?: boolean }): void {
|
||||
const entry = this.runningFeatures.get(featureId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.force) {
|
||||
this.runningFeatures.delete(featureId);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.leaseCount -= 1;
|
||||
if (entry.leaseCount <= 0) {
|
||||
this.runningFeatures.delete(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is currently running
|
||||
*
|
||||
* @param featureId - ID of the feature to check
|
||||
* @returns true if the feature is in the runningFeatures map
|
||||
*/
|
||||
isRunning(featureId: string): boolean {
|
||||
return this.runningFeatures.has(featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RunningFeature entry for a feature
|
||||
*
|
||||
* @param featureId - ID of the feature
|
||||
* @returns The RunningFeature entry or undefined if not running
|
||||
*/
|
||||
getRunningFeature(featureId: string): RunningFeature | undefined {
|
||||
return this.runningFeatures.get(featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of running features for a specific project
|
||||
*
|
||||
* @param projectPath - The project path to count features for
|
||||
* @returns Number of running features for the project
|
||||
*/
|
||||
getRunningCount(projectPath: string): number {
|
||||
let count = 0;
|
||||
for (const [, feature] of this.runningFeatures) {
|
||||
if (feature.projectPath === projectPath) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of running features for a specific worktree
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param branchName - The branch name, or null for main worktree
|
||||
* (features without branchName or matching primary branch)
|
||||
* @returns Number of running features for the worktree
|
||||
*/
|
||||
async getRunningCountForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<number> {
|
||||
// Get the actual primary branch name for the project
|
||||
const primaryBranch = await this.getCurrentBranch(projectPath);
|
||||
|
||||
let count = 0;
|
||||
for (const [, feature] of this.runningFeatures) {
|
||||
// Filter by project path AND branchName to get accurate worktree-specific count
|
||||
const featureBranch = feature.branchName ?? null;
|
||||
if (branchName === null) {
|
||||
// Main worktree: match features with branchName === null OR branchName matching primary branch
|
||||
const isPrimaryBranch =
|
||||
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
||||
if (feature.projectPath === projectPath && isPrimaryBranch) {
|
||||
count++;
|
||||
}
|
||||
} else {
|
||||
// Feature worktree: exact match
|
||||
if (feature.projectPath === projectPath && featureBranch === branchName) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currently running features
|
||||
*
|
||||
* @returns Array of all RunningFeature entries
|
||||
*/
|
||||
getAllRunning(): RunningFeature[] {
|
||||
return Array.from(this.runningFeatures.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get running feature IDs for a specific worktree, with proper primary branch normalization.
|
||||
*
|
||||
* When branchName is null (main worktree), matches features with branchName === null
|
||||
* OR branchName matching the primary branch (e.g., "main", "master").
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param branchName - The branch name, or null for main worktree
|
||||
* @returns Array of feature IDs running in the specified worktree
|
||||
*/
|
||||
async getRunningFeaturesForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<string[]> {
|
||||
const primaryBranch = await this.getCurrentBranch(projectPath);
|
||||
const featureIds: string[] = [];
|
||||
|
||||
for (const [, feature] of this.runningFeatures) {
|
||||
if (feature.projectPath !== projectPath) continue;
|
||||
const featureBranch = feature.branchName ?? null;
|
||||
|
||||
if (branchName === null) {
|
||||
// Main worktree: match features with null branchName OR primary branch name
|
||||
const isPrimaryBranch =
|
||||
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
||||
if (isPrimaryBranch) featureIds.push(feature.featureId);
|
||||
} else {
|
||||
// Feature worktree: exact match
|
||||
if (featureBranch === branchName) featureIds.push(feature.featureId);
|
||||
}
|
||||
}
|
||||
|
||||
return featureIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update properties of a running feature
|
||||
*
|
||||
* @param featureId - ID of the feature to update
|
||||
* @param updates - Partial RunningFeature properties to update
|
||||
*/
|
||||
updateRunningFeature(featureId: string, updates: Partial<RunningFeature>): void {
|
||||
const entry = this.runningFeatures.get(featureId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(entry, updates);
|
||||
}
|
||||
}
|
||||
470
apps/server/src/services/execution-service.ts
Normal file
470
apps/server/src/services/execution-service.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* ExecutionService - Feature execution lifecycle coordination
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { createLogger, classifyError, loadContextFiles, recordMemoryUsage } from '@automaker/utils';
|
||||
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
filterClaudeMdFromContext,
|
||||
} from '../lib/settings-helpers.js';
|
||||
import { validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
import { extractSummary } from './spec-parser.js';
|
||||
import type { TypedEventBus } from './typed-event-bus.js';
|
||||
import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js';
|
||||
import type { WorktreeResolver } from './worktree-resolver.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import type { PipelineContext } from './pipeline-orchestrator.js';
|
||||
import { pipelineService } from './pipeline-service.js';
|
||||
|
||||
// Re-export callback types from execution-types.ts for backward compatibility
|
||||
export type {
|
||||
RunAgentFn,
|
||||
ExecutePipelineFn,
|
||||
UpdateFeatureStatusFn,
|
||||
LoadFeatureFn,
|
||||
GetPlanningPromptPrefixFn,
|
||||
SaveFeatureSummaryFn,
|
||||
RecordLearningsFn,
|
||||
ContextExistsFn,
|
||||
ResumeFeatureFn,
|
||||
TrackFailureFn,
|
||||
SignalPauseFn,
|
||||
RecordSuccessFn,
|
||||
SaveExecutionStateFn,
|
||||
LoadContextFilesFn,
|
||||
} from './execution-types.js';
|
||||
|
||||
import type {
|
||||
RunAgentFn,
|
||||
ExecutePipelineFn,
|
||||
UpdateFeatureStatusFn,
|
||||
LoadFeatureFn,
|
||||
GetPlanningPromptPrefixFn,
|
||||
SaveFeatureSummaryFn,
|
||||
RecordLearningsFn,
|
||||
ContextExistsFn,
|
||||
ResumeFeatureFn,
|
||||
TrackFailureFn,
|
||||
SignalPauseFn,
|
||||
RecordSuccessFn,
|
||||
SaveExecutionStateFn,
|
||||
LoadContextFilesFn,
|
||||
} from './execution-types.js';
|
||||
|
||||
const logger = createLogger('ExecutionService');
|
||||
|
||||
export class ExecutionService {
|
||||
constructor(
|
||||
private eventBus: TypedEventBus,
|
||||
private concurrencyManager: ConcurrencyManager,
|
||||
private worktreeResolver: WorktreeResolver,
|
||||
private settingsService: SettingsService | null,
|
||||
// Callback dependencies for delegation
|
||||
private runAgentFn: RunAgentFn,
|
||||
private executePipelineFn: ExecutePipelineFn,
|
||||
private updateFeatureStatusFn: UpdateFeatureStatusFn,
|
||||
private loadFeatureFn: LoadFeatureFn,
|
||||
private getPlanningPromptPrefixFn: GetPlanningPromptPrefixFn,
|
||||
private saveFeatureSummaryFn: SaveFeatureSummaryFn,
|
||||
private recordLearningsFn: RecordLearningsFn,
|
||||
private contextExistsFn: ContextExistsFn,
|
||||
private resumeFeatureFn: ResumeFeatureFn,
|
||||
private trackFailureFn: TrackFailureFn,
|
||||
private signalPauseFn: SignalPauseFn,
|
||||
private recordSuccessFn: RecordSuccessFn,
|
||||
private saveExecutionStateFn: SaveExecutionStateFn,
|
||||
private loadContextFilesFn: LoadContextFilesFn
|
||||
) {}
|
||||
|
||||
private acquireRunningFeature(options: {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
isAutoMode: boolean;
|
||||
allowReuse?: boolean;
|
||||
}): RunningFeature {
|
||||
return this.concurrencyManager.acquire(options);
|
||||
}
|
||||
|
||||
private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
|
||||
this.concurrencyManager.release(featureId, options);
|
||||
}
|
||||
|
||||
private extractTitleFromDescription(description: string | undefined): string {
|
||||
if (!description?.trim()) return 'Untitled Feature';
|
||||
const firstLine = description.split('\n')[0].trim();
|
||||
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
|
||||
}
|
||||
|
||||
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}
|
||||
`;
|
||||
}
|
||||
|
||||
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
||||
const imagesList = feature.imagePaths
|
||||
.map((img, idx) => {
|
||||
const imgPath = typeof img === 'string' ? img : img.path;
|
||||
const filename =
|
||||
typeof img === 'string'
|
||||
? imgPath.split('/').pop()
|
||||
: img.filename || imgPath.split('/').pop();
|
||||
const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*';
|
||||
return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${imgPath}`;
|
||||
})
|
||||
.join('\n');
|
||||
prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
|
||||
}
|
||||
|
||||
prompt += feature.skipTests
|
||||
? `\n${taskExecutionPrompts.implementationInstructions}`
|
||||
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
async executeFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees = false,
|
||||
isAutoMode = false,
|
||||
providedWorktreePath?: string,
|
||||
options?: { continuationPrompt?: string; _calledInternally?: boolean }
|
||||
): Promise<void> {
|
||||
const tempRunningFeature = this.acquireRunningFeature({
|
||||
featureId,
|
||||
projectPath,
|
||||
isAutoMode,
|
||||
allowReuse: options?._calledInternally,
|
||||
});
|
||||
const abortController = tempRunningFeature.abortController;
|
||||
if (isAutoMode) await this.saveExecutionStateFn(projectPath);
|
||||
let feature: Feature | null = null;
|
||||
|
||||
try {
|
||||
validateWorkingDirectory(projectPath);
|
||||
feature = await this.loadFeatureFn(projectPath, featureId);
|
||||
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||
|
||||
if (!options?.continuationPrompt) {
|
||||
if (feature.planSpec?.status === 'approved') {
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
|
||||
let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
|
||||
continuationPrompt = continuationPrompt
|
||||
.replace(/\{\{userFeedback\}\}/g, '')
|
||||
.replace(/\{\{approvedPlan\}\}/g, feature.planSpec.content || '');
|
||||
return await this.executeFeature(
|
||||
projectPath,
|
||||
featureId,
|
||||
useWorktrees,
|
||||
isAutoMode,
|
||||
providedWorktreePath,
|
||||
{ continuationPrompt, _calledInternally: true }
|
||||
);
|
||||
}
|
||||
if (await this.contextExistsFn(projectPath, featureId)) {
|
||||
return await this.resumeFeatureFn(projectPath, featureId, useWorktrees, true);
|
||||
}
|
||||
}
|
||||
|
||||
let worktreePath: string | null = providedWorktreePath ?? null;
|
||||
const branchName = feature.branchName;
|
||||
if (!worktreePath && useWorktrees && branchName) {
|
||||
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
|
||||
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
||||
}
|
||||
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
||||
validateWorkingDirectory(workDir);
|
||||
tempRunningFeature.worktreePath = worktreePath;
|
||||
tempRunningFeature.branchName = branchName ?? null;
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName: feature.branchName ?? null,
|
||||
feature: {
|
||||
id: featureId,
|
||||
title: feature.title || 'Loading...',
|
||||
description: feature.description || 'Feature is starting',
|
||||
},
|
||||
});
|
||||
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
this.settingsService,
|
||||
'[ExecutionService]'
|
||||
);
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
|
||||
let prompt: string;
|
||||
const contextResult = await this.loadContextFilesFn({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
taskContext: {
|
||||
title: feature.title ?? '',
|
||||
description: feature.description ?? '',
|
||||
},
|
||||
});
|
||||
const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||
|
||||
if (options?.continuationPrompt) {
|
||||
prompt = options.continuationPrompt;
|
||||
} else {
|
||||
prompt =
|
||||
(await this.getPlanningPromptPrefixFn(feature)) +
|
||||
this.buildFeaturePrompt(feature, prompts.taskExecution);
|
||||
if (feature.planningMode && feature.planningMode !== 'skip') {
|
||||
this.eventBus.emitAutoModeEvent('planning_started', {
|
||||
featureId: feature.id,
|
||||
mode: feature.planningMode,
|
||||
message: `Starting ${feature.planningMode} planning phase`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const imagePaths = feature.imagePaths?.map((img) =>
|
||||
typeof img === 'string' ? img : img.path
|
||||
);
|
||||
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
||||
tempRunningFeature.model = model;
|
||||
tempRunningFeature.provider = ProviderFactory.getProviderNameForModel(model);
|
||||
|
||||
await this.runAgentFn(
|
||||
workDir,
|
||||
featureId,
|
||||
prompt,
|
||||
abortController,
|
||||
projectPath,
|
||||
imagePaths,
|
||||
model,
|
||||
{
|
||||
projectPath,
|
||||
planningMode: feature.planningMode,
|
||||
requirePlanApproval: feature.requirePlanApproval,
|
||||
systemPrompt: combinedSystemPrompt || undefined,
|
||||
autoLoadClaudeMd,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
branchName: feature.branchName ?? null,
|
||||
}
|
||||
);
|
||||
|
||||
// Check for incomplete tasks after agent execution.
|
||||
// The agent may have finished early (hit max turns, decided it was done, etc.)
|
||||
// while tasks are still pending. If so, re-run the agent to complete remaining tasks.
|
||||
const MAX_TASK_RETRY_ATTEMPTS = 3;
|
||||
let taskRetryAttempts = 0;
|
||||
while (!abortController.signal.aborted && taskRetryAttempts < MAX_TASK_RETRY_ATTEMPTS) {
|
||||
const currentFeature = await this.loadFeatureFn(projectPath, featureId);
|
||||
if (!currentFeature?.planSpec?.tasks) break;
|
||||
|
||||
const pendingTasks = currentFeature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'pending' || t.status === 'in_progress'
|
||||
);
|
||||
if (pendingTasks.length === 0) break;
|
||||
|
||||
taskRetryAttempts++;
|
||||
const totalTasks = currentFeature.planSpec.tasks.length;
|
||||
const completedTasks = currentFeature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
logger.info(
|
||||
`[executeFeature] Feature ${featureId} has ${pendingTasks.length} incomplete tasks (${completedTasks}/${totalTasks} completed). Re-running agent (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})`
|
||||
);
|
||||
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
branchName: feature.branchName ?? null,
|
||||
content: `Agent finished with ${pendingTasks.length} tasks remaining. Re-running to complete tasks (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})...`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Build a continuation prompt that tells the agent to finish remaining tasks
|
||||
const remainingTasksList = pendingTasks
|
||||
.map((t) => `- ${t.id}: ${t.description} (${t.status})`)
|
||||
.join('\n');
|
||||
|
||||
const continuationPrompt = `## Continue Implementation - Incomplete Tasks
|
||||
|
||||
The previous agent session ended before all tasks were completed. Please continue implementing the remaining tasks.
|
||||
|
||||
**Completed:** ${completedTasks}/${totalTasks} tasks
|
||||
**Remaining tasks:**
|
||||
${remainingTasksList}
|
||||
|
||||
Please continue from where you left off and complete all remaining tasks. Use the same [TASK_START:ID] and [TASK_COMPLETE:ID] markers for each task.`;
|
||||
|
||||
await this.runAgentFn(
|
||||
workDir,
|
||||
featureId,
|
||||
continuationPrompt,
|
||||
abortController,
|
||||
projectPath,
|
||||
undefined,
|
||||
model,
|
||||
{
|
||||
projectPath,
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
systemPrompt: combinedSystemPrompt || undefined,
|
||||
autoLoadClaudeMd,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
branchName: feature.branchName ?? null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Log if tasks are still incomplete after retry attempts
|
||||
if (taskRetryAttempts >= MAX_TASK_RETRY_ATTEMPTS) {
|
||||
const finalFeature = await this.loadFeatureFn(projectPath, featureId);
|
||||
const stillPending = finalFeature?.planSpec?.tasks?.filter(
|
||||
(t) => t.status === 'pending' || t.status === 'in_progress'
|
||||
);
|
||||
if (stillPending && stillPending.length > 0) {
|
||||
logger.warn(
|
||||
`[executeFeature] Feature ${featureId} still has ${stillPending.length} incomplete tasks after ${MAX_TASK_RETRY_ATTEMPTS} retry attempts. Moving to final status.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
||||
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
||||
const sortedSteps = [...(pipelineConfig?.steps || [])]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.filter((step) => !excludedStepIds.has(step.id));
|
||||
if (sortedSteps.length > 0) {
|
||||
await this.executePipelineFn({
|
||||
projectPath,
|
||||
featureId,
|
||||
feature,
|
||||
steps: sortedSteps,
|
||||
workDir,
|
||||
worktreePath,
|
||||
branchName: feature.branchName ?? null,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
});
|
||||
// Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
|
||||
const refreshed = await this.loadFeatureFn(projectPath, featureId);
|
||||
if (refreshed?.status === 'merge_conflict') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||
this.recordSuccessFn();
|
||||
|
||||
// Check final task completion state for accurate reporting
|
||||
const completedFeature = await this.loadFeatureFn(projectPath, featureId);
|
||||
const totalTasks = completedFeature?.planSpec?.tasks?.length ?? 0;
|
||||
const completedTasks =
|
||||
completedFeature?.planSpec?.tasks?.filter((t) => t.status === 'completed').length ?? 0;
|
||||
const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks;
|
||||
|
||||
try {
|
||||
const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
|
||||
let agentOutput = '';
|
||||
try {
|
||||
agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
if (agentOutput) {
|
||||
const summary = extractSummary(agentOutput);
|
||||
if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary);
|
||||
}
|
||||
if (contextResult.memoryFiles.length > 0 && agentOutput) {
|
||||
await recordMemoryUsage(
|
||||
projectPath,
|
||||
contextResult.memoryFiles,
|
||||
agentOutput,
|
||||
true,
|
||||
secureFs as Parameters<typeof recordMemoryUsage>[4]
|
||||
);
|
||||
}
|
||||
await this.recordLearningsFn(projectPath, feature, agentOutput);
|
||||
} catch {
|
||||
/* learnings recording failed */
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.round((Date.now() - tempRunningFeature.startTime) / 1000);
|
||||
let completionMessage = `Feature completed in ${elapsedSeconds}s`;
|
||||
if (finalStatus === 'verified') completionMessage += ' - auto-verified';
|
||||
if (hasIncompleteTasks)
|
||||
completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`;
|
||||
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
passes: true,
|
||||
message: completionMessage,
|
||||
projectPath,
|
||||
model: tempRunningFeature.model,
|
||||
provider: tempRunningFeature.provider,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
if (errorInfo.isAbort) {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature?.title,
|
||||
branchName: feature?.branchName ?? null,
|
||||
passes: false,
|
||||
message: 'Feature stopped by user',
|
||||
projectPath,
|
||||
});
|
||||
} else {
|
||||
logger.error(`Feature ${featureId} failed:`, error);
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
||||
featureId,
|
||||
featureName: feature?.title,
|
||||
branchName: feature?.branchName ?? null,
|
||||
error: errorInfo.message,
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
if (this.trackFailureFn({ type: errorInfo.type, message: errorInfo.message })) {
|
||||
this.signalPauseFn({ type: errorInfo.type, message: errorInfo.message });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.releaseRunningFeature(featureId);
|
||||
if (isAutoMode && projectPath) await this.saveExecutionStateFn(projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
async stopFeature(featureId: string): Promise<boolean> {
|
||||
const running = this.concurrencyManager.getRunningFeature(featureId);
|
||||
if (!running) return false;
|
||||
running.abortController.abort();
|
||||
this.releaseRunningFeature(featureId, { force: true });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
212
apps/server/src/services/execution-types.ts
Normal file
212
apps/server/src/services/execution-types.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Execution Types - Type definitions for ExecutionService and related services
|
||||
*
|
||||
* Contains callback types used by ExecutionService for dependency injection,
|
||||
* allowing the service to delegate to other services without circular dependencies.
|
||||
*/
|
||||
|
||||
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
|
||||
import type { loadContextFiles } from '@automaker/utils';
|
||||
import type { PipelineContext } from './pipeline-orchestrator.js';
|
||||
|
||||
// =============================================================================
|
||||
// ExecutionService Callback Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Function to run the agent with a prompt
|
||||
*/
|
||||
export type RunAgentFn = (
|
||||
workDir: string,
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
abortController: AbortController,
|
||||
projectPath: string,
|
||||
imagePaths?: string[],
|
||||
model?: string,
|
||||
options?: {
|
||||
projectPath?: string;
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
previousContent?: string;
|
||||
systemPrompt?: string;
|
||||
autoLoadClaudeMd?: boolean;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
branchName?: string | null;
|
||||
}
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to execute pipeline steps
|
||||
*/
|
||||
export type ExecutePipelineFn = (context: PipelineContext) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to update feature status
|
||||
*/
|
||||
export type UpdateFeatureStatusFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
status: string
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to load a feature by ID
|
||||
*/
|
||||
export type LoadFeatureFn = (projectPath: string, featureId: string) => Promise<Feature | null>;
|
||||
|
||||
/**
|
||||
* Function to get the planning prompt prefix based on feature's planning mode
|
||||
*/
|
||||
export type GetPlanningPromptPrefixFn = (feature: Feature) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Function to save a feature summary
|
||||
*/
|
||||
export type SaveFeatureSummaryFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
summary: string
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to record learnings from a completed feature
|
||||
*/
|
||||
export type RecordLearningsFn = (
|
||||
projectPath: string,
|
||||
feature: Feature,
|
||||
agentOutput: string
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to check if context exists for a feature
|
||||
*/
|
||||
export type ContextExistsFn = (projectPath: string, featureId: string) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Function to resume a feature (continues from saved context or starts fresh)
|
||||
*/
|
||||
export type ResumeFeatureFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees: boolean,
|
||||
_calledInternally: boolean
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to track failure and check if pause threshold is reached
|
||||
* Returns true if auto-mode should pause
|
||||
*/
|
||||
export type TrackFailureFn = (errorInfo: { type: string; message: string }) => boolean;
|
||||
|
||||
/**
|
||||
* Function to signal that auto-mode should pause due to failures
|
||||
*/
|
||||
export type SignalPauseFn = (errorInfo: { type: string; message: string }) => void;
|
||||
|
||||
/**
|
||||
* Function to record a successful execution (resets failure tracking)
|
||||
*/
|
||||
export type RecordSuccessFn = () => void;
|
||||
|
||||
/**
|
||||
* Function to save execution state
|
||||
*/
|
||||
export type SaveExecutionStateFn = (projectPath: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Type alias for loadContextFiles function
|
||||
*/
|
||||
export type LoadContextFilesFn = typeof loadContextFiles;
|
||||
|
||||
// =============================================================================
|
||||
// PipelineOrchestrator Callback Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Function to build feature prompt
|
||||
*/
|
||||
export type BuildFeaturePromptFn = (
|
||||
feature: Feature,
|
||||
prompts: { implementationInstructions: string; playwrightVerificationInstructions: string }
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Function to execute a feature
|
||||
*/
|
||||
export type ExecuteFeatureFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees: boolean,
|
||||
isAutoMode: boolean,
|
||||
providedWorktreePath?: string,
|
||||
options?: { continuationPrompt?: string; _calledInternally?: boolean }
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to run agent (for PipelineOrchestrator)
|
||||
*/
|
||||
export type PipelineRunAgentFn = (
|
||||
workDir: string,
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
abortController: AbortController,
|
||||
projectPath: string,
|
||||
imagePaths?: string[],
|
||||
model?: string,
|
||||
options?: Record<string, unknown>
|
||||
) => Promise<void>;
|
||||
|
||||
// =============================================================================
|
||||
// AutoLoopCoordinator Callback Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Function to execute a feature in auto-loop
|
||||
*/
|
||||
export type AutoLoopExecuteFeatureFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees: boolean,
|
||||
isAutoMode: boolean
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to load pending features for a worktree
|
||||
*/
|
||||
export type LoadPendingFeaturesFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
) => Promise<Feature[]>;
|
||||
|
||||
/**
|
||||
* Function to save execution state for auto-loop
|
||||
*/
|
||||
export type AutoLoopSaveExecutionStateFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null,
|
||||
maxConcurrency: number
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to clear execution state
|
||||
*/
|
||||
export type ClearExecutionStateFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to reset stuck features
|
||||
*/
|
||||
export type ResetStuckFeaturesFn = (projectPath: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to check if a feature is finished
|
||||
*/
|
||||
export type IsFeatureFinishedFn = (feature: Feature) => boolean;
|
||||
|
||||
/**
|
||||
* Function to check if a feature is running
|
||||
*/
|
||||
export type IsFeatureRunningFn = (featureId: string) => boolean;
|
||||
542
apps/server/src/services/feature-state-manager.ts
Normal file
542
apps/server/src/services/feature-state-manager.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* FeatureStateManager - Manages feature status updates with proper persistence
|
||||
*
|
||||
* Extracted from AutoModeService to provide a standalone service for:
|
||||
* - Updating feature status with proper disk persistence
|
||||
* - Handling corrupted JSON with backup recovery
|
||||
* - Emitting events AFTER successful persistence (prevent stale data on refresh)
|
||||
* - Resetting stuck features after server restart
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Persist BEFORE emit (Pitfall 2 from research)
|
||||
* - Use readJsonWithRecovery for all reads
|
||||
* - markInterrupted preserves pipeline_* statuses
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature, ParsedTask, PlanSpec } from '@automaker/types';
|
||||
import {
|
||||
atomicWriteJson,
|
||||
readJsonWithRecovery,
|
||||
logRecoveryWarning,
|
||||
DEFAULT_BACKUP_COUNT,
|
||||
createLogger,
|
||||
} from '@automaker/utils';
|
||||
import { getFeatureDir, getFeaturesDir } from '@automaker/platform';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import { getNotificationService } from './notification-service.js';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
|
||||
const logger = createLogger('FeatureStateManager');
|
||||
|
||||
/**
|
||||
* FeatureStateManager handles feature status updates with persistence guarantees.
|
||||
*
|
||||
* This service is responsible for:
|
||||
* 1. Updating feature status and persisting to disk BEFORE emitting events
|
||||
* 2. Handling corrupted JSON with automatic backup recovery
|
||||
* 3. Resetting stuck features after server restarts
|
||||
* 4. Managing justFinishedAt timestamps for UI badges
|
||||
*/
|
||||
export class FeatureStateManager {
|
||||
private events: EventEmitter;
|
||||
private featureLoader: FeatureLoader;
|
||||
|
||||
constructor(events: EventEmitter, featureLoader: FeatureLoader) {
|
||||
this.events = events;
|
||||
this.featureLoader = featureLoader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a feature from disk with recovery support
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param featureId - ID of the feature to load
|
||||
* @returns The feature data, or null if not found/recoverable
|
||||
*/
|
||||
async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
autoRestore: true,
|
||||
});
|
||||
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
||||
return result.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update feature status with proper persistence and event ordering.
|
||||
*
|
||||
* IMPORTANT: Persists to disk BEFORE emitting events to prevent stale data
|
||||
* on client refresh (Pitfall 2 from research).
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param featureId - ID of the feature to update
|
||||
* @param status - New status value
|
||||
*/
|
||||
async updateFeatureStatus(projectPath: string, featureId: string, status: string): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
// Use recovery-enabled read for corrupted file handling
|
||||
const result = await readJsonWithRecovery<Feature | null>(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 or could not be recovered`);
|
||||
return;
|
||||
}
|
||||
|
||||
feature.status = status;
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
|
||||
// Badge will show for 2 minutes after this timestamp
|
||||
if (status === 'waiting_approval') {
|
||||
feature.justFinishedAt = new Date().toISOString();
|
||||
|
||||
// Finalize task statuses when feature is done:
|
||||
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them)
|
||||
// - Do NOT mark pending tasks as completed (they were never started)
|
||||
// - Clear currentTaskId since no task is actively running
|
||||
// This prevents cards in "waiting for review" from appearing to still have running tasks
|
||||
if (feature.planSpec?.tasks) {
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
|
||||
);
|
||||
}
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
// Update tasksCompleted count to reflect actual completed tasks
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
}
|
||||
} else if (status === 'verified') {
|
||||
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
|
||||
// Do NOT mark pending tasks as completed - they were never started
|
||||
if (feature.planSpec?.tasks) {
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified`
|
||||
);
|
||||
}
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
}
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
} else {
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
}
|
||||
|
||||
// PERSIST BEFORE EMIT (Pitfall 2)
|
||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
|
||||
// Emit status change event so UI can react without polling
|
||||
this.emitAutoModeEvent('feature_status_changed', {
|
||||
featureId,
|
||||
projectPath,
|
||||
status,
|
||||
});
|
||||
|
||||
// Create notifications for important status changes
|
||||
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
if (status === 'waiting_approval') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'Feature Ready for Review',
|
||||
message: `"${feature.name || featureId}" is ready for your review and approval.`,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
} else if (status === 'verified') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_verified',
|
||||
title: 'Feature Verified',
|
||||
message: `"${feature.name || featureId}" has been verified and is complete.`,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
} catch (notificationError) {
|
||||
logger.warn(`Failed to create notification for feature ${featureId}:`, notificationError);
|
||||
}
|
||||
|
||||
// Sync completed/verified features to app_spec.txt
|
||||
if (status === 'verified' || status === 'completed') {
|
||||
try {
|
||||
await this.featureLoader.syncFeatureToAppSpec(projectPath, feature);
|
||||
} catch (syncError) {
|
||||
// Log but don't fail the status update if sync fails
|
||||
logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update feature status for ${featureId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 resumePipelineFeature() 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<void> {
|
||||
// Load the feature to check its current status
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
const currentStatus = feature?.status;
|
||||
|
||||
// Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step
|
||||
if (currentStatus && currentStatus.startsWith('pipeline_')) {
|
||||
logger.info(
|
||||
`Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
logger.info(`Marking feature ${featureId} as interrupted: ${reason}`);
|
||||
} else {
|
||||
logger.info(`Marking feature ${featureId} as interrupted`);
|
||||
}
|
||||
|
||||
await this.updateFeatureStatus(projectPath, featureId, 'interrupted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset features that were stuck in transient states due to server crash.
|
||||
* Called when auto mode is enabled to clean up from previous session.
|
||||
*
|
||||
* Resets:
|
||||
* - in_progress features back to ready (if has plan) or backlog (if no plan)
|
||||
* - generating planSpec status back to pending
|
||||
* - in_progress tasks back to pending
|
||||
*
|
||||
* @param projectPath - The project path to reset features for
|
||||
*/
|
||||
async resetStuckFeatures(projectPath: string): Promise<void> {
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
let featuresScanned = 0;
|
||||
let featuresReset = 0;
|
||||
|
||||
try {
|
||||
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
featuresScanned++;
|
||||
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
autoRestore: true,
|
||||
});
|
||||
|
||||
const feature = result.data;
|
||||
if (!feature) continue;
|
||||
|
||||
let needsUpdate = false;
|
||||
|
||||
// Reset in_progress features back to ready/backlog
|
||||
if (feature.status === 'in_progress') {
|
||||
const hasApprovedPlan = feature.planSpec?.status === 'approved';
|
||||
feature.status = hasApprovedPlan ? 'ready' : 'backlog';
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Reset feature ${feature.id} from in_progress to ${feature.status}`
|
||||
);
|
||||
}
|
||||
|
||||
// Reset generating planSpec status back to pending (spec generation was interrupted)
|
||||
if (feature.planSpec?.status === 'generating') {
|
||||
feature.planSpec.status = 'pending';
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Reset feature ${feature.id} planSpec status from generating to pending`
|
||||
);
|
||||
}
|
||||
|
||||
// Reset any in_progress tasks back to pending (task execution was interrupted)
|
||||
if (feature.planSpec?.tasks) {
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'pending';
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Reset task ${task.id} for feature ${feature.id} from in_progress to pending`
|
||||
);
|
||||
// Clear currentTaskId if it points to this reverted task
|
||||
if (feature.planSpec?.currentTaskId === task.id) {
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
featuresReset++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Scanned ${featuresScanned} features, reset ${featuresReset} features for ${projectPath}`
|
||||
);
|
||||
} catch (error) {
|
||||
// If features directory doesn't exist, that's fine
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error(`[resetStuckFeatures] Error resetting features for ${projectPath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the planSpec of a feature with partial updates.
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param featureId - The feature ID
|
||||
* @param updates - Partial PlanSpec updates to apply
|
||||
*/
|
||||
async updateFeaturePlanSpec(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
updates: Partial<PlanSpec>
|
||||
): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(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 or could not be recovered`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize planSpec if it doesn't exist
|
||||
if (!feature.planSpec) {
|
||||
feature.planSpec = {
|
||||
status: 'pending',
|
||||
version: 1,
|
||||
reviewedByUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Capture old content BEFORE applying updates for version comparison
|
||||
const oldContent = feature.planSpec.content;
|
||||
|
||||
// Apply updates
|
||||
Object.assign(feature.planSpec, updates);
|
||||
|
||||
// If content is being updated and it's different from old content, increment version
|
||||
if (updates.content !== undefined && updates.content !== oldContent) {
|
||||
feature.planSpec.version = (feature.planSpec.version || 0) + 1;
|
||||
}
|
||||
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// PERSIST BEFORE EMIT
|
||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
|
||||
// Emit event for UI update
|
||||
this.emitAutoModeEvent('plan_spec_updated', {
|
||||
featureId,
|
||||
projectPath,
|
||||
planSpec: feature.planSpec,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update planSpec for ${featureId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <summary> tags.
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param featureId - The feature ID
|
||||
* @param summary - The summary text to save
|
||||
*/
|
||||
async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(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 or could not be recovered`);
|
||||
return;
|
||||
}
|
||||
|
||||
feature.summary = summary;
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// PERSIST BEFORE EMIT
|
||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
|
||||
// Emit event for UI update
|
||||
this.emitAutoModeEvent('auto_mode_summary', {
|
||||
featureId,
|
||||
projectPath,
|
||||
summary,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save summary for ${featureId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of a specific task within planSpec.tasks
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param featureId - The feature ID
|
||||
* @param taskId - The task ID to update
|
||||
* @param status - The new task status
|
||||
*/
|
||||
async updateTaskStatus(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
taskId: string,
|
||||
status: ParsedTask['status']
|
||||
): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
autoRestore: true,
|
||||
});
|
||||
|
||||
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
||||
|
||||
const feature = result.data;
|
||||
if (!feature || !feature.planSpec?.tasks) {
|
||||
logger.warn(`Feature ${featureId} not found or has no tasks`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find and update the task
|
||||
const task = feature.planSpec.tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
task.status = status;
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// PERSIST BEFORE EMIT
|
||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
|
||||
// Emit event for UI update
|
||||
this.emitAutoModeEvent('auto_mode_task_status', {
|
||||
featureId,
|
||||
projectPath,
|
||||
taskId,
|
||||
status,
|
||||
tasks: feature.planSpec.tasks,
|
||||
});
|
||||
} else {
|
||||
const availableIds = feature.planSpec.tasks.map((t) => t.id).join(', ');
|
||||
logger.warn(
|
||||
`[updateTaskStatus] Task ${taskId} not found in feature ${featureId} (${projectPath}). Available task IDs: [${availableIds}]`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update task ${taskId} status for ${featureId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an auto-mode event via the event emitter
|
||||
*
|
||||
* @param eventType - The event type (e.g., 'auto_mode_summary')
|
||||
* @param data - The event payload
|
||||
*/
|
||||
private emitAutoModeEvent(eventType: string, data: Record<string, unknown>): void {
|
||||
// Wrap the event in auto-mode:event format expected by the client
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: eventType,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -230,10 +230,9 @@ export class IdeationService {
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
// Use resolved model from provider if available (maps to Claude model)
|
||||
if (providerResult.resolvedModel) {
|
||||
modelId = providerResult.resolvedModel;
|
||||
}
|
||||
// CRITICAL: For custom providers, use the provider's model ID (e.g. "GLM-4.7")
|
||||
// for the API call, NOT the resolved Claude model - otherwise we get "model not found"
|
||||
modelId = options.model;
|
||||
credentials = providerResult.credentials ?? credentials;
|
||||
}
|
||||
}
|
||||
|
||||
185
apps/server/src/services/merge-service.ts
Normal file
185
apps/server/src/services/merge-service.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* MergeService - Direct merge operations without HTTP
|
||||
*
|
||||
* Extracted from worktree merge route to allow internal service calls.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { spawnProcess } from '@automaker/platform';
|
||||
const logger = createLogger('MergeService');
|
||||
|
||||
export interface MergeOptions {
|
||||
squash?: boolean;
|
||||
message?: string;
|
||||
deleteWorktreeAndBranch?: boolean;
|
||||
}
|
||||
|
||||
export interface MergeServiceResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
hasConflicts?: boolean;
|
||||
mergedBranch?: string;
|
||||
targetBranch?: string;
|
||||
deleted?: {
|
||||
worktreeDeleted: boolean;
|
||||
branchDeleted: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute git command with array arguments to prevent command injection.
|
||||
*/
|
||||
async function execGitCommand(args: string[], cwd: string): Promise<string> {
|
||||
const result = await spawnProcess({
|
||||
command: 'git',
|
||||
args,
|
||||
cwd,
|
||||
});
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
return result.stdout;
|
||||
} else {
|
||||
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate branch name to prevent command injection.
|
||||
*/
|
||||
function isValidBranchName(name: string): boolean {
|
||||
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < 250;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a git merge operation directly without HTTP.
|
||||
*
|
||||
* @param projectPath - Path to the git repository
|
||||
* @param branchName - Source branch to merge
|
||||
* @param worktreePath - Path to the worktree (used for deletion if requested)
|
||||
* @param targetBranch - Branch to merge into (defaults to 'main')
|
||||
* @param options - Merge options (squash, message, deleteWorktreeAndBranch)
|
||||
*/
|
||||
export async function performMerge(
|
||||
projectPath: string,
|
||||
branchName: string,
|
||||
worktreePath: string,
|
||||
targetBranch: string = 'main',
|
||||
options?: MergeOptions
|
||||
): Promise<MergeServiceResult> {
|
||||
if (!projectPath || !branchName || !worktreePath) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'projectPath, branchName, and worktreePath are required',
|
||||
};
|
||||
}
|
||||
|
||||
const mergeTo = targetBranch || 'main';
|
||||
|
||||
// Validate branch names early to reject invalid input before any git operations
|
||||
if (!isValidBranchName(branchName)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid source branch name: "${branchName}"`,
|
||||
};
|
||||
}
|
||||
if (!isValidBranchName(mergeTo)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid target branch name: "${mergeTo}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate source branch exists (using safe array-based command)
|
||||
try {
|
||||
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: `Branch "${branchName}" does not exist`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate target branch exists (using safe array-based command)
|
||||
try {
|
||||
await execGitCommand(['rev-parse', '--verify', mergeTo], projectPath);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: `Target branch "${mergeTo}" does not exist`,
|
||||
};
|
||||
}
|
||||
|
||||
// Merge the feature branch into the target branch (using safe array-based commands)
|
||||
const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`;
|
||||
const mergeArgs = options?.squash
|
||||
? ['merge', '--squash', branchName]
|
||||
: ['merge', branchName, '-m', mergeMessage];
|
||||
|
||||
try {
|
||||
await execGitCommand(mergeArgs, projectPath);
|
||||
} catch (mergeError: unknown) {
|
||||
// Check if this is a merge conflict
|
||||
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
|
||||
const hasConflicts = output.includes('CONFLICT') || output.includes('Automatic merge failed');
|
||||
|
||||
if (hasConflicts) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
|
||||
hasConflicts: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Re-throw non-conflict errors
|
||||
throw mergeError;
|
||||
}
|
||||
|
||||
// If squash merge, need to commit (using safe array-based command)
|
||||
if (options?.squash) {
|
||||
const squashMessage = options?.message || `Merge ${branchName} (squash)`;
|
||||
await execGitCommand(['commit', '-m', squashMessage], projectPath);
|
||||
}
|
||||
|
||||
// Optionally delete the worktree and branch after merging
|
||||
let worktreeDeleted = false;
|
||||
let branchDeleted = false;
|
||||
|
||||
if (options?.deleteWorktreeAndBranch) {
|
||||
// Remove the worktree
|
||||
try {
|
||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||
worktreeDeleted = true;
|
||||
} catch {
|
||||
// Try with prune if remove fails
|
||||
try {
|
||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||
worktreeDeleted = true;
|
||||
} catch {
|
||||
logger.warn(`Failed to remove worktree: ${worktreePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the branch (but not main/master)
|
||||
if (branchName !== 'main' && branchName !== 'master') {
|
||||
if (!isValidBranchName(branchName)) {
|
||||
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
||||
} else {
|
||||
try {
|
||||
await execGitCommand(['branch', '-D', branchName], projectPath);
|
||||
branchDeleted = true;
|
||||
} catch {
|
||||
logger.warn(`Failed to delete branch: ${branchName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mergedBranch: branchName,
|
||||
targetBranch: mergeTo,
|
||||
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
|
||||
};
|
||||
}
|
||||
612
apps/server/src/services/pipeline-orchestrator.ts
Normal file
612
apps/server/src/services/pipeline-orchestrator.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* PipelineOrchestrator - Pipeline step execution and coordination
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type {
|
||||
Feature,
|
||||
PipelineStep,
|
||||
PipelineConfig,
|
||||
FeatureStatusWithPipeline,
|
||||
} from '@automaker/types';
|
||||
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
filterClaudeMdFromContext,
|
||||
} from '../lib/settings-helpers.js';
|
||||
import { validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
import type { TypedEventBus } from './typed-event-bus.js';
|
||||
import type { FeatureStateManager } from './feature-state-manager.js';
|
||||
import type { AgentExecutor } from './agent-executor.js';
|
||||
import type { WorktreeResolver } from './worktree-resolver.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import type { ConcurrencyManager } from './concurrency-manager.js';
|
||||
import { pipelineService } from './pipeline-service.js';
|
||||
import type { TestRunnerService, TestRunStatus } from './test-runner-service.js';
|
||||
import { performMerge } from './merge-service.js';
|
||||
import type {
|
||||
PipelineContext,
|
||||
PipelineStatusInfo,
|
||||
StepResult,
|
||||
MergeResult,
|
||||
UpdateFeatureStatusFn,
|
||||
BuildFeaturePromptFn,
|
||||
ExecuteFeatureFn,
|
||||
RunAgentFn,
|
||||
} from './pipeline-types.js';
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
export type {
|
||||
PipelineContext,
|
||||
PipelineStatusInfo,
|
||||
StepResult,
|
||||
MergeResult,
|
||||
UpdateFeatureStatusFn,
|
||||
BuildFeaturePromptFn,
|
||||
ExecuteFeatureFn,
|
||||
RunAgentFn,
|
||||
} from './pipeline-types.js';
|
||||
|
||||
const logger = createLogger('PipelineOrchestrator');
|
||||
|
||||
export class PipelineOrchestrator {
|
||||
constructor(
|
||||
private eventBus: TypedEventBus,
|
||||
private featureStateManager: FeatureStateManager,
|
||||
private agentExecutor: AgentExecutor,
|
||||
private testRunnerService: TestRunnerService,
|
||||
private worktreeResolver: WorktreeResolver,
|
||||
private concurrencyManager: ConcurrencyManager,
|
||||
private settingsService: SettingsService | null,
|
||||
private updateFeatureStatusFn: UpdateFeatureStatusFn,
|
||||
private loadContextFilesFn: typeof loadContextFiles,
|
||||
private buildFeaturePromptFn: BuildFeaturePromptFn,
|
||||
private executeFeatureFn: ExecuteFeatureFn,
|
||||
private runAgentFn: RunAgentFn
|
||||
) {}
|
||||
|
||||
async executePipeline(ctx: PipelineContext): Promise<void> {
|
||||
const { projectPath, featureId, feature, steps, workDir, abortController, autoLoadClaudeMd } =
|
||||
ctx;
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
||||
const contextResult = await this.loadContextFilesFn({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
taskContext: { title: feature.title ?? '', description: feature.description ?? '' },
|
||||
});
|
||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||
const contextPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
|
||||
let previousContext = '';
|
||||
try {
|
||||
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (abortController.signal.aborted) throw new Error('Pipeline execution aborted');
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, `pipeline_${step.id}`);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
branchName: feature.branchName ?? null,
|
||||
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
|
||||
projectPath,
|
||||
});
|
||||
this.eventBus.emitAutoModeEvent('pipeline_step_started', {
|
||||
featureId,
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: i,
|
||||
totalSteps: steps.length,
|
||||
projectPath,
|
||||
});
|
||||
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
||||
await this.runAgentFn(
|
||||
workDir,
|
||||
featureId,
|
||||
this.buildPipelineStepPrompt(step, feature, previousContext, prompts.taskExecution),
|
||||
abortController,
|
||||
projectPath,
|
||||
undefined,
|
||||
model,
|
||||
{
|
||||
projectPath,
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
previousContent: previousContext,
|
||||
systemPrompt: contextFilesPrompt || undefined,
|
||||
autoLoadClaudeMd,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
}
|
||||
);
|
||||
try {
|
||||
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
this.eventBus.emitAutoModeEvent('pipeline_step_complete', {
|
||||
featureId,
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: i,
|
||||
totalSteps: steps.length,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
if (ctx.branchName) {
|
||||
const mergeResult = await this.attemptMerge(ctx);
|
||||
if (!mergeResult.success && mergeResult.hasConflicts) return;
|
||||
}
|
||||
}
|
||||
|
||||
buildPipelineStepPrompt(
|
||||
step: PipelineStep,
|
||||
feature: Feature,
|
||||
previousContext: string,
|
||||
taskPrompts: { implementationInstructions: string; playwrightVerificationInstructions: string }
|
||||
): string {
|
||||
let prompt = `## Pipeline Step: ${step.name}\n\nThis is an automated pipeline step.\n\n### Feature Context\n${this.buildFeaturePromptFn(feature, taskPrompts)}\n\n`;
|
||||
if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`;
|
||||
return (
|
||||
prompt +
|
||||
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.`
|
||||
);
|
||||
}
|
||||
|
||||
async detectPipelineStatus(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
currentStatus: FeatureStatusWithPipeline
|
||||
): Promise<PipelineStatusInfo> {
|
||||
const isPipeline = pipelineService.isPipelineStatus(currentStatus);
|
||||
if (!isPipeline)
|
||||
return {
|
||||
isPipeline: false,
|
||||
stepId: null,
|
||||
stepIndex: -1,
|
||||
totalSteps: 0,
|
||||
step: null,
|
||||
config: null,
|
||||
};
|
||||
const stepId = pipelineService.getStepIdFromStatus(currentStatus);
|
||||
if (!stepId)
|
||||
return {
|
||||
isPipeline: true,
|
||||
stepId: null,
|
||||
stepIndex: -1,
|
||||
totalSteps: 0,
|
||||
step: null,
|
||||
config: null,
|
||||
};
|
||||
const config = await pipelineService.getPipelineConfig(projectPath);
|
||||
if (!config || config.steps.length === 0)
|
||||
return { isPipeline: true, stepId, stepIndex: -1, totalSteps: 0, step: null, config: null };
|
||||
const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order);
|
||||
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
|
||||
return {
|
||||
isPipeline: true,
|
||||
stepId,
|
||||
stepIndex,
|
||||
totalSteps: sortedSteps.length,
|
||||
step: stepIndex === -1 ? null : sortedSteps[stepIndex],
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
async resumePipeline(
|
||||
projectPath: string,
|
||||
feature: Feature,
|
||||
useWorktrees: boolean,
|
||||
pipelineInfo: PipelineStatusInfo
|
||||
): Promise<void> {
|
||||
const featureId = feature.id;
|
||||
const contextPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
|
||||
let hasContext = false;
|
||||
try {
|
||||
await secureFs.access(contextPath);
|
||||
hasContext = true;
|
||||
} catch {
|
||||
/* No context */
|
||||
}
|
||||
|
||||
if (!hasContext) {
|
||||
logger.warn(`No context for feature ${featureId}, restarting pipeline`);
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
|
||||
return this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, {
|
||||
_calledInternally: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (pipelineInfo.stepIndex === -1) {
|
||||
logger.warn(`Step ${pipelineInfo.stepId} no longer exists, completing feature`);
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
passes: true,
|
||||
message: 'Pipeline step no longer exists',
|
||||
projectPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pipelineInfo.config) throw new Error('Pipeline config is null but stepIndex is valid');
|
||||
return this.resumeFromStep(
|
||||
projectPath,
|
||||
feature,
|
||||
useWorktrees,
|
||||
pipelineInfo.stepIndex,
|
||||
pipelineInfo.config
|
||||
);
|
||||
}
|
||||
|
||||
/** Resume from a specific step index */
|
||||
async resumeFromStep(
|
||||
projectPath: string,
|
||||
feature: Feature,
|
||||
useWorktrees: boolean,
|
||||
startFromStepIndex: number,
|
||||
pipelineConfig: PipelineConfig
|
||||
): Promise<void> {
|
||||
const featureId = feature.id;
|
||||
const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
|
||||
if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length)
|
||||
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
||||
|
||||
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
||||
let currentStep = allSortedSteps[startFromStepIndex];
|
||||
|
||||
if (excludedStepIds.has(currentStep.id)) {
|
||||
const nextStatus = pipelineService.getNextStatus(
|
||||
`pipeline_${currentStep.id}`,
|
||||
pipelineConfig,
|
||||
feature.skipTests ?? false,
|
||||
feature.excludedPipelineSteps
|
||||
);
|
||||
if (!pipelineService.isPipelineStatus(nextStatus)) {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, nextStatus);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
passes: true,
|
||||
message: 'Pipeline completed (remaining steps excluded)',
|
||||
projectPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
|
||||
const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId);
|
||||
if (nextStepIndex === -1) throw new Error(`Next step ${nextStepId} not found`);
|
||||
startFromStepIndex = nextStepIndex;
|
||||
}
|
||||
|
||||
const stepsToExecute = allSortedSteps
|
||||
.slice(startFromStepIndex)
|
||||
.filter((step) => !excludedStepIds.has(step.id));
|
||||
if (stepsToExecute.length === 0) {
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
passes: true,
|
||||
message: 'Pipeline completed (all steps excluded)',
|
||||
projectPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const runningEntry = this.concurrencyManager.acquire({
|
||||
featureId,
|
||||
projectPath,
|
||||
isAutoMode: false,
|
||||
allowReuse: true,
|
||||
});
|
||||
const abortController = runningEntry.abortController;
|
||||
runningEntry.branchName = feature.branchName ?? null;
|
||||
|
||||
try {
|
||||
validateWorkingDirectory(projectPath);
|
||||
let worktreePath: string | null = null;
|
||||
const branchName = feature.branchName;
|
||||
|
||||
if (useWorktrees && branchName) {
|
||||
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
|
||||
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
||||
}
|
||||
|
||||
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
||||
validateWorkingDirectory(workDir);
|
||||
runningEntry.worktreePath = worktreePath;
|
||||
runningEntry.branchName = branchName ?? null;
|
||||
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName: branchName ?? null,
|
||||
feature: {
|
||||
id: featureId,
|
||||
title: feature.title || 'Resuming Pipeline',
|
||||
description: feature.description,
|
||||
},
|
||||
});
|
||||
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
this.settingsService,
|
||||
'[AutoMode]'
|
||||
);
|
||||
const context: PipelineContext = {
|
||||
projectPath,
|
||||
featureId,
|
||||
feature,
|
||||
steps: stepsToExecute,
|
||||
workDir,
|
||||
worktreePath,
|
||||
branchName: branchName ?? null,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
};
|
||||
|
||||
await this.executePipeline(context);
|
||||
|
||||
// Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict)
|
||||
const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
|
||||
// Only update status if not already in a terminal state
|
||||
if (reloadedFeature && reloadedFeature.status !== 'merge_conflict') {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||
}
|
||||
logger.info(`Pipeline resume completed for feature ${featureId}`);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
passes: true,
|
||||
message: 'Pipeline resumed successfully',
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
if (errorInfo.isAbort) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
passes: false,
|
||||
message: 'Pipeline stopped by user',
|
||||
projectPath,
|
||||
});
|
||||
} else {
|
||||
logger.error(`Pipeline resume failed for ${featureId}:`, error);
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
error: errorInfo.message,
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.concurrencyManager.release(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute test step with agent fix loop (REQ-F07) */
|
||||
async executeTestStep(context: PipelineContext, testCommand: string): Promise<StepResult> {
|
||||
const { featureId, projectPath, workDir, abortController, maxTestAttempts } = context;
|
||||
|
||||
for (let attempt = 1; attempt <= maxTestAttempts; attempt++) {
|
||||
if (abortController.signal.aborted)
|
||||
return { success: false, message: 'Test execution aborted' };
|
||||
logger.info(`Running tests for ${featureId} (attempt ${attempt}/${maxTestAttempts})`);
|
||||
|
||||
const testResult = await this.testRunnerService.startTests(workDir, { command: testCommand });
|
||||
if (!testResult.success || !testResult.result?.sessionId)
|
||||
return {
|
||||
success: false,
|
||||
testsPassed: false,
|
||||
message: testResult.error || 'Failed to start tests',
|
||||
};
|
||||
|
||||
const completionResult = await this.waitForTestCompletion(
|
||||
testResult.result.sessionId,
|
||||
abortController.signal
|
||||
);
|
||||
if (completionResult.status === 'passed') return { success: true, testsPassed: true };
|
||||
|
||||
const sessionOutput = this.testRunnerService.getSessionOutput(testResult.result.sessionId);
|
||||
const scrollback = sessionOutput.result?.output || '';
|
||||
this.eventBus.emitAutoModeEvent('pipeline_test_failed', {
|
||||
featureId,
|
||||
attempt,
|
||||
maxAttempts: maxTestAttempts,
|
||||
failedTests: this.extractFailedTestNames(scrollback),
|
||||
projectPath,
|
||||
});
|
||||
|
||||
if (attempt < maxTestAttempts) {
|
||||
const fixPrompt = `## Test Failures - Please Fix\n\n${this.buildTestFailureSummary(scrollback)}\n\nFix the failing tests without modifying test code unless clearly wrong.`;
|
||||
await this.runAgentFn(
|
||||
workDir,
|
||||
featureId,
|
||||
fixPrompt,
|
||||
abortController,
|
||||
projectPath,
|
||||
undefined,
|
||||
undefined,
|
||||
{ projectPath, planningMode: 'skip', requirePlanApproval: false }
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
testsPassed: false,
|
||||
message: `Tests failed after ${maxTestAttempts} attempts`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wait for test completion */
|
||||
private async waitForTestCompletion(
|
||||
sessionId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<{ status: TestRunStatus; exitCode: number | null; duration: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
// Check for abort
|
||||
if (signal.aborted) {
|
||||
clearInterval(checkInterval);
|
||||
clearTimeout(timeoutId);
|
||||
resolve({ status: 'failed', exitCode: null, duration: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.testRunnerService.getSession(sessionId);
|
||||
if (session && session.status !== 'running' && session.status !== 'pending') {
|
||||
clearInterval(checkInterval);
|
||||
clearTimeout(timeoutId);
|
||||
resolve({
|
||||
status: session.status,
|
||||
exitCode: session.exitCode,
|
||||
duration: session.finishedAt
|
||||
? session.finishedAt.getTime() - session.startedAt.getTime()
|
||||
: 0,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Check for abort before timeout resolution
|
||||
if (signal.aborted) {
|
||||
clearInterval(checkInterval);
|
||||
resolve({ status: 'failed', exitCode: null, duration: 0 });
|
||||
return;
|
||||
}
|
||||
clearInterval(checkInterval);
|
||||
resolve({ status: 'failed', exitCode: null, duration: 600000 });
|
||||
}, 600000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Attempt to merge feature branch (REQ-F05) */
|
||||
async attemptMerge(context: PipelineContext): Promise<MergeResult> {
|
||||
const { projectPath, featureId, branchName, worktreePath, feature } = context;
|
||||
if (!branchName) return { success: false, error: 'No branch name for merge' };
|
||||
|
||||
logger.info(`Attempting auto-merge for feature ${featureId} (branch: ${branchName})`);
|
||||
try {
|
||||
// Get the primary branch dynamically instead of hardcoding 'main'
|
||||
const targetBranch = await this.worktreeResolver.getCurrentBranch(projectPath);
|
||||
|
||||
// Call merge service directly instead of HTTP fetch
|
||||
const result = await performMerge(
|
||||
projectPath,
|
||||
branchName,
|
||||
worktreePath || projectPath,
|
||||
targetBranch || 'main',
|
||||
{
|
||||
deleteWorktreeAndBranch: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.hasConflicts) {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'merge_conflict');
|
||||
this.eventBus.emitAutoModeEvent('pipeline_merge_conflict', {
|
||||
featureId,
|
||||
branchName,
|
||||
projectPath,
|
||||
});
|
||||
return { success: false, hasConflicts: true, needsAgentResolution: true };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
logger.info(`Auto-merge successful for feature ${featureId}`);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName,
|
||||
passes: true,
|
||||
message: 'Pipeline completed and merged',
|
||||
projectPath,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`Merge failed for ${featureId}:`, error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared helper to parse test output lines and extract failure information */
|
||||
private parseTestLines(scrollback: string): {
|
||||
failedTests: string[];
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
} {
|
||||
const lines = scrollback.split('\n');
|
||||
const failedTests: string[] = [];
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
let inFailureContext = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) {
|
||||
const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/);
|
||||
if (match) failedTests.push(match[1].trim());
|
||||
failCount++;
|
||||
inFailureContext = true;
|
||||
} else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) {
|
||||
passCount++;
|
||||
inFailureContext = false;
|
||||
}
|
||||
if (trimmed.match(/^>\s+.*\.(test|spec)\./)) {
|
||||
failedTests.push(trimmed.replace(/^>\s+/, ''));
|
||||
}
|
||||
// Only capture assertion details when they appear in failure context
|
||||
// or match explicit assertion error / expect patterns
|
||||
if (trimmed.includes('AssertionError') || trimmed.includes('AssertionError')) {
|
||||
failedTests.push(trimmed);
|
||||
} else if (
|
||||
inFailureContext &&
|
||||
/expect\(.+\)\.(toBe|toEqual|toMatch|toThrow|toContain)\s*\(/.test(trimmed)
|
||||
) {
|
||||
failedTests.push(trimmed);
|
||||
} else if (
|
||||
inFailureContext &&
|
||||
(trimmed.startsWith('Expected') || trimmed.startsWith('Received'))
|
||||
) {
|
||||
failedTests.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return { failedTests, passCount, failCount };
|
||||
}
|
||||
|
||||
/** Build a concise test failure summary for the agent */
|
||||
buildTestFailureSummary(scrollback: string): string {
|
||||
const { failedTests, passCount, failCount } = this.parseTestLines(scrollback);
|
||||
const unique = [...new Set(failedTests)].slice(0, 10);
|
||||
return `Test Results: ${passCount} passed, ${failCount} failed.\n\nFailed tests:\n${unique.map((t) => `- ${t}`).join('\n')}\n\nOutput (last 2000 chars):\n${scrollback.slice(-2000)}`;
|
||||
}
|
||||
|
||||
/** Extract failed test names from scrollback */
|
||||
private extractFailedTestNames(scrollback: string): string[] {
|
||||
const { failedTests } = this.parseTestLines(scrollback);
|
||||
return [...new Set(failedTests)].slice(0, 20);
|
||||
}
|
||||
}
|
||||
72
apps/server/src/services/pipeline-types.ts
Normal file
72
apps/server/src/services/pipeline-types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Pipeline Types - Type definitions for PipelineOrchestrator
|
||||
*/
|
||||
|
||||
import type { Feature, PipelineStep, PipelineConfig } from '@automaker/types';
|
||||
|
||||
export interface PipelineContext {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
feature: Feature;
|
||||
steps: PipelineStep[];
|
||||
workDir: string;
|
||||
worktreePath: string | null;
|
||||
branchName: string | null;
|
||||
abortController: AbortController;
|
||||
autoLoadClaudeMd: boolean;
|
||||
testAttempts: number;
|
||||
maxTestAttempts: number;
|
||||
}
|
||||
|
||||
export interface PipelineStatusInfo {
|
||||
isPipeline: boolean;
|
||||
stepId: string | null;
|
||||
stepIndex: number;
|
||||
totalSteps: number;
|
||||
step: PipelineStep | null;
|
||||
config: PipelineConfig | null;
|
||||
}
|
||||
|
||||
export interface StepResult {
|
||||
success: boolean;
|
||||
testsPassed?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
success: boolean;
|
||||
hasConflicts?: boolean;
|
||||
needsAgentResolution?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type UpdateFeatureStatusFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
status: string
|
||||
) => Promise<void>;
|
||||
|
||||
export type BuildFeaturePromptFn = (
|
||||
feature: Feature,
|
||||
prompts: { implementationInstructions: string; playwrightVerificationInstructions: string }
|
||||
) => string;
|
||||
|
||||
export type ExecuteFeatureFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees: boolean,
|
||||
useScreenshots: boolean,
|
||||
model?: string,
|
||||
options?: { _calledInternally?: boolean }
|
||||
) => Promise<void>;
|
||||
|
||||
export type RunAgentFn = (
|
||||
workDir: string,
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
abortController: AbortController,
|
||||
projectPath: string,
|
||||
imagePaths?: string[],
|
||||
model?: string,
|
||||
options?: Record<string, unknown>
|
||||
) => Promise<void>;
|
||||
332
apps/server/src/services/plan-approval-service.ts
Normal file
332
apps/server/src/services/plan-approval-service.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* PlanApprovalService - Manages plan approval workflow with timeout and recovery
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Timeout stored in closure, wrapped resolve/reject ensures cleanup
|
||||
* - Recovery returns needsRecovery flag (caller handles execution)
|
||||
* - Auto-reject on timeout (safety feature, not auto-approve)
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { TypedEventBus } from './typed-event-bus.js';
|
||||
import type { FeatureStateManager } from './feature-state-manager.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
|
||||
const logger = createLogger('PlanApprovalService');
|
||||
|
||||
/** Result returned when approval is resolved */
|
||||
export interface PlanApprovalResult {
|
||||
approved: boolean;
|
||||
editedPlan?: string;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
/** Result returned from resolveApproval method */
|
||||
export interface ResolveApprovalResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
needsRecovery?: boolean;
|
||||
}
|
||||
|
||||
/** Represents an orphaned approval that needs recovery after server restart */
|
||||
export interface OrphanedApproval {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
generatedAt?: string;
|
||||
planContent?: string;
|
||||
}
|
||||
|
||||
/** Internal: timeoutId stored in closure, NOT in this object */
|
||||
interface PendingApproval {
|
||||
resolve: (result: PlanApprovalResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/** Default timeout: 30 minutes */
|
||||
const DEFAULT_APPROVAL_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* PlanApprovalService handles the plan approval workflow with lifecycle management.
|
||||
*/
|
||||
export class PlanApprovalService {
|
||||
private pendingApprovals = new Map<string, PendingApproval>();
|
||||
private eventBus: TypedEventBus;
|
||||
private featureStateManager: FeatureStateManager;
|
||||
private settingsService: SettingsService | null;
|
||||
|
||||
constructor(
|
||||
eventBus: TypedEventBus,
|
||||
featureStateManager: FeatureStateManager,
|
||||
settingsService: SettingsService | null
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.featureStateManager = featureStateManager;
|
||||
this.settingsService = settingsService;
|
||||
}
|
||||
|
||||
/** Generate project-scoped key to prevent collisions across projects */
|
||||
private approvalKey(projectPath: string, featureId: string): string {
|
||||
return `${projectPath}::${featureId}`;
|
||||
}
|
||||
|
||||
/** Wait for plan approval with timeout (default 30 min). Rejects on timeout/cancellation. */
|
||||
async waitForApproval(featureId: string, projectPath: string): Promise<PlanApprovalResult> {
|
||||
const timeoutMs = await this.getTimeoutMs(projectPath);
|
||||
const timeoutMinutes = Math.round(timeoutMs / 60000);
|
||||
const key = this.approvalKey(projectPath, featureId);
|
||||
|
||||
logger.info(`Registering pending approval for feature ${featureId} in project ${projectPath}`);
|
||||
logger.info(
|
||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Prevent duplicate registrations for the same key — reject and clean up existing entry
|
||||
const existing = this.pendingApprovals.get(key);
|
||||
if (existing) {
|
||||
existing.reject(new Error('Superseded by a new waitForApproval call'));
|
||||
this.pendingApprovals.delete(key);
|
||||
}
|
||||
|
||||
// Wrap resolve/reject to clear timeout when approval is resolved
|
||||
// This ensures timeout is ALWAYS cleared on any resolution path
|
||||
// Define wrappers BEFORE setTimeout so they can be used in timeout callback
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const wrappedResolve = (result: PlanApprovalResult) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const wrappedReject = (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
// Set up timeout to prevent indefinite waiting and memory leaks
|
||||
// Now timeoutId assignment happens after wrappers are defined
|
||||
timeoutId = setTimeout(() => {
|
||||
const pending = this.pendingApprovals.get(key);
|
||||
if (pending) {
|
||||
logger.warn(
|
||||
`Plan approval for feature ${featureId} timed out after ${timeoutMinutes} minutes`
|
||||
);
|
||||
this.pendingApprovals.delete(key);
|
||||
wrappedReject(
|
||||
new Error(
|
||||
`Plan approval timed out after ${timeoutMinutes} minutes - feature execution cancelled`
|
||||
)
|
||||
);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingApprovals.set(key, {
|
||||
resolve: wrappedResolve,
|
||||
reject: wrappedReject,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Pending approval registered for feature ${featureId} (timeout: ${timeoutMinutes} minutes)`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve approval. Recovery path: returns needsRecovery=true if planSpec.status='generated'. */
|
||||
async resolveApproval(
|
||||
featureId: string,
|
||||
approved: boolean,
|
||||
options?: { editedPlan?: string; feedback?: string; projectPath?: string }
|
||||
): Promise<ResolveApprovalResult> {
|
||||
const { editedPlan, feedback, projectPath: projectPathFromClient } = options ?? {};
|
||||
|
||||
logger.info(`resolveApproval called for feature ${featureId}, approved=${approved}`);
|
||||
logger.info(
|
||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
|
||||
// Try to find pending approval using project-scoped key if projectPath is available
|
||||
let foundKey: string | undefined;
|
||||
let pending: PendingApproval | undefined;
|
||||
|
||||
if (projectPathFromClient) {
|
||||
foundKey = this.approvalKey(projectPathFromClient, featureId);
|
||||
pending = this.pendingApprovals.get(foundKey);
|
||||
} else {
|
||||
// Fallback: search by featureId (backward compatibility)
|
||||
for (const [key, approval] of this.pendingApprovals) {
|
||||
if (approval.featureId === featureId) {
|
||||
foundKey = key;
|
||||
pending = approval;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pending) {
|
||||
logger.info(`No pending approval in Map for feature ${featureId}`);
|
||||
|
||||
// RECOVERY: If no pending approval but we have projectPath from client,
|
||||
// check if feature's planSpec.status is 'generated' and handle recovery
|
||||
if (projectPathFromClient) {
|
||||
logger.info(`Attempting recovery with projectPath: ${projectPathFromClient}`);
|
||||
const feature = await this.featureStateManager.loadFeature(
|
||||
projectPathFromClient,
|
||||
featureId
|
||||
);
|
||||
|
||||
if (feature?.planSpec?.status === 'generated') {
|
||||
logger.info(`Feature ${featureId} has planSpec.status='generated', performing recovery`);
|
||||
|
||||
if (approved) {
|
||||
// Update planSpec to approved
|
||||
await this.featureStateManager.updateFeaturePlanSpec(projectPathFromClient, featureId, {
|
||||
status: 'approved',
|
||||
approvedAt: new Date().toISOString(),
|
||||
reviewedByUser: true,
|
||||
content: editedPlan || feature.planSpec.content,
|
||||
});
|
||||
|
||||
logger.info(`Recovery approval complete for feature ${featureId}`);
|
||||
|
||||
// Return needsRecovery flag - caller (AutoModeService) handles execution
|
||||
return { success: true, needsRecovery: true };
|
||||
} else {
|
||||
// Rejection recovery
|
||||
await this.featureStateManager.updateFeaturePlanSpec(projectPathFromClient, featureId, {
|
||||
status: 'rejected',
|
||||
reviewedByUser: true,
|
||||
});
|
||||
|
||||
await this.featureStateManager.updateFeatureStatus(
|
||||
projectPathFromClient,
|
||||
featureId,
|
||||
'backlog'
|
||||
);
|
||||
|
||||
this.eventBus.emitAutoModeEvent('plan_rejected', {
|
||||
featureId,
|
||||
projectPath: projectPathFromClient,
|
||||
feedback,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`ERROR: No pending approval found for feature ${featureId} and recovery not possible`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: `No pending approval for feature ${featureId}`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`Found pending approval for feature ${featureId}, proceeding...`);
|
||||
|
||||
const { projectPath } = pending;
|
||||
|
||||
// Update feature's planSpec status
|
||||
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, {
|
||||
status: approved ? 'approved' : 'rejected',
|
||||
approvedAt: approved ? new Date().toISOString() : undefined,
|
||||
reviewedByUser: true,
|
||||
...(editedPlan !== undefined && { content: editedPlan }), // Only update content if user provided an edited version
|
||||
});
|
||||
|
||||
// If rejected, emit event so client knows the rejection reason (even without feedback)
|
||||
if (!approved) {
|
||||
this.eventBus.emitAutoModeEvent('plan_rejected', {
|
||||
featureId,
|
||||
projectPath,
|
||||
feedback,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the promise with all data including feedback
|
||||
// This triggers the wrapped resolve which clears the timeout
|
||||
pending.resolve({ approved, editedPlan, feedback });
|
||||
if (foundKey) {
|
||||
this.pendingApprovals.delete(foundKey);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Cancel approval (e.g., when feature stopped). Timeout cleared via wrapped reject. */
|
||||
cancelApproval(featureId: string, projectPath?: string): void {
|
||||
logger.info(`cancelApproval called for feature ${featureId}`);
|
||||
logger.info(
|
||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
|
||||
// If projectPath provided, use project-scoped key; otherwise search by featureId
|
||||
let foundKey: string | undefined;
|
||||
let pending: PendingApproval | undefined;
|
||||
|
||||
if (projectPath) {
|
||||
foundKey = this.approvalKey(projectPath, featureId);
|
||||
pending = this.pendingApprovals.get(foundKey);
|
||||
} else {
|
||||
// Fallback: search for any approval with this featureId (backward compatibility)
|
||||
for (const [key, approval] of this.pendingApprovals) {
|
||||
if (approval.featureId === featureId) {
|
||||
foundKey = key;
|
||||
pending = approval;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pending && foundKey) {
|
||||
logger.info(`Found and cancelling pending approval for feature ${featureId}`);
|
||||
// Wrapped reject clears timeout automatically
|
||||
pending.reject(new Error('Plan approval cancelled - feature was stopped'));
|
||||
this.pendingApprovals.delete(foundKey);
|
||||
} else {
|
||||
logger.info(`No pending approval to cancel for feature ${featureId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a feature has a pending plan approval. */
|
||||
hasPendingApproval(featureId: string, projectPath?: string): boolean {
|
||||
if (projectPath) {
|
||||
return this.pendingApprovals.has(this.approvalKey(projectPath, featureId));
|
||||
}
|
||||
// Fallback: search by featureId (backward compatibility)
|
||||
for (const approval of this.pendingApprovals.values()) {
|
||||
if (approval.featureId === featureId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Get timeout from project settings or default (30 min). */
|
||||
private async getTimeoutMs(projectPath: string): Promise<number> {
|
||||
if (!this.settingsService) {
|
||||
return DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
try {
|
||||
const projectSettings = await this.settingsService.getProjectSettings(projectPath);
|
||||
// Check for planApprovalTimeoutMs in project settings
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const timeoutMs = (projectSettings as any).planApprovalTimeoutMs;
|
||||
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
|
||||
return timeoutMs;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to get project settings for ${projectPath}, using default timeout`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
302
apps/server/src/services/recovery-service.ts
Normal file
302
apps/server/src/services/recovery-service.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* RecoveryService - Crash recovery and feature resumption
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||
import {
|
||||
createLogger,
|
||||
readJsonWithRecovery,
|
||||
logRecoveryWarning,
|
||||
DEFAULT_BACKUP_COUNT,
|
||||
} from '@automaker/utils';
|
||||
import {
|
||||
getFeatureDir,
|
||||
getFeaturesDir,
|
||||
getExecutionStatePath,
|
||||
ensureAutomakerDir,
|
||||
} from '@automaker/platform';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { getPromptCustomization } from '../lib/settings-helpers.js';
|
||||
import type { TypedEventBus } from './typed-event-bus.js';
|
||||
import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import type { PipelineStatusInfo } from './pipeline-orchestrator.js';
|
||||
|
||||
const logger = createLogger('RecoveryService');
|
||||
|
||||
export interface ExecutionState {
|
||||
version: 1;
|
||||
autoLoopWasRunning: boolean;
|
||||
maxConcurrency: number;
|
||||
projectPath: string;
|
||||
branchName: string | null;
|
||||
runningFeatureIds: string[];
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_EXECUTION_STATE: ExecutionState = {
|
||||
version: 1,
|
||||
autoLoopWasRunning: false,
|
||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||
projectPath: '',
|
||||
branchName: null,
|
||||
runningFeatureIds: [],
|
||||
savedAt: '',
|
||||
};
|
||||
|
||||
export type ExecuteFeatureFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees: boolean,
|
||||
isAutoMode: boolean,
|
||||
providedWorktreePath?: string,
|
||||
options?: { continuationPrompt?: string; _calledInternally?: boolean }
|
||||
) => Promise<void>;
|
||||
export type LoadFeatureFn = (projectPath: string, featureId: string) => Promise<Feature | null>;
|
||||
export type DetectPipelineStatusFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
status: FeatureStatusWithPipeline
|
||||
) => Promise<PipelineStatusInfo>;
|
||||
export type ResumePipelineFn = (
|
||||
projectPath: string,
|
||||
feature: Feature,
|
||||
useWorktrees: boolean,
|
||||
pipelineInfo: PipelineStatusInfo
|
||||
) => Promise<void>;
|
||||
export type IsFeatureRunningFn = (featureId: string) => boolean;
|
||||
export type AcquireRunningFeatureFn = (options: {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
isAutoMode: boolean;
|
||||
allowReuse?: boolean;
|
||||
}) => RunningFeature;
|
||||
export type ReleaseRunningFeatureFn = (featureId: string) => void;
|
||||
|
||||
export class RecoveryService {
|
||||
constructor(
|
||||
private eventBus: TypedEventBus,
|
||||
private concurrencyManager: ConcurrencyManager,
|
||||
private settingsService: SettingsService | null,
|
||||
private executeFeatureFn: ExecuteFeatureFn,
|
||||
private loadFeatureFn: LoadFeatureFn,
|
||||
private detectPipelineStatusFn: DetectPipelineStatusFn,
|
||||
private resumePipelineFn: ResumePipelineFn,
|
||||
private isFeatureRunningFn: IsFeatureRunningFn,
|
||||
private acquireRunningFeatureFn: AcquireRunningFeatureFn,
|
||||
private releaseRunningFeatureFn: ReleaseRunningFeatureFn
|
||||
) {}
|
||||
|
||||
async saveExecutionStateForProject(
|
||||
projectPath: string,
|
||||
branchName: string | null,
|
||||
maxConcurrency: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureAutomakerDir(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(
|
||||
getExecutionStatePath(projectPath),
|
||||
JSON.stringify(state, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async saveExecutionState(
|
||||
projectPath: string,
|
||||
autoLoopWasRunning = false,
|
||||
maxConcurrency = DEFAULT_MAX_CONCURRENCY
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const state: ExecutionState = {
|
||||
version: 1,
|
||||
autoLoopWasRunning,
|
||||
maxConcurrency,
|
||||
projectPath,
|
||||
branchName: null,
|
||||
runningFeatureIds: this.concurrencyManager.getAllRunning().map((rf) => rf.featureId),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
await secureFs.writeFile(
|
||||
getExecutionStatePath(projectPath),
|
||||
JSON.stringify(state, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async loadExecutionState(projectPath: string): Promise<ExecutionState> {
|
||||
try {
|
||||
const content = (await secureFs.readFile(
|
||||
getExecutionStatePath(projectPath),
|
||||
'utf-8'
|
||||
)) as string;
|
||||
return JSON.parse(content) as ExecutionState;
|
||||
} catch {
|
||||
return DEFAULT_EXECUTION_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
async clearExecutionState(projectPath: string, _branchName: string | null = null): Promise<void> {
|
||||
try {
|
||||
await secureFs.unlink(getExecutionStatePath(projectPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
||||
try {
|
||||
await secureFs.access(path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeFeatureWithContext(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
context: string,
|
||||
useWorktrees: boolean
|
||||
): Promise<void> {
|
||||
const feature = await this.loadFeatureFn(projectPath, featureId);
|
||||
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[RecoveryService]');
|
||||
const featurePrompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled Feature'}\n**Description:** ${feature.description}\n`;
|
||||
let prompt = prompts.taskExecution.resumeFeatureTemplate;
|
||||
prompt = prompt
|
||||
.replace(/\{\{featurePrompt\}\}/g, featurePrompt)
|
||||
.replace(/\{\{previousContext\}\}/g, context);
|
||||
return this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, {
|
||||
continuationPrompt: prompt,
|
||||
_calledInternally: true,
|
||||
});
|
||||
}
|
||||
|
||||
async resumeFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees = false,
|
||||
_calledInternally = false
|
||||
): Promise<void> {
|
||||
if (!_calledInternally && this.isFeatureRunningFn(featureId)) return;
|
||||
this.acquireRunningFeatureFn({
|
||||
featureId,
|
||||
projectPath,
|
||||
isAutoMode: false,
|
||||
allowReuse: _calledInternally,
|
||||
});
|
||||
try {
|
||||
const feature = await this.loadFeatureFn(projectPath, featureId);
|
||||
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||
const pipelineInfo = await this.detectPipelineStatusFn(
|
||||
projectPath,
|
||||
featureId,
|
||||
(feature.status || '') as FeatureStatusWithPipeline
|
||||
);
|
||||
if (pipelineInfo.isPipeline)
|
||||
return await this.resumePipelineFn(projectPath, feature, useWorktrees, pipelineInfo);
|
||||
const hasContext = await this.contextExists(projectPath, featureId);
|
||||
if (hasContext) {
|
||||
const context = (await secureFs.readFile(
|
||||
path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'),
|
||||
'utf-8'
|
||||
)) as string;
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_resuming', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
projectPath,
|
||||
hasContext: true,
|
||||
message: `Resuming feature "${feature.title}" from saved context`,
|
||||
});
|
||||
return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
|
||||
}
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_resuming', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
projectPath,
|
||||
hasContext: false,
|
||||
message: `Starting fresh execution for interrupted feature "${feature.title}"`,
|
||||
});
|
||||
return await this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, {
|
||||
_calledInternally: true,
|
||||
});
|
||||
} finally {
|
||||
this.releaseRunningFeatureFn(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
try {
|
||||
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
||||
const featuresWithContext: Feature[] = [];
|
||||
const featuresWithoutContext: Feature[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const result = await readJsonWithRecovery<Feature | null>(
|
||||
path.join(featuresDir, entry.name, 'feature.json'),
|
||||
null,
|
||||
{ maxBackups: DEFAULT_BACKUP_COUNT, autoRestore: true }
|
||||
);
|
||||
logRecoveryWarning(result, `Feature ${entry.name}`, logger);
|
||||
const feature = result.data;
|
||||
if (!feature) continue;
|
||||
if (
|
||||
feature.status === 'in_progress' ||
|
||||
(feature.status && feature.status.startsWith('pipeline_'))
|
||||
) {
|
||||
(await this.contextExists(projectPath, feature.id))
|
||||
? featuresWithContext.push(feature)
|
||||
: featuresWithoutContext.push(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
const allInterruptedFeatures = [...featuresWithContext, ...featuresWithoutContext];
|
||||
if (allInterruptedFeatures.length === 0) return;
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_resuming_features', {
|
||||
message: `Resuming ${allInterruptedFeatures.length} interrupted feature(s)`,
|
||||
projectPath,
|
||||
featureIds: allInterruptedFeatures.map((f) => f.id),
|
||||
features: allInterruptedFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
status: f.status,
|
||||
branchName: f.branchName ?? null,
|
||||
hasContext: featuresWithContext.some((fc) => fc.id === f.id),
|
||||
})),
|
||||
});
|
||||
for (const feature of allInterruptedFeatures) {
|
||||
try {
|
||||
if (!this.isFeatureRunningFn(feature.id))
|
||||
await this.resumeFeature(projectPath, feature.id, true);
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
227
apps/server/src/services/spec-parser.ts
Normal file
227
apps/server/src/services/spec-parser.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Spec Parser - Pure functions for parsing spec content and detecting markers
|
||||
*
|
||||
* Extracts tasks from generated specs, detects progress markers,
|
||||
* and extracts summary content from various formats.
|
||||
*/
|
||||
|
||||
import type { ParsedTask } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Parse a single task line
|
||||
* Format: - [ ] T###: Description | File: path/to/file
|
||||
*/
|
||||
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
|
||||
// Match pattern: - [ ] T###: Description | File: path
|
||||
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
|
||||
if (!taskMatch) {
|
||||
// Try simpler pattern without file
|
||||
const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/);
|
||||
if (simpleMatch) {
|
||||
return {
|
||||
id: simpleMatch[1],
|
||||
description: simpleMatch[2].trim(),
|
||||
phase: currentPhase,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: taskMatch[1],
|
||||
description: taskMatch[2].trim(),
|
||||
filePath: taskMatch[3]?.trim(),
|
||||
phase: currentPhase,
|
||||
status: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tasks from generated spec content
|
||||
* Looks for the ```tasks code block and extracts task lines
|
||||
* Format: - [ ] T###: Description | File: path/to/file
|
||||
*/
|
||||
export function parseTasksFromSpec(specContent: string): ParsedTask[] {
|
||||
const tasks: ParsedTask[] = [];
|
||||
|
||||
// Extract content within ```tasks ... ``` block
|
||||
const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/);
|
||||
if (!tasksBlockMatch) {
|
||||
// Try fallback: look for task lines anywhere in content
|
||||
const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm);
|
||||
if (!taskLines) {
|
||||
return tasks;
|
||||
}
|
||||
// Parse fallback task lines
|
||||
let currentPhase: string | undefined;
|
||||
for (const line of taskLines) {
|
||||
const parsed = parseTaskLine(line, currentPhase);
|
||||
if (parsed) {
|
||||
tasks.push(parsed);
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
const tasksContent = tasksBlockMatch[1];
|
||||
const lines = tasksContent.split('\n');
|
||||
|
||||
let currentPhase: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Check for phase header (e.g., "## Phase 1: Foundation")
|
||||
const phaseMatch = trimmedLine.match(/^##\s*(.+)$/);
|
||||
if (phaseMatch) {
|
||||
currentPhase = phaseMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for task line
|
||||
if (trimmedLine.startsWith('- [ ]')) {
|
||||
const parsed = parseTaskLine(trimmedLine, currentPhase);
|
||||
if (parsed) {
|
||||
tasks.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect [TASK_START] marker in text and extract task ID
|
||||
* Format: [TASK_START] T###: Description
|
||||
*/
|
||||
export function detectTaskStartMarker(text: string): string | null {
|
||||
const match = text.match(/\[TASK_START\]\s*(T\d{3})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect [TASK_COMPLETE] marker in text and extract task ID
|
||||
* Format: [TASK_COMPLETE] T###: Brief summary
|
||||
*/
|
||||
export function detectTaskCompleteMarker(text: string): string | null {
|
||||
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect [PHASE_COMPLETE] marker in text and extract phase number
|
||||
* Format: [PHASE_COMPLETE] Phase N complete
|
||||
*/
|
||||
export function detectPhaseCompleteMarker(text: string): number | null {
|
||||
const match = text.match(/\[PHASE_COMPLETE\]\s*Phase\s*(\d+)/i);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback spec detection when [SPEC_GENERATED] marker is missing
|
||||
* Looks for structural elements that indicate a spec was generated.
|
||||
* This is especially important for non-Claude models that may not output
|
||||
* the explicit [SPEC_GENERATED] marker.
|
||||
*
|
||||
* @param text - The text content to check for spec structure
|
||||
* @returns true if the text appears to be a generated spec
|
||||
*/
|
||||
export function detectSpecFallback(text: string): boolean {
|
||||
// Check for key structural elements of a spec
|
||||
const hasTasksBlock = /```tasks[\s\S]*```/.test(text);
|
||||
const hasTaskLines = /- \[ \] T\d{3}:/.test(text);
|
||||
|
||||
// Check for common spec sections (case-insensitive)
|
||||
const hasAcceptanceCriteria = /acceptance criteria/i.test(text);
|
||||
const hasTechnicalContext = /technical context/i.test(text);
|
||||
const hasProblemStatement = /problem statement/i.test(text);
|
||||
const hasUserStory = /user story/i.test(text);
|
||||
// Additional patterns for different model outputs
|
||||
const hasGoal = /\*\*Goal\*\*:/i.test(text);
|
||||
const hasSolution = /\*\*Solution\*\*:/i.test(text);
|
||||
const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text);
|
||||
const hasOverview = /##\s*(overview|summary)/i.test(text);
|
||||
|
||||
// Spec is detected if we have task structure AND at least some spec content
|
||||
const hasTaskStructure = hasTasksBlock || hasTaskLines;
|
||||
const hasSpecContent =
|
||||
hasAcceptanceCriteria ||
|
||||
hasTechnicalContext ||
|
||||
hasProblemStatement ||
|
||||
hasUserStory ||
|
||||
hasGoal ||
|
||||
hasSolution ||
|
||||
hasImplementation ||
|
||||
hasOverview;
|
||||
|
||||
return hasTaskStructure && hasSpecContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract summary from text content
|
||||
* Checks for multiple formats in order of priority:
|
||||
* 1. Explicit <summary> tags
|
||||
* 2. ## Summary section (markdown)
|
||||
* 3. **Goal**: section (lite planning mode)
|
||||
* 4. **Problem**: or **Problem Statement**: section (spec/full modes)
|
||||
* 5. **Solution**: section as fallback
|
||||
*
|
||||
* Note: Uses last match for each pattern to avoid stale summaries
|
||||
* when agent output accumulates across multiple runs.
|
||||
*
|
||||
* @param text - The text content to extract summary from
|
||||
* @returns The extracted summary string, or null if no summary found
|
||||
*/
|
||||
export function extractSummary(text: string): string | null {
|
||||
// Helper to truncate content to first paragraph with max length
|
||||
const truncate = (content: string, maxLength: number): string => {
|
||||
const firstPara = content.split(/\n\n/)[0];
|
||||
return firstPara.length > maxLength ? `${firstPara.substring(0, maxLength)}...` : firstPara;
|
||||
};
|
||||
|
||||
// Helper to get last match from matchAll results
|
||||
const getLastMatch = (matches: IterableIterator<RegExpMatchArray>): RegExpMatchArray | null => {
|
||||
const arr = [...matches];
|
||||
return arr.length > 0 ? arr[arr.length - 1] : null;
|
||||
};
|
||||
|
||||
// Check for explicit <summary> tags first (use last match to avoid stale summaries)
|
||||
const summaryMatches = text.matchAll(/<summary>([\s\S]*?)<\/summary>/g);
|
||||
const summaryMatch = getLastMatch(summaryMatches);
|
||||
if (summaryMatch) {
|
||||
return summaryMatch[1].trim();
|
||||
}
|
||||
|
||||
// Check for ## Summary section (use last match)
|
||||
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi);
|
||||
const sectionMatch = getLastMatch(sectionMatches);
|
||||
if (sectionMatch) {
|
||||
return truncate(sectionMatch[1].trim(), 500);
|
||||
}
|
||||
|
||||
// Check for **Goal**: section (lite mode, use last match)
|
||||
const goalMatches = text.matchAll(/\*\*Goal\*\*:\s*(.+?)(?:\n|$)/gi);
|
||||
const goalMatch = getLastMatch(goalMatches);
|
||||
if (goalMatch) {
|
||||
return goalMatch[1].trim();
|
||||
}
|
||||
|
||||
// Check for **Problem**: or **Problem Statement**: section (spec/full modes, use last match)
|
||||
const problemMatches = text.matchAll(
|
||||
/\*\*Problem(?:\s*Statement)?\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi
|
||||
);
|
||||
const problemMatch = getLastMatch(problemMatches);
|
||||
if (problemMatch) {
|
||||
return truncate(problemMatch[1].trim(), 500);
|
||||
}
|
||||
|
||||
// Check for **Solution**: section as fallback (use last match)
|
||||
const solutionMatches = text.matchAll(/\*\*Solution\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi);
|
||||
const solutionMatch = getLastMatch(solutionMatches);
|
||||
if (solutionMatch) {
|
||||
return truncate(solutionMatch[1].trim(), 300);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
108
apps/server/src/services/typed-event-bus.ts
Normal file
108
apps/server/src/services/typed-event-bus.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* TypedEventBus - Type-safe event emission wrapper for AutoModeService
|
||||
*
|
||||
* This class wraps the existing EventEmitter to provide type-safe event emission,
|
||||
* specifically encapsulating the `emitAutoModeEvent` pattern used throughout AutoModeService.
|
||||
*
|
||||
* Key behavior:
|
||||
* - emitAutoModeEvent wraps events in 'auto-mode:event' format for frontend consumption
|
||||
* - Preserves all existing event emission patterns for backward compatibility
|
||||
* - Frontend receives events in the exact same format as before (no breaking changes)
|
||||
*/
|
||||
|
||||
import type { EventEmitter, EventType, EventCallback } from '../lib/events.js';
|
||||
|
||||
/**
|
||||
* Auto-mode event types that can be emitted through the TypedEventBus.
|
||||
* These correspond to the event types expected by the frontend.
|
||||
*/
|
||||
export type AutoModeEventType =
|
||||
| 'auto_mode_started'
|
||||
| 'auto_mode_stopped'
|
||||
| 'auto_mode_idle'
|
||||
| 'auto_mode_error'
|
||||
| 'auto_mode_paused_failures'
|
||||
| 'auto_mode_feature_start'
|
||||
| 'auto_mode_feature_complete'
|
||||
| 'auto_mode_feature_resuming'
|
||||
| 'auto_mode_progress'
|
||||
| 'auto_mode_tool'
|
||||
| 'auto_mode_task_started'
|
||||
| 'auto_mode_task_complete'
|
||||
| 'auto_mode_task_status'
|
||||
| 'auto_mode_phase_complete'
|
||||
| 'auto_mode_summary'
|
||||
| 'auto_mode_resuming_features'
|
||||
| 'planning_started'
|
||||
| 'plan_approval_required'
|
||||
| 'plan_approved'
|
||||
| 'plan_auto_approved'
|
||||
| 'plan_rejected'
|
||||
| 'plan_revision_requested'
|
||||
| 'plan_revision_warning'
|
||||
| 'pipeline_step_started'
|
||||
| 'pipeline_step_complete'
|
||||
| string; // Allow other strings for extensibility
|
||||
|
||||
/**
|
||||
* TypedEventBus wraps an EventEmitter to provide type-safe event emission
|
||||
* with the auto-mode event wrapping pattern.
|
||||
*/
|
||||
export class TypedEventBus {
|
||||
private events: EventEmitter;
|
||||
|
||||
/**
|
||||
* Create a TypedEventBus wrapping an existing EventEmitter.
|
||||
* @param events - The underlying EventEmitter to wrap
|
||||
*/
|
||||
constructor(events: EventEmitter) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a raw event directly to subscribers.
|
||||
* Use this for non-auto-mode events that don't need wrapping.
|
||||
* @param type - The event type
|
||||
* @param payload - The event payload
|
||||
*/
|
||||
emit(type: EventType, payload: unknown): void {
|
||||
this.events.emit(type, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an auto-mode event wrapped in the correct format for the client.
|
||||
* All auto-mode events are sent as type "auto-mode:event" with the actual
|
||||
* event type and data in the payload.
|
||||
*
|
||||
* This produces the exact same event format that the frontend expects:
|
||||
* { type: eventType, ...data }
|
||||
*
|
||||
* @param eventType - The auto-mode event type (e.g., 'auto_mode_started')
|
||||
* @param data - Additional data to include in the event payload
|
||||
*/
|
||||
emitAutoModeEvent(eventType: AutoModeEventType, data: Record<string, unknown>): void {
|
||||
// Wrap the event in auto-mode:event format expected by the client
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: eventType,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all events from the underlying emitter.
|
||||
* @param callback - Function called with (type, payload) for each event
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
subscribe(callback: EventCallback): () => void {
|
||||
return this.events.subscribe(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying EventEmitter for cases where direct access is needed.
|
||||
* Use sparingly - prefer the typed methods when possible.
|
||||
* @returns The wrapped EventEmitter
|
||||
*/
|
||||
getUnderlyingEmitter(): EventEmitter {
|
||||
return this.events;
|
||||
}
|
||||
}
|
||||
170
apps/server/src/services/worktree-resolver.ts
Normal file
170
apps/server/src/services/worktree-resolver.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* WorktreeResolver - Git worktree discovery and resolution
|
||||
*
|
||||
* Extracted from AutoModeService to provide a standalone service for:
|
||||
* - Finding existing worktrees for a given branch
|
||||
* - Getting the current branch of a repository
|
||||
* - Listing all worktrees with their metadata
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Parses `git worktree list --porcelain` output
|
||||
* - Always resolves paths to absolute (cross-platform compatibility)
|
||||
* - Handles detached HEAD and bare worktrees gracefully
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Information about a git worktree
|
||||
*/
|
||||
export interface WorktreeInfo {
|
||||
/** Absolute path to the worktree directory */
|
||||
path: string;
|
||||
/** Branch name (without refs/heads/ prefix), or null if detached HEAD */
|
||||
branch: string | null;
|
||||
/** Whether this is the main worktree (first in git worktree list) */
|
||||
isMain: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WorktreeResolver handles git worktree discovery and path resolution.
|
||||
*
|
||||
* This service is responsible for:
|
||||
* 1. Finding existing worktrees by branch name
|
||||
* 2. Getting the current branch of a repository
|
||||
* 3. Listing all worktrees with normalized paths
|
||||
*/
|
||||
export class WorktreeResolver {
|
||||
/**
|
||||
* Get the current branch name for a git repository
|
||||
*
|
||||
* @param projectPath - Path to the git repository
|
||||
* @returns The current branch name, or null if not in a git repo or on detached HEAD
|
||||
*/
|
||||
async getCurrentBranch(projectPath: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath });
|
||||
const branch = stdout.trim();
|
||||
return branch || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing worktree for a given branch name
|
||||
*
|
||||
* @param projectPath - Path to the git repository (main worktree)
|
||||
* @param branchName - Branch name to find worktree for
|
||||
* @returns Absolute path to the worktree, or null if not found
|
||||
*/
|
||||
async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
const lines = stdout.split('\n');
|
||||
let currentPath: string | null = null;
|
||||
let currentBranch: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
currentPath = line.slice(9);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
currentBranch = line.slice(7).replace('refs/heads/', '');
|
||||
} else if (line === '' && currentPath && currentBranch) {
|
||||
// End of a worktree entry
|
||||
if (currentBranch === branchName) {
|
||||
// Resolve to absolute path - git may return relative paths
|
||||
// On Windows, this is critical for cwd to work correctly
|
||||
// On all platforms, absolute paths ensure consistent behavior
|
||||
return this.resolvePath(projectPath, currentPath);
|
||||
}
|
||||
currentPath = null;
|
||||
currentBranch = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the last entry (if file doesn't end with newline)
|
||||
if (currentPath && currentBranch && currentBranch === branchName) {
|
||||
return this.resolvePath(projectPath, currentPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all worktrees for a repository
|
||||
*
|
||||
* @param projectPath - Path to the git repository
|
||||
* @returns Array of WorktreeInfo objects with normalized paths
|
||||
*/
|
||||
async listWorktrees(projectPath: string): Promise<WorktreeInfo[]> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
const worktrees: WorktreeInfo[] = [];
|
||||
const lines = stdout.split('\n');
|
||||
let currentPath: string | null = null;
|
||||
let currentBranch: string | null = null;
|
||||
let isFirstWorktree = true;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
currentPath = line.slice(9);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
currentBranch = line.slice(7).replace('refs/heads/', '');
|
||||
} else if (line.startsWith('detached')) {
|
||||
// Detached HEAD - branch is null
|
||||
currentBranch = null;
|
||||
} else if (line === '' && currentPath) {
|
||||
// End of a worktree entry
|
||||
worktrees.push({
|
||||
path: this.resolvePath(projectPath, currentPath),
|
||||
branch: currentBranch,
|
||||
isMain: isFirstWorktree,
|
||||
});
|
||||
currentPath = null;
|
||||
currentBranch = null;
|
||||
isFirstWorktree = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle last entry if file doesn't end with newline
|
||||
if (currentPath) {
|
||||
worktrees.push({
|
||||
path: this.resolvePath(projectPath, currentPath),
|
||||
branch: currentBranch,
|
||||
isMain: isFirstWorktree,
|
||||
});
|
||||
}
|
||||
|
||||
return worktrees;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path to absolute, handling both relative and absolute inputs
|
||||
*
|
||||
* @param projectPath - Base path for relative resolution
|
||||
* @param worktreePath - Path from git worktree list output
|
||||
* @returns Absolute path
|
||||
*/
|
||||
private resolvePath(projectPath: string, worktreePath: string): string {
|
||||
return path.isAbsolute(worktreePath)
|
||||
? path.resolve(worktreePath)
|
||||
: path.resolve(projectPath, worktreePath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user