mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10:03:08 +00:00
- Replace stream processing loop with AgentExecutor.execute() delegation - Build AgentExecutionOptions object from runAgent() parameters - Create callbacks for waitForApproval, saveFeatureSummary, etc. - Remove ~930 lines of duplicated stream processing code - Progress events now flow through AgentExecutor File: auto-mode-service.ts reduced from 5086 to 4157 lines
4266 lines
151 KiB
TypeScript
4266 lines
151 KiB
TypeScript
/**
|
|
* Auto Mode Service - Autonomous feature implementation using Claude Agent SDK
|
|
*
|
|
* Manages:
|
|
* - Worktree creation for isolated development
|
|
* - Feature execution with Claude
|
|
* - Concurrent execution with max concurrency limits
|
|
* - Progress streaming via events
|
|
* - Verification and merge workflows
|
|
*/
|
|
|
|
import { ProviderFactory } from '../providers/provider-factory.js';
|
|
import { simpleQuery } from '../providers/simple-query-service.js';
|
|
import type {
|
|
ExecuteOptions,
|
|
Feature,
|
|
ModelProvider,
|
|
PipelineStep,
|
|
FeatureStatusWithPipeline,
|
|
PipelineConfig,
|
|
ThinkingLevel,
|
|
PlanningMode,
|
|
ParsedTask,
|
|
PlanSpec,
|
|
} from '@automaker/types';
|
|
import {
|
|
DEFAULT_PHASE_MODELS,
|
|
DEFAULT_MAX_CONCURRENCY,
|
|
isClaudeModel,
|
|
stripProviderPrefix,
|
|
} from '@automaker/types';
|
|
import {
|
|
buildPromptWithImages,
|
|
classifyError,
|
|
loadContextFiles,
|
|
appendLearning,
|
|
recordMemoryUsage,
|
|
createLogger,
|
|
atomicWriteJson,
|
|
readJsonWithRecovery,
|
|
logRecoveryWarning,
|
|
DEFAULT_BACKUP_COUNT,
|
|
} from '@automaker/utils';
|
|
|
|
const logger = createLogger('AutoMode');
|
|
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver';
|
|
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
|
|
import {
|
|
getFeatureDir,
|
|
getAutomakerDir,
|
|
getFeaturesDir,
|
|
getExecutionStatePath,
|
|
ensureAutomakerDir,
|
|
} from '@automaker/platform';
|
|
import { exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import path from 'path';
|
|
import * as secureFs from '../lib/secure-fs.js';
|
|
import type { EventEmitter } from '../lib/events.js';
|
|
import {
|
|
createAutoModeOptions,
|
|
createCustomOptions,
|
|
validateWorkingDirectory,
|
|
} from '../lib/sdk-options.js';
|
|
import { FeatureLoader } from './feature-loader.js';
|
|
import {
|
|
ConcurrencyManager,
|
|
type RunningFeature,
|
|
type GetCurrentBranchFn,
|
|
} from './concurrency-manager.js';
|
|
import { TypedEventBus } from './typed-event-bus.js';
|
|
import { WorktreeResolver } from './worktree-resolver.js';
|
|
import { FeatureStateManager } from './feature-state-manager.js';
|
|
import { PlanApprovalService } from './plan-approval-service.js';
|
|
import type { SettingsService } from './settings-service.js';
|
|
import { pipelineService, PipelineService } from './pipeline-service.js';
|
|
import {
|
|
getAutoLoadClaudeMdSetting,
|
|
filterClaudeMdFromContext,
|
|
getMCPServersFromSettings,
|
|
getPromptCustomization,
|
|
getProviderByModelId,
|
|
getPhaseModelWithOverrides,
|
|
} from '../lib/settings-helpers.js';
|
|
import { getNotificationService } from './notification-service.js';
|
|
import {
|
|
parseTasksFromSpec,
|
|
detectTaskStartMarker,
|
|
detectTaskCompleteMarker,
|
|
detectPhaseCompleteMarker,
|
|
detectSpecFallback,
|
|
extractSummary,
|
|
} from './spec-parser.js';
|
|
import { AgentExecutor } from './agent-executor.js';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
// ParsedTask and PlanSpec types are imported from @automaker/types
|
|
|
|
/**
|
|
* Information about pipeline status when resuming a feature.
|
|
* Used to determine how to handle features stuck in pipeline execution.
|
|
*
|
|
* @property {boolean} isPipeline - Whether the feature is in a pipeline step
|
|
* @property {string | null} stepId - ID of the current pipeline step (e.g., 'step_123')
|
|
* @property {number} stepIndex - Index of the step in the sorted pipeline steps (-1 if not found)
|
|
* @property {number} totalSteps - Total number of steps in the pipeline
|
|
* @property {PipelineStep | null} step - The pipeline step configuration, or null if step not found
|
|
* @property {PipelineConfig | null} config - The full pipeline configuration, or null if no pipeline
|
|
*/
|
|
interface PipelineStatusInfo {
|
|
isPipeline: boolean;
|
|
stepId: string | null;
|
|
stepIndex: number;
|
|
totalSteps: number;
|
|
step: PipelineStep | null;
|
|
config: PipelineConfig | null;
|
|
}
|
|
|
|
// Spec parsing functions are imported from spec-parser.js
|
|
|
|
// Feature type is imported from feature-loader.js
|
|
// Extended type with planning fields for local use
|
|
interface FeatureWithPlanning extends Feature {
|
|
planningMode?: PlanningMode;
|
|
planSpec?: PlanSpec;
|
|
requirePlanApproval?: boolean;
|
|
}
|
|
|
|
interface AutoLoopState {
|
|
projectPath: string;
|
|
maxConcurrency: number;
|
|
abortController: AbortController;
|
|
isRunning: boolean;
|
|
}
|
|
|
|
// PendingApproval interface moved to PlanApprovalService
|
|
|
|
interface AutoModeConfig {
|
|
maxConcurrency: number;
|
|
useWorktrees: boolean;
|
|
projectPath: string;
|
|
branchName: string | null; // null = main worktree
|
|
}
|
|
|
|
/**
|
|
* Generate a unique key for worktree-scoped auto loop state
|
|
* @param projectPath - The project path
|
|
* @param branchName - The branch name, or null for main worktree
|
|
*/
|
|
function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
|
|
const normalizedBranch = branchName === 'main' ? null : branchName;
|
|
return `${projectPath}::${normalizedBranch ?? '__main__'}`;
|
|
}
|
|
|
|
/**
|
|
* Per-worktree autoloop state for multi-project/worktree support
|
|
*/
|
|
interface ProjectAutoLoopState {
|
|
abortController: AbortController;
|
|
config: AutoModeConfig;
|
|
isRunning: boolean;
|
|
consecutiveFailures: { timestamp: number; error: string }[];
|
|
pausedDueToFailures: boolean;
|
|
hasEmittedIdleEvent: boolean;
|
|
branchName: string | null; // null = main worktree
|
|
}
|
|
|
|
/**
|
|
* Execution state for recovery after server restart
|
|
* Tracks which features were running and auto-loop configuration
|
|
*/
|
|
interface ExecutionState {
|
|
version: 1;
|
|
autoLoopWasRunning: boolean;
|
|
maxConcurrency: number;
|
|
projectPath: string;
|
|
branchName: string | null; // null = main worktree
|
|
runningFeatureIds: string[];
|
|
savedAt: string;
|
|
}
|
|
|
|
// Default empty execution state
|
|
const DEFAULT_EXECUTION_STATE: ExecutionState = {
|
|
version: 1,
|
|
autoLoopWasRunning: false,
|
|
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
|
projectPath: '',
|
|
branchName: null,
|
|
runningFeatureIds: [],
|
|
savedAt: '',
|
|
};
|
|
|
|
// Constants for consecutive failure tracking
|
|
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
|
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
|
|
|
export class AutoModeService {
|
|
private events: EventEmitter;
|
|
private eventBus: TypedEventBus;
|
|
private concurrencyManager: ConcurrencyManager;
|
|
private worktreeResolver: WorktreeResolver;
|
|
private featureStateManager: FeatureStateManager;
|
|
private autoLoop: AutoLoopState | null = null;
|
|
private featureLoader = new FeatureLoader();
|
|
// Per-project autoloop state (supports multiple concurrent projects)
|
|
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
|
|
// Legacy single-project properties (kept for backward compatibility during transition)
|
|
private autoLoopRunning = false;
|
|
private autoLoopAbortController: AbortController | null = null;
|
|
private config: AutoModeConfig | null = null;
|
|
private planApprovalService: PlanApprovalService;
|
|
private agentExecutor: AgentExecutor;
|
|
private settingsService: SettingsService | null = null;
|
|
// Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject)
|
|
private consecutiveFailures: { timestamp: number; error: string }[] = [];
|
|
private pausedDueToFailures = false;
|
|
// Track if idle event has been emitted (legacy, now per-project in autoLoopsByProject)
|
|
private hasEmittedIdleEvent = false;
|
|
|
|
constructor(
|
|
events: EventEmitter,
|
|
settingsService?: SettingsService,
|
|
concurrencyManager?: ConcurrencyManager,
|
|
eventBus?: TypedEventBus,
|
|
worktreeResolver?: WorktreeResolver,
|
|
featureStateManager?: FeatureStateManager,
|
|
planApprovalService?: PlanApprovalService,
|
|
agentExecutor?: AgentExecutor
|
|
) {
|
|
this.events = events;
|
|
this.eventBus = eventBus ?? new TypedEventBus(events);
|
|
this.settingsService = settingsService ?? null;
|
|
this.worktreeResolver = worktreeResolver ?? new WorktreeResolver();
|
|
this.featureStateManager =
|
|
featureStateManager ?? new FeatureStateManager(events, this.featureLoader);
|
|
// Pass the WorktreeResolver's getCurrentBranch to ConcurrencyManager for worktree counting
|
|
this.concurrencyManager =
|
|
concurrencyManager ??
|
|
new ConcurrencyManager((projectPath) => this.worktreeResolver.getCurrentBranch(projectPath));
|
|
this.planApprovalService =
|
|
planApprovalService ??
|
|
new PlanApprovalService(this.eventBus, this.featureStateManager, this.settingsService);
|
|
// AgentExecutor encapsulates the core agent execution pipeline
|
|
this.agentExecutor =
|
|
agentExecutor ??
|
|
new AgentExecutor(
|
|
this.eventBus,
|
|
this.featureStateManager,
|
|
this.planApprovalService,
|
|
this.settingsService
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Acquire a slot in the runningFeatures map for a feature.
|
|
* Delegates to ConcurrencyManager for lease-based reference counting.
|
|
*
|
|
* @param params.featureId - ID of the feature to track
|
|
* @param params.projectPath - Path to the project
|
|
* @param params.isAutoMode - Whether this is an auto-mode execution
|
|
* @param params.allowReuse - If true, allows incrementing leaseCount for already-running features
|
|
* @param params.abortController - Optional abort controller to use
|
|
* @returns The RunningFeature entry (existing or newly created)
|
|
* @throws Error if feature is already running and allowReuse is false
|
|
*/
|
|
private acquireRunningFeature(params: {
|
|
featureId: string;
|
|
projectPath: string;
|
|
isAutoMode: boolean;
|
|
allowReuse?: boolean;
|
|
abortController?: AbortController;
|
|
}): RunningFeature {
|
|
return this.concurrencyManager.acquire(params);
|
|
}
|
|
|
|
/**
|
|
* Release a slot in the runningFeatures map for a feature.
|
|
* Delegates to ConcurrencyManager for lease-based reference counting.
|
|
*
|
|
* @param featureId - ID of the feature to release
|
|
* @param options.force - If true, immediately removes the entry regardless of leaseCount
|
|
*/
|
|
private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
|
|
this.concurrencyManager.release(featureId, options);
|
|
}
|
|
|
|
/**
|
|
* Reset features that were stuck in transient states due to server crash
|
|
* Called when auto mode is enabled to clean up from previous session
|
|
* @param projectPath - The project path to reset features for
|
|
*/
|
|
async resetStuckFeatures(projectPath: string): Promise<void> {
|
|
await this.featureStateManager.resetStuckFeatures(projectPath);
|
|
}
|
|
|
|
/**
|
|
* Track a failure and check if we should pause due to consecutive failures.
|
|
* This handles cases where the SDK doesn't return useful error messages.
|
|
* @param projectPath - The project to track failure for
|
|
* @param errorInfo - Error information
|
|
*/
|
|
private trackFailureAndCheckPauseForProject(
|
|
projectPath: string,
|
|
errorInfo: { type: string; message: string }
|
|
): boolean {
|
|
const projectState = this.autoLoopsByProject.get(projectPath);
|
|
if (!projectState) {
|
|
// Fall back to legacy global tracking
|
|
return this.trackFailureAndCheckPause(errorInfo);
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
// Add this failure
|
|
projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
|
|
|
|
// Remove old failures outside the window
|
|
projectState.consecutiveFailures = projectState.consecutiveFailures.filter(
|
|
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
|
);
|
|
|
|
// Check if we've hit the threshold
|
|
if (projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
|
return true; // Should pause
|
|
}
|
|
|
|
// Also immediately pause for known quota/rate limit errors
|
|
if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Track a failure and check if we should pause due to consecutive failures (legacy global).
|
|
*/
|
|
private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean {
|
|
const now = Date.now();
|
|
|
|
// Add this failure
|
|
this.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
|
|
|
|
// Remove old failures outside the window
|
|
this.consecutiveFailures = this.consecutiveFailures.filter(
|
|
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
|
);
|
|
|
|
// Check if we've hit the threshold
|
|
if (this.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
|
return true; // Should pause
|
|
}
|
|
|
|
// Also immediately pause for known quota/rate limit errors
|
|
if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Signal that we should pause due to repeated failures or quota exhaustion.
|
|
* This will pause the auto loop for a specific project.
|
|
* @param projectPath - The project to pause
|
|
* @param errorInfo - Error information
|
|
*/
|
|
private signalShouldPauseForProject(
|
|
projectPath: string,
|
|
errorInfo: { type: string; message: string }
|
|
): void {
|
|
const projectState = this.autoLoopsByProject.get(projectPath);
|
|
if (!projectState) {
|
|
// Fall back to legacy global pause
|
|
this.signalShouldPause(errorInfo);
|
|
return;
|
|
}
|
|
|
|
if (projectState.pausedDueToFailures) {
|
|
return; // Already paused
|
|
}
|
|
|
|
projectState.pausedDueToFailures = true;
|
|
const failureCount = projectState.consecutiveFailures.length;
|
|
logger.info(
|
|
`Pausing auto loop for ${projectPath} after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
|
|
);
|
|
|
|
// Emit event to notify UI
|
|
this.eventBus.emitAutoModeEvent('auto_mode_paused_failures', {
|
|
message:
|
|
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
|
? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
|
|
: 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
|
|
errorType: errorInfo.type,
|
|
originalError: errorInfo.message,
|
|
failureCount,
|
|
projectPath,
|
|
});
|
|
|
|
// Stop the auto loop for this project
|
|
this.stopAutoLoopForProject(projectPath);
|
|
}
|
|
|
|
/**
|
|
* Signal that we should pause due to repeated failures or quota exhaustion (legacy global).
|
|
*/
|
|
private signalShouldPause(errorInfo: { type: string; message: string }): void {
|
|
if (this.pausedDueToFailures) {
|
|
return; // Already paused
|
|
}
|
|
|
|
this.pausedDueToFailures = true;
|
|
const failureCount = this.consecutiveFailures.length;
|
|
logger.info(
|
|
`Pausing auto loop after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
|
|
);
|
|
|
|
// Emit event to notify UI
|
|
this.eventBus.emitAutoModeEvent('auto_mode_paused_failures', {
|
|
message:
|
|
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
|
? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
|
|
: 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
|
|
errorType: errorInfo.type,
|
|
originalError: errorInfo.message,
|
|
failureCount,
|
|
projectPath: this.config?.projectPath,
|
|
});
|
|
|
|
// Stop the auto loop
|
|
this.stopAutoLoop();
|
|
}
|
|
|
|
/**
|
|
* Reset failure tracking for a specific project
|
|
* @param projectPath - The project to reset failure tracking for
|
|
*/
|
|
private resetFailureTrackingForProject(projectPath: string): void {
|
|
const projectState = this.autoLoopsByProject.get(projectPath);
|
|
if (projectState) {
|
|
projectState.consecutiveFailures = [];
|
|
projectState.pausedDueToFailures = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset failure tracking (called when user manually restarts auto mode) - legacy global
|
|
*/
|
|
private resetFailureTracking(): void {
|
|
this.consecutiveFailures = [];
|
|
this.pausedDueToFailures = false;
|
|
}
|
|
|
|
/**
|
|
* Record a successful feature completion to reset consecutive failure count for a project
|
|
* @param projectPath - The project to record success for
|
|
*/
|
|
private recordSuccessForProject(projectPath: string): void {
|
|
const projectState = this.autoLoopsByProject.get(projectPath);
|
|
if (projectState) {
|
|
projectState.consecutiveFailures = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a successful feature completion to reset consecutive failure count - legacy global
|
|
*/
|
|
private recordSuccess(): void {
|
|
this.consecutiveFailures = [];
|
|
}
|
|
|
|
private 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((project) => project.path === projectPath)?.id;
|
|
const autoModeByWorktree = settings.autoModeByWorktree;
|
|
|
|
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
|
|
// Normalize branch name to match UI convention:
|
|
// - null or "main" -> "__main__" (UI treats "main" as the main worktree)
|
|
// This ensures consistency with how the UI stores worktree settings
|
|
const normalizedBranch = branchName === 'main' ? null : branchName;
|
|
const key = `${projectId}::${normalizedBranch ?? '__main__'}`;
|
|
const entry = autoModeByWorktree[key];
|
|
if (entry && typeof entry.maxConcurrency === 'number') {
|
|
return entry.maxConcurrency;
|
|
}
|
|
}
|
|
|
|
return globalMax;
|
|
} catch {
|
|
return DEFAULT_MAX_CONCURRENCY;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
|
logger.info(
|
|
`Starting auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
|
|
);
|
|
|
|
// Reset any features that were stuck in transient states due to previous server crash
|
|
try {
|
|
await this.resetStuckFeatures(projectPath);
|
|
} catch (error) {
|
|
logger.warn(`[startAutoLoopForProject] Error resetting stuck features:`, error);
|
|
// Don't fail startup due to reset errors
|
|
}
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_started', {
|
|
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
|
|
projectPath,
|
|
branchName,
|
|
maxConcurrency: resolvedMaxConcurrency,
|
|
});
|
|
|
|
// Save execution state for recovery after restart
|
|
await this.saveExecutionStateForProject(projectPath, branchName, resolvedMaxConcurrency);
|
|
|
|
// Run the loop in the background
|
|
this.runAutoLoopForProject(worktreeKey).catch((error) => {
|
|
const worktreeDescErr = branchName ? `worktree ${branchName}` : 'main worktree';
|
|
logger.error(`Loop error for ${worktreeDescErr} in ${projectPath}:`, error);
|
|
const errorInfo = classifyError(error);
|
|
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
|
error: errorInfo.message,
|
|
errorType: errorInfo.type,
|
|
projectPath,
|
|
branchName,
|
|
});
|
|
});
|
|
|
|
return resolvedMaxConcurrency;
|
|
}
|
|
|
|
/**
|
|
* Run the auto loop for a specific project/worktree
|
|
* @param worktreeKey - The worktree key (projectPath::branchName or projectPath::__main__)
|
|
*/
|
|
private async runAutoLoopForProject(worktreeKey: string): Promise<void> {
|
|
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
|
if (!projectState) {
|
|
logger.warn(`No project state found for ${worktreeKey}, stopping loop`);
|
|
return;
|
|
}
|
|
|
|
const { projectPath, branchName } = projectState.config;
|
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
|
|
|
logger.info(
|
|
`[AutoLoop] Starting loop for ${worktreeDesc} in ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}`
|
|
);
|
|
let iterationCount = 0;
|
|
|
|
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
|
iterationCount++;
|
|
try {
|
|
// Count running features for THIS project/worktree only
|
|
const projectRunningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
|
|
|
// Check if we have capacity for this project/worktree
|
|
if (projectRunningCount >= projectState.config.maxConcurrency) {
|
|
logger.debug(
|
|
`[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...`
|
|
);
|
|
await this.sleep(5000);
|
|
continue;
|
|
}
|
|
|
|
// Load pending features for this project/worktree
|
|
const pendingFeatures = await this.loadPendingFeatures(projectPath, branchName);
|
|
|
|
logger.info(
|
|
`[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount}/${projectState.config.maxConcurrency} running for ${worktreeDesc}`
|
|
);
|
|
|
|
if (pendingFeatures.length === 0) {
|
|
// Emit idle event only once when backlog is empty AND no features are running
|
|
if (projectRunningCount === 0 && !projectState.hasEmittedIdleEvent) {
|
|
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
|
message: 'No pending features - auto mode idle',
|
|
projectPath,
|
|
branchName,
|
|
});
|
|
projectState.hasEmittedIdleEvent = true;
|
|
logger.info(`[AutoLoop] Backlog complete, auto mode now idle for ${worktreeDesc}`);
|
|
} else if (projectRunningCount > 0) {
|
|
logger.info(
|
|
`[AutoLoop] No pending features available, ${projectRunningCount} still running, waiting...`
|
|
);
|
|
} else {
|
|
logger.warn(
|
|
`[AutoLoop] No pending features found for ${worktreeDesc} (branchName: ${branchName === null ? 'null (main)' : branchName}). Check server logs for filtering details.`
|
|
);
|
|
}
|
|
await this.sleep(10000);
|
|
continue;
|
|
}
|
|
|
|
// Find a feature not currently running and not yet finished
|
|
const nextFeature = pendingFeatures.find(
|
|
(f) => !this.concurrencyManager.isRunning(f.id) && !this.isFeatureFinished(f)
|
|
);
|
|
|
|
if (nextFeature) {
|
|
logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`);
|
|
// Reset idle event flag since we're doing work again
|
|
projectState.hasEmittedIdleEvent = false;
|
|
// Start feature execution in background
|
|
this.executeFeature(
|
|
projectPath,
|
|
nextFeature.id,
|
|
projectState.config.useWorktrees,
|
|
true
|
|
).catch((error) => {
|
|
logger.error(`Feature ${nextFeature.id} error:`, error);
|
|
});
|
|
} else {
|
|
logger.debug(`[AutoLoop] All pending features are already running`);
|
|
}
|
|
|
|
await this.sleep(2000);
|
|
} catch (error) {
|
|
logger.error(`[AutoLoop] Loop iteration error for ${projectPath}:`, error);
|
|
await this.sleep(5000);
|
|
}
|
|
}
|
|
|
|
// Mark as not running when loop exits
|
|
projectState.isRunning = false;
|
|
logger.info(
|
|
`[AutoLoop] Loop stopped for project: ${projectPath} after ${iterationCount} iterations`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get count of running features for a specific project
|
|
* Delegates to ConcurrencyManager.
|
|
*/
|
|
private getRunningCountForProject(projectPath: string): number {
|
|
return this.concurrencyManager.getRunningCount(projectPath);
|
|
}
|
|
|
|
/**
|
|
* Get count of running features for a specific worktree
|
|
* Delegates to ConcurrencyManager.
|
|
* @param projectPath - The project path
|
|
* @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch)
|
|
*/
|
|
private async getRunningCountForWorktree(
|
|
projectPath: string,
|
|
branchName: string | null
|
|
): Promise<number> {
|
|
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName);
|
|
}
|
|
|
|
/**
|
|
* Stop the auto mode loop for a specific project/worktree
|
|
* @param projectPath - The project to stop auto mode for
|
|
* @param branchName - The branch name, or null for main worktree
|
|
*/
|
|
async stopAutoLoopForProject(
|
|
projectPath: string,
|
|
branchName: string | null = null
|
|
): Promise<number> {
|
|
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
|
|
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
|
if (!projectState) {
|
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
|
logger.warn(`No auto loop running for ${worktreeDesc} in project: ${projectPath}`);
|
|
return 0;
|
|
}
|
|
|
|
const wasRunning = projectState.isRunning;
|
|
projectState.isRunning = false;
|
|
projectState.abortController.abort();
|
|
|
|
// Clear execution state when auto-loop is explicitly stopped
|
|
await this.clearExecutionState(projectPath, branchName);
|
|
|
|
// Emit stop event
|
|
if (wasRunning) {
|
|
this.eventBus.emitAutoModeEvent('auto_mode_stopped', {
|
|
message: 'Auto mode stopped',
|
|
projectPath,
|
|
branchName,
|
|
});
|
|
}
|
|
|
|
// Remove from map
|
|
this.autoLoopsByProject.delete(worktreeKey);
|
|
|
|
return await this.getRunningCountForWorktree(projectPath, branchName);
|
|
}
|
|
|
|
/**
|
|
* Check if auto mode is running for a specific project/worktree
|
|
* @param projectPath - The project path
|
|
* @param branchName - The branch name, or null for main worktree
|
|
*/
|
|
isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Save execution state for a specific project/worktree
|
|
* @param projectPath - The project path
|
|
* @param branchName - The branch name, or null for main worktree
|
|
* @param maxConcurrency - Maximum concurrent features
|
|
*/
|
|
private async saveExecutionStateForProject(
|
|
projectPath: string,
|
|
branchName: string | null,
|
|
maxConcurrency: number
|
|
): Promise<void> {
|
|
try {
|
|
await ensureAutomakerDir(projectPath);
|
|
const statePath = getExecutionStatePath(projectPath);
|
|
const runningFeatureIds = this.concurrencyManager
|
|
.getAllRunning()
|
|
.filter((f) => f.projectPath === projectPath)
|
|
.map((f) => f.featureId);
|
|
|
|
const state: ExecutionState = {
|
|
version: 1,
|
|
autoLoopWasRunning: true,
|
|
maxConcurrency,
|
|
projectPath,
|
|
branchName,
|
|
runningFeatureIds,
|
|
savedAt: new Date().toISOString(),
|
|
};
|
|
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
|
logger.info(
|
|
`Saved execution state for ${worktreeDesc} in ${projectPath}: ${runningFeatureIds.length} running features`
|
|
);
|
|
} catch (error) {
|
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
|
logger.error(`Failed to save execution state for ${worktreeDesc} in ${projectPath}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the auto mode loop - continuously picks and executes pending features
|
|
* @deprecated Use startAutoLoopForProject instead for multi-project support
|
|
*/
|
|
async startAutoLoop(
|
|
projectPath: string,
|
|
maxConcurrency = DEFAULT_MAX_CONCURRENCY
|
|
): Promise<void> {
|
|
// For backward compatibility, delegate to the new per-project method
|
|
// But also maintain legacy state for existing code that might check it
|
|
if (this.autoLoopRunning) {
|
|
throw new Error('Auto mode is already running');
|
|
}
|
|
|
|
// Reset failure tracking when user manually starts auto mode
|
|
this.resetFailureTracking();
|
|
|
|
this.autoLoopRunning = true;
|
|
this.autoLoopAbortController = new AbortController();
|
|
this.config = {
|
|
maxConcurrency,
|
|
useWorktrees: true,
|
|
projectPath,
|
|
branchName: null,
|
|
};
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_started', {
|
|
message: `Auto mode started with max ${maxConcurrency} concurrent features`,
|
|
projectPath,
|
|
});
|
|
|
|
// Save execution state for recovery after restart
|
|
await this.saveExecutionState(projectPath);
|
|
|
|
// Note: Memory folder initialization is now handled by loadContextFiles
|
|
|
|
// Run the loop in the background
|
|
this.runAutoLoop().catch((error) => {
|
|
logger.error('Loop error:', error);
|
|
const errorInfo = classifyError(error);
|
|
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
|
error: errorInfo.message,
|
|
errorType: errorInfo.type,
|
|
projectPath,
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use runAutoLoopForProject instead
|
|
*/
|
|
private async runAutoLoop(): Promise<void> {
|
|
while (
|
|
this.autoLoopRunning &&
|
|
this.autoLoopAbortController &&
|
|
!this.autoLoopAbortController.signal.aborted
|
|
) {
|
|
try {
|
|
// Check if we have capacity
|
|
const totalRunning = this.concurrencyManager.getAllRunning().length;
|
|
if (totalRunning >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) {
|
|
await this.sleep(5000);
|
|
continue;
|
|
}
|
|
|
|
// Load pending features
|
|
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath);
|
|
|
|
if (pendingFeatures.length === 0) {
|
|
// Emit idle event only once when backlog is empty AND no features are running
|
|
const runningCount = this.concurrencyManager.getAllRunning().length;
|
|
if (runningCount === 0 && !this.hasEmittedIdleEvent) {
|
|
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
|
message: 'No pending features - auto mode idle',
|
|
projectPath: this.config!.projectPath,
|
|
});
|
|
this.hasEmittedIdleEvent = true;
|
|
logger.info(`[AutoLoop] Backlog complete, auto mode now idle`);
|
|
} else if (runningCount > 0) {
|
|
logger.debug(
|
|
`[AutoLoop] No pending features, ${runningCount} still running, waiting...`
|
|
);
|
|
} else {
|
|
logger.debug(`[AutoLoop] No pending features, waiting for new items...`);
|
|
}
|
|
await this.sleep(10000);
|
|
continue;
|
|
}
|
|
|
|
// Find a feature not currently running
|
|
const nextFeature = pendingFeatures.find((f) => !this.concurrencyManager.isRunning(f.id));
|
|
|
|
if (nextFeature) {
|
|
// Reset idle event flag since we're doing work again
|
|
this.hasEmittedIdleEvent = false;
|
|
// Start feature execution in background
|
|
this.executeFeature(
|
|
this.config!.projectPath,
|
|
nextFeature.id,
|
|
this.config!.useWorktrees,
|
|
true
|
|
).catch((error) => {
|
|
logger.error(`Feature ${nextFeature.id} error:`, error);
|
|
});
|
|
}
|
|
|
|
await this.sleep(2000);
|
|
} catch (error) {
|
|
logger.error('Loop iteration error:', error);
|
|
await this.sleep(5000);
|
|
}
|
|
}
|
|
|
|
this.autoLoopRunning = false;
|
|
}
|
|
|
|
/**
|
|
* Stop the auto mode loop
|
|
* @deprecated Use stopAutoLoopForProject instead for multi-project support
|
|
*/
|
|
async stopAutoLoop(): Promise<number> {
|
|
const wasRunning = this.autoLoopRunning;
|
|
const projectPath = this.config?.projectPath;
|
|
this.autoLoopRunning = false;
|
|
if (this.autoLoopAbortController) {
|
|
this.autoLoopAbortController.abort();
|
|
this.autoLoopAbortController = null;
|
|
}
|
|
|
|
// Clear execution state when auto-loop is explicitly stopped
|
|
if (projectPath) {
|
|
await this.clearExecutionState(projectPath);
|
|
}
|
|
|
|
// Emit stop event immediately when user explicitly stops
|
|
if (wasRunning) {
|
|
this.eventBus.emitAutoModeEvent('auto_mode_stopped', {
|
|
message: 'Auto mode stopped',
|
|
projectPath,
|
|
});
|
|
}
|
|
|
|
return this.concurrencyManager.getAllRunning().length;
|
|
}
|
|
|
|
/**
|
|
* Check if there's capacity to start a feature on a worktree.
|
|
* This respects per-worktree agent limits from autoModeByWorktree settings.
|
|
*
|
|
* @param projectPath - The main project path
|
|
* @param featureId - The feature ID to check capacity for
|
|
* @returns Object with hasCapacity boolean and details about current/max agents
|
|
*/
|
|
async checkWorktreeCapacity(
|
|
projectPath: string,
|
|
featureId: string
|
|
): Promise<{
|
|
hasCapacity: boolean;
|
|
currentAgents: number;
|
|
maxAgents: number;
|
|
branchName: string | null;
|
|
}> {
|
|
// Load feature to get branchName
|
|
const feature = await this.loadFeature(projectPath, featureId);
|
|
const rawBranchName = feature?.branchName ?? null;
|
|
// Normalize "main" to null to match UI convention for main worktree
|
|
const branchName = rawBranchName === 'main' ? null : rawBranchName;
|
|
|
|
// Get per-worktree limit
|
|
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
|
|
|
|
// Get current running count for this worktree
|
|
const currentAgents = await this.getRunningCountForWorktree(projectPath, branchName);
|
|
|
|
return {
|
|
hasCapacity: currentAgents < maxAgents,
|
|
currentAgents,
|
|
maxAgents,
|
|
branchName,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Execute a single feature
|
|
* @param projectPath - The main project path
|
|
* @param featureId - The feature ID to execute
|
|
* @param useWorktrees - Whether to use worktrees for isolation
|
|
* @param isAutoMode - Whether this is running in auto mode
|
|
*/
|
|
async executeFeature(
|
|
projectPath: string,
|
|
featureId: string,
|
|
useWorktrees = false,
|
|
isAutoMode = false,
|
|
providedWorktreePath?: string,
|
|
options?: {
|
|
continuationPrompt?: string;
|
|
/** Internal flag: set to true when called from a method that already tracks the feature */
|
|
_calledInternally?: boolean;
|
|
}
|
|
): Promise<void> {
|
|
const tempRunningFeature = this.acquireRunningFeature({
|
|
featureId,
|
|
projectPath,
|
|
isAutoMode,
|
|
allowReuse: options?._calledInternally,
|
|
});
|
|
const abortController = tempRunningFeature.abortController;
|
|
|
|
// Save execution state when feature starts
|
|
if (isAutoMode) {
|
|
await this.saveExecutionState(projectPath);
|
|
}
|
|
// Declare feature outside try block so it's available in catch for error reporting
|
|
let feature: Awaited<ReturnType<typeof this.loadFeature>> | null = null;
|
|
|
|
try {
|
|
// Validate that project path is allowed using centralized validation
|
|
validateWorkingDirectory(projectPath);
|
|
|
|
// Load feature details FIRST to get status and plan info
|
|
feature = await this.loadFeature(projectPath, featureId);
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
// Check if feature has existing context - if so, resume instead of starting fresh
|
|
// Skip this check if we're already being called with a continuation prompt (from resumeFeature)
|
|
if (!options?.continuationPrompt) {
|
|
// If feature has an approved plan but we don't have a continuation prompt yet,
|
|
// we should build one to ensure it proceeds with multi-agent execution
|
|
if (feature.planSpec?.status === 'approved') {
|
|
logger.info(`Feature ${featureId} has approved plan, building continuation prompt`);
|
|
|
|
// Get customized prompts from settings
|
|
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
|
const planContent = feature.planSpec.content || '';
|
|
|
|
// Build continuation prompt using centralized template
|
|
let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
|
|
continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, '');
|
|
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
|
|
|
|
// Recursively call executeFeature with the continuation prompt
|
|
// Feature is already tracked, the recursive call will reuse the entry
|
|
return await this.executeFeature(
|
|
projectPath,
|
|
featureId,
|
|
useWorktrees,
|
|
isAutoMode,
|
|
providedWorktreePath,
|
|
{
|
|
continuationPrompt,
|
|
_calledInternally: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
const hasExistingContext = await this.contextExists(projectPath, featureId);
|
|
if (hasExistingContext) {
|
|
logger.info(
|
|
`Feature ${featureId} has existing context, resuming instead of starting fresh`
|
|
);
|
|
// Feature is already tracked, resumeFeature will reuse the entry
|
|
return await this.resumeFeature(projectPath, featureId, useWorktrees, true);
|
|
}
|
|
}
|
|
|
|
// Derive workDir from feature.branchName
|
|
// Worktrees should already be created when the feature is added/edited
|
|
let worktreePath: string | null = null;
|
|
const branchName = feature.branchName;
|
|
|
|
if (useWorktrees && branchName) {
|
|
// Try to find existing worktree for this branch
|
|
// Worktree should already exist (created when feature was added/edited)
|
|
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
|
|
|
|
if (worktreePath) {
|
|
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
|
} else {
|
|
// Worktree doesn't exist - log warning and continue with project path
|
|
logger.warn(`Worktree for branch "${branchName}" not found, using project path`);
|
|
}
|
|
}
|
|
|
|
// Ensure workDir is always an absolute path for cross-platform compatibility
|
|
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
|
|
|
// Validate that working directory is allowed using centralized validation
|
|
validateWorkingDirectory(workDir);
|
|
|
|
// Update running feature with actual worktree info
|
|
tempRunningFeature.worktreePath = worktreePath;
|
|
tempRunningFeature.branchName = branchName ?? null;
|
|
|
|
// Update feature status to in_progress BEFORE emitting event
|
|
// This ensures the frontend sees the updated status when it reloads features
|
|
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
|
|
|
|
// Emit feature start event AFTER status update so frontend sees correct status
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
|
|
featureId,
|
|
projectPath,
|
|
branchName: feature.branchName ?? null,
|
|
feature: {
|
|
id: featureId,
|
|
title: feature.title || 'Loading...',
|
|
description: feature.description || 'Feature is starting',
|
|
},
|
|
});
|
|
|
|
// Load autoLoadClaudeMd setting to determine context loading strategy
|
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
|
projectPath,
|
|
this.settingsService,
|
|
'[AutoMode]'
|
|
);
|
|
|
|
// Get customized prompts from settings
|
|
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
|
|
|
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
|
|
let prompt: string;
|
|
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
|
|
// Context loader uses task context to select relevant memory files
|
|
const contextResult = await loadContextFiles({
|
|
projectPath,
|
|
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
|
taskContext: {
|
|
title: feature.title ?? '',
|
|
description: feature.description ?? '',
|
|
},
|
|
});
|
|
|
|
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
|
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
|
|
// Note: contextResult.formattedPrompt now includes both context AND memory
|
|
const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
|
|
|
if (options?.continuationPrompt) {
|
|
// Continuation prompt is used when recovering from a plan approval
|
|
// The plan was already approved, so skip the planning phase
|
|
prompt = options.continuationPrompt;
|
|
logger.info(`Using continuation prompt for feature ${featureId}`);
|
|
} else {
|
|
// Normal flow: build prompt with planning phase
|
|
const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
|
|
const planningPrefix = await this.getPlanningPromptPrefix(feature);
|
|
prompt = planningPrefix + featurePrompt;
|
|
|
|
// Emit planning mode info
|
|
if (feature.planningMode && feature.planningMode !== 'skip') {
|
|
this.eventBus.emitAutoModeEvent('planning_started', {
|
|
featureId: feature.id,
|
|
mode: feature.planningMode,
|
|
message: `Starting ${feature.planningMode} planning phase`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Extract image paths from feature
|
|
const imagePaths = feature.imagePaths?.map((img) =>
|
|
typeof img === 'string' ? img : img.path
|
|
);
|
|
|
|
// Get model from feature and determine provider
|
|
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
|
const provider = ProviderFactory.getProviderNameForModel(model);
|
|
logger.info(
|
|
`Executing feature ${featureId} with model: ${model}, provider: ${provider} in ${workDir}`
|
|
);
|
|
|
|
// Store model and provider in running feature for tracking
|
|
tempRunningFeature.model = model;
|
|
tempRunningFeature.provider = provider;
|
|
|
|
// Run the agent with the feature's model and images
|
|
// Context files are passed as system prompt for higher priority
|
|
await this.runAgent(
|
|
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 pipeline steps and execute them
|
|
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
|
// Filter out excluded pipeline steps and sort by order
|
|
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
|
const sortedSteps = [...(pipelineConfig?.steps || [])]
|
|
.sort((a, b) => a.order - b.order)
|
|
.filter((step) => !excludedStepIds.has(step.id));
|
|
|
|
if (sortedSteps.length > 0) {
|
|
// Execute pipeline steps sequentially
|
|
await this.executePipelineSteps(
|
|
projectPath,
|
|
featureId,
|
|
feature,
|
|
sortedSteps,
|
|
workDir,
|
|
abortController,
|
|
autoLoadClaudeMd
|
|
);
|
|
}
|
|
|
|
// Determine final status based on testing mode:
|
|
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
|
|
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
|
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
|
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
|
|
|
// Record success to reset consecutive failure tracking
|
|
this.recordSuccess();
|
|
|
|
// Record learnings, memory usage, and extract summary after successful feature completion
|
|
try {
|
|
const featureDir = getFeatureDir(projectPath, featureId);
|
|
const outputPath = path.join(featureDir, 'agent-output.md');
|
|
let agentOutput = '';
|
|
try {
|
|
const outputContent = await secureFs.readFile(outputPath, 'utf-8');
|
|
agentOutput =
|
|
typeof outputContent === 'string' ? outputContent : outputContent.toString();
|
|
} catch {
|
|
// Agent output might not exist yet
|
|
}
|
|
|
|
// Extract and save summary from agent output
|
|
if (agentOutput) {
|
|
const summary = extractSummary(agentOutput);
|
|
if (summary) {
|
|
logger.info(`Extracted summary for feature ${featureId}`);
|
|
await this.saveFeatureSummary(projectPath, featureId, summary);
|
|
}
|
|
}
|
|
|
|
// Record memory usage if we loaded any memory files
|
|
if (contextResult.memoryFiles.length > 0 && agentOutput) {
|
|
await recordMemoryUsage(
|
|
projectPath,
|
|
contextResult.memoryFiles,
|
|
agentOutput,
|
|
true, // success
|
|
secureFs as Parameters<typeof recordMemoryUsage>[4]
|
|
);
|
|
}
|
|
|
|
// Extract and record learnings from the agent output
|
|
await this.recordLearningsFromFeature(projectPath, feature, agentOutput);
|
|
} catch (learningError) {
|
|
console.warn('[AutoMode] Failed to record learnings:', learningError);
|
|
}
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
|
featureId,
|
|
featureName: feature.title,
|
|
branchName: feature.branchName ?? null,
|
|
passes: true,
|
|
message: `Feature completed in ${Math.round(
|
|
(Date.now() - tempRunningFeature.startTime) / 1000
|
|
)}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
|
|
projectPath,
|
|
model: tempRunningFeature.model,
|
|
provider: tempRunningFeature.provider,
|
|
});
|
|
} 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: 'Feature stopped by user',
|
|
projectPath,
|
|
});
|
|
} else {
|
|
logger.error(`Feature ${featureId} failed:`, error);
|
|
await this.updateFeatureStatus(projectPath, featureId, 'backlog');
|
|
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
|
featureId,
|
|
featureName: feature?.title,
|
|
branchName: feature?.branchName ?? null,
|
|
error: errorInfo.message,
|
|
errorType: errorInfo.type,
|
|
projectPath,
|
|
});
|
|
|
|
// Track this failure and check if we should pause auto mode
|
|
// This handles both specific quota/rate limit errors AND generic failures
|
|
// that may indicate quota exhaustion (SDK doesn't always return useful errors)
|
|
const shouldPause = this.trackFailureAndCheckPause({
|
|
type: errorInfo.type,
|
|
message: errorInfo.message,
|
|
});
|
|
|
|
if (shouldPause) {
|
|
this.signalShouldPause({
|
|
type: errorInfo.type,
|
|
message: errorInfo.message,
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
logger.info(`Feature ${featureId} execution ended, cleaning up runningFeatures`);
|
|
this.releaseRunningFeature(featureId);
|
|
|
|
// Update execution state after feature completes
|
|
if (this.autoLoopRunning && projectPath) {
|
|
await this.saveExecutionState(projectPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute pipeline steps sequentially after initial feature implementation
|
|
*/
|
|
private async executePipelineSteps(
|
|
projectPath: string,
|
|
featureId: string,
|
|
feature: Feature,
|
|
steps: PipelineStep[],
|
|
workDir: string,
|
|
abortController: AbortController,
|
|
autoLoadClaudeMd: boolean
|
|
): Promise<void> {
|
|
logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
|
|
|
|
// Get customized prompts from settings
|
|
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
|
|
|
// Load context files once with feature context for smart memory selection
|
|
const contextResult = await loadContextFiles({
|
|
projectPath,
|
|
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
|
taskContext: {
|
|
title: feature.title ?? '',
|
|
description: feature.description ?? '',
|
|
},
|
|
});
|
|
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
|
|
|
// Load previous agent output for context continuity
|
|
const featureDir = getFeatureDir(projectPath, featureId);
|
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
|
let previousContext = '';
|
|
try {
|
|
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
|
} catch {
|
|
// No previous context
|
|
}
|
|
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const step = steps[i];
|
|
const pipelineStatus = `pipeline_${step.id}`;
|
|
|
|
// Update feature status to current pipeline step
|
|
await this.updateFeatureStatus(projectPath, featureId, pipelineStatus);
|
|
|
|
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,
|
|
});
|
|
|
|
// Build prompt for this pipeline step
|
|
const prompt = this.buildPipelineStepPrompt(
|
|
step,
|
|
feature,
|
|
previousContext,
|
|
prompts.taskExecution
|
|
);
|
|
|
|
// Get model from feature
|
|
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
|
|
|
// Run the agent for this pipeline step
|
|
await this.runAgent(
|
|
workDir,
|
|
featureId,
|
|
prompt,
|
|
abortController,
|
|
projectPath,
|
|
undefined, // no images for pipeline steps
|
|
model,
|
|
{
|
|
projectPath,
|
|
planningMode: 'skip', // Pipeline steps don't need planning
|
|
requirePlanApproval: false,
|
|
previousContent: previousContext,
|
|
systemPrompt: contextFilesPrompt || undefined,
|
|
autoLoadClaudeMd,
|
|
thinkingLevel: feature.thinkingLevel,
|
|
}
|
|
);
|
|
|
|
// Load updated context for next step
|
|
try {
|
|
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
|
} catch {
|
|
// No context update
|
|
}
|
|
|
|
this.eventBus.emitAutoModeEvent('pipeline_step_complete', {
|
|
featureId,
|
|
stepId: step.id,
|
|
stepName: step.name,
|
|
stepIndex: i,
|
|
totalSteps: steps.length,
|
|
projectPath,
|
|
});
|
|
|
|
logger.info(
|
|
`Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}`
|
|
);
|
|
}
|
|
|
|
logger.info(`All pipeline steps completed for feature ${featureId}`);
|
|
}
|
|
|
|
/**
|
|
* Build the prompt for a pipeline step
|
|
*/
|
|
private buildPipelineStepPrompt(
|
|
step: PipelineStep,
|
|
feature: Feature,
|
|
previousContext: string,
|
|
taskExecutionPrompts: {
|
|
implementationInstructions: string;
|
|
playwrightVerificationInstructions: string;
|
|
}
|
|
): string {
|
|
let prompt = `## Pipeline Step: ${step.name}
|
|
|
|
This is an automated pipeline step following the initial feature implementation.
|
|
|
|
### Feature Context
|
|
${this.buildFeaturePrompt(feature, taskExecutionPrompts)}
|
|
|
|
`;
|
|
|
|
if (previousContext) {
|
|
prompt += `### Previous Work
|
|
The following is the output from the previous work on this feature:
|
|
|
|
${previousContext}
|
|
|
|
`;
|
|
}
|
|
|
|
prompt += `### Pipeline Step Instructions
|
|
${step.instructions}
|
|
|
|
### Task
|
|
Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`;
|
|
|
|
return prompt;
|
|
}
|
|
|
|
/**
|
|
* Stop a specific feature
|
|
*/
|
|
async stopFeature(featureId: string): Promise<boolean> {
|
|
const running = this.concurrencyManager.getRunningFeature(featureId);
|
|
if (!running) {
|
|
return false;
|
|
}
|
|
|
|
// Cancel any pending plan approval for this feature
|
|
this.cancelPlanApproval(featureId);
|
|
|
|
running.abortController.abort();
|
|
|
|
// Remove from running features immediately to allow resume
|
|
// The abort signal will still propagate to stop any ongoing execution
|
|
this.releaseRunningFeature(featureId, { force: true });
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Resume a feature (continues from saved context or starts fresh if no context)
|
|
*
|
|
* This method handles interrupted features regardless of whether they have saved context:
|
|
* - With context: Continues from where the agent left off using the saved agent-output.md
|
|
* - Without context: Starts fresh execution (feature was interrupted before any agent output)
|
|
* - Pipeline features: Delegates to resumePipelineFeature for specialized handling
|
|
*
|
|
* @param projectPath - Path to the project
|
|
* @param featureId - ID of the feature to resume
|
|
* @param useWorktrees - Whether to use git worktrees for isolation
|
|
* @param _calledInternally - Internal flag to prevent double-tracking when called from other methods
|
|
*/
|
|
async resumeFeature(
|
|
projectPath: string,
|
|
featureId: string,
|
|
useWorktrees = false,
|
|
/** Internal flag: set to true when called from a method that already tracks the feature */
|
|
_calledInternally = false
|
|
): Promise<void> {
|
|
// Idempotent check: if feature is already being resumed/running, skip silently
|
|
// This prevents race conditions when multiple callers try to resume the same feature
|
|
if (!_calledInternally && this.isFeatureRunning(featureId)) {
|
|
logger.info(
|
|
`[AutoMode] Feature ${featureId} is already being resumed/running, skipping duplicate resume request`
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.acquireRunningFeature({
|
|
featureId,
|
|
projectPath,
|
|
isAutoMode: false,
|
|
allowReuse: _calledInternally,
|
|
});
|
|
|
|
try {
|
|
// Load feature to check status
|
|
const feature = await this.loadFeature(projectPath, featureId);
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
logger.info(
|
|
`[AutoMode] Resuming feature ${featureId} (${feature.title}) - current status: ${feature.status}`
|
|
);
|
|
|
|
// Check if feature is stuck in a pipeline step
|
|
const pipelineInfo = await this.detectPipelineStatus(
|
|
projectPath,
|
|
featureId,
|
|
(feature.status || '') as FeatureStatusWithPipeline
|
|
);
|
|
|
|
if (pipelineInfo.isPipeline) {
|
|
// Feature stuck in pipeline - use pipeline resume
|
|
// Pass _alreadyTracked to prevent double-tracking
|
|
logger.info(
|
|
`[AutoMode] Feature ${featureId} is in pipeline step ${pipelineInfo.stepId}, using pipeline resume`
|
|
);
|
|
return await this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
|
|
}
|
|
|
|
// Normal resume flow for non-pipeline features
|
|
// Check if context exists in .automaker directory
|
|
const featureDir = getFeatureDir(projectPath, featureId);
|
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
|
|
|
let hasContext = false;
|
|
try {
|
|
await secureFs.access(contextPath);
|
|
hasContext = true;
|
|
} catch {
|
|
// No context - feature was interrupted before any agent output was saved
|
|
}
|
|
|
|
if (hasContext) {
|
|
// Load previous context and continue
|
|
// executeFeatureWithContext -> executeFeature will see feature is already tracked
|
|
const context = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
|
logger.info(
|
|
`[AutoMode] Resuming feature ${featureId} with saved context (${context.length} chars)`
|
|
);
|
|
|
|
// Emit event for UI notification
|
|
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);
|
|
}
|
|
|
|
// No context - feature was interrupted before any agent output was saved
|
|
// Start fresh execution instead of leaving the feature stuck
|
|
logger.info(
|
|
`[AutoMode] Feature ${featureId} has no saved context - starting fresh execution`
|
|
);
|
|
|
|
// Emit event for UI notification
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_resuming', {
|
|
featureId,
|
|
featureName: feature.title,
|
|
projectPath,
|
|
hasContext: false,
|
|
message: `Starting fresh execution for interrupted feature "${feature.title}" (no previous context found)`,
|
|
});
|
|
|
|
return await this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
|
|
_calledInternally: true,
|
|
});
|
|
} finally {
|
|
this.releaseRunningFeature(featureId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resume a feature that crashed during pipeline execution.
|
|
* Handles multiple edge cases to ensure robust recovery:
|
|
* - No context file: Restart entire pipeline from beginning
|
|
* - Step deleted from config: Complete feature without remaining pipeline steps
|
|
* - Valid step exists: Resume from the crashed step and continue
|
|
*
|
|
* @param {string} projectPath - Absolute path to the project directory
|
|
* @param {Feature} feature - The feature object (already loaded to avoid redundant reads)
|
|
* @param {boolean} useWorktrees - Whether to use git worktrees for isolation
|
|
* @param {PipelineStatusInfo} pipelineInfo - Information about the pipeline status from detectPipelineStatus()
|
|
* @returns {Promise<void>} Resolves when resume operation completes or throws on error
|
|
* @throws {Error} If pipeline config is null but stepIndex is valid (should never happen)
|
|
* @private
|
|
*/
|
|
private async resumePipelineFeature(
|
|
projectPath: string,
|
|
feature: Feature,
|
|
useWorktrees: boolean,
|
|
pipelineInfo: PipelineStatusInfo
|
|
): Promise<void> {
|
|
const featureId = feature.id;
|
|
console.log(
|
|
`[AutoMode] Resuming feature ${featureId} from pipeline step ${pipelineInfo.stepId}`
|
|
);
|
|
|
|
// Check for context file
|
|
const featureDir = getFeatureDir(projectPath, featureId);
|
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
|
|
|
let hasContext = false;
|
|
try {
|
|
await secureFs.access(contextPath);
|
|
hasContext = true;
|
|
} catch {
|
|
// No context
|
|
}
|
|
|
|
// Edge Case 1: No context file - restart entire pipeline from beginning
|
|
if (!hasContext) {
|
|
console.warn(
|
|
`[AutoMode] No context found for pipeline feature ${featureId}, restarting from beginning`
|
|
);
|
|
|
|
// Reset status to in_progress and start fresh
|
|
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
|
|
|
|
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
|
|
_calledInternally: true,
|
|
});
|
|
}
|
|
|
|
// Edge Case 2: Step no longer exists in pipeline config
|
|
if (pipelineInfo.stepIndex === -1) {
|
|
console.warn(
|
|
`[AutoMode] Step ${pipelineInfo.stepId} no longer exists in pipeline, completing feature without pipeline`
|
|
);
|
|
|
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
|
|
|
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
|
featureId,
|
|
featureName: feature.title,
|
|
branchName: feature.branchName ?? null,
|
|
passes: true,
|
|
message:
|
|
'Pipeline step no longer exists - feature completed without remaining pipeline steps',
|
|
projectPath,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
// Normal case: Valid pipeline step exists, has context
|
|
// Resume from the stuck step (re-execute the step that crashed)
|
|
if (!pipelineInfo.config) {
|
|
throw new Error('Pipeline config is null but stepIndex is valid - this should not happen');
|
|
}
|
|
|
|
return this.resumeFromPipelineStep(
|
|
projectPath,
|
|
feature,
|
|
useWorktrees,
|
|
pipelineInfo.stepIndex,
|
|
pipelineInfo.config
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resume pipeline execution from a specific step index.
|
|
* Re-executes the step that crashed (to handle partial completion),
|
|
* then continues executing all remaining pipeline steps in order.
|
|
*
|
|
* This method handles the complete pipeline resume workflow:
|
|
* - Validates feature and step index
|
|
* - Locates or creates git worktree if needed
|
|
* - Executes remaining steps starting from the crashed step
|
|
* - Updates feature status to verified/waiting_approval when complete
|
|
* - Emits progress events throughout execution
|
|
*
|
|
* @param {string} projectPath - Absolute path to the project directory
|
|
* @param {Feature} feature - The feature object (already loaded to avoid redundant reads)
|
|
* @param {boolean} useWorktrees - Whether to use git worktrees for isolation
|
|
* @param {number} startFromStepIndex - Zero-based index of the step to resume from
|
|
* @param {PipelineConfig} pipelineConfig - Pipeline config passed from detectPipelineStatus to avoid re-reading
|
|
* @returns {Promise<void>} Resolves when pipeline execution completes successfully
|
|
* @throws {Error} If feature not found, step index invalid, or pipeline execution fails
|
|
* @private
|
|
*/
|
|
private async resumeFromPipelineStep(
|
|
projectPath: string,
|
|
feature: Feature,
|
|
useWorktrees: boolean,
|
|
startFromStepIndex: number,
|
|
pipelineConfig: PipelineConfig
|
|
): Promise<void> {
|
|
const featureId = feature.id;
|
|
|
|
// Sort all steps first
|
|
const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
|
|
|
|
// Get the current step we're resuming from (using the index from unfiltered list)
|
|
if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) {
|
|
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
|
}
|
|
const currentStep = allSortedSteps[startFromStepIndex];
|
|
|
|
// Filter out excluded pipeline steps
|
|
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
|
|
|
// Check if the current step is excluded
|
|
// If so, use getNextStatus to find the appropriate next step
|
|
if (excludedStepIds.has(currentStep.id)) {
|
|
logger.info(
|
|
`Current step ${currentStep.id} is excluded for feature ${featureId}, finding next valid step`
|
|
);
|
|
const nextStatus = pipelineService.getNextStatus(
|
|
`pipeline_${currentStep.id}`,
|
|
pipelineConfig,
|
|
feature.skipTests ?? false,
|
|
feature.excludedPipelineSteps
|
|
);
|
|
|
|
// If next status is not a pipeline step, feature is done
|
|
if (!pipelineService.isPipelineStatus(nextStatus)) {
|
|
await this.updateFeatureStatus(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;
|
|
}
|
|
|
|
// Find the next step and update the start index
|
|
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
|
|
const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId);
|
|
if (nextStepIndex === -1) {
|
|
throw new Error(`Next step ${nextStepId} not found in pipeline config`);
|
|
}
|
|
startFromStepIndex = nextStepIndex;
|
|
}
|
|
|
|
// Get steps to execute (from startFromStepIndex onwards, excluding excluded steps)
|
|
const stepsToExecute = allSortedSteps
|
|
.slice(startFromStepIndex)
|
|
.filter((step) => !excludedStepIds.has(step.id));
|
|
|
|
// If no steps left to execute, complete the feature
|
|
if (stepsToExecute.length === 0) {
|
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
|
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
|
featureId,
|
|
featureName: feature.title,
|
|
branchName: feature.branchName ?? null,
|
|
passes: true,
|
|
message: 'Pipeline completed (all remaining steps excluded)',
|
|
projectPath,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Use the filtered steps for counting
|
|
const sortedSteps = allSortedSteps.filter((step) => !excludedStepIds.has(step.id));
|
|
|
|
logger.info(
|
|
`Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
|
);
|
|
|
|
const runningEntry = this.acquireRunningFeature({
|
|
featureId,
|
|
projectPath,
|
|
isAutoMode: false,
|
|
allowReuse: true,
|
|
});
|
|
const abortController = runningEntry.abortController;
|
|
runningEntry.branchName = feature.branchName ?? null;
|
|
|
|
try {
|
|
// Validate project path
|
|
validateWorkingDirectory(projectPath);
|
|
|
|
// Derive workDir from feature.branchName
|
|
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}`);
|
|
} else {
|
|
logger.warn(`Worktree for branch "${branchName}" not found, using project path`);
|
|
}
|
|
}
|
|
|
|
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
|
validateWorkingDirectory(workDir);
|
|
|
|
// Update running feature with worktree info
|
|
runningEntry.worktreePath = worktreePath;
|
|
runningEntry.branchName = branchName ?? null;
|
|
|
|
// Emit resume event
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
|
|
featureId,
|
|
projectPath,
|
|
branchName: branchName ?? null,
|
|
feature: {
|
|
id: featureId,
|
|
title: feature.title || 'Resuming Pipeline',
|
|
description: feature.description,
|
|
},
|
|
});
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId,
|
|
projectPath,
|
|
branchName: branchName ?? null,
|
|
content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`,
|
|
});
|
|
|
|
// Load autoLoadClaudeMd setting
|
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
|
projectPath,
|
|
this.settingsService,
|
|
'[AutoMode]'
|
|
);
|
|
|
|
// Execute remaining pipeline steps (starting from crashed step)
|
|
await this.executePipelineSteps(
|
|
projectPath,
|
|
featureId,
|
|
feature,
|
|
stepsToExecute,
|
|
workDir,
|
|
abortController,
|
|
autoLoadClaudeMd
|
|
);
|
|
|
|
// Determine final status
|
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
|
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
|
|
|
logger.info(`Pipeline resume completed successfully for feature ${featureId}`);
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
|
featureId,
|
|
featureName: feature.title,
|
|
branchName: feature.branchName ?? null,
|
|
passes: true,
|
|
message: 'Pipeline resumed and completed 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 resume stopped by user',
|
|
projectPath,
|
|
});
|
|
} else {
|
|
logger.error(`Pipeline resume failed for feature ${featureId}:`, error);
|
|
await this.updateFeatureStatus(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.releaseRunningFeature(featureId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Follow up on a feature with additional instructions
|
|
*/
|
|
async followUpFeature(
|
|
projectPath: string,
|
|
featureId: string,
|
|
prompt: string,
|
|
imagePaths?: string[],
|
|
useWorktrees = true
|
|
): Promise<void> {
|
|
// Validate project path early for fast failure
|
|
validateWorkingDirectory(projectPath);
|
|
|
|
const runningEntry = this.acquireRunningFeature({
|
|
featureId,
|
|
projectPath,
|
|
isAutoMode: false,
|
|
});
|
|
const abortController = runningEntry.abortController;
|
|
|
|
// Load feature info for context FIRST to get branchName
|
|
const feature = await this.loadFeature(projectPath, featureId);
|
|
|
|
// Derive workDir from feature.branchName
|
|
// If no branchName, derive from feature ID: feature/{featureId}
|
|
let workDir = path.resolve(projectPath);
|
|
let worktreePath: string | null = null;
|
|
const branchName = feature?.branchName || `feature/${featureId}`;
|
|
|
|
if (useWorktrees && branchName) {
|
|
// Try to find existing worktree for this branch
|
|
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
|
|
|
|
if (worktreePath) {
|
|
workDir = worktreePath;
|
|
logger.info(`Follow-up using worktree for branch "${branchName}": ${workDir}`);
|
|
}
|
|
}
|
|
|
|
// Load previous agent output if it exists
|
|
const featureDir = getFeatureDir(projectPath, featureId);
|
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
|
let previousContext = '';
|
|
try {
|
|
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
|
} catch {
|
|
// No previous context
|
|
}
|
|
|
|
// Load autoLoadClaudeMd setting to determine context loading strategy
|
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
|
projectPath,
|
|
this.settingsService,
|
|
'[AutoMode]'
|
|
);
|
|
|
|
// Get customized prompts from settings
|
|
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
|
|
|
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
|
|
const contextResult = await loadContextFiles({
|
|
projectPath,
|
|
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
|
taskContext: {
|
|
title: feature?.title ?? prompt.substring(0, 200),
|
|
description: feature?.description ?? prompt,
|
|
},
|
|
});
|
|
|
|
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
|
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
|
|
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
|
|
|
// Build complete prompt with feature info, previous context, and follow-up instructions
|
|
let fullPrompt = `## Follow-up on Feature Implementation
|
|
|
|
${feature ? this.buildFeaturePrompt(feature, prompts.taskExecution) : `**Feature ID:** ${featureId}`}
|
|
`;
|
|
|
|
if (previousContext) {
|
|
fullPrompt += `
|
|
## Previous Agent Work
|
|
The following is the output from the previous implementation attempt:
|
|
|
|
${previousContext}
|
|
`;
|
|
}
|
|
|
|
fullPrompt += `
|
|
## Follow-up Instructions
|
|
${prompt}
|
|
|
|
## Task
|
|
Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`;
|
|
|
|
// Get model from feature and determine provider early for tracking
|
|
const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude);
|
|
const provider = ProviderFactory.getProviderNameForModel(model);
|
|
logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`);
|
|
|
|
runningEntry.worktreePath = worktreePath;
|
|
runningEntry.branchName = branchName;
|
|
runningEntry.model = model;
|
|
runningEntry.provider = provider;
|
|
|
|
try {
|
|
// Update feature status to in_progress BEFORE emitting event
|
|
// This ensures the frontend sees the updated status when it reloads features
|
|
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
|
|
|
|
// Emit feature start event AFTER status update so frontend sees correct status
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
|
|
featureId,
|
|
projectPath,
|
|
branchName,
|
|
feature: feature || {
|
|
id: featureId,
|
|
title: 'Follow-up',
|
|
description: prompt.substring(0, 100),
|
|
},
|
|
model,
|
|
provider,
|
|
});
|
|
|
|
// Copy follow-up images to feature folder
|
|
const copiedImagePaths: string[] = [];
|
|
if (imagePaths && imagePaths.length > 0) {
|
|
const featureDirForImages = getFeatureDir(projectPath, featureId);
|
|
const featureImagesDir = path.join(featureDirForImages, 'images');
|
|
|
|
await secureFs.mkdir(featureImagesDir, { recursive: true });
|
|
|
|
for (const imagePath of imagePaths) {
|
|
try {
|
|
// Get the filename from the path
|
|
const filename = path.basename(imagePath);
|
|
const destPath = path.join(featureImagesDir, filename);
|
|
|
|
// Copy the image
|
|
await secureFs.copyFile(imagePath, destPath);
|
|
|
|
// Store the absolute path (external storage uses absolute paths)
|
|
copiedImagePaths.push(destPath);
|
|
} catch (error) {
|
|
logger.error(`Failed to copy follow-up image ${imagePath}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update feature object with new follow-up images BEFORE building prompt
|
|
if (copiedImagePaths.length > 0 && feature) {
|
|
const currentImagePaths = feature.imagePaths || [];
|
|
const newImagePaths = copiedImagePaths.map((p) => ({
|
|
path: p,
|
|
filename: path.basename(p),
|
|
mimeType: 'image/png', // Default, could be improved
|
|
}));
|
|
|
|
feature.imagePaths = [...currentImagePaths, ...newImagePaths];
|
|
}
|
|
|
|
// Combine original feature images with new follow-up images
|
|
const allImagePaths: string[] = [];
|
|
|
|
// Add all images from feature (now includes both original and new)
|
|
if (feature?.imagePaths) {
|
|
const allPaths = feature.imagePaths.map((img) =>
|
|
typeof img === 'string' ? img : img.path
|
|
);
|
|
allImagePaths.push(...allPaths);
|
|
}
|
|
|
|
// Save updated feature.json with new images (atomic write with backup)
|
|
if (copiedImagePaths.length > 0 && feature) {
|
|
const featureDirForSave = getFeatureDir(projectPath, featureId);
|
|
const featurePath = path.join(featureDirForSave, 'feature.json');
|
|
|
|
try {
|
|
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
|
} catch (error) {
|
|
logger.error(`Failed to save feature.json:`, error);
|
|
}
|
|
}
|
|
|
|
// Use fullPrompt (already built above) with model and all images
|
|
// Note: Follow-ups skip planning mode - they continue from previous work
|
|
// Pass previousContext so the history is preserved in the output file
|
|
// Context files are passed as system prompt for higher priority
|
|
await this.runAgent(
|
|
workDir,
|
|
featureId,
|
|
fullPrompt,
|
|
abortController,
|
|
projectPath,
|
|
allImagePaths.length > 0 ? allImagePaths : imagePaths,
|
|
model,
|
|
{
|
|
projectPath,
|
|
planningMode: 'skip', // Follow-ups don't require approval
|
|
previousContent: previousContext || undefined,
|
|
systemPrompt: contextFilesPrompt || undefined,
|
|
autoLoadClaudeMd,
|
|
thinkingLevel: feature?.thinkingLevel,
|
|
}
|
|
);
|
|
|
|
// Determine final status based on testing mode:
|
|
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
|
|
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
|
|
const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified';
|
|
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
|
|
|
// Record success to reset consecutive failure tracking
|
|
this.recordSuccess();
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
|
featureId,
|
|
featureName: feature?.title,
|
|
branchName: branchName ?? null,
|
|
passes: true,
|
|
message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
|
|
projectPath,
|
|
model,
|
|
provider,
|
|
});
|
|
} catch (error) {
|
|
const errorInfo = classifyError(error);
|
|
if (!errorInfo.isCancellation) {
|
|
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
|
featureId,
|
|
featureName: feature?.title,
|
|
branchName: branchName ?? null,
|
|
error: errorInfo.message,
|
|
errorType: errorInfo.type,
|
|
projectPath,
|
|
});
|
|
|
|
// Track this failure and check if we should pause auto mode
|
|
const shouldPause = this.trackFailureAndCheckPause({
|
|
type: errorInfo.type,
|
|
message: errorInfo.message,
|
|
});
|
|
|
|
if (shouldPause) {
|
|
this.signalShouldPause({
|
|
type: errorInfo.type,
|
|
message: errorInfo.message,
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
this.releaseRunningFeature(featureId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify a feature's implementation
|
|
*/
|
|
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
|
|
// Load feature to get the name for event reporting
|
|
const feature = await this.loadFeature(projectPath, featureId);
|
|
|
|
// Worktrees are in project dir
|
|
// Sanitize featureId the same way it's sanitized when creating worktrees
|
|
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
|
|
let workDir = projectPath;
|
|
|
|
try {
|
|
await secureFs.access(worktreePath);
|
|
workDir = worktreePath;
|
|
} catch {
|
|
// No worktree
|
|
}
|
|
|
|
// Run verification - check if tests pass, build works, etc.
|
|
const verificationChecks = [
|
|
{ cmd: 'npm run lint', name: 'Lint' },
|
|
{ cmd: 'npm run typecheck', name: 'Type check' },
|
|
{ cmd: 'npm test', name: 'Tests' },
|
|
{ cmd: 'npm run build', name: 'Build' },
|
|
];
|
|
|
|
let allPassed = true;
|
|
const results: Array<{ check: string; passed: boolean; output?: string }> = [];
|
|
|
|
for (const check of verificationChecks) {
|
|
try {
|
|
const { stdout, stderr } = await execAsync(check.cmd, {
|
|
cwd: workDir,
|
|
timeout: 120000,
|
|
});
|
|
results.push({
|
|
check: check.name,
|
|
passed: true,
|
|
output: stdout || stderr,
|
|
});
|
|
} catch (error) {
|
|
allPassed = false;
|
|
results.push({
|
|
check: check.name,
|
|
passed: false,
|
|
output: (error as Error).message,
|
|
});
|
|
break; // Stop on first failure
|
|
}
|
|
}
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
|
featureId,
|
|
featureName: feature?.title,
|
|
branchName: feature?.branchName ?? null,
|
|
passes: allPassed,
|
|
message: allPassed
|
|
? 'All verification checks passed'
|
|
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
|
|
projectPath,
|
|
});
|
|
|
|
return allPassed;
|
|
}
|
|
|
|
/**
|
|
* Commit feature changes
|
|
* @param projectPath - The main project path
|
|
* @param featureId - The feature ID to commit
|
|
* @param providedWorktreePath - Optional: the worktree path where the feature's changes are located
|
|
*/
|
|
async commitFeature(
|
|
projectPath: string,
|
|
featureId: string,
|
|
providedWorktreePath?: string
|
|
): Promise<string | null> {
|
|
let workDir = projectPath;
|
|
|
|
// Use the provided worktree path if given
|
|
if (providedWorktreePath) {
|
|
try {
|
|
await secureFs.access(providedWorktreePath);
|
|
workDir = providedWorktreePath;
|
|
logger.info(`Committing in provided worktree: ${workDir}`);
|
|
} catch {
|
|
logger.info(
|
|
`Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`
|
|
);
|
|
}
|
|
} else {
|
|
// Fallback: try to find worktree at legacy location
|
|
// Sanitize featureId the same way it's sanitized when creating worktrees
|
|
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
const legacyWorktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
|
|
try {
|
|
await secureFs.access(legacyWorktreePath);
|
|
workDir = legacyWorktreePath;
|
|
logger.info(`Committing in legacy worktree: ${workDir}`);
|
|
} catch {
|
|
logger.info(`No worktree found, committing in project path: ${workDir}`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Check for changes
|
|
const { stdout: status } = await execAsync('git status --porcelain', {
|
|
cwd: workDir,
|
|
});
|
|
if (!status.trim()) {
|
|
return null; // No changes
|
|
}
|
|
|
|
// Load feature for commit message
|
|
const feature = await this.loadFeature(projectPath, featureId);
|
|
const commitMessage = feature
|
|
? await this.generateCommitMessage(feature, workDir)
|
|
: `feat: Feature ${featureId}\n\nImplemented by Automaker auto-mode`;
|
|
|
|
// Determine which files to stage
|
|
// For feature branches, only stage files changed on this branch to avoid committing unrelated changes
|
|
let filesToStage: string[] = [];
|
|
|
|
try {
|
|
// Get the current branch
|
|
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
|
cwd: workDir,
|
|
});
|
|
const branch = currentBranch.trim();
|
|
|
|
// Get the base branch (usually main/master)
|
|
const { stdout: baseBranchOutput } = await execAsync(
|
|
'git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo "refs/remotes/origin/main"',
|
|
{ cwd: workDir }
|
|
);
|
|
const baseBranch = baseBranchOutput.trim().replace('refs/remotes/origin/', '');
|
|
|
|
// If we're on a feature branch (not the base branch), only stage files changed on this branch
|
|
if (branch !== baseBranch && feature?.branchName) {
|
|
try {
|
|
// Get files changed on this branch compared to base
|
|
const { stdout: branchFiles } = await execAsync(
|
|
`git diff --name-only ${baseBranch}...HEAD`,
|
|
{ cwd: workDir }
|
|
);
|
|
|
|
if (branchFiles.trim()) {
|
|
filesToStage = branchFiles.trim().split('\n').filter(Boolean);
|
|
logger.info(`Staging ${filesToStage.length} files changed on branch ${branch}`);
|
|
}
|
|
} catch (diffError) {
|
|
// If diff fails (e.g., base branch doesn't exist), fall back to staging all changes
|
|
logger.warn(`Could not diff against base branch, staging all changes: ${diffError}`);
|
|
filesToStage = [];
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`Could not determine branch-specific files: ${error}`);
|
|
}
|
|
|
|
// Stage files
|
|
if (filesToStage.length > 0) {
|
|
// Stage only the specific files changed on this branch
|
|
for (const file of filesToStage) {
|
|
try {
|
|
await execAsync(`git add "${file.replace(/"/g, '\\"')}"`, { cwd: workDir });
|
|
} catch (error) {
|
|
logger.warn(`Failed to stage file ${file}: ${error}`);
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback: stage all changes (original behavior)
|
|
// This happens for main branch features or when branch detection fails
|
|
await execAsync('git add -A', { cwd: workDir });
|
|
}
|
|
|
|
// Commit
|
|
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
|
|
cwd: workDir,
|
|
});
|
|
|
|
// Get commit hash
|
|
const { stdout: hash } = await execAsync('git rev-parse HEAD', {
|
|
cwd: workDir,
|
|
});
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
|
featureId,
|
|
featureName: feature?.title,
|
|
branchName: feature?.branchName ?? null,
|
|
passes: true,
|
|
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
|
|
projectPath,
|
|
});
|
|
|
|
return hash.trim();
|
|
} catch (error) {
|
|
logger.error(`Commit failed for ${featureId}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if context exists for a feature
|
|
*/
|
|
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
|
// Context is stored in .automaker directory
|
|
const featureDir = getFeatureDir(projectPath, featureId);
|
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
|
|
|
try {
|
|
await secureFs.access(contextPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Analyze project to gather context
|
|
*/
|
|
async analyzeProject(projectPath: string): Promise<void> {
|
|
const abortController = new AbortController();
|
|
|
|
const analysisFeatureId = `analysis-${Date.now()}`;
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
|
|
featureId: analysisFeatureId,
|
|
projectPath,
|
|
branchName: null, // Project analysis is not worktree-specific
|
|
feature: {
|
|
id: analysisFeatureId,
|
|
title: 'Project Analysis',
|
|
description: 'Analyzing project structure',
|
|
},
|
|
});
|
|
|
|
const prompt = `Analyze this project and provide a summary of:
|
|
1. Project structure and architecture
|
|
2. Main technologies and frameworks used
|
|
3. Key components and their responsibilities
|
|
4. Build and test commands
|
|
5. Any existing conventions or patterns
|
|
|
|
Format your response as a structured markdown document.`;
|
|
|
|
try {
|
|
// Get model from phase settings with provider info
|
|
const {
|
|
phaseModel: phaseModelEntry,
|
|
provider: analysisClaudeProvider,
|
|
credentials,
|
|
} = await getPhaseModelWithOverrides(
|
|
'projectAnalysisModel',
|
|
this.settingsService,
|
|
projectPath,
|
|
'[AutoMode]'
|
|
);
|
|
const { model: analysisModel, thinkingLevel: analysisThinkingLevel } =
|
|
resolvePhaseModel(phaseModelEntry);
|
|
logger.info(
|
|
'Using model for project analysis:',
|
|
analysisModel,
|
|
analysisClaudeProvider ? `via provider: ${analysisClaudeProvider.name}` : 'direct API'
|
|
);
|
|
|
|
const provider = ProviderFactory.getProviderForModel(analysisModel);
|
|
|
|
// Load autoLoadClaudeMd setting
|
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
|
projectPath,
|
|
this.settingsService,
|
|
'[AutoMode]'
|
|
);
|
|
|
|
// Use createCustomOptions for centralized SDK configuration with CLAUDE.md support
|
|
const sdkOptions = createCustomOptions({
|
|
cwd: projectPath,
|
|
model: analysisModel,
|
|
maxTurns: 5,
|
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
|
abortController,
|
|
autoLoadClaudeMd,
|
|
thinkingLevel: analysisThinkingLevel,
|
|
});
|
|
|
|
const options: ExecuteOptions = {
|
|
prompt,
|
|
model: sdkOptions.model ?? analysisModel,
|
|
cwd: sdkOptions.cwd ?? projectPath,
|
|
maxTurns: sdkOptions.maxTurns,
|
|
allowedTools: sdkOptions.allowedTools as string[],
|
|
abortController,
|
|
settingSources: sdkOptions.settingSources,
|
|
thinkingLevel: analysisThinkingLevel, // Pass thinking level
|
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
|
claudeCompatibleProvider: analysisClaudeProvider, // Pass provider for alternative endpoint configuration
|
|
};
|
|
|
|
const stream = provider.executeQuery(options);
|
|
let analysisResult = '';
|
|
|
|
for await (const msg of stream) {
|
|
if (msg.type === 'assistant' && msg.message?.content) {
|
|
for (const block of msg.message.content) {
|
|
if (block.type === 'text') {
|
|
analysisResult = block.text || '';
|
|
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId: analysisFeatureId,
|
|
content: block.text,
|
|
projectPath,
|
|
});
|
|
}
|
|
}
|
|
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
analysisResult = msg.result || analysisResult;
|
|
}
|
|
}
|
|
|
|
// Save analysis to .automaker directory
|
|
const automakerDir = getAutomakerDir(projectPath);
|
|
const analysisPath = path.join(automakerDir, 'project-analysis.md');
|
|
await secureFs.mkdir(automakerDir, { recursive: true });
|
|
await secureFs.writeFile(analysisPath, analysisResult);
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
|
featureId: analysisFeatureId,
|
|
featureName: 'Project Analysis',
|
|
branchName: null, // Project analysis is not worktree-specific
|
|
passes: true,
|
|
message: 'Project analysis completed',
|
|
projectPath,
|
|
});
|
|
} catch (error) {
|
|
const errorInfo = classifyError(error);
|
|
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
|
featureId: analysisFeatureId,
|
|
featureName: 'Project Analysis',
|
|
branchName: null, // Project analysis is not worktree-specific
|
|
error: errorInfo.message,
|
|
errorType: errorInfo.type,
|
|
projectPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current status
|
|
*/
|
|
getStatus(): {
|
|
isRunning: boolean;
|
|
runningFeatures: string[];
|
|
runningCount: number;
|
|
} {
|
|
const allRunning = this.concurrencyManager.getAllRunning();
|
|
return {
|
|
isRunning: allRunning.length > 0,
|
|
runningFeatures: allRunning.map((rf) => rf.featureId),
|
|
runningCount: allRunning.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get status for a specific project/worktree
|
|
* @param projectPath - The project path
|
|
* @param branchName - The branch name, or null for main worktree
|
|
*/
|
|
getStatusForProject(
|
|
projectPath: string,
|
|
branchName: string | null = null
|
|
): {
|
|
isAutoLoopRunning: boolean;
|
|
runningFeatures: string[];
|
|
runningCount: number;
|
|
maxConcurrency: number;
|
|
branchName: string | null;
|
|
} {
|
|
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
|
|
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
|
const runningFeatures = this.concurrencyManager
|
|
.getAllRunning()
|
|
.filter((f) => f.projectPath === projectPath && f.branchName === branchName)
|
|
.map((f) => f.featureId);
|
|
|
|
return {
|
|
isAutoLoopRunning: projectState?.isRunning ?? false,
|
|
runningFeatures,
|
|
runningCount: runningFeatures.length,
|
|
maxConcurrency: projectState?.config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
|
branchName,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all active auto loop worktrees with their project paths and branch names
|
|
*/
|
|
getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
|
|
const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = [];
|
|
for (const [, state] of this.autoLoopsByProject) {
|
|
if (state.isRunning) {
|
|
activeWorktrees.push({
|
|
projectPath: state.config.projectPath,
|
|
branchName: state.branchName,
|
|
});
|
|
}
|
|
}
|
|
return activeWorktrees;
|
|
}
|
|
|
|
/**
|
|
* Get all projects that have auto mode running (legacy, returns unique project paths)
|
|
* @deprecated Use getActiveAutoLoopWorktrees instead for full worktree information
|
|
*/
|
|
getActiveAutoLoopProjects(): string[] {
|
|
const activeProjects = new Set<string>();
|
|
for (const [, state] of this.autoLoopsByProject) {
|
|
if (state.isRunning) {
|
|
activeProjects.add(state.config.projectPath);
|
|
}
|
|
}
|
|
return Array.from(activeProjects);
|
|
}
|
|
|
|
/**
|
|
* Get detailed info about all running agents
|
|
*/
|
|
async getRunningAgents(): Promise<
|
|
Array<{
|
|
featureId: string;
|
|
projectPath: string;
|
|
projectName: string;
|
|
isAutoMode: boolean;
|
|
model?: string;
|
|
provider?: ModelProvider;
|
|
title?: string;
|
|
description?: string;
|
|
branchName?: string;
|
|
}>
|
|
> {
|
|
const agents = await Promise.all(
|
|
this.concurrencyManager.getAllRunning().map(async (rf) => {
|
|
// Try to fetch feature data to get title, description, and branchName
|
|
let title: string | undefined;
|
|
let description: string | undefined;
|
|
let branchName: string | undefined;
|
|
|
|
try {
|
|
const feature = await this.featureLoader.get(rf.projectPath, rf.featureId);
|
|
if (feature) {
|
|
title = feature.title;
|
|
description = feature.description;
|
|
branchName = feature.branchName;
|
|
}
|
|
} catch (error) {
|
|
// Silently ignore errors - title/description/branchName are optional
|
|
}
|
|
|
|
return {
|
|
featureId: rf.featureId,
|
|
projectPath: rf.projectPath,
|
|
projectName: path.basename(rf.projectPath),
|
|
isAutoMode: rf.isAutoMode,
|
|
model: rf.model,
|
|
provider: rf.provider,
|
|
title,
|
|
description,
|
|
branchName,
|
|
};
|
|
})
|
|
);
|
|
return agents;
|
|
}
|
|
|
|
/**
|
|
* Wait for plan approval from the user.
|
|
* Delegates to PlanApprovalService.
|
|
*/
|
|
waitForPlanApproval(
|
|
featureId: string,
|
|
projectPath: string
|
|
): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> {
|
|
return this.planApprovalService.waitForApproval(featureId, projectPath);
|
|
}
|
|
|
|
/**
|
|
* Resolve a pending plan approval.
|
|
* Delegates to PlanApprovalService, handles recovery execution when needsRecovery=true.
|
|
*/
|
|
async resolvePlanApproval(
|
|
featureId: string,
|
|
approved: boolean,
|
|
editedPlan?: string,
|
|
feedback?: string,
|
|
projectPathFromClient?: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const result = await this.planApprovalService.resolveApproval(featureId, approved, {
|
|
editedPlan,
|
|
feedback,
|
|
projectPath: projectPathFromClient,
|
|
});
|
|
|
|
// Handle recovery case - PlanApprovalService returns flag, AutoModeService executes
|
|
if (result.success && result.needsRecovery && projectPathFromClient) {
|
|
const feature = await this.loadFeature(projectPathFromClient, featureId);
|
|
if (feature) {
|
|
// Get customized prompts from settings
|
|
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
|
|
|
// Build continuation prompt using centralized template
|
|
const planContent = editedPlan || feature.planSpec?.content || '';
|
|
let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
|
|
continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, feedback || '');
|
|
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
|
|
|
|
logger.info(`Starting recovery execution for feature ${featureId}`);
|
|
|
|
// Start feature execution with the continuation prompt (async, don't await)
|
|
this.executeFeature(projectPathFromClient, featureId, true, false, undefined, {
|
|
continuationPrompt,
|
|
}).catch((error) => {
|
|
logger.error(`Recovery execution failed for feature ${featureId}:`, error);
|
|
});
|
|
}
|
|
}
|
|
|
|
return { success: result.success, error: result.error };
|
|
}
|
|
|
|
/**
|
|
* Cancel a pending plan approval (e.g., when feature is stopped).
|
|
* Delegates to PlanApprovalService.
|
|
*/
|
|
cancelPlanApproval(featureId: string): void {
|
|
this.planApprovalService.cancelApproval(featureId);
|
|
}
|
|
|
|
/**
|
|
* Check if a feature has a pending plan approval.
|
|
* Delegates to PlanApprovalService.
|
|
*/
|
|
hasPendingApproval(featureId: string): boolean {
|
|
return this.planApprovalService.hasPendingApproval(featureId);
|
|
}
|
|
|
|
// Private helpers - delegate to extracted services
|
|
|
|
private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
|
|
return this.featureStateManager.loadFeature(projectPath, featureId);
|
|
}
|
|
|
|
private async updateFeatureStatus(
|
|
projectPath: string,
|
|
featureId: string,
|
|
status: string
|
|
): Promise<void> {
|
|
await this.featureStateManager.updateFeatureStatus(projectPath, featureId, status);
|
|
}
|
|
|
|
/**
|
|
* Mark a feature as interrupted due to server restart or other interruption.
|
|
*
|
|
* This is a convenience helper that updates the feature status to 'interrupted',
|
|
* indicating the feature was in progress but execution was disrupted (e.g., server
|
|
* restart, process crash, or manual stop). Features with this status can be
|
|
* resumed later using the resume functionality.
|
|
*
|
|
* Note: Features with pipeline_* statuses are preserved rather than overwritten
|
|
* to 'interrupted'. This ensures that 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> {
|
|
await this.featureStateManager.markFeatureInterrupted(projectPath, featureId, reason);
|
|
}
|
|
|
|
/**
|
|
* Mark all currently running features as interrupted.
|
|
*
|
|
* This method is called during graceful server shutdown to ensure that all
|
|
* features currently being executed are properly marked as 'interrupted'.
|
|
* This allows them to be detected and resumed when the server restarts.
|
|
*
|
|
* @param reason - Optional reason for the interruption (logged for debugging)
|
|
* @returns Promise that resolves when all features have been marked as interrupted
|
|
*/
|
|
async markAllRunningFeaturesInterrupted(reason?: string): Promise<void> {
|
|
const allRunning = this.concurrencyManager.getAllRunning();
|
|
const runningCount = allRunning.length;
|
|
|
|
if (runningCount === 0) {
|
|
logger.info('No running features to mark as interrupted');
|
|
return;
|
|
}
|
|
|
|
const logReason = reason || 'server shutdown';
|
|
logger.info(`Marking ${runningCount} running feature(s) as interrupted due to: ${logReason}`);
|
|
|
|
const markPromises: Promise<void>[] = [];
|
|
|
|
for (const runningFeature of allRunning) {
|
|
markPromises.push(
|
|
this.markFeatureInterrupted(
|
|
runningFeature.projectPath,
|
|
runningFeature.featureId,
|
|
logReason
|
|
).catch((error) => {
|
|
logger.error(`Failed to mark feature ${runningFeature.featureId} as interrupted:`, error);
|
|
})
|
|
);
|
|
}
|
|
|
|
await Promise.all(markPromises);
|
|
|
|
logger.info(`Finished marking ${runningCount} feature(s) as interrupted`);
|
|
}
|
|
|
|
private isFeatureFinished(feature: Feature): boolean {
|
|
const isCompleted = feature.status === 'completed' || feature.status === 'verified';
|
|
|
|
// Even if marked as completed, if it has an approved plan with pending tasks, it's not finished
|
|
if (feature.planSpec?.status === 'approved') {
|
|
const tasksCompleted = feature.planSpec.tasksCompleted ?? 0;
|
|
const tasksTotal = feature.planSpec.tasksTotal ?? 0;
|
|
if (tasksCompleted < tasksTotal) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return isCompleted;
|
|
}
|
|
|
|
/**
|
|
* Check if a feature is currently running (being executed or resumed).
|
|
* This is used for idempotent checks to prevent race conditions when
|
|
* multiple callers try to resume the same feature simultaneously.
|
|
*
|
|
* @param featureId - The ID of the feature to check
|
|
* @returns true if the feature is currently running, false otherwise
|
|
*/
|
|
isFeatureRunning(featureId: string): boolean {
|
|
return this.concurrencyManager.isRunning(featureId);
|
|
}
|
|
|
|
/**
|
|
* Update the planSpec of a feature
|
|
*/
|
|
private async updateFeaturePlanSpec(
|
|
projectPath: string,
|
|
featureId: string,
|
|
updates: Partial<PlanSpec>
|
|
): Promise<void> {
|
|
await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, updates);
|
|
}
|
|
|
|
/**
|
|
* Save the extracted summary to a feature's summary field.
|
|
* This is called after agent execution completes to save a summary
|
|
* extracted from the agent's output using <summary> tags.
|
|
*
|
|
* Note: This is different from updateFeatureSummary which updates
|
|
* the description field during plan generation.
|
|
*
|
|
* @param projectPath - The project path
|
|
* @param featureId - The feature ID
|
|
* @param summary - The summary text to save
|
|
*/
|
|
private async saveFeatureSummary(
|
|
projectPath: string,
|
|
featureId: string,
|
|
summary: string
|
|
): Promise<void> {
|
|
await this.featureStateManager.saveFeatureSummary(projectPath, featureId, summary);
|
|
}
|
|
|
|
/**
|
|
* Update the status of a specific task within planSpec.tasks
|
|
*/
|
|
private async updateTaskStatus(
|
|
projectPath: string,
|
|
featureId: string,
|
|
taskId: string,
|
|
status: ParsedTask['status']
|
|
): Promise<void> {
|
|
await this.featureStateManager.updateTaskStatus(projectPath, featureId, taskId, status);
|
|
}
|
|
|
|
/**
|
|
* Update the description of a feature based on extracted summary from plan content.
|
|
* This is called when a plan is generated during spec/full planning modes.
|
|
*
|
|
* Only updates the description if it's short (<50 chars), same as title,
|
|
* or starts with generic verbs like "implement/add/create/fix/update".
|
|
*
|
|
* Note: This is different from saveFeatureSummary which saves to the
|
|
* separate summary field after agent execution.
|
|
*
|
|
* @param projectPath - The project path
|
|
* @param featureId - The feature ID
|
|
* @param summary - The summary text extracted from the plan
|
|
*/
|
|
private async updateFeatureSummary(
|
|
projectPath: string,
|
|
featureId: string,
|
|
summary: string
|
|
): Promise<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`);
|
|
return;
|
|
}
|
|
|
|
// Only update if the feature doesn't already have a detailed description
|
|
// (Don't overwrite user-provided descriptions with extracted summaries)
|
|
const currentDesc = feature.description || '';
|
|
const isShortOrGeneric =
|
|
currentDesc.length < 50 ||
|
|
currentDesc === feature.title ||
|
|
/^(implement|add|create|fix|update)\s/i.test(currentDesc);
|
|
|
|
if (isShortOrGeneric) {
|
|
feature.description = summary;
|
|
feature.updatedAt = new Date().toISOString();
|
|
|
|
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
|
logger.info(`Updated feature ${featureId} description with extracted summary`);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to update summary for ${featureId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load pending features for a specific project/worktree
|
|
* @param projectPath - The project path
|
|
* @param branchName - The branch name to filter by, or null for main worktree (features without branchName)
|
|
*/
|
|
private async loadPendingFeatures(
|
|
projectPath: string,
|
|
branchName: string | null = null
|
|
): Promise<Feature[]> {
|
|
// Features are stored in .automaker directory
|
|
const featuresDir = getFeaturesDir(projectPath);
|
|
|
|
// Get the actual primary branch name for the project (e.g., "main", "master", "develop")
|
|
// This is needed to correctly match features when branchName is null (main worktree)
|
|
const primaryBranch = await this.worktreeResolver.getCurrentBranch(projectPath);
|
|
|
|
try {
|
|
const entries = await secureFs.readdir(featuresDir, {
|
|
withFileTypes: true,
|
|
});
|
|
const allFeatures: Feature[] = [];
|
|
const pendingFeatures: Feature[] = [];
|
|
|
|
// Load all features (for dependency checking) with recovery support
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
|
|
|
// Use recovery-enabled read for corrupted file handling
|
|
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
|
maxBackups: DEFAULT_BACKUP_COUNT,
|
|
autoRestore: true,
|
|
});
|
|
|
|
logRecoveryWarning(result, `Feature ${entry.name}`, logger);
|
|
|
|
const feature = result.data;
|
|
if (!feature) {
|
|
// Skip features that couldn't be loaded or recovered
|
|
continue;
|
|
}
|
|
|
|
allFeatures.push(feature);
|
|
|
|
// Track pending features separately, filtered by worktree/branch
|
|
// Note: waiting_approval is NOT included - those features have completed execution
|
|
// and are waiting for user review, they should not be picked up again
|
|
//
|
|
// Recovery cases:
|
|
// 1. Standard pending/ready/backlog statuses
|
|
// 2. Features with approved plans that have incomplete tasks (crash recovery)
|
|
// 3. Features stuck in 'in_progress' or 'interrupted' status (crash recovery)
|
|
// 4. Features with 'generating' planSpec status (spec generation was interrupted)
|
|
const needsRecovery =
|
|
feature.status === 'pending' ||
|
|
feature.status === 'ready' ||
|
|
feature.status === 'backlog' ||
|
|
feature.status === 'in_progress' || // Recover features that were in progress when server crashed
|
|
feature.status === 'interrupted' || // Recover features explicitly marked interrupted on shutdown
|
|
(feature.planSpec?.status === 'approved' &&
|
|
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) ||
|
|
feature.planSpec?.status === 'generating'; // Recover interrupted spec generation
|
|
|
|
if (needsRecovery) {
|
|
// Filter by branchName:
|
|
// - If branchName is null (main worktree), include features with:
|
|
// - branchName === null, OR
|
|
// - branchName === primaryBranch (e.g., "main", "master", "develop")
|
|
// - If branchName is set, only include features with matching branchName
|
|
const featureBranch = feature.branchName ?? null;
|
|
if (branchName === null) {
|
|
// Main worktree: include features without branchName OR with branchName matching primary branch
|
|
// This handles repos where the primary branch is named something other than "main"
|
|
const isPrimaryBranch =
|
|
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
|
if (isPrimaryBranch) {
|
|
pendingFeatures.push(feature);
|
|
} else {
|
|
logger.debug(
|
|
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, primaryBranch: ${primaryBranch}) for main worktree`
|
|
);
|
|
}
|
|
} else {
|
|
// Feature worktree: include features with matching branchName
|
|
if (featureBranch === branchName) {
|
|
pendingFeatures.push(feature);
|
|
} else {
|
|
logger.debug(
|
|
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, expected: ${branchName}) for worktree ${branchName}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
|
logger.info(
|
|
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/interrupted/approved_with_pending_tasks/generating) for ${worktreeDesc}`
|
|
);
|
|
|
|
if (pendingFeatures.length === 0) {
|
|
logger.warn(
|
|
`[loadPendingFeatures] No pending features found for ${worktreeDesc}. Check branchName matching - looking for branchName: ${branchName === null ? 'null (main)' : branchName}`
|
|
);
|
|
// Log all backlog features to help debug branchName matching
|
|
const allBacklogFeatures = allFeatures.filter(
|
|
(f) =>
|
|
f.status === 'backlog' ||
|
|
f.status === 'pending' ||
|
|
f.status === 'ready' ||
|
|
(f.planSpec?.status === 'approved' &&
|
|
(f.planSpec.tasksCompleted ?? 0) < (f.planSpec.tasksTotal ?? 0))
|
|
);
|
|
if (allBacklogFeatures.length > 0) {
|
|
logger.info(
|
|
`[loadPendingFeatures] Found ${allBacklogFeatures.length} backlog features with branchNames: ${allBacklogFeatures.map((f) => `${f.id}(${f.branchName ?? 'null'})`).join(', ')}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Apply dependency-aware ordering
|
|
const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures);
|
|
|
|
// Remove missing dependencies from features and save them
|
|
// This allows features to proceed when their dependencies have been deleted or don't exist
|
|
if (missingDependencies.size > 0) {
|
|
for (const [featureId, missingDepIds] of missingDependencies) {
|
|
const feature = pendingFeatures.find((f) => f.id === featureId);
|
|
if (feature && feature.dependencies) {
|
|
// Filter out the missing dependency IDs
|
|
const validDependencies = feature.dependencies.filter(
|
|
(depId) => !missingDepIds.includes(depId)
|
|
);
|
|
|
|
logger.warn(
|
|
`[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.`
|
|
);
|
|
|
|
// Update the feature in memory
|
|
feature.dependencies = validDependencies.length > 0 ? validDependencies : undefined;
|
|
|
|
// Save the updated feature to disk
|
|
try {
|
|
await this.featureLoader.update(projectPath, featureId, {
|
|
dependencies: feature.dependencies,
|
|
});
|
|
logger.info(
|
|
`[loadPendingFeatures] Updated feature ${featureId} - removed missing dependencies`
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`[loadPendingFeatures] Failed to save feature ${featureId} after removing missing dependencies:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get skipVerificationInAutoMode setting
|
|
const settings = await this.settingsService?.getGlobalSettings();
|
|
const skipVerification = settings?.skipVerificationInAutoMode ?? false;
|
|
|
|
// Filter to only features with satisfied dependencies
|
|
const readyFeatures: Feature[] = [];
|
|
const blockedFeatures: Array<{ feature: Feature; reason: string }> = [];
|
|
|
|
for (const feature of orderedFeatures) {
|
|
const isSatisfied = areDependenciesSatisfied(feature, allFeatures, { skipVerification });
|
|
if (isSatisfied) {
|
|
readyFeatures.push(feature);
|
|
} else {
|
|
// Find which dependencies are blocking
|
|
const blockingDeps =
|
|
feature.dependencies?.filter((depId) => {
|
|
const dep = allFeatures.find((f) => f.id === depId);
|
|
if (!dep) return true; // Missing dependency
|
|
if (skipVerification) {
|
|
return dep.status === 'running';
|
|
}
|
|
return dep.status !== 'completed' && dep.status !== 'verified';
|
|
}) || [];
|
|
blockedFeatures.push({
|
|
feature,
|
|
reason:
|
|
blockingDeps.length > 0
|
|
? `Blocked by dependencies: ${blockingDeps.join(', ')}`
|
|
: 'Unknown dependency issue',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (blockedFeatures.length > 0) {
|
|
logger.info(
|
|
`[loadPendingFeatures] ${blockedFeatures.length} features blocked by dependencies: ${blockedFeatures.map((b) => `${b.feature.id} (${b.reason})`).join('; ')}`
|
|
);
|
|
}
|
|
|
|
logger.info(
|
|
`[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})`
|
|
);
|
|
|
|
return readyFeatures;
|
|
} catch (error) {
|
|
logger.error(`[loadPendingFeatures] Error loading features:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract a title from feature description (first line or truncated)
|
|
*/
|
|
private extractTitleFromDescription(description: string): string {
|
|
if (!description || !description.trim()) {
|
|
return 'Untitled Feature';
|
|
}
|
|
|
|
// Get first line, or first 60 characters if no newline
|
|
const firstLine = description.split('\n')[0].trim();
|
|
if (firstLine.length <= 60) {
|
|
return firstLine;
|
|
}
|
|
|
|
// Truncate to 60 characters and add ellipsis
|
|
return firstLine.substring(0, 57) + '...';
|
|
}
|
|
|
|
/**
|
|
* Generate a comprehensive commit message for a feature
|
|
* Includes title, description summary, and file statistics
|
|
*/
|
|
private async generateCommitMessage(feature: Feature, workDir: string): Promise<string> {
|
|
const title = this.extractTitleFromDescription(feature.description);
|
|
|
|
// Extract description summary (first 3-5 lines, up to 300 chars)
|
|
let descriptionSummary = '';
|
|
if (feature.description && feature.description.trim()) {
|
|
const lines = feature.description.split('\n').filter((l) => l.trim());
|
|
const summaryLines = lines.slice(0, 5); // First 5 non-empty lines
|
|
descriptionSummary = summaryLines.join('\n');
|
|
|
|
// Limit to 300 characters
|
|
if (descriptionSummary.length > 300) {
|
|
descriptionSummary = descriptionSummary.substring(0, 297) + '...';
|
|
}
|
|
}
|
|
|
|
// Get file statistics to add context
|
|
let fileStats = '';
|
|
try {
|
|
const { stdout: diffStat } = await execAsync('git diff --cached --stat', { cwd: workDir });
|
|
if (diffStat.trim()) {
|
|
// Extract just the summary line (last line with file count)
|
|
const statLines = diffStat.trim().split('\n');
|
|
const summaryLine = statLines[statLines.length - 1];
|
|
if (summaryLine && summaryLine.includes('file')) {
|
|
fileStats = `\n${summaryLine.trim()}`;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore errors getting stats
|
|
}
|
|
|
|
// Build commit message
|
|
let message = `feat: ${title}`;
|
|
|
|
if (descriptionSummary && descriptionSummary !== title) {
|
|
message += `\n\n${descriptionSummary}`;
|
|
}
|
|
|
|
if (fileStats) {
|
|
message += fileStats;
|
|
}
|
|
|
|
message += '\n\nImplemented by Automaker auto-mode';
|
|
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* Get the planning prompt prefix based on feature's planning mode
|
|
*/
|
|
private async getPlanningPromptPrefix(feature: Feature): Promise<string> {
|
|
const mode = feature.planningMode || 'skip';
|
|
|
|
if (mode === 'skip') {
|
|
return ''; // No planning phase
|
|
}
|
|
|
|
// Load prompts from settings (no caching - allows hot reload of custom prompts)
|
|
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
|
const planningPrompts: Record<string, string> = {
|
|
lite: prompts.autoMode.planningLite,
|
|
lite_with_approval: prompts.autoMode.planningLiteWithApproval,
|
|
spec: prompts.autoMode.planningSpec,
|
|
full: prompts.autoMode.planningFull,
|
|
};
|
|
|
|
// For lite mode, use the approval variant if requirePlanApproval is true
|
|
let promptKey: string = mode;
|
|
if (mode === 'lite' && feature.requirePlanApproval === true) {
|
|
promptKey = 'lite_with_approval';
|
|
}
|
|
|
|
const planningPrompt = planningPrompts[promptKey];
|
|
if (!planningPrompt) {
|
|
return '';
|
|
}
|
|
|
|
return planningPrompt + '\n\n---\n\n## Feature Request\n\n';
|
|
}
|
|
|
|
private buildFeaturePrompt(
|
|
feature: Feature,
|
|
taskExecutionPrompts: {
|
|
implementationInstructions: string;
|
|
playwrightVerificationInstructions: string;
|
|
}
|
|
): string {
|
|
const title = this.extractTitleFromDescription(feature.description);
|
|
|
|
let prompt = `## Feature Implementation Task
|
|
|
|
**Feature ID:** ${feature.id}
|
|
**Title:** ${title}
|
|
**Description:** ${feature.description}
|
|
`;
|
|
|
|
if (feature.spec) {
|
|
prompt += `
|
|
**Specification:**
|
|
${feature.spec}
|
|
`;
|
|
}
|
|
|
|
// Add images note (like old implementation)
|
|
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
|
const imagesList = feature.imagePaths
|
|
.map((img, idx) => {
|
|
const path = typeof img === 'string' ? img : img.path;
|
|
const filename =
|
|
typeof img === 'string' ? path.split('/').pop() : img.filename || path.split('/').pop();
|
|
const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*';
|
|
return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`;
|
|
})
|
|
.join('\n');
|
|
|
|
prompt += `
|
|
**📎 Context Images Attached:**
|
|
The user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read:
|
|
|
|
${imagesList}
|
|
|
|
You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing.
|
|
`;
|
|
}
|
|
|
|
// Add verification instructions based on testing mode
|
|
if (feature.skipTests) {
|
|
// Manual verification - just implement the feature
|
|
prompt += `\n${taskExecutionPrompts.implementationInstructions}`;
|
|
} else {
|
|
// Automated testing - implement and verify with Playwright
|
|
prompt += `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
|
|
}
|
|
|
|
return prompt;
|
|
}
|
|
|
|
private async runAgent(
|
|
workDir: string,
|
|
featureId: string,
|
|
prompt: string,
|
|
abortController: AbortController,
|
|
projectPath: string,
|
|
imagePaths?: string[],
|
|
model?: string,
|
|
options?: {
|
|
projectPath?: string;
|
|
planningMode?: PlanningMode;
|
|
requirePlanApproval?: boolean;
|
|
previousContent?: string;
|
|
systemPrompt?: string;
|
|
autoLoadClaudeMd?: boolean;
|
|
thinkingLevel?: ThinkingLevel;
|
|
branchName?: string | null;
|
|
}
|
|
): Promise<void> {
|
|
const finalProjectPath = options?.projectPath || projectPath;
|
|
const branchName = options?.branchName ?? null;
|
|
const planningMode = options?.planningMode || 'skip';
|
|
const previousContent = options?.previousContent;
|
|
|
|
// Validate vision support before processing images
|
|
const effectiveModel = model || 'claude-sonnet-4-20250514';
|
|
if (imagePaths && imagePaths.length > 0) {
|
|
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
|
|
if (!supportsVision) {
|
|
throw new Error(
|
|
`This model (${effectiveModel}) does not support image input. ` +
|
|
`Please switch to a model that supports vision (like Claude models), or remove the images and try again.`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check if this planning mode can generate a spec/plan that needs approval
|
|
// - spec and full always generate specs
|
|
// - lite only generates approval-ready content when requirePlanApproval is true
|
|
const planningModeRequiresApproval =
|
|
planningMode === 'spec' ||
|
|
planningMode === 'full' ||
|
|
(planningMode === 'lite' && options?.requirePlanApproval === true);
|
|
const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true;
|
|
|
|
// Check if feature already has an approved plan with tasks (recovery scenario)
|
|
// If so, we should skip spec detection and use persisted task status
|
|
let existingApprovedPlan: Feature['planSpec'] | undefined;
|
|
let persistedTasks: ParsedTask[] | undefined;
|
|
if (planningModeRequiresApproval) {
|
|
const feature = await this.loadFeature(projectPath, featureId);
|
|
if (feature?.planSpec?.status === 'approved' && feature.planSpec.tasks) {
|
|
existingApprovedPlan = feature.planSpec;
|
|
persistedTasks = feature.planSpec.tasks;
|
|
logger.info(
|
|
`Recovery: Using persisted tasks for feature ${featureId} (${persistedTasks.length} tasks, ${persistedTasks.filter((t) => t.status === 'completed').length} completed)`
|
|
);
|
|
}
|
|
}
|
|
|
|
// CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set
|
|
// This prevents actual API calls during automated testing
|
|
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
|
logger.info(`MOCK MODE: Skipping real agent execution for feature ${featureId}`);
|
|
|
|
// Simulate some work being done
|
|
await this.sleep(500);
|
|
|
|
// Emit mock progress events to simulate agent activity
|
|
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId,
|
|
content: 'Mock agent: Analyzing the codebase...',
|
|
});
|
|
|
|
await this.sleep(300);
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId,
|
|
content: 'Mock agent: Implementing the feature...',
|
|
});
|
|
|
|
await this.sleep(300);
|
|
|
|
// Create a mock file with "yellow" content as requested in the test
|
|
const mockFilePath = path.join(workDir, 'yellow.txt');
|
|
await secureFs.writeFile(mockFilePath, 'yellow');
|
|
|
|
this.eventBus.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId,
|
|
content: "Mock agent: Created yellow.txt file with content 'yellow'",
|
|
});
|
|
|
|
await this.sleep(200);
|
|
|
|
// Save mock agent output
|
|
const featureDirForOutput = getFeatureDir(projectPath, featureId);
|
|
const outputPath = path.join(featureDirForOutput, 'agent-output.md');
|
|
|
|
const mockOutput = `# Mock Agent Output
|
|
|
|
## Summary
|
|
This is a mock agent response for CI/CD testing.
|
|
|
|
## Changes Made
|
|
- Created \`yellow.txt\` with content "yellow"
|
|
|
|
## Notes
|
|
This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|
`;
|
|
|
|
await secureFs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
await secureFs.writeFile(outputPath, mockOutput);
|
|
|
|
logger.info(`MOCK MODE: Completed mock execution for feature ${featureId}`);
|
|
return;
|
|
}
|
|
|
|
// Load autoLoadClaudeMd setting (project setting takes precedence over global)
|
|
// Use provided value if available, otherwise load from settings
|
|
const autoLoadClaudeMd =
|
|
options?.autoLoadClaudeMd !== undefined
|
|
? options.autoLoadClaudeMd
|
|
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
|
|
|
|
// Load MCP servers from settings (global setting only)
|
|
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
|
|
|
|
// Load MCP permission settings (global setting only)
|
|
|
|
// Build SDK options using centralized configuration for feature implementation
|
|
const sdkOptions = createAutoModeOptions({
|
|
cwd: workDir,
|
|
model: model,
|
|
abortController,
|
|
autoLoadClaudeMd,
|
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
|
thinkingLevel: options?.thinkingLevel,
|
|
});
|
|
|
|
// Extract model, maxTurns, and allowedTools from SDK options
|
|
const finalModel = sdkOptions.model!;
|
|
const maxTurns = sdkOptions.maxTurns;
|
|
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
|
|
|
logger.info(
|
|
`runAgent called for feature ${featureId} with model: ${finalModel}, planningMode: ${planningMode}, requiresApproval: ${requiresApproval}`
|
|
);
|
|
|
|
// Get provider for this model
|
|
const provider = ProviderFactory.getProviderForModel(finalModel);
|
|
|
|
// Strip provider prefix - providers should receive bare model IDs
|
|
const bareModel = stripProviderPrefix(finalModel);
|
|
|
|
logger.info(
|
|
`Using provider "${provider.getName()}" for model "${finalModel}" (bare: ${bareModel})`
|
|
);
|
|
|
|
// Build prompt content with images using utility
|
|
const { content: promptContent } = await buildPromptWithImages(
|
|
prompt,
|
|
imagePaths,
|
|
workDir,
|
|
false // don't duplicate paths in text
|
|
);
|
|
|
|
// Debug: Log if system prompt is provided
|
|
if (options?.systemPrompt) {
|
|
logger.info(
|
|
`System prompt provided (${options.systemPrompt.length} chars), first 200 chars:\n${options.systemPrompt.substring(0, 200)}...`
|
|
);
|
|
}
|
|
|
|
// Get credentials for API calls (model comes from request, no phase model)
|
|
const credentials = await this.settingsService?.getCredentials();
|
|
|
|
// Try to find a provider for the model (if it's a provider model like "GLM-4.7")
|
|
// This allows users to select provider models in the Auto Mode / Feature execution
|
|
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
|
let providerResolvedModel: string | undefined;
|
|
if (finalModel && this.settingsService) {
|
|
const providerResult = await getProviderByModelId(
|
|
finalModel,
|
|
this.settingsService,
|
|
'[AutoMode]'
|
|
);
|
|
if (providerResult.provider) {
|
|
claudeCompatibleProvider = providerResult.provider;
|
|
providerResolvedModel = providerResult.resolvedModel;
|
|
logger.info(
|
|
`[AutoMode] Using provider "${providerResult.provider.name}" for model "${finalModel}"` +
|
|
(providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
|
|
);
|
|
}
|
|
}
|
|
|
|
// Use the resolved model if available (from mapsToClaudeModel), otherwise use bareModel
|
|
const effectiveBareModel = providerResolvedModel
|
|
? stripProviderPrefix(providerResolvedModel)
|
|
: bareModel;
|
|
|
|
// Build AgentExecutionOptions for delegation to AgentExecutor
|
|
const agentOptions = {
|
|
workDir,
|
|
featureId,
|
|
prompt,
|
|
projectPath,
|
|
abortController,
|
|
imagePaths,
|
|
model: finalModel,
|
|
planningMode,
|
|
requirePlanApproval: options?.requirePlanApproval,
|
|
previousContent,
|
|
systemPrompt: options?.systemPrompt,
|
|
autoLoadClaudeMd,
|
|
thinkingLevel: options?.thinkingLevel,
|
|
branchName,
|
|
credentials,
|
|
claudeCompatibleProvider,
|
|
mcpServers,
|
|
sdkOptions: {
|
|
maxTurns,
|
|
allowedTools,
|
|
systemPrompt: sdkOptions.systemPrompt,
|
|
settingSources: sdkOptions.settingSources,
|
|
},
|
|
provider,
|
|
effectiveBareModel,
|
|
// Recovery options
|
|
specAlreadyDetected: !!existingApprovedPlan,
|
|
existingApprovedPlanContent: existingApprovedPlan?.content,
|
|
persistedTasks,
|
|
};
|
|
|
|
// Delegate to AgentExecutor with callbacks that wrap AutoModeService methods
|
|
logger.info(`Delegating to AgentExecutor for feature ${featureId}...`);
|
|
await this.agentExecutor.execute(agentOptions, {
|
|
waitForApproval: async (fId: string, pPath: string) => {
|
|
return this.planApprovalService.waitForApproval(fId, pPath);
|
|
},
|
|
saveFeatureSummary: async (pPath: string, fId: string, summary: string) => {
|
|
await this.saveFeatureSummary(pPath, fId, summary);
|
|
},
|
|
updateFeatureSummary: async (pPath: string, fId: string, summary: string) => {
|
|
await this.updateFeatureSummary(pPath, fId, summary);
|
|
},
|
|
buildTaskPrompt: (task, allTasks, taskIndex, planContent, template, feedback) => {
|
|
return this.buildTaskPrompt(task, allTasks, taskIndex, planContent, template, feedback);
|
|
},
|
|
});
|
|
|
|
logger.info(`AgentExecutor completed for feature ${featureId}`);
|
|
}
|
|
|
|
private async executeFeatureWithContext(
|
|
projectPath: string,
|
|
featureId: string,
|
|
context: string,
|
|
useWorktrees: boolean
|
|
): Promise<void> {
|
|
const feature = await this.loadFeature(projectPath, featureId);
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
// Get customized prompts from settings
|
|
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
|
|
|
// Build the feature prompt
|
|
const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
|
|
|
|
// Use the resume feature template with variable substitution
|
|
let prompt = prompts.taskExecution.resumeFeatureTemplate;
|
|
prompt = prompt.replace(/\{\{featurePrompt\}\}/g, featurePrompt);
|
|
prompt = prompt.replace(/\{\{previousContext\}\}/g, context);
|
|
|
|
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
|
|
continuationPrompt: prompt,
|
|
_calledInternally: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Detect if a feature is stuck in a pipeline step and extract step information.
|
|
* Parses the feature status to determine if it's a pipeline status (e.g., 'pipeline_step_xyz'),
|
|
* loads the pipeline configuration, and validates that the step still exists.
|
|
*
|
|
* This method handles several scenarios:
|
|
* - Non-pipeline status: Returns default PipelineStatusInfo with isPipeline=false
|
|
* - Invalid pipeline status format: Returns isPipeline=true but null step info
|
|
* - Step deleted from config: Returns stepIndex=-1 to signal missing step
|
|
* - Valid pipeline step: Returns full step information and config
|
|
*
|
|
* @param {string} projectPath - Absolute path to the project directory
|
|
* @param {string} featureId - Unique identifier of the feature
|
|
* @param {FeatureStatusWithPipeline} currentStatus - Current feature status (may include pipeline step info)
|
|
* @returns {Promise<PipelineStatusInfo>} Information about the pipeline status and step
|
|
* @private
|
|
*/
|
|
private async detectPipelineStatus(
|
|
projectPath: string,
|
|
featureId: string,
|
|
currentStatus: FeatureStatusWithPipeline
|
|
): Promise<PipelineStatusInfo> {
|
|
// Check if status is pipeline format using PipelineService
|
|
const isPipeline = pipelineService.isPipelineStatus(currentStatus);
|
|
|
|
if (!isPipeline) {
|
|
return {
|
|
isPipeline: false,
|
|
stepId: null,
|
|
stepIndex: -1,
|
|
totalSteps: 0,
|
|
step: null,
|
|
config: null,
|
|
};
|
|
}
|
|
|
|
// Extract step ID using PipelineService
|
|
const stepId = pipelineService.getStepIdFromStatus(currentStatus);
|
|
|
|
if (!stepId) {
|
|
console.warn(
|
|
`[AutoMode] Feature ${featureId} has invalid pipeline status format: ${currentStatus}`
|
|
);
|
|
return {
|
|
isPipeline: true,
|
|
stepId: null,
|
|
stepIndex: -1,
|
|
totalSteps: 0,
|
|
step: null,
|
|
config: null,
|
|
};
|
|
}
|
|
|
|
// Load pipeline config
|
|
const config = await pipelineService.getPipelineConfig(projectPath);
|
|
|
|
if (!config || config.steps.length === 0) {
|
|
// Pipeline config doesn't exist or empty - feature stuck with invalid pipeline status
|
|
console.warn(
|
|
`[AutoMode] Feature ${featureId} has pipeline status but no pipeline config exists`
|
|
);
|
|
return {
|
|
isPipeline: true,
|
|
stepId,
|
|
stepIndex: -1,
|
|
totalSteps: 0,
|
|
step: null,
|
|
config: null,
|
|
};
|
|
}
|
|
|
|
// Find the step directly from config (already loaded, avoid redundant file read)
|
|
const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order);
|
|
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
|
|
const step = stepIndex === -1 ? null : sortedSteps[stepIndex];
|
|
|
|
if (!step) {
|
|
// Step not found in current config - step was deleted/changed
|
|
console.warn(
|
|
`[AutoMode] Feature ${featureId} stuck in step ${stepId} which no longer exists in pipeline config`
|
|
);
|
|
return {
|
|
isPipeline: true,
|
|
stepId,
|
|
stepIndex: -1,
|
|
totalSteps: sortedSteps.length,
|
|
step: null,
|
|
config,
|
|
};
|
|
}
|
|
|
|
console.log(
|
|
`[AutoMode] Detected pipeline status for feature ${featureId}: step ${stepIndex + 1}/${sortedSteps.length} (${step.name})`
|
|
);
|
|
|
|
return {
|
|
isPipeline: true,
|
|
stepId,
|
|
stepIndex,
|
|
totalSteps: sortedSteps.length,
|
|
step,
|
|
config,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a focused prompt for executing a single task.
|
|
* Each task gets minimal context to keep the agent focused.
|
|
*/
|
|
private buildTaskPrompt(
|
|
task: ParsedTask,
|
|
allTasks: ParsedTask[],
|
|
taskIndex: number,
|
|
planContent: string,
|
|
taskPromptTemplate: string,
|
|
userFeedback?: string
|
|
): string {
|
|
const completedTasks = allTasks.slice(0, taskIndex);
|
|
const remainingTasks = allTasks.slice(taskIndex + 1);
|
|
|
|
// Build completed tasks string
|
|
const completedTasksStr =
|
|
completedTasks.length > 0
|
|
? `### Already Completed (${completedTasks.length} tasks)\n${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')}\n`
|
|
: '';
|
|
|
|
// Build remaining tasks string
|
|
const remainingTasksStr =
|
|
remainingTasks.length > 0
|
|
? `### Coming Up Next (${remainingTasks.length} tasks remaining)\n${remainingTasks
|
|
.slice(0, 3)
|
|
.map((t) => `- [ ] ${t.id}: ${t.description}`)
|
|
.join(
|
|
'\n'
|
|
)}${remainingTasks.length > 3 ? `\n... and ${remainingTasks.length - 3} more tasks` : ''}\n`
|
|
: '';
|
|
|
|
// Build user feedback string
|
|
const userFeedbackStr = userFeedback ? `### User Feedback\n${userFeedback}\n` : '';
|
|
|
|
// Use centralized template with variable substitution
|
|
let prompt = taskPromptTemplate;
|
|
prompt = prompt.replace(/\{\{taskId\}\}/g, task.id);
|
|
prompt = prompt.replace(/\{\{taskDescription\}\}/g, task.description);
|
|
prompt = prompt.replace(/\{\{taskFilePath\}\}/g, task.filePath || '');
|
|
prompt = prompt.replace(/\{\{taskPhase\}\}/g, task.phase || '');
|
|
prompt = prompt.replace(/\{\{completedTasks\}\}/g, completedTasksStr);
|
|
prompt = prompt.replace(/\{\{remainingTasks\}\}/g, remainingTasksStr);
|
|
prompt = prompt.replace(/\{\{userFeedback\}\}/g, userFeedbackStr);
|
|
prompt = prompt.replace(/\{\{planContent\}\}/g, planContent);
|
|
|
|
return prompt;
|
|
}
|
|
|
|
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(resolve, ms);
|
|
|
|
// If signal is provided and already aborted, reject immediately
|
|
if (signal?.aborted) {
|
|
clearTimeout(timeout);
|
|
reject(new Error('Aborted'));
|
|
return;
|
|
}
|
|
|
|
// Listen for abort signal
|
|
if (signal) {
|
|
signal.addEventListener(
|
|
'abort',
|
|
() => {
|
|
clearTimeout(timeout);
|
|
reject(new Error('Aborted'));
|
|
},
|
|
{ once: true }
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Execution State Persistence - For recovery after server restart
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Save execution state to disk for recovery after server restart
|
|
*/
|
|
private async saveExecutionState(projectPath: string): Promise<void> {
|
|
try {
|
|
await ensureAutomakerDir(projectPath);
|
|
const statePath = getExecutionStatePath(projectPath);
|
|
const runningFeatureIds = this.concurrencyManager.getAllRunning().map((rf) => rf.featureId);
|
|
const state: ExecutionState = {
|
|
version: 1,
|
|
autoLoopWasRunning: this.autoLoopRunning,
|
|
maxConcurrency: this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
|
projectPath,
|
|
branchName: null, // Legacy global auto mode uses main worktree
|
|
runningFeatureIds,
|
|
savedAt: new Date().toISOString(),
|
|
};
|
|
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
|
logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`);
|
|
} catch (error) {
|
|
logger.error('Failed to save execution state:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load execution state from disk
|
|
*/
|
|
private async loadExecutionState(projectPath: string): Promise<ExecutionState> {
|
|
try {
|
|
const statePath = getExecutionStatePath(projectPath);
|
|
const content = (await secureFs.readFile(statePath, 'utf-8')) as string;
|
|
const state = JSON.parse(content) as ExecutionState;
|
|
return state;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
logger.error('Failed to load execution state:', error);
|
|
}
|
|
return DEFAULT_EXECUTION_STATE;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear execution state (called on successful shutdown or when auto-loop stops)
|
|
*/
|
|
private async clearExecutionState(
|
|
projectPath: string,
|
|
branchName: string | null = null
|
|
): Promise<void> {
|
|
try {
|
|
const statePath = getExecutionStatePath(projectPath);
|
|
await secureFs.unlink(statePath);
|
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
|
logger.info(`Cleared execution state for ${worktreeDesc}`);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
logger.error('Failed to clear execution state:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for and resume interrupted features after server restart
|
|
* This should be called during server initialization
|
|
*/
|
|
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
|
|
logger.info('Checking for interrupted features to resume...');
|
|
|
|
// Load all features and find those that were interrupted
|
|
const featuresDir = getFeaturesDir(projectPath);
|
|
|
|
try {
|
|
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
|
// Track features with and without context separately for better logging
|
|
const featuresWithContext: Feature[] = [];
|
|
const featuresWithoutContext: Feature[] = [];
|
|
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
|
|
|
// Use recovery-enabled read for corrupted file handling
|
|
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
|
maxBackups: DEFAULT_BACKUP_COUNT,
|
|
autoRestore: true,
|
|
});
|
|
|
|
logRecoveryWarning(result, `Feature ${entry.name}`, logger);
|
|
|
|
const feature = result.data;
|
|
if (!feature) {
|
|
// Skip features that couldn't be loaded or recovered
|
|
continue;
|
|
}
|
|
|
|
// Check if feature was interrupted (in_progress/interrupted or pipeline_*)
|
|
if (
|
|
feature.status === 'in_progress' ||
|
|
feature.status === 'interrupted' ||
|
|
(feature.status && feature.status.startsWith('pipeline_'))
|
|
) {
|
|
// Check if context (agent-output.md) exists
|
|
const featureDir = getFeatureDir(projectPath, feature.id);
|
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
|
try {
|
|
await secureFs.access(contextPath);
|
|
featuresWithContext.push(feature);
|
|
logger.info(
|
|
`Found interrupted feature with context: ${feature.id} (${feature.title}) - status: ${feature.status}`
|
|
);
|
|
} catch {
|
|
// No context file - feature was interrupted before any agent output
|
|
// Still include it for resumption (will start fresh)
|
|
featuresWithoutContext.push(feature);
|
|
logger.info(
|
|
`Found interrupted feature without context: ${feature.id} (${feature.title}) - status: ${feature.status} (will restart fresh)`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Combine all interrupted features (with and without context)
|
|
const allInterruptedFeatures = [...featuresWithContext, ...featuresWithoutContext];
|
|
|
|
if (allInterruptedFeatures.length === 0) {
|
|
logger.info('No interrupted features found');
|
|
return;
|
|
}
|
|
|
|
logger.info(
|
|
`Found ${allInterruptedFeatures.length} interrupted feature(s) to resume ` +
|
|
`(${featuresWithContext.length} with context, ${featuresWithoutContext.length} without context)`
|
|
);
|
|
|
|
// Emit event to notify UI with context information
|
|
this.eventBus.emitAutoModeEvent('auto_mode_resuming_features', {
|
|
message: `Resuming ${allInterruptedFeatures.length} interrupted feature(s) after server restart`,
|
|
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),
|
|
})),
|
|
});
|
|
|
|
// Resume each interrupted feature
|
|
for (const feature of allInterruptedFeatures) {
|
|
try {
|
|
// Idempotent check: skip if feature is already being resumed (prevents race conditions)
|
|
if (this.isFeatureRunning(feature.id)) {
|
|
logger.info(
|
|
`Feature ${feature.id} (${feature.title}) is already being resumed, skipping`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const hasContext = featuresWithContext.some((fc) => fc.id === feature.id);
|
|
logger.info(
|
|
`Resuming feature: ${feature.id} (${feature.title}) - ${hasContext ? 'continuing from context' : 'starting fresh'}`
|
|
);
|
|
// Use resumeFeature which will detect the existing context and continue,
|
|
// or start fresh if no context exists
|
|
await this.resumeFeature(projectPath, feature.id, true);
|
|
} catch (error) {
|
|
logger.error(`Failed to resume feature ${feature.id}:`, error);
|
|
// Continue with other features
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
logger.info('No features directory found, nothing to resume');
|
|
} else {
|
|
logger.error('Error checking for interrupted features:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract and record learnings from a completed feature
|
|
* Uses a quick Claude call to identify important decisions and patterns
|
|
*/
|
|
private async recordLearningsFromFeature(
|
|
projectPath: string,
|
|
feature: Feature,
|
|
agentOutput: string
|
|
): Promise<void> {
|
|
if (!agentOutput || agentOutput.length < 100) {
|
|
// Not enough output to extract learnings from
|
|
console.log(
|
|
`[AutoMode] Skipping learning extraction - output too short (${agentOutput?.length || 0} chars)`
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
`[AutoMode] Extracting learnings from feature "${feature.title}" (${agentOutput.length} chars)`
|
|
);
|
|
|
|
// Limit output to avoid token limits
|
|
const truncatedOutput = agentOutput.length > 10000 ? agentOutput.slice(-10000) : agentOutput;
|
|
|
|
// Get customized prompts from settings
|
|
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
|
|
|
// Build user prompt using centralized template with variable substitution
|
|
let userPrompt = prompts.taskExecution.learningExtractionUserPromptTemplate;
|
|
userPrompt = userPrompt.replace(/\{\{featureTitle\}\}/g, feature.title || '');
|
|
userPrompt = userPrompt.replace(/\{\{implementationLog\}\}/g, truncatedOutput);
|
|
|
|
try {
|
|
// Get model from phase settings
|
|
const settings = await this.settingsService?.getGlobalSettings();
|
|
const phaseModelEntry =
|
|
settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel;
|
|
const { model } = resolvePhaseModel(phaseModelEntry);
|
|
const hasClaudeKey = Boolean(process.env.ANTHROPIC_API_KEY);
|
|
let resolvedModel = model;
|
|
|
|
if (isClaudeModel(model) && !hasClaudeKey) {
|
|
const fallbackModel = feature.model
|
|
? resolveModelString(feature.model, DEFAULT_MODELS.claude)
|
|
: null;
|
|
if (fallbackModel && !isClaudeModel(fallbackModel)) {
|
|
console.log(
|
|
`[AutoMode] Claude not configured for memory extraction; using feature model "${fallbackModel}".`
|
|
);
|
|
resolvedModel = fallbackModel;
|
|
} else {
|
|
console.log(
|
|
'[AutoMode] Claude not configured for memory extraction; skipping learning extraction.'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const result = await simpleQuery({
|
|
prompt: userPrompt,
|
|
model: resolvedModel,
|
|
cwd: projectPath,
|
|
maxTurns: 1,
|
|
allowedTools: [],
|
|
systemPrompt: prompts.taskExecution.learningExtractionSystemPrompt,
|
|
});
|
|
|
|
const responseText = result.text;
|
|
|
|
console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`);
|
|
console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`);
|
|
|
|
// Parse the response - handle JSON in markdown code blocks or raw
|
|
let jsonStr: string | null = null;
|
|
|
|
// First try to find JSON in markdown code blocks
|
|
const codeBlockMatch = responseText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
|
if (codeBlockMatch) {
|
|
console.log('[AutoMode] Found JSON in code block');
|
|
jsonStr = codeBlockMatch[1];
|
|
} else {
|
|
// Fall back to finding balanced braces containing "learnings"
|
|
// Use a more precise approach: find the opening brace before "learnings"
|
|
const learningsIndex = responseText.indexOf('"learnings"');
|
|
if (learningsIndex !== -1) {
|
|
// Find the opening brace before "learnings"
|
|
let braceStart = responseText.lastIndexOf('{', learningsIndex);
|
|
if (braceStart !== -1) {
|
|
// Find matching closing brace
|
|
let braceCount = 0;
|
|
let braceEnd = -1;
|
|
for (let i = braceStart; i < responseText.length; i++) {
|
|
if (responseText[i] === '{') braceCount++;
|
|
if (responseText[i] === '}') braceCount--;
|
|
if (braceCount === 0) {
|
|
braceEnd = i;
|
|
break;
|
|
}
|
|
}
|
|
if (braceEnd !== -1) {
|
|
jsonStr = responseText.substring(braceStart, braceEnd + 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!jsonStr) {
|
|
console.log('[AutoMode] Could not extract JSON from response');
|
|
return;
|
|
}
|
|
|
|
console.log(`[AutoMode] Extracted JSON: ${jsonStr.substring(0, 200)}`);
|
|
|
|
let parsed: { learnings?: unknown[] };
|
|
try {
|
|
parsed = JSON.parse(jsonStr);
|
|
} catch {
|
|
console.warn('[AutoMode] Failed to parse learnings JSON:', jsonStr.substring(0, 200));
|
|
return;
|
|
}
|
|
|
|
if (!parsed.learnings || !Array.isArray(parsed.learnings)) {
|
|
console.log('[AutoMode] No learnings array in parsed response');
|
|
return;
|
|
}
|
|
|
|
console.log(`[AutoMode] Found ${parsed.learnings.length} potential learnings`);
|
|
|
|
// Valid learning types
|
|
const validTypes = new Set(['decision', 'learning', 'pattern', 'gotcha']);
|
|
|
|
// Record each learning
|
|
for (const item of parsed.learnings) {
|
|
// Validate required fields with proper type narrowing
|
|
if (!item || typeof item !== 'object') continue;
|
|
|
|
const learning = item as Record<string, unknown>;
|
|
if (
|
|
!learning.category ||
|
|
typeof learning.category !== 'string' ||
|
|
!learning.content ||
|
|
typeof learning.content !== 'string' ||
|
|
!learning.content.trim()
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// Validate and normalize type
|
|
const typeStr = typeof learning.type === 'string' ? learning.type : 'learning';
|
|
const learningType = validTypes.has(typeStr)
|
|
? (typeStr as 'decision' | 'learning' | 'pattern' | 'gotcha')
|
|
: 'learning';
|
|
|
|
console.log(
|
|
`[AutoMode] Appending learning: category=${learning.category}, type=${learningType}`
|
|
);
|
|
await appendLearning(
|
|
projectPath,
|
|
{
|
|
category: learning.category,
|
|
type: learningType,
|
|
content: learning.content.trim(),
|
|
context: typeof learning.context === 'string' ? learning.context : undefined,
|
|
why: typeof learning.why === 'string' ? learning.why : undefined,
|
|
rejected: typeof learning.rejected === 'string' ? learning.rejected : undefined,
|
|
tradeoffs: typeof learning.tradeoffs === 'string' ? learning.tradeoffs : undefined,
|
|
breaking: typeof learning.breaking === 'string' ? learning.breaking : undefined,
|
|
},
|
|
secureFs as Parameters<typeof appendLearning>[2]
|
|
);
|
|
}
|
|
|
|
const validLearnings = parsed.learnings.filter(
|
|
(l) => l && typeof l === 'object' && (l as Record<string, unknown>).content
|
|
);
|
|
if (validLearnings.length > 0) {
|
|
console.log(
|
|
`[AutoMode] Recorded ${parsed.learnings.length} learning(s) from feature ${feature.id}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`[AutoMode] Failed to extract learnings from feature ${feature.id}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect orphaned features - features whose branchName points to a branch that no longer exists.
|
|
*
|
|
* Orphaned features can occur when:
|
|
* - A feature branch is deleted after merge
|
|
* - A worktree is manually removed
|
|
* - A branch is force-deleted
|
|
*
|
|
* @param projectPath - Path to the project
|
|
* @returns Array of orphaned features with their missing branch names
|
|
*/
|
|
async detectOrphanedFeatures(
|
|
projectPath: string
|
|
): Promise<Array<{ feature: Feature; missingBranch: string }>> {
|
|
const orphanedFeatures: Array<{ feature: Feature; missingBranch: string }> = [];
|
|
|
|
try {
|
|
// Get all features for this project
|
|
const allFeatures = await this.featureLoader.getAll(projectPath);
|
|
|
|
// Get features that have a branchName set (excludes main branch features)
|
|
const featuresWithBranches = allFeatures.filter(
|
|
(f) => f.branchName && f.branchName.trim() !== ''
|
|
);
|
|
|
|
if (featuresWithBranches.length === 0) {
|
|
logger.debug('[detectOrphanedFeatures] No features with branch names found');
|
|
return orphanedFeatures;
|
|
}
|
|
|
|
// Get all existing branches (local)
|
|
const existingBranches = await this.getExistingBranches(projectPath);
|
|
|
|
// Get current/primary branch (features with null branchName are implicitly on this)
|
|
const primaryBranch = await this.worktreeResolver.getCurrentBranch(projectPath);
|
|
|
|
// Check each feature with a branchName
|
|
for (const feature of featuresWithBranches) {
|
|
const branchName = feature.branchName!;
|
|
|
|
// Skip if the branchName matches the primary branch (implicitly valid)
|
|
if (primaryBranch && branchName === primaryBranch) {
|
|
continue;
|
|
}
|
|
|
|
// Check if the branch exists
|
|
if (!existingBranches.has(branchName)) {
|
|
orphanedFeatures.push({
|
|
feature,
|
|
missingBranch: branchName,
|
|
});
|
|
logger.info(
|
|
`[detectOrphanedFeatures] Found orphaned feature: ${feature.id} (${feature.title}) - branch "${branchName}" no longer exists`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (orphanedFeatures.length > 0) {
|
|
logger.info(
|
|
`[detectOrphanedFeatures] Found ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
|
|
);
|
|
} else {
|
|
logger.debug('[detectOrphanedFeatures] No orphaned features found');
|
|
}
|
|
|
|
return orphanedFeatures;
|
|
} catch (error) {
|
|
logger.error('[detectOrphanedFeatures] Error detecting orphaned features:', error);
|
|
return orphanedFeatures;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all existing local branches for a project
|
|
* @param projectPath - Path to the git repository
|
|
* @returns Set of branch names
|
|
*/
|
|
private async getExistingBranches(projectPath: string): Promise<Set<string>> {
|
|
const branches = new Set<string>();
|
|
|
|
try {
|
|
// Use git for-each-ref to get all local branches
|
|
const { stdout } = await execAsync(
|
|
'git for-each-ref --format="%(refname:short)" refs/heads/',
|
|
{ cwd: projectPath }
|
|
);
|
|
|
|
const branchLines = stdout.trim().split('\n');
|
|
for (const branch of branchLines) {
|
|
const trimmed = branch.trim();
|
|
if (trimmed) {
|
|
branches.add(trimmed);
|
|
}
|
|
}
|
|
|
|
logger.debug(`[getExistingBranches] Found ${branches.size} local branches`);
|
|
} catch (error) {
|
|
logger.error('[getExistingBranches] Failed to get branches:', error);
|
|
}
|
|
|
|
return branches;
|
|
}
|
|
}
|