mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
- Simplified the graceful shutdown process by removing redundant error handling for marking features as interrupted, as it is now managed internally. - Updated orphan detection logging to streamline the process and enhance clarity. - Added logic to preserve specific pipeline statuses when marking features as interrupted, ensuring correct resumption of features after a server restart. - Enhanced unit tests to cover new behavior for preserving pipeline statuses and handling various feature states.
5106 lines
183 KiB
TypeScript
5106 lines
183 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,
|
|
} 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 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';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
/**
|
|
* Get the current branch name for a git repository
|
|
* @param projectPath - Path to the git repository
|
|
* @returns The current branch name, or null if not in a git repo or on detached HEAD
|
|
*/
|
|
async function getCurrentBranch(projectPath: string): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath });
|
|
const branch = stdout.trim();
|
|
return branch || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// PlanningMode type is imported from @automaker/types
|
|
|
|
interface ParsedTask {
|
|
id: string; // e.g., "T001"
|
|
description: string; // e.g., "Create user model"
|
|
filePath?: string; // e.g., "src/models/user.ts"
|
|
phase?: string; // e.g., "Phase 1: Foundation" (for full mode)
|
|
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
|
}
|
|
|
|
interface PlanSpec {
|
|
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
|
|
content?: string;
|
|
version: number;
|
|
generatedAt?: string;
|
|
approvedAt?: string;
|
|
reviewedByUser: boolean;
|
|
tasksCompleted?: number;
|
|
tasksTotal?: number;
|
|
currentTaskId?: string;
|
|
tasks?: ParsedTask[];
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Parse tasks from generated spec content
|
|
* Looks for the ```tasks code block and extracts task lines
|
|
* Format: - [ ] T###: Description | File: path/to/file
|
|
*/
|
|
function parseTasksFromSpec(specContent: string): ParsedTask[] {
|
|
const tasks: ParsedTask[] = [];
|
|
|
|
// Extract content within ```tasks ... ``` block
|
|
const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/);
|
|
if (!tasksBlockMatch) {
|
|
// Try fallback: look for task lines anywhere in content
|
|
const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm);
|
|
if (!taskLines) {
|
|
return tasks;
|
|
}
|
|
// Parse fallback task lines
|
|
let currentPhase: string | undefined;
|
|
for (const line of taskLines) {
|
|
const parsed = parseTaskLine(line, currentPhase);
|
|
if (parsed) {
|
|
tasks.push(parsed);
|
|
}
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
const tasksContent = tasksBlockMatch[1];
|
|
const lines = tasksContent.split('\n');
|
|
|
|
let currentPhase: string | undefined;
|
|
|
|
for (const line of lines) {
|
|
const trimmedLine = line.trim();
|
|
|
|
// Check for phase header (e.g., "## Phase 1: Foundation")
|
|
const phaseMatch = trimmedLine.match(/^##\s*(.+)$/);
|
|
if (phaseMatch) {
|
|
currentPhase = phaseMatch[1].trim();
|
|
continue;
|
|
}
|
|
|
|
// Check for task line
|
|
if (trimmedLine.startsWith('- [ ]')) {
|
|
const parsed = parseTaskLine(trimmedLine, currentPhase);
|
|
if (parsed) {
|
|
tasks.push(parsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
return tasks;
|
|
}
|
|
|
|
/**
|
|
* Parse a single task line
|
|
* Format: - [ ] T###: Description | File: path/to/file
|
|
*/
|
|
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
|
|
// Match pattern: - [ ] T###: Description | File: path
|
|
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
|
|
if (!taskMatch) {
|
|
// Try simpler pattern without file
|
|
const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/);
|
|
if (simpleMatch) {
|
|
return {
|
|
id: simpleMatch[1],
|
|
description: simpleMatch[2].trim(),
|
|
phase: currentPhase,
|
|
status: 'pending',
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: taskMatch[1],
|
|
description: taskMatch[2].trim(),
|
|
filePath: taskMatch[3]?.trim(),
|
|
phase: currentPhase,
|
|
status: 'pending',
|
|
};
|
|
}
|
|
|
|
// 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 RunningFeature {
|
|
featureId: string;
|
|
projectPath: string;
|
|
worktreePath: string | null;
|
|
branchName: string | null;
|
|
abortController: AbortController;
|
|
isAutoMode: boolean;
|
|
startTime: number;
|
|
leaseCount: number;
|
|
model?: string;
|
|
provider?: ModelProvider;
|
|
}
|
|
|
|
interface AutoLoopState {
|
|
projectPath: string;
|
|
maxConcurrency: number;
|
|
abortController: AbortController;
|
|
isRunning: boolean;
|
|
}
|
|
|
|
interface PendingApproval {
|
|
resolve: (result: { approved: boolean; editedPlan?: string; feedback?: string }) => void;
|
|
reject: (error: Error) => void;
|
|
featureId: string;
|
|
projectPath: string;
|
|
}
|
|
|
|
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 runningFeatures = new Map<string, RunningFeature>();
|
|
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 pendingApprovals = new Map<string, PendingApproval>();
|
|
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) {
|
|
this.events = events;
|
|
this.settingsService = settingsService ?? null;
|
|
}
|
|
|
|
/**
|
|
* Acquire a slot in the runningFeatures map for a feature.
|
|
* Implements reference counting via leaseCount to support nested calls
|
|
* (e.g., resumeFeature -> executeFeature).
|
|
*
|
|
* @param params.featureId - ID of the feature to track
|
|
* @param params.projectPath - Path to the project
|
|
* @param params.isAutoMode - Whether this is an auto-mode execution
|
|
* @param params.allowReuse - If true, allows incrementing leaseCount for already-running features
|
|
* @param params.abortController - Optional abort controller to use
|
|
* @returns The RunningFeature entry (existing or newly created)
|
|
* @throws Error if feature is already running and allowReuse is false
|
|
*/
|
|
private acquireRunningFeature(params: {
|
|
featureId: string;
|
|
projectPath: string;
|
|
isAutoMode: boolean;
|
|
allowReuse?: boolean;
|
|
abortController?: AbortController;
|
|
}): RunningFeature {
|
|
const existing = this.runningFeatures.get(params.featureId);
|
|
if (existing) {
|
|
if (!params.allowReuse) {
|
|
throw new Error('already running');
|
|
}
|
|
existing.leaseCount += 1;
|
|
return existing;
|
|
}
|
|
|
|
const abortController = params.abortController ?? new AbortController();
|
|
const entry: RunningFeature = {
|
|
featureId: params.featureId,
|
|
projectPath: params.projectPath,
|
|
worktreePath: null,
|
|
branchName: null,
|
|
abortController,
|
|
isAutoMode: params.isAutoMode,
|
|
startTime: Date.now(),
|
|
leaseCount: 1,
|
|
};
|
|
this.runningFeatures.set(params.featureId, entry);
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* Release a slot in the runningFeatures map for a feature.
|
|
* Decrements leaseCount and only removes the entry when it reaches zero,
|
|
* unless force option is used.
|
|
*
|
|
* @param featureId - ID of the feature to release
|
|
* @param options.force - If true, immediately removes the entry regardless of leaseCount
|
|
*/
|
|
private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
|
|
const entry = this.runningFeatures.get(featureId);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
|
|
if (options?.force) {
|
|
this.runningFeatures.delete(featureId);
|
|
return;
|
|
}
|
|
|
|
entry.leaseCount -= 1;
|
|
if (entry.leaseCount <= 0) {
|
|
this.runningFeatures.delete(featureId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.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.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}`
|
|
);
|
|
|
|
this.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.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.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.runningFeatures.has(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
|
|
*/
|
|
private getRunningCountForProject(projectPath: string): number {
|
|
let count = 0;
|
|
for (const [, feature] of this.runningFeatures) {
|
|
if (feature.projectPath === projectPath) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Get count of running features for a specific worktree
|
|
* @param projectPath - The project path
|
|
* @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch)
|
|
*/
|
|
private async getRunningCountForWorktree(
|
|
projectPath: string,
|
|
branchName: string | null
|
|
): Promise<number> {
|
|
// Get the actual primary branch name for the project
|
|
const primaryBranch = await getCurrentBranch(projectPath);
|
|
|
|
let count = 0;
|
|
for (const [, feature] of this.runningFeatures) {
|
|
// Filter by project path AND branchName to get accurate worktree-specific count
|
|
const featureBranch = feature.branchName ?? null;
|
|
if (branchName === null) {
|
|
// Main worktree: match features with branchName === null OR branchName matching primary branch
|
|
const isPrimaryBranch =
|
|
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
|
if (feature.projectPath === projectPath && isPrimaryBranch) {
|
|
count++;
|
|
}
|
|
} else {
|
|
// Feature worktree: exact match
|
|
if (feature.projectPath === projectPath && featureBranch === branchName) {
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* 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.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 = Array.from(this.runningFeatures.entries())
|
|
.filter(([, f]) => f.projectPath === projectPath)
|
|
.map(([id]) => id);
|
|
|
|
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.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.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
|
|
if (this.runningFeatures.size >= (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.runningFeatures.size;
|
|
if (runningCount === 0 && !this.hasEmittedIdleEvent) {
|
|
this.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.runningFeatures.has(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.emitAutoModeEvent('auto_mode_stopped', {
|
|
message: 'Auto mode stopped',
|
|
projectPath,
|
|
});
|
|
}
|
|
|
|
return this.runningFeatures.size;
|
|
}
|
|
|
|
/**
|
|
* 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.findExistingWorktreeForBranch(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.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.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 and memory usage 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
|
|
}
|
|
|
|
// 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.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.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.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`);
|
|
logger.info(
|
|
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
|
);
|
|
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.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId,
|
|
branchName: feature.branchName ?? null,
|
|
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
|
|
projectPath,
|
|
});
|
|
|
|
this.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.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.runningFeatures.get(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.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.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.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.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.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.findExistingWorktreeForBranch(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.emitAutoModeEvent('auto_mode_feature_start', {
|
|
featureId,
|
|
projectPath,
|
|
branchName: branchName ?? null,
|
|
feature: {
|
|
id: featureId,
|
|
title: feature.title || 'Resuming Pipeline',
|
|
description: feature.description,
|
|
},
|
|
});
|
|
|
|
this.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.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.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.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.findExistingWorktreeForBranch(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.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.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.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.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
|
|
? `feat: ${this.extractTitleFromDescription(
|
|
feature.description
|
|
)}\n\nImplemented by Automaker auto-mode`
|
|
: `feat: Feature ${featureId}`;
|
|
|
|
// Stage and commit
|
|
await execAsync('git add -A', { cwd: workDir });
|
|
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
|
|
cwd: workDir,
|
|
});
|
|
|
|
// Get commit hash
|
|
const { stdout: hash } = await execAsync('git rev-parse HEAD', {
|
|
cwd: workDir,
|
|
});
|
|
|
|
this.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.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.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.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.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;
|
|
} {
|
|
return {
|
|
isRunning: this.runningFeatures.size > 0,
|
|
runningFeatures: Array.from(this.runningFeatures.keys()),
|
|
runningCount: this.runningFeatures.size,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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: string[] = [];
|
|
|
|
for (const [featureId, feature] of this.runningFeatures) {
|
|
// Filter by project path AND branchName to get worktree-specific features
|
|
if (feature.projectPath === projectPath && feature.branchName === branchName) {
|
|
runningFeatures.push(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(
|
|
Array.from(this.runningFeatures.values()).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.
|
|
* Returns a promise that resolves when the user approves/rejects the plan.
|
|
* Times out after 30 minutes to prevent indefinite memory retention.
|
|
*/
|
|
waitForPlanApproval(
|
|
featureId: string,
|
|
projectPath: string
|
|
): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> {
|
|
const APPROVAL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
|
logger.info(`Registering pending approval for feature ${featureId}`);
|
|
logger.info(
|
|
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
|
);
|
|
return new Promise((resolve, reject) => {
|
|
// Set up timeout to prevent indefinite waiting and memory leaks
|
|
const timeoutId = setTimeout(() => {
|
|
const pending = this.pendingApprovals.get(featureId);
|
|
if (pending) {
|
|
logger.warn(`Plan approval for feature ${featureId} timed out after 30 minutes`);
|
|
this.pendingApprovals.delete(featureId);
|
|
reject(
|
|
new Error('Plan approval timed out after 30 minutes - feature execution cancelled')
|
|
);
|
|
}
|
|
}, APPROVAL_TIMEOUT_MS);
|
|
|
|
// Wrap resolve/reject to clear timeout when approval is resolved
|
|
const wrappedResolve = (result: {
|
|
approved: boolean;
|
|
editedPlan?: string;
|
|
feedback?: string;
|
|
}) => {
|
|
clearTimeout(timeoutId);
|
|
resolve(result);
|
|
};
|
|
|
|
const wrappedReject = (error: Error) => {
|
|
clearTimeout(timeoutId);
|
|
reject(error);
|
|
};
|
|
|
|
this.pendingApprovals.set(featureId, {
|
|
resolve: wrappedResolve,
|
|
reject: wrappedReject,
|
|
featureId,
|
|
projectPath,
|
|
});
|
|
logger.info(`Pending approval registered for feature ${featureId} (timeout: 30 minutes)`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve a pending plan approval.
|
|
* Called when the user approves or rejects the plan via API.
|
|
*/
|
|
async resolvePlanApproval(
|
|
featureId: string,
|
|
approved: boolean,
|
|
editedPlan?: string,
|
|
feedback?: string,
|
|
projectPathFromClient?: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
logger.info(`resolvePlanApproval called for feature ${featureId}, approved=${approved}`);
|
|
logger.info(
|
|
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
|
);
|
|
const pending = this.pendingApprovals.get(featureId);
|
|
|
|
if (!pending) {
|
|
logger.info(`No pending approval in Map for feature ${featureId}`);
|
|
|
|
// RECOVERY: If no pending approval but we have projectPath from client,
|
|
// check if feature's planSpec.status is 'generated' and handle recovery
|
|
if (projectPathFromClient) {
|
|
logger.info(`Attempting recovery with projectPath: ${projectPathFromClient}`);
|
|
const feature = await this.loadFeature(projectPathFromClient, featureId);
|
|
|
|
if (feature?.planSpec?.status === 'generated') {
|
|
logger.info(`Feature ${featureId} has planSpec.status='generated', performing recovery`);
|
|
|
|
if (approved) {
|
|
// Update planSpec to approved
|
|
await this.updateFeaturePlanSpec(projectPathFromClient, featureId, {
|
|
status: 'approved',
|
|
approvedAt: new Date().toISOString(),
|
|
reviewedByUser: true,
|
|
content: editedPlan || feature.planSpec.content,
|
|
});
|
|
|
|
// 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)
|
|
// Pass undefined for providedWorktreePath, use options for continuation prompt
|
|
this.executeFeature(projectPathFromClient, featureId, true, false, undefined, {
|
|
continuationPrompt,
|
|
}).catch((error) => {
|
|
logger.error(`Recovery execution failed for feature ${featureId}:`, error);
|
|
});
|
|
|
|
return { success: true };
|
|
} else {
|
|
// Rejected - update status and emit event
|
|
await this.updateFeaturePlanSpec(projectPathFromClient, featureId, {
|
|
status: 'rejected',
|
|
reviewedByUser: true,
|
|
});
|
|
|
|
await this.updateFeatureStatus(projectPathFromClient, featureId, 'backlog');
|
|
|
|
this.emitAutoModeEvent('plan_rejected', {
|
|
featureId,
|
|
projectPath: projectPathFromClient,
|
|
feedback,
|
|
});
|
|
|
|
return { success: true };
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
`ERROR: No pending approval found for feature ${featureId} and recovery not possible`
|
|
);
|
|
return {
|
|
success: false,
|
|
error: `No pending approval for feature ${featureId}`,
|
|
};
|
|
}
|
|
logger.info(`Found pending approval for feature ${featureId}, proceeding...`);
|
|
|
|
const { projectPath } = pending;
|
|
|
|
// Update feature's planSpec status
|
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
|
status: approved ? 'approved' : 'rejected',
|
|
approvedAt: approved ? new Date().toISOString() : undefined,
|
|
reviewedByUser: true,
|
|
content: editedPlan, // Update content if user provided an edited version
|
|
});
|
|
|
|
// If rejected with feedback, we can store it for the user to see
|
|
if (!approved && feedback) {
|
|
// Emit event so client knows the rejection reason
|
|
this.emitAutoModeEvent('plan_rejected', {
|
|
featureId,
|
|
projectPath,
|
|
feedback,
|
|
});
|
|
}
|
|
|
|
// Resolve the promise with all data including feedback
|
|
pending.resolve({ approved, editedPlan, feedback });
|
|
this.pendingApprovals.delete(featureId);
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Cancel a pending plan approval (e.g., when feature is stopped).
|
|
*/
|
|
cancelPlanApproval(featureId: string): void {
|
|
logger.info(`cancelPlanApproval called for feature ${featureId}`);
|
|
logger.info(
|
|
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
|
);
|
|
const pending = this.pendingApprovals.get(featureId);
|
|
if (pending) {
|
|
logger.info(`Found and cancelling pending approval for feature ${featureId}`);
|
|
pending.reject(new Error('Plan approval cancelled - feature was stopped'));
|
|
this.pendingApprovals.delete(featureId);
|
|
} else {
|
|
logger.info(`No pending approval to cancel for feature ${featureId}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a feature has a pending plan approval.
|
|
*/
|
|
hasPendingApproval(featureId: string): boolean {
|
|
return this.pendingApprovals.has(featureId);
|
|
}
|
|
|
|
// Private helpers
|
|
|
|
/**
|
|
* Find an existing worktree for a given branch by checking git worktree list
|
|
*/
|
|
private async findExistingWorktreeForBranch(
|
|
projectPath: string,
|
|
branchName: string
|
|
): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await execAsync('git worktree list --porcelain', {
|
|
cwd: projectPath,
|
|
});
|
|
|
|
const lines = stdout.split('\n');
|
|
let currentPath: string | null = null;
|
|
let currentBranch: string | null = null;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('worktree ')) {
|
|
currentPath = line.slice(9);
|
|
} else if (line.startsWith('branch ')) {
|
|
currentBranch = line.slice(7).replace('refs/heads/', '');
|
|
} else if (line === '' && currentPath && currentBranch) {
|
|
// End of a worktree entry
|
|
if (currentBranch === branchName) {
|
|
// Resolve to absolute path - git may return relative paths
|
|
// On Windows, this is critical for cwd to work correctly
|
|
// On all platforms, absolute paths ensure consistent behavior
|
|
const resolvedPath = path.isAbsolute(currentPath)
|
|
? path.resolve(currentPath)
|
|
: path.resolve(projectPath, currentPath);
|
|
return resolvedPath;
|
|
}
|
|
currentPath = null;
|
|
currentBranch = null;
|
|
}
|
|
}
|
|
|
|
// Check the last entry (if file doesn't end with newline)
|
|
if (currentPath && currentBranch && currentBranch === branchName) {
|
|
// Resolve to absolute path for cross-platform compatibility
|
|
const resolvedPath = path.isAbsolute(currentPath)
|
|
? path.resolve(currentPath)
|
|
: path.resolve(projectPath, currentPath);
|
|
return resolvedPath;
|
|
}
|
|
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
|
|
// Features are stored in .automaker directory
|
|
const featureDir = getFeatureDir(projectPath, featureId);
|
|
const featurePath = path.join(featureDir, 'feature.json');
|
|
|
|
try {
|
|
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
|
|
return JSON.parse(data);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async updateFeatureStatus(
|
|
projectPath: string,
|
|
featureId: string,
|
|
status: string
|
|
): Promise<void> {
|
|
// Features are stored in .automaker directory
|
|
const featureDir = getFeatureDir(projectPath, featureId);
|
|
const featurePath = path.join(featureDir, 'feature.json');
|
|
|
|
try {
|
|
// Use recovery-enabled read for corrupted file handling
|
|
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
|
maxBackups: DEFAULT_BACKUP_COUNT,
|
|
autoRestore: true,
|
|
});
|
|
|
|
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
|
|
|
const feature = result.data;
|
|
if (!feature) {
|
|
logger.warn(`Feature ${featureId} not found or could not be recovered`);
|
|
return;
|
|
}
|
|
|
|
feature.status = status;
|
|
feature.updatedAt = new Date().toISOString();
|
|
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
|
|
// Badge will show for 2 minutes after this timestamp
|
|
if (status === 'waiting_approval') {
|
|
feature.justFinishedAt = new Date().toISOString();
|
|
} else {
|
|
// Clear the timestamp when moving to other statuses
|
|
feature.justFinishedAt = undefined;
|
|
}
|
|
|
|
// Use atomic write with backup support
|
|
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
|
|
|
// Create notifications for important status changes
|
|
const notificationService = getNotificationService();
|
|
if (status === 'waiting_approval') {
|
|
await notificationService.createNotification({
|
|
type: 'feature_waiting_approval',
|
|
title: 'Feature Ready for Review',
|
|
message: `"${feature.name || featureId}" is ready for your review and approval.`,
|
|
featureId,
|
|
projectPath,
|
|
});
|
|
} else if (status === 'verified') {
|
|
await notificationService.createNotification({
|
|
type: 'feature_verified',
|
|
title: 'Feature Verified',
|
|
message: `"${feature.name || featureId}" has been verified and is complete.`,
|
|
featureId,
|
|
projectPath,
|
|
});
|
|
}
|
|
|
|
// Sync completed/verified features to app_spec.txt
|
|
if (status === 'verified' || status === 'completed') {
|
|
try {
|
|
await this.featureLoader.syncFeatureToAppSpec(projectPath, feature);
|
|
} catch (syncError) {
|
|
// Log but don't fail the status update if sync fails
|
|
logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to update feature status for ${featureId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark a feature as interrupted due to server restart or other interruption.
|
|
*
|
|
* This is a convenience helper that updates the feature status to 'interrupted',
|
|
* indicating the feature was in progress but execution was disrupted (e.g., server
|
|
* restart, process crash, or manual stop). Features with this status can be
|
|
* resumed later using the resume functionality.
|
|
*
|
|
* Note: Features with pipeline_* statuses are preserved rather than overwritten
|
|
* to 'interrupted'. This ensures that resumePipelineFeature() can pick up from
|
|
* the correct pipeline step after a restart.
|
|
*
|
|
* @param projectPath - Path to the project
|
|
* @param featureId - ID of the feature to mark as interrupted
|
|
* @param reason - Optional reason for the interruption (logged for debugging)
|
|
*/
|
|
async markFeatureInterrupted(
|
|
projectPath: string,
|
|
featureId: string,
|
|
reason?: string
|
|
): Promise<void> {
|
|
// Load the feature to check its current status
|
|
const feature = await this.loadFeature(projectPath, featureId);
|
|
const currentStatus = feature?.status;
|
|
|
|
// Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step
|
|
if (currentStatus && currentStatus.startsWith('pipeline_')) {
|
|
logger.info(
|
|
`Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (reason) {
|
|
logger.info(`Marking feature ${featureId} as interrupted: ${reason}`);
|
|
} else {
|
|
logger.info(`Marking feature ${featureId} as interrupted`);
|
|
}
|
|
|
|
await this.updateFeatureStatus(projectPath, featureId, 'interrupted');
|
|
}
|
|
|
|
/**
|
|
* 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 runningCount = this.runningFeatures.size;
|
|
|
|
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 [featureId, runningFeature] of this.runningFeatures) {
|
|
markPromises.push(
|
|
this.markFeatureInterrupted(runningFeature.projectPath, featureId, logReason).catch(
|
|
(error) => {
|
|
logger.error(`Failed to mark feature ${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.runningFeatures.has(featureId);
|
|
}
|
|
|
|
/**
|
|
* Update the planSpec of a feature
|
|
*/
|
|
private async updateFeaturePlanSpec(
|
|
projectPath: string,
|
|
featureId: string,
|
|
updates: Partial<PlanSpec>
|
|
): Promise<void> {
|
|
// Use getFeatureDir helper for consistent path resolution
|
|
const featureDir = getFeatureDir(projectPath, featureId);
|
|
const featurePath = path.join(featureDir, 'feature.json');
|
|
|
|
try {
|
|
// Use recovery-enabled read for corrupted file handling
|
|
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
|
maxBackups: DEFAULT_BACKUP_COUNT,
|
|
autoRestore: true,
|
|
});
|
|
|
|
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
|
|
|
const feature = result.data;
|
|
if (!feature) {
|
|
logger.warn(`Feature ${featureId} not found or could not be recovered`);
|
|
return;
|
|
}
|
|
|
|
// Initialize planSpec if it doesn't exist
|
|
if (!feature.planSpec) {
|
|
feature.planSpec = {
|
|
status: 'pending',
|
|
version: 1,
|
|
reviewedByUser: false,
|
|
};
|
|
}
|
|
|
|
// Apply updates
|
|
Object.assign(feature.planSpec, updates);
|
|
|
|
// If content is being updated and it's a new version, increment version
|
|
if (updates.content && updates.content !== feature.planSpec.content) {
|
|
feature.planSpec.version = (feature.planSpec.version || 0) + 1;
|
|
}
|
|
|
|
feature.updatedAt = new Date().toISOString();
|
|
|
|
// Use atomic write with backup support
|
|
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
|
} catch (error) {
|
|
logger.error(`Failed to update planSpec 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 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
|
|
if (
|
|
feature.status === 'pending' ||
|
|
feature.status === 'ready' ||
|
|
feature.status === 'backlog' ||
|
|
(feature.planSpec?.status === 'approved' &&
|
|
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
|
|
) {
|
|
// 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/approved_with_pending_tasks) for ${worktreeDesc}`
|
|
);
|
|
|
|
if (pendingFeatures.length === 0) {
|
|
logger.warn(
|
|
`[loadPendingFeatures] No pending features found for ${worktreeDesc}. Check branchName matching - looking for branchName: ${branchName === null ? 'null (main)' : branchName}`
|
|
);
|
|
// Log all backlog features to help debug branchName matching
|
|
const allBacklogFeatures = allFeatures.filter(
|
|
(f) =>
|
|
f.status === 'backlog' ||
|
|
f.status === 'pending' ||
|
|
f.status === 'ready' ||
|
|
(f.planSpec?.status === 'approved' &&
|
|
(f.planSpec.tasksCompleted ?? 0) < (f.planSpec.tasksTotal ?? 0))
|
|
);
|
|
if (allBacklogFeatures.length > 0) {
|
|
logger.info(
|
|
`[loadPendingFeatures] Found ${allBacklogFeatures.length} backlog features with branchNames: ${allBacklogFeatures.map((f) => `${f.id}(${f.branchName ?? 'null'})`).join(', ')}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Apply dependency-aware ordering
|
|
const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures);
|
|
|
|
// Remove missing dependencies from features and save them
|
|
// This allows features to proceed when their dependencies have been deleted or don't exist
|
|
if (missingDependencies.size > 0) {
|
|
for (const [featureId, missingDepIds] of missingDependencies) {
|
|
const feature = pendingFeatures.find((f) => f.id === featureId);
|
|
if (feature && feature.dependencies) {
|
|
// Filter out the missing dependency IDs
|
|
const validDependencies = feature.dependencies.filter(
|
|
(depId) => !missingDepIds.includes(depId)
|
|
);
|
|
|
|
logger.warn(
|
|
`[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.`
|
|
);
|
|
|
|
// Update the feature in memory
|
|
feature.dependencies = validDependencies.length > 0 ? validDependencies : undefined;
|
|
|
|
// Save the updated feature to disk
|
|
try {
|
|
await this.featureLoader.update(projectPath, featureId, {
|
|
dependencies: feature.dependencies,
|
|
});
|
|
logger.info(
|
|
`[loadPendingFeatures] Updated feature ${featureId} - removed missing dependencies`
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`[loadPendingFeatures] Failed to save feature ${featureId} after removing missing dependencies:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get skipVerificationInAutoMode setting
|
|
const settings = await this.settingsService?.getGlobalSettings();
|
|
const skipVerification = settings?.skipVerificationInAutoMode ?? false;
|
|
|
|
// Filter to only features with satisfied dependencies
|
|
const readyFeatures: Feature[] = [];
|
|
const blockedFeatures: Array<{ feature: Feature; reason: string }> = [];
|
|
|
|
for (const feature of orderedFeatures) {
|
|
const isSatisfied = areDependenciesSatisfied(feature, allFeatures, { skipVerification });
|
|
if (isSatisfied) {
|
|
readyFeatures.push(feature);
|
|
} else {
|
|
// Find which dependencies are blocking
|
|
const blockingDeps =
|
|
feature.dependencies?.filter((depId) => {
|
|
const dep = allFeatures.find((f) => f.id === depId);
|
|
if (!dep) return true; // Missing dependency
|
|
if (skipVerification) {
|
|
return dep.status === 'running';
|
|
}
|
|
return dep.status !== 'completed' && dep.status !== 'verified';
|
|
}) || [];
|
|
blockedFeatures.push({
|
|
feature,
|
|
reason:
|
|
blockingDeps.length > 0
|
|
? `Blocked by dependencies: ${blockingDeps.join(', ')}`
|
|
: 'Unknown dependency issue',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (blockedFeatures.length > 0) {
|
|
logger.info(
|
|
`[loadPendingFeatures] ${blockedFeatures.length} features blocked by dependencies: ${blockedFeatures.map((b) => `${b.feature.id} (${b.reason})`).join('; ')}`
|
|
);
|
|
}
|
|
|
|
logger.info(
|
|
`[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})`
|
|
);
|
|
|
|
return readyFeatures;
|
|
} catch (error) {
|
|
logger.error(`[loadPendingFeatures] Error loading features:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract a title from feature description (first line or truncated)
|
|
*/
|
|
private extractTitleFromDescription(description: string): string {
|
|
if (!description || !description.trim()) {
|
|
return 'Untitled Feature';
|
|
}
|
|
|
|
// Get first line, or first 60 characters if no newline
|
|
const firstLine = description.split('\n')[0].trim();
|
|
if (firstLine.length <= 60) {
|
|
return firstLine;
|
|
}
|
|
|
|
// Truncate to 60 characters and add ellipsis
|
|
return firstLine.substring(0, 57) + '...';
|
|
}
|
|
|
|
/**
|
|
* Get the planning prompt prefix based on feature's planning mode
|
|
*/
|
|
private async getPlanningPromptPrefix(feature: Feature): Promise<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;
|
|
|
|
// 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.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId,
|
|
content: 'Mock agent: Analyzing the codebase...',
|
|
});
|
|
|
|
await this.sleep(300);
|
|
|
|
this.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.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;
|
|
|
|
const executeOptions: ExecuteOptions = {
|
|
prompt: promptContent,
|
|
model: effectiveBareModel,
|
|
maxTurns: maxTurns,
|
|
cwd: workDir,
|
|
allowedTools: allowedTools,
|
|
abortController,
|
|
systemPrompt: sdkOptions.systemPrompt,
|
|
settingSources: sdkOptions.settingSources,
|
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
|
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
|
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
|
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.)
|
|
};
|
|
|
|
// Execute via provider
|
|
logger.info(`Starting stream for feature ${featureId}...`);
|
|
const stream = provider.executeQuery(executeOptions);
|
|
logger.info(`Stream created, starting to iterate...`);
|
|
// Initialize with previous content if this is a follow-up, with a separator
|
|
let responseText = previousContent
|
|
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
|
|
: '';
|
|
let specDetected = false;
|
|
|
|
// Agent output goes to .automaker directory
|
|
// Note: We use projectPath here, not workDir, because workDir might be a worktree path
|
|
const featureDirForOutput = getFeatureDir(projectPath, featureId);
|
|
const outputPath = path.join(featureDirForOutput, 'agent-output.md');
|
|
const rawOutputPath = path.join(featureDirForOutput, 'raw-output.jsonl');
|
|
|
|
// Raw output logging is configurable via environment variable
|
|
// Set AUTOMAKER_DEBUG_RAW_OUTPUT=true to enable raw stream event logging
|
|
const enableRawOutput =
|
|
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
|
|
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1';
|
|
|
|
// Incremental file writing state
|
|
let writeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms
|
|
|
|
// Raw output accumulator for debugging (NDJSON format)
|
|
let rawOutputLines: string[] = [];
|
|
let rawWriteTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// Helper to append raw stream event for debugging (only when enabled)
|
|
const appendRawEvent = (event: unknown): void => {
|
|
if (!enableRawOutput) return;
|
|
|
|
try {
|
|
const timestamp = new Date().toISOString();
|
|
const rawLine = JSON.stringify({ timestamp, event }, null, 4); // Pretty print for readability
|
|
rawOutputLines.push(rawLine);
|
|
|
|
// Debounced write of raw output
|
|
if (rawWriteTimeout) {
|
|
clearTimeout(rawWriteTimeout);
|
|
}
|
|
rawWriteTimeout = setTimeout(async () => {
|
|
try {
|
|
await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true });
|
|
await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n');
|
|
rawOutputLines = []; // Clear after writing
|
|
} catch (error) {
|
|
logger.error(`Failed to write raw output for ${featureId}:`, error);
|
|
}
|
|
}, WRITE_DEBOUNCE_MS);
|
|
} catch {
|
|
// Ignore serialization errors
|
|
}
|
|
};
|
|
|
|
// Helper to write current responseText to file
|
|
const writeToFile = async (): Promise<void> => {
|
|
try {
|
|
await secureFs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
await secureFs.writeFile(outputPath, responseText);
|
|
} catch (error) {
|
|
// Log but don't crash - file write errors shouldn't stop execution
|
|
logger.error(`Failed to write agent output for ${featureId}:`, error);
|
|
}
|
|
};
|
|
|
|
// Debounced write - schedules a write after WRITE_DEBOUNCE_MS
|
|
const scheduleWrite = (): void => {
|
|
if (writeTimeout) {
|
|
clearTimeout(writeTimeout);
|
|
}
|
|
writeTimeout = setTimeout(() => {
|
|
writeToFile();
|
|
}, WRITE_DEBOUNCE_MS);
|
|
};
|
|
|
|
// Heartbeat logging so "silent" model calls are visible.
|
|
// Some runs can take a while before the first streamed message arrives.
|
|
const streamStartTime = Date.now();
|
|
let receivedAnyStreamMessage = false;
|
|
const STREAM_HEARTBEAT_MS = 15_000;
|
|
const streamHeartbeat = setInterval(() => {
|
|
if (receivedAnyStreamMessage) return;
|
|
const elapsedSeconds = Math.round((Date.now() - streamStartTime) / 1000);
|
|
logger.info(
|
|
`Waiting for first model response for feature ${featureId} (${elapsedSeconds}s elapsed)...`
|
|
);
|
|
}, STREAM_HEARTBEAT_MS);
|
|
|
|
// Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort
|
|
try {
|
|
streamLoop: for await (const msg of stream) {
|
|
receivedAnyStreamMessage = true;
|
|
// Log raw stream event for debugging
|
|
appendRawEvent(msg);
|
|
|
|
logger.info(`Stream message received:`, msg.type, msg.subtype || '');
|
|
if (msg.type === 'assistant' && msg.message?.content) {
|
|
for (const block of msg.message.content) {
|
|
if (block.type === 'text') {
|
|
const newText = block.text || '';
|
|
|
|
// Skip empty text
|
|
if (!newText) continue;
|
|
|
|
// Note: Cursor-specific dedup (duplicate blocks, accumulated text) is now
|
|
// handled in CursorProvider.deduplicateTextBlocks() for cleaner separation
|
|
|
|
// Only add separator when we're at a natural paragraph break:
|
|
// - Previous text ends with sentence terminator AND new text starts a new thought
|
|
// - Don't add separators mid-word or mid-sentence (for streaming providers like Cursor)
|
|
if (responseText.length > 0 && newText.length > 0) {
|
|
const lastChar = responseText.slice(-1);
|
|
const endsWithSentence = /[.!?:]\s*$/.test(responseText);
|
|
const endsWithNewline = /\n\s*$/.test(responseText);
|
|
const startsNewParagraph = /^[\n#\-*>]/.test(newText);
|
|
|
|
// Add paragraph break only at natural boundaries
|
|
if (
|
|
!endsWithNewline &&
|
|
(endsWithSentence || startsNewParagraph) &&
|
|
!/[a-zA-Z0-9]/.test(lastChar) // Not mid-word
|
|
) {
|
|
responseText += '\n\n';
|
|
}
|
|
}
|
|
responseText += newText;
|
|
|
|
// Check for authentication errors in the response
|
|
if (
|
|
block.text &&
|
|
(block.text.includes('Invalid API key') ||
|
|
block.text.includes('authentication_failed') ||
|
|
block.text.includes('Fix external API key'))
|
|
) {
|
|
throw new Error(
|
|
'Authentication failed: Invalid or expired API key. ' +
|
|
"Please check your ANTHROPIC_API_KEY, or run 'claude login' to re-authenticate."
|
|
);
|
|
}
|
|
|
|
// Schedule incremental file write (debounced)
|
|
scheduleWrite();
|
|
|
|
// Check for [SPEC_GENERATED] marker in planning modes (spec or full)
|
|
if (
|
|
planningModeRequiresApproval &&
|
|
!specDetected &&
|
|
responseText.includes('[SPEC_GENERATED]')
|
|
) {
|
|
specDetected = true;
|
|
|
|
// Extract plan content (everything before the marker)
|
|
const markerIndex = responseText.indexOf('[SPEC_GENERATED]');
|
|
const planContent = responseText.substring(0, markerIndex).trim();
|
|
|
|
// Parse tasks from the generated spec (for spec and full modes)
|
|
// Use let since we may need to update this after plan revision
|
|
let parsedTasks = parseTasksFromSpec(planContent);
|
|
const tasksTotal = parsedTasks.length;
|
|
|
|
logger.info(`Parsed ${tasksTotal} tasks from spec for feature ${featureId}`);
|
|
if (parsedTasks.length > 0) {
|
|
logger.info(`Tasks: ${parsedTasks.map((t) => t.id).join(', ')}`);
|
|
}
|
|
|
|
// Update planSpec status to 'generated' and save content with parsed tasks
|
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
|
status: 'generated',
|
|
content: planContent,
|
|
version: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
reviewedByUser: false,
|
|
tasks: parsedTasks,
|
|
tasksTotal,
|
|
tasksCompleted: 0,
|
|
});
|
|
|
|
let approvedPlanContent = planContent;
|
|
let userFeedback: string | undefined;
|
|
let currentPlanContent = planContent;
|
|
let planVersion = 1;
|
|
|
|
// Only pause for approval if requirePlanApproval is true
|
|
if (requiresApproval) {
|
|
// ========================================
|
|
// PLAN REVISION LOOP
|
|
// Keep regenerating plan until user approves
|
|
// ========================================
|
|
let planApproved = false;
|
|
|
|
while (!planApproved) {
|
|
logger.info(
|
|
`Spec v${planVersion} generated for feature ${featureId}, waiting for approval`
|
|
);
|
|
|
|
// CRITICAL: Register pending approval BEFORE emitting event
|
|
const approvalPromise = this.waitForPlanApproval(featureId, projectPath);
|
|
|
|
// Emit plan_approval_required event
|
|
this.emitAutoModeEvent('plan_approval_required', {
|
|
featureId,
|
|
projectPath,
|
|
branchName,
|
|
planContent: currentPlanContent,
|
|
planningMode,
|
|
planVersion,
|
|
});
|
|
|
|
// Wait for user response
|
|
try {
|
|
const approvalResult = await approvalPromise;
|
|
|
|
if (approvalResult.approved) {
|
|
// User approved the plan
|
|
logger.info(`Plan v${planVersion} approved for feature ${featureId}`);
|
|
planApproved = true;
|
|
|
|
// If user provided edits, use the edited version
|
|
if (approvalResult.editedPlan) {
|
|
approvedPlanContent = approvalResult.editedPlan;
|
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
|
content: approvalResult.editedPlan,
|
|
});
|
|
} else {
|
|
approvedPlanContent = currentPlanContent;
|
|
}
|
|
|
|
// Capture any additional feedback for implementation
|
|
userFeedback = approvalResult.feedback;
|
|
|
|
// Emit approval event
|
|
this.emitAutoModeEvent('plan_approved', {
|
|
featureId,
|
|
projectPath,
|
|
branchName,
|
|
hasEdits: !!approvalResult.editedPlan,
|
|
planVersion,
|
|
});
|
|
} else {
|
|
// User rejected - check if they provided feedback for revision
|
|
const hasFeedback =
|
|
approvalResult.feedback && approvalResult.feedback.trim().length > 0;
|
|
const hasEdits =
|
|
approvalResult.editedPlan && approvalResult.editedPlan.trim().length > 0;
|
|
|
|
if (!hasFeedback && !hasEdits) {
|
|
// No feedback or edits = explicit cancel
|
|
logger.info(
|
|
`Plan rejected without feedback for feature ${featureId}, cancelling`
|
|
);
|
|
throw new Error('Plan cancelled by user');
|
|
}
|
|
|
|
// User wants revisions - regenerate the plan
|
|
logger.info(
|
|
`Plan v${planVersion} rejected with feedback for feature ${featureId}, regenerating...`
|
|
);
|
|
planVersion++;
|
|
|
|
// Emit revision event
|
|
this.emitAutoModeEvent('plan_revision_requested', {
|
|
featureId,
|
|
projectPath,
|
|
branchName,
|
|
feedback: approvalResult.feedback,
|
|
hasEdits: !!hasEdits,
|
|
planVersion,
|
|
});
|
|
|
|
// Build revision prompt
|
|
let revisionPrompt = `The user has requested revisions to the plan/specification.
|
|
|
|
## Previous Plan (v${planVersion - 1})
|
|
${hasEdits ? approvalResult.editedPlan : currentPlanContent}
|
|
|
|
## User Feedback
|
|
${approvalResult.feedback || 'Please revise the plan based on the edits above.'}
|
|
|
|
## Instructions
|
|
Please regenerate the specification incorporating the user's feedback.
|
|
Keep the same format with the \`\`\`tasks block for task definitions.
|
|
After generating the revised spec, output:
|
|
"[SPEC_GENERATED] Please review the revised specification above."
|
|
`;
|
|
|
|
// Update status to regenerating
|
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
|
status: 'generating',
|
|
version: planVersion,
|
|
});
|
|
|
|
// Make revision call
|
|
const revisionStream = provider.executeQuery({
|
|
prompt: revisionPrompt,
|
|
model: bareModel,
|
|
maxTurns: maxTurns || 100,
|
|
cwd: workDir,
|
|
allowedTools: allowedTools,
|
|
abortController,
|
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
|
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
|
});
|
|
|
|
let revisionText = '';
|
|
for await (const msg of revisionStream) {
|
|
if (msg.type === 'assistant' && msg.message?.content) {
|
|
for (const block of msg.message.content) {
|
|
if (block.type === 'text') {
|
|
revisionText += block.text || '';
|
|
this.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId,
|
|
content: block.text,
|
|
});
|
|
}
|
|
}
|
|
} else if (msg.type === 'error') {
|
|
throw new Error(msg.error || 'Error during plan revision');
|
|
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
revisionText += msg.result || '';
|
|
}
|
|
}
|
|
|
|
// Extract new plan content
|
|
const markerIndex = revisionText.indexOf('[SPEC_GENERATED]');
|
|
if (markerIndex > 0) {
|
|
currentPlanContent = revisionText.substring(0, markerIndex).trim();
|
|
} else {
|
|
currentPlanContent = revisionText.trim();
|
|
}
|
|
|
|
// Re-parse tasks from revised plan
|
|
const revisedTasks = parseTasksFromSpec(currentPlanContent);
|
|
logger.info(`Revised plan has ${revisedTasks.length} tasks`);
|
|
|
|
// Update planSpec with revised content
|
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
|
status: 'generated',
|
|
content: currentPlanContent,
|
|
version: planVersion,
|
|
tasks: revisedTasks,
|
|
tasksTotal: revisedTasks.length,
|
|
tasksCompleted: 0,
|
|
});
|
|
|
|
// Update parsedTasks for implementation
|
|
parsedTasks = revisedTasks;
|
|
|
|
responseText += revisionText;
|
|
}
|
|
} catch (error) {
|
|
if ((error as Error).message.includes('cancelled')) {
|
|
throw error;
|
|
}
|
|
throw new Error(`Plan approval failed: ${(error as Error).message}`);
|
|
}
|
|
}
|
|
} else {
|
|
// Auto-approve: requirePlanApproval is false, just continue without pausing
|
|
logger.info(
|
|
`Spec generated for feature ${featureId}, auto-approving (requirePlanApproval=false)`
|
|
);
|
|
|
|
// Emit info event for frontend
|
|
this.emitAutoModeEvent('plan_auto_approved', {
|
|
featureId,
|
|
projectPath,
|
|
branchName,
|
|
planContent,
|
|
planningMode,
|
|
});
|
|
|
|
approvedPlanContent = planContent;
|
|
}
|
|
|
|
// CRITICAL: After approval, we need to make a second call to continue implementation
|
|
// The agent is waiting for "approved" - we need to send it and continue
|
|
logger.info(
|
|
`Making continuation call after plan approval for feature ${featureId}`
|
|
);
|
|
|
|
// Update planSpec status to approved (handles both manual and auto-approval paths)
|
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
|
status: 'approved',
|
|
approvedAt: new Date().toISOString(),
|
|
reviewedByUser: requiresApproval,
|
|
});
|
|
|
|
// ========================================
|
|
// MULTI-AGENT TASK EXECUTION
|
|
// Each task gets its own focused agent call
|
|
// ========================================
|
|
|
|
if (parsedTasks.length > 0) {
|
|
logger.info(
|
|
`Starting multi-agent execution: ${parsedTasks.length} tasks for feature ${featureId}`
|
|
);
|
|
|
|
// Get customized prompts for task execution
|
|
const taskPrompts = await getPromptCustomization(
|
|
this.settingsService,
|
|
'[AutoMode]'
|
|
);
|
|
|
|
// Execute each task with a separate agent
|
|
for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) {
|
|
const task = parsedTasks[taskIndex];
|
|
|
|
// Check for abort
|
|
if (abortController.signal.aborted) {
|
|
throw new Error('Feature execution aborted');
|
|
}
|
|
|
|
// Emit task started
|
|
logger.info(`Starting task ${task.id}: ${task.description}`);
|
|
this.emitAutoModeEvent('auto_mode_task_started', {
|
|
featureId,
|
|
projectPath,
|
|
branchName,
|
|
taskId: task.id,
|
|
taskDescription: task.description,
|
|
taskIndex,
|
|
tasksTotal: parsedTasks.length,
|
|
});
|
|
|
|
// Update planSpec with current task
|
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
|
currentTaskId: task.id,
|
|
});
|
|
|
|
// Build focused prompt for this specific task
|
|
const taskPrompt = this.buildTaskPrompt(
|
|
task,
|
|
parsedTasks,
|
|
taskIndex,
|
|
approvedPlanContent,
|
|
taskPrompts.taskExecution.taskPromptTemplate,
|
|
userFeedback
|
|
);
|
|
|
|
// Execute task with dedicated agent
|
|
const taskStream = provider.executeQuery({
|
|
prompt: taskPrompt,
|
|
model: bareModel,
|
|
maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task
|
|
cwd: workDir,
|
|
allowedTools: allowedTools,
|
|
abortController,
|
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
|
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
|
});
|
|
|
|
let taskOutput = '';
|
|
|
|
// Process task stream
|
|
for await (const msg of taskStream) {
|
|
if (msg.type === 'assistant' && msg.message?.content) {
|
|
for (const block of msg.message.content) {
|
|
if (block.type === 'text') {
|
|
taskOutput += block.text || '';
|
|
responseText += block.text || '';
|
|
this.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId,
|
|
branchName,
|
|
content: block.text,
|
|
});
|
|
} else if (block.type === 'tool_use') {
|
|
this.emitAutoModeEvent('auto_mode_tool', {
|
|
featureId,
|
|
branchName,
|
|
tool: block.name,
|
|
input: block.input,
|
|
});
|
|
}
|
|
}
|
|
} else if (msg.type === 'error') {
|
|
throw new Error(msg.error || `Error during task ${task.id}`);
|
|
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
taskOutput += msg.result || '';
|
|
responseText += msg.result || '';
|
|
}
|
|
}
|
|
|
|
// Emit task completed
|
|
logger.info(`Task ${task.id} completed for feature ${featureId}`);
|
|
this.emitAutoModeEvent('auto_mode_task_complete', {
|
|
featureId,
|
|
projectPath,
|
|
branchName,
|
|
taskId: task.id,
|
|
tasksCompleted: taskIndex + 1,
|
|
tasksTotal: parsedTasks.length,
|
|
});
|
|
|
|
// Update planSpec with progress
|
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
|
tasksCompleted: taskIndex + 1,
|
|
});
|
|
|
|
// Check for phase completion (group tasks by phase)
|
|
if (task.phase) {
|
|
const nextTask = parsedTasks[taskIndex + 1];
|
|
if (!nextTask || nextTask.phase !== task.phase) {
|
|
// Phase changed, emit phase complete
|
|
const phaseMatch = task.phase.match(/Phase\s*(\d+)/i);
|
|
if (phaseMatch) {
|
|
this.emitAutoModeEvent('auto_mode_phase_complete', {
|
|
featureId,
|
|
projectPath,
|
|
branchName,
|
|
phaseNumber: parseInt(phaseMatch[1], 10),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`All ${parsedTasks.length} tasks completed for feature ${featureId}`);
|
|
} else {
|
|
// No parsed tasks - fall back to single-agent execution
|
|
logger.info(
|
|
`No parsed tasks, using single-agent execution for feature ${featureId}`
|
|
);
|
|
|
|
// Get customized prompts for continuation
|
|
const taskPrompts = await getPromptCustomization(
|
|
this.settingsService,
|
|
'[AutoMode]'
|
|
);
|
|
let continuationPrompt =
|
|
taskPrompts.taskExecution.continuationAfterApprovalTemplate;
|
|
continuationPrompt = continuationPrompt.replace(
|
|
/\{\{userFeedback\}\}/g,
|
|
userFeedback || ''
|
|
);
|
|
continuationPrompt = continuationPrompt.replace(
|
|
/\{\{approvedPlan\}\}/g,
|
|
approvedPlanContent
|
|
);
|
|
|
|
const continuationStream = provider.executeQuery({
|
|
prompt: continuationPrompt,
|
|
model: bareModel,
|
|
maxTurns: maxTurns,
|
|
cwd: workDir,
|
|
allowedTools: allowedTools,
|
|
abortController,
|
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
|
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
|
});
|
|
|
|
for await (const msg of continuationStream) {
|
|
if (msg.type === 'assistant' && msg.message?.content) {
|
|
for (const block of msg.message.content) {
|
|
if (block.type === 'text') {
|
|
responseText += block.text || '';
|
|
this.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId,
|
|
branchName,
|
|
content: block.text,
|
|
});
|
|
} else if (block.type === 'tool_use') {
|
|
this.emitAutoModeEvent('auto_mode_tool', {
|
|
featureId,
|
|
branchName,
|
|
tool: block.name,
|
|
input: block.input,
|
|
});
|
|
}
|
|
}
|
|
} else if (msg.type === 'error') {
|
|
throw new Error(msg.error || 'Unknown error during implementation');
|
|
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
responseText += msg.result || '';
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`Implementation completed for feature ${featureId}`);
|
|
// Exit the original stream loop since continuation is done
|
|
break streamLoop;
|
|
}
|
|
|
|
// Only emit progress for non-marker text (marker was already handled above)
|
|
if (!specDetected) {
|
|
logger.info(
|
|
`Emitting progress event for ${featureId}, content length: ${block.text?.length || 0}`
|
|
);
|
|
this.emitAutoModeEvent('auto_mode_progress', {
|
|
featureId,
|
|
branchName,
|
|
content: block.text,
|
|
});
|
|
}
|
|
} else if (block.type === 'tool_use') {
|
|
// Emit event for real-time UI
|
|
this.emitAutoModeEvent('auto_mode_tool', {
|
|
featureId,
|
|
branchName,
|
|
tool: block.name,
|
|
input: block.input,
|
|
});
|
|
|
|
// Also add to file output for persistence
|
|
if (responseText.length > 0 && !responseText.endsWith('\n')) {
|
|
responseText += '\n';
|
|
}
|
|
responseText += `\n🔧 Tool: ${block.name}\n`;
|
|
if (block.input) {
|
|
responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`;
|
|
}
|
|
scheduleWrite();
|
|
}
|
|
}
|
|
} else if (msg.type === 'error') {
|
|
// Handle error messages
|
|
throw new Error(msg.error || 'Unknown error');
|
|
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
// Don't replace responseText - the accumulated content is the full history
|
|
// The msg.result is just a summary which would lose all tool use details
|
|
// Just ensure final write happens
|
|
scheduleWrite();
|
|
}
|
|
}
|
|
|
|
// Final write - ensure all accumulated content is saved (on success path)
|
|
await writeToFile();
|
|
|
|
// Flush remaining raw output (only if enabled, on success path)
|
|
if (enableRawOutput && rawOutputLines.length > 0) {
|
|
try {
|
|
await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true });
|
|
await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n');
|
|
} catch (error) {
|
|
logger.error(`Failed to write final raw output for ${featureId}:`, error);
|
|
}
|
|
}
|
|
} finally {
|
|
clearInterval(streamHeartbeat);
|
|
// ALWAYS clear pending timeouts to prevent memory leaks
|
|
// This runs on success, error, or abort
|
|
if (writeTimeout) {
|
|
clearTimeout(writeTimeout);
|
|
writeTimeout = null;
|
|
}
|
|
if (rawWriteTimeout) {
|
|
clearTimeout(rawWriteTimeout);
|
|
rawWriteTimeout = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Emit an auto-mode event wrapped in the correct format for the client.
|
|
* All auto-mode events are sent as type "auto-mode:event" with the actual
|
|
* event type and data in the payload.
|
|
*/
|
|
private emitAutoModeEvent(eventType: string, data: Record<string, unknown>): void {
|
|
// Wrap the event in auto-mode:event format expected by the client
|
|
this.events.emit('auto-mode:event', {
|
|
type: eventType,
|
|
...data,
|
|
});
|
|
}
|
|
|
|
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 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: Array.from(this.runningFeatures.keys()),
|
|
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 or pipeline_*)
|
|
if (
|
|
feature.status === 'in_progress' ||
|
|
(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.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 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;
|
|
}
|
|
}
|