mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
1354 lines
43 KiB
JavaScript
1354 lines
43 KiB
JavaScript
const featureLoader = require("./services/feature-loader");
|
|
const featureExecutor = require("./services/feature-executor");
|
|
const featureVerifier = require("./services/feature-verifier");
|
|
const contextManager = require("./services/context-manager");
|
|
const projectAnalyzer = require("./services/project-analyzer");
|
|
const worktreeManager = require("./services/worktree-manager");
|
|
|
|
/**
|
|
* Auto Mode Service - Autonomous feature implementation
|
|
* Automatically picks and implements features from the kanban board
|
|
*
|
|
* This service acts as the main orchestrator, delegating work to specialized services:
|
|
* - featureLoader: Loading and selecting features
|
|
* - featureExecutor: Implementing features
|
|
* - featureVerifier: Running tests and verification
|
|
* - contextManager: Managing context files
|
|
* - projectAnalyzer: Analyzing project structure
|
|
*/
|
|
class AutoModeService {
|
|
constructor() {
|
|
// Track multiple concurrent feature executions
|
|
this.runningFeatures = new Map(); // featureId -> { abortController, query, projectPath, sendToRenderer }
|
|
|
|
// Per-project auto loop state (keyed by projectPath)
|
|
this.projectLoops = new Map(); // projectPath -> { isRunning, interval, abortController, sendToRenderer, maxConcurrency }
|
|
|
|
this.checkIntervalMs = 5000; // Check every 5 seconds
|
|
this.maxConcurrency = 3; // Default max concurrency (global default)
|
|
}
|
|
|
|
/**
|
|
* Get or create project loop state
|
|
*/
|
|
getProjectLoopState(projectPath) {
|
|
if (!this.projectLoops.has(projectPath)) {
|
|
this.projectLoops.set(projectPath, {
|
|
isRunning: false,
|
|
interval: null,
|
|
abortController: null,
|
|
sendToRenderer: null,
|
|
maxConcurrency: this.maxConcurrency,
|
|
});
|
|
}
|
|
return this.projectLoops.get(projectPath);
|
|
}
|
|
|
|
/**
|
|
* Check if any project has auto mode running
|
|
*/
|
|
hasAnyAutoLoopRunning() {
|
|
for (const [, state] of this.projectLoops) {
|
|
if (state.isRunning) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get running features for a specific project
|
|
*/
|
|
getRunningFeaturesForProject(projectPath) {
|
|
const features = [];
|
|
for (const [featureId, execution] of this.runningFeatures) {
|
|
if (execution.projectPath === projectPath) {
|
|
features.push(featureId);
|
|
}
|
|
}
|
|
return features;
|
|
}
|
|
|
|
/**
|
|
* Count running features for a specific project
|
|
*/
|
|
getRunningCountForProject(projectPath) {
|
|
let count = 0;
|
|
for (const [, execution] of this.runningFeatures) {
|
|
if (execution.projectPath === projectPath) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Helper to create execution context with isActive check
|
|
*/
|
|
createExecutionContext(featureId) {
|
|
const context = {
|
|
abortController: null,
|
|
query: null,
|
|
projectPath: null, // Original project path
|
|
worktreePath: null, // Path to worktree (where agent works)
|
|
branchName: null, // Feature branch name
|
|
sendToRenderer: null,
|
|
isActive: () => this.runningFeatures.has(featureId),
|
|
};
|
|
return context;
|
|
}
|
|
|
|
/**
|
|
* Helper to emit event with projectPath included
|
|
*/
|
|
emitEvent(projectPath, sendToRenderer, event) {
|
|
if (sendToRenderer) {
|
|
sendToRenderer({
|
|
...event,
|
|
projectPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup worktree for a feature
|
|
* Creates an isolated git worktree where the agent can work
|
|
* @param {Object} feature - The feature object
|
|
* @param {string} projectPath - Path to the project
|
|
* @param {Function} sendToRenderer - Function to send events to the renderer
|
|
* @param {boolean} useWorktreesEnabled - Whether worktrees are enabled in settings (default: false)
|
|
*/
|
|
async setupWorktreeForFeature(feature, projectPath, sendToRenderer, useWorktreesEnabled = false) {
|
|
// If worktrees are disabled in settings, skip entirely
|
|
if (!useWorktreesEnabled) {
|
|
console.log(`[AutoMode] Worktrees disabled in settings, working directly on main project`);
|
|
return { useWorktree: false, workPath: projectPath };
|
|
}
|
|
|
|
// Check if worktrees are enabled (project must be a git repo)
|
|
const isGit = await worktreeManager.isGitRepo(projectPath);
|
|
if (!isGit) {
|
|
console.log(`[AutoMode] Project is not a git repo, skipping worktree creation`);
|
|
return { useWorktree: false, workPath: projectPath };
|
|
}
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_progress",
|
|
featureId: feature.id,
|
|
content: "Creating isolated worktree for feature...\n",
|
|
});
|
|
|
|
const result = await worktreeManager.createWorktree(projectPath, feature);
|
|
|
|
if (!result.success) {
|
|
console.warn(`[AutoMode] Failed to create worktree: ${result.error}. Falling back to main project.`);
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_progress",
|
|
featureId: feature.id,
|
|
content: `Warning: Could not create worktree (${result.error}). Working directly on main project.\n`,
|
|
});
|
|
return { useWorktree: false, workPath: projectPath };
|
|
}
|
|
|
|
console.log(`[AutoMode] Created worktree at: ${result.worktreePath}, branch: ${result.branchName}`);
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_progress",
|
|
featureId: feature.id,
|
|
content: `Working in isolated branch: ${result.branchName}\n`,
|
|
});
|
|
|
|
// Update feature with worktree info
|
|
await featureLoader.updateFeatureWorktree(
|
|
feature.id,
|
|
projectPath,
|
|
result.worktreePath,
|
|
result.branchName
|
|
);
|
|
|
|
return {
|
|
useWorktree: true,
|
|
workPath: result.worktreePath,
|
|
branchName: result.branchName,
|
|
baseBranch: result.baseBranch,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start auto mode for a specific project - continuously implement features
|
|
* Each project can have its own independent auto mode loop
|
|
*/
|
|
async start({ projectPath, sendToRenderer, maxConcurrency }) {
|
|
const projectState = this.getProjectLoopState(projectPath);
|
|
|
|
if (projectState.isRunning) {
|
|
throw new Error(`Auto mode loop is already running for project: ${projectPath}`);
|
|
}
|
|
|
|
projectState.isRunning = true;
|
|
projectState.maxConcurrency = maxConcurrency || 3;
|
|
projectState.sendToRenderer = sendToRenderer;
|
|
|
|
console.log(
|
|
`[AutoMode] Starting auto mode for project: ${projectPath} with max concurrency: ${projectState.maxConcurrency}`
|
|
);
|
|
|
|
// Start the periodic checking loop for this project
|
|
this.runPeriodicLoopForProject(projectPath);
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Stop auto mode for a specific project - stops the auto loop but lets running features complete
|
|
* This only turns off the auto toggle to prevent picking up new features.
|
|
* Running tasks will continue until they complete naturally.
|
|
*/
|
|
async stop({ projectPath }) {
|
|
console.log(`[AutoMode] Stopping auto mode for project: ${projectPath} (letting running features complete)`);
|
|
|
|
const projectState = this.projectLoops.get(projectPath);
|
|
if (!projectState) {
|
|
console.log(`[AutoMode] No auto mode state found for project: ${projectPath}`);
|
|
return { success: true, runningFeatures: 0 };
|
|
}
|
|
|
|
projectState.isRunning = false;
|
|
|
|
// Clear the interval timer for this project
|
|
if (projectState.interval) {
|
|
clearInterval(projectState.interval);
|
|
projectState.interval = null;
|
|
}
|
|
|
|
// Abort auto loop if running
|
|
if (projectState.abortController) {
|
|
projectState.abortController.abort();
|
|
projectState.abortController = null;
|
|
}
|
|
|
|
// NOTE: We intentionally do NOT abort running features here.
|
|
// Stopping auto mode should only turn off the toggle to prevent new features
|
|
// from being picked up. Running features will complete naturally.
|
|
// Use stopFeature() to cancel a specific running feature if needed.
|
|
|
|
const runningCount = this.getRunningCountForProject(projectPath);
|
|
console.log(`[AutoMode] Auto loop stopped for ${projectPath}. ${runningCount} feature(s) still running and will complete.`);
|
|
|
|
return { success: true, runningFeatures: runningCount };
|
|
}
|
|
|
|
/**
|
|
* Get status of auto mode (global and per-project)
|
|
*/
|
|
getStatus({ projectPath } = {}) {
|
|
// If projectPath is specified, return status for that project
|
|
if (projectPath) {
|
|
const projectState = this.projectLoops.get(projectPath);
|
|
return {
|
|
autoLoopRunning: projectState?.isRunning || false,
|
|
runningFeatures: this.getRunningFeaturesForProject(projectPath),
|
|
runningCount: this.getRunningCountForProject(projectPath),
|
|
};
|
|
}
|
|
|
|
// Otherwise return global status
|
|
const allRunningProjects = [];
|
|
for (const [path, state] of this.projectLoops) {
|
|
if (state.isRunning) {
|
|
allRunningProjects.push(path);
|
|
}
|
|
}
|
|
|
|
return {
|
|
autoLoopRunning: this.hasAnyAutoLoopRunning(),
|
|
runningProjects: allRunningProjects,
|
|
runningFeatures: Array.from(this.runningFeatures.keys()),
|
|
runningCount: this.runningFeatures.size,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get status for all projects with auto mode
|
|
*/
|
|
getAllProjectStatuses() {
|
|
const statuses = {};
|
|
for (const [projectPath, state] of this.projectLoops) {
|
|
statuses[projectPath] = {
|
|
isRunning: state.isRunning,
|
|
runningFeatures: this.getRunningFeaturesForProject(projectPath),
|
|
runningCount: this.getRunningCountForProject(projectPath),
|
|
maxConcurrency: state.maxConcurrency,
|
|
};
|
|
}
|
|
return statuses;
|
|
}
|
|
|
|
/**
|
|
* Run a specific feature by ID
|
|
* @param {string} projectPath - Path to the project
|
|
* @param {string} featureId - ID of the feature to run
|
|
* @param {Function} sendToRenderer - Function to send events to renderer
|
|
* @param {boolean} useWorktrees - Whether to use git worktree isolation (default: false)
|
|
*/
|
|
async runFeature({ projectPath, featureId, sendToRenderer, useWorktrees = false }) {
|
|
// Check if this specific feature is already running
|
|
if (this.runningFeatures.has(featureId)) {
|
|
throw new Error(`Feature ${featureId} is already running`);
|
|
}
|
|
|
|
console.log(`[AutoMode] Running specific feature: ${featureId} (worktrees: ${useWorktrees})`);
|
|
|
|
// Register this feature as running
|
|
const execution = this.createExecutionContext(featureId);
|
|
execution.projectPath = projectPath;
|
|
execution.sendToRenderer = sendToRenderer;
|
|
this.runningFeatures.set(featureId, execution);
|
|
|
|
try {
|
|
// Load features
|
|
const features = await featureLoader.loadFeatures(projectPath);
|
|
const feature = features.find((f) => f.id === featureId);
|
|
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
console.log(`[AutoMode] Running feature: ${feature.description}`);
|
|
|
|
// Setup worktree for isolated work (if enabled)
|
|
const worktreeSetup = await this.setupWorktreeForFeature(feature, projectPath, sendToRenderer, useWorktrees);
|
|
execution.worktreePath = worktreeSetup.workPath;
|
|
execution.branchName = worktreeSetup.branchName;
|
|
|
|
// Determine working path (worktree or main project)
|
|
const workPath = worktreeSetup.workPath;
|
|
|
|
// Update feature status to in_progress
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
"in_progress",
|
|
projectPath
|
|
);
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_start",
|
|
featureId: feature.id,
|
|
feature: { ...feature, worktreePath: worktreeSetup.workPath, branchName: worktreeSetup.branchName },
|
|
});
|
|
|
|
// Implement the feature (agent works in worktree)
|
|
const result = await featureExecutor.implementFeature(
|
|
feature,
|
|
workPath, // Use worktree path instead of main project
|
|
sendToRenderer,
|
|
execution
|
|
);
|
|
|
|
// Update feature status based on result
|
|
// For skipTests features, go to waiting_approval on success instead of verified
|
|
// On failure, ALL features go to waiting_approval so user can review and decide next steps
|
|
// This prevents infinite retry loops when the same issue keeps failing
|
|
let newStatus;
|
|
if (result.passes) {
|
|
newStatus = feature.skipTests ? "waiting_approval" : "verified";
|
|
} else {
|
|
// On failure, go to waiting_approval for user review
|
|
// Don't automatically move back to backlog to avoid infinite retry loops
|
|
// (especially when hitting rate limits or persistent errors)
|
|
newStatus = "waiting_approval";
|
|
}
|
|
await featureLoader.updateFeatureStatus(
|
|
feature.id,
|
|
newStatus,
|
|
projectPath
|
|
);
|
|
|
|
// Keep context file for viewing output later (deleted only when card is removed)
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_complete",
|
|
featureId: feature.id,
|
|
passes: result.passes,
|
|
message: result.message,
|
|
});
|
|
|
|
return { success: true, passes: result.passes };
|
|
} catch (error) {
|
|
console.error("[AutoMode] Error running feature:", error);
|
|
|
|
// Write error to context file
|
|
try {
|
|
await contextManager.writeToContextFile(
|
|
projectPath,
|
|
featureId,
|
|
`\n\n❌ ERROR: ${error.message}\n\n${error.stack || ''}\n`
|
|
);
|
|
} catch (contextError) {
|
|
console.error("[AutoMode] Failed to write error to context:", contextError);
|
|
}
|
|
|
|
// Update feature status to waiting_approval so user can review the error
|
|
try {
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
"waiting_approval",
|
|
projectPath,
|
|
{ error: error.message }
|
|
);
|
|
} catch (statusError) {
|
|
console.error("[AutoMode] Failed to update feature status after error:", statusError);
|
|
}
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_error",
|
|
error: error.message,
|
|
featureId: featureId,
|
|
});
|
|
throw error;
|
|
} finally {
|
|
// Clean up this feature's execution
|
|
this.runningFeatures.delete(featureId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify a specific feature by running its tests
|
|
*/
|
|
async verifyFeature({ projectPath, featureId, sendToRenderer }) {
|
|
console.log(`[AutoMode] verifyFeature called with:`, {
|
|
projectPath,
|
|
featureId,
|
|
});
|
|
|
|
// Check if this specific feature is already running
|
|
if (this.runningFeatures.has(featureId)) {
|
|
throw new Error(`Feature ${featureId} is already running`);
|
|
}
|
|
|
|
console.log(`[AutoMode] Verifying feature: ${featureId}`);
|
|
|
|
// Register this feature as running
|
|
const execution = this.createExecutionContext(featureId);
|
|
execution.projectPath = projectPath;
|
|
execution.sendToRenderer = sendToRenderer;
|
|
this.runningFeatures.set(featureId, execution);
|
|
|
|
try {
|
|
// Load features
|
|
const features = await featureLoader.loadFeatures(projectPath);
|
|
const feature = features.find((f) => f.id === featureId);
|
|
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
console.log(`[AutoMode] Verifying feature: ${feature.description}`);
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_start",
|
|
featureId: feature.id,
|
|
feature: feature,
|
|
});
|
|
|
|
// Verify the feature by running tests
|
|
const result = await featureVerifier.verifyFeatureTests(
|
|
feature,
|
|
projectPath,
|
|
sendToRenderer,
|
|
execution
|
|
);
|
|
|
|
// Update feature status based on result
|
|
const newStatus = result.passes ? "verified" : "in_progress";
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
newStatus,
|
|
projectPath
|
|
);
|
|
|
|
// Keep context file for viewing output later (deleted only when card is removed)
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_complete",
|
|
featureId: feature.id,
|
|
passes: result.passes,
|
|
message: result.message,
|
|
});
|
|
|
|
return { success: true, passes: result.passes };
|
|
} catch (error) {
|
|
console.error("[AutoMode] Error verifying feature:", error);
|
|
|
|
// Write error to context file
|
|
try {
|
|
await contextManager.writeToContextFile(
|
|
projectPath,
|
|
featureId,
|
|
`\n\n❌ ERROR: ${error.message}\n\n${error.stack || ''}\n`
|
|
);
|
|
} catch (contextError) {
|
|
console.error("[AutoMode] Failed to write error to context:", contextError);
|
|
}
|
|
|
|
// Update feature status to waiting_approval so user can review the error
|
|
try {
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
"waiting_approval",
|
|
projectPath,
|
|
{ error: error.message }
|
|
);
|
|
} catch (statusError) {
|
|
console.error("[AutoMode] Failed to update feature status after error:", statusError);
|
|
}
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_error",
|
|
error: error.message,
|
|
featureId: featureId,
|
|
});
|
|
throw error;
|
|
} finally {
|
|
// Clean up this feature's execution
|
|
this.runningFeatures.delete(featureId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resume a feature that has previous context - loads existing context and continues implementation
|
|
*/
|
|
async resumeFeature({ projectPath, featureId, sendToRenderer }) {
|
|
console.log(`[AutoMode] resumeFeature called with:`, {
|
|
projectPath,
|
|
featureId,
|
|
});
|
|
|
|
// Check if this specific feature is already running
|
|
if (this.runningFeatures.has(featureId)) {
|
|
throw new Error(`Feature ${featureId} is already running`);
|
|
}
|
|
|
|
console.log(`[AutoMode] Resuming feature: ${featureId}`);
|
|
|
|
// Register this feature as running
|
|
const execution = this.createExecutionContext(featureId);
|
|
execution.projectPath = projectPath;
|
|
execution.sendToRenderer = sendToRenderer;
|
|
this.runningFeatures.set(featureId, execution);
|
|
|
|
try {
|
|
// Load features
|
|
const features = await featureLoader.loadFeatures(projectPath);
|
|
const feature = features.find((f) => f.id === featureId);
|
|
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
console.log(`[AutoMode] Resuming feature: ${feature.description}`);
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_start",
|
|
featureId: feature.id,
|
|
feature: feature,
|
|
});
|
|
|
|
// Read existing context
|
|
const previousContext = await contextManager.readContextFile(
|
|
projectPath,
|
|
featureId
|
|
);
|
|
|
|
// Resume implementation with context
|
|
const result = await featureExecutor.resumeFeatureWithContext(
|
|
feature,
|
|
projectPath,
|
|
sendToRenderer,
|
|
previousContext,
|
|
execution
|
|
);
|
|
|
|
// If the agent ends early without finishing, automatically re-run
|
|
let attempts = 0;
|
|
const maxAttempts = 3;
|
|
let finalResult = result;
|
|
|
|
while (!finalResult.passes && attempts < maxAttempts) {
|
|
// Check if feature is still in progress (not verified)
|
|
const updatedFeatures = await featureLoader.loadFeatures(projectPath);
|
|
const updatedFeature = updatedFeatures.find((f) => f.id === featureId);
|
|
|
|
if (updatedFeature && updatedFeature.status === "in_progress") {
|
|
attempts++;
|
|
console.log(
|
|
`[AutoMode] Feature ended early, auto-retrying (attempt ${attempts}/${maxAttempts})...`
|
|
);
|
|
|
|
// Update context file with retry message
|
|
await contextManager.writeToContextFile(
|
|
projectPath,
|
|
featureId,
|
|
`\n\n🔄 Auto-retry #${attempts} - Continuing implementation...\n\n`
|
|
);
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_progress",
|
|
featureId: feature.id,
|
|
content: `\n🔄 Auto-retry #${attempts} - Agent ended early, continuing...\n`,
|
|
});
|
|
|
|
// Read updated context
|
|
const retryContext = await contextManager.readContextFile(
|
|
projectPath,
|
|
featureId
|
|
);
|
|
|
|
// Resume again with full context
|
|
finalResult = await featureExecutor.resumeFeatureWithContext(
|
|
feature,
|
|
projectPath,
|
|
sendToRenderer,
|
|
retryContext,
|
|
execution
|
|
);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Update feature status based on final result
|
|
// For skipTests features, go to waiting_approval on success instead of verified
|
|
// On failure, go to waiting_approval so user can review and decide next steps
|
|
let newStatus;
|
|
if (finalResult.passes) {
|
|
newStatus = feature.skipTests ? "waiting_approval" : "verified";
|
|
} else {
|
|
// On failure after all retry attempts, go to waiting_approval for user review
|
|
newStatus = "waiting_approval";
|
|
}
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
newStatus,
|
|
projectPath
|
|
);
|
|
|
|
// Keep context file for viewing output later (deleted only when card is removed)
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_complete",
|
|
featureId: feature.id,
|
|
passes: finalResult.passes,
|
|
message: finalResult.message,
|
|
});
|
|
|
|
return { success: true, passes: finalResult.passes };
|
|
} catch (error) {
|
|
console.error("[AutoMode] Error resuming feature:", error);
|
|
|
|
// Write error to context file
|
|
try {
|
|
await contextManager.writeToContextFile(
|
|
projectPath,
|
|
featureId,
|
|
`\n\n❌ ERROR: ${error.message}\n\n${error.stack || ''}\n`
|
|
);
|
|
} catch (contextError) {
|
|
console.error("[AutoMode] Failed to write error to context:", contextError);
|
|
}
|
|
|
|
// Update feature status to waiting_approval so user can review the error
|
|
try {
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
"waiting_approval",
|
|
projectPath,
|
|
{ error: error.message }
|
|
);
|
|
} catch (statusError) {
|
|
console.error("[AutoMode] Failed to update feature status after error:", statusError);
|
|
}
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_error",
|
|
error: error.message,
|
|
featureId: featureId,
|
|
});
|
|
throw error;
|
|
} finally {
|
|
// Clean up this feature's execution
|
|
this.runningFeatures.delete(featureId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* New periodic loop for a specific project - checks available slots and starts features up to max concurrency
|
|
* This loop continues running even if there are no backlog items
|
|
*/
|
|
runPeriodicLoopForProject(projectPath) {
|
|
const projectState = this.getProjectLoopState(projectPath);
|
|
|
|
console.log(
|
|
`[AutoMode] Starting periodic loop for ${projectPath} with interval: ${this.checkIntervalMs}ms`
|
|
);
|
|
|
|
// Initial check immediately
|
|
this.checkAndStartFeaturesForProject(projectPath);
|
|
|
|
// Then check periodically
|
|
projectState.interval = setInterval(() => {
|
|
if (projectState.isRunning) {
|
|
this.checkAndStartFeaturesForProject(projectPath);
|
|
}
|
|
}, this.checkIntervalMs);
|
|
}
|
|
|
|
/**
|
|
* Check how many features are running for a specific project and start new ones if under max concurrency
|
|
*/
|
|
async checkAndStartFeaturesForProject(projectPath) {
|
|
const projectState = this.projectLoops.get(projectPath);
|
|
if (!projectState || !projectState.isRunning) {
|
|
return;
|
|
}
|
|
|
|
const sendToRenderer = projectState.sendToRenderer;
|
|
const maxConcurrency = projectState.maxConcurrency;
|
|
|
|
try {
|
|
// Check how many are currently running FOR THIS PROJECT
|
|
const currentRunningCount = this.getRunningCountForProject(projectPath);
|
|
|
|
console.log(
|
|
`[AutoMode] [${projectPath}] Checking features - Running: ${currentRunningCount}/${maxConcurrency}`
|
|
);
|
|
|
|
// Calculate available slots for this project
|
|
const availableSlots = maxConcurrency - currentRunningCount;
|
|
|
|
if (availableSlots <= 0) {
|
|
console.log(`[AutoMode] [${projectPath}] At max concurrency, waiting...`);
|
|
return;
|
|
}
|
|
|
|
// Load features from backlog
|
|
const features = await featureLoader.loadFeatures(projectPath);
|
|
const backlogFeatures = features.filter((f) => f.status === "backlog");
|
|
|
|
if (backlogFeatures.length === 0) {
|
|
console.log(`[AutoMode] [${projectPath}] No backlog features available, waiting...`);
|
|
return;
|
|
}
|
|
|
|
// Grab up to availableSlots features from backlog
|
|
const featuresToStart = backlogFeatures.slice(0, availableSlots);
|
|
|
|
console.log(
|
|
`[AutoMode] [${projectPath}] Starting ${featuresToStart.length} feature(s) from backlog`
|
|
);
|
|
|
|
// Start each feature (don't await - run in parallel like drag operations)
|
|
for (const feature of featuresToStart) {
|
|
this.startFeatureAsync(feature, projectPath, sendToRenderer);
|
|
}
|
|
} catch (error) {
|
|
console.error(`[AutoMode] [${projectPath}] Error checking/starting features:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start a feature asynchronously (similar to drag operation)
|
|
* @param {Object} feature - The feature to start
|
|
* @param {string} projectPath - Path to the project
|
|
* @param {Function} sendToRenderer - Function to send events to renderer
|
|
* @param {boolean} useWorktrees - Whether to use git worktree isolation (default: false)
|
|
*/
|
|
async startFeatureAsync(feature, projectPath, sendToRenderer, useWorktrees = false) {
|
|
const featureId = feature.id;
|
|
|
|
// Skip if already running
|
|
if (this.runningFeatures.has(featureId)) {
|
|
console.log(`[AutoMode] Feature ${featureId} already running, skipping`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log(
|
|
`[AutoMode] Starting feature: ${feature.description.slice(0, 50)}... (worktrees: ${useWorktrees})`
|
|
);
|
|
|
|
// Register this feature as running
|
|
const execution = this.createExecutionContext(featureId);
|
|
execution.projectPath = projectPath;
|
|
execution.sendToRenderer = sendToRenderer;
|
|
this.runningFeatures.set(featureId, execution);
|
|
|
|
// Setup worktree for isolated work (if enabled)
|
|
const worktreeSetup = await this.setupWorktreeForFeature(feature, projectPath, sendToRenderer, useWorktrees);
|
|
execution.worktreePath = worktreeSetup.workPath;
|
|
execution.branchName = worktreeSetup.branchName;
|
|
|
|
// Determine working path (worktree or main project)
|
|
const workPath = worktreeSetup.workPath;
|
|
|
|
// Update status to in_progress with timestamp
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
"in_progress",
|
|
projectPath
|
|
);
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_start",
|
|
featureId: feature.id,
|
|
feature: { ...feature, worktreePath: worktreeSetup.workPath, branchName: worktreeSetup.branchName },
|
|
});
|
|
|
|
// Implement the feature (agent works in worktree)
|
|
const result = await featureExecutor.implementFeature(
|
|
feature,
|
|
workPath, // Use worktree path instead of main project
|
|
sendToRenderer,
|
|
execution
|
|
);
|
|
|
|
// Update feature status based on result
|
|
// For skipTests features, go to waiting_approval on success instead of verified
|
|
// On failure, ALL features go to waiting_approval so user can review and decide next steps
|
|
// This prevents infinite retry loops when the same issue keeps failing
|
|
let newStatus;
|
|
if (result.passes) {
|
|
newStatus = feature.skipTests ? "waiting_approval" : "verified";
|
|
} else {
|
|
// On failure, go to waiting_approval for user review
|
|
// Don't automatically move back to backlog to avoid infinite retry loops
|
|
// (especially when hitting rate limits or persistent errors)
|
|
newStatus = "waiting_approval";
|
|
}
|
|
await featureLoader.updateFeatureStatus(
|
|
feature.id,
|
|
newStatus,
|
|
projectPath
|
|
);
|
|
|
|
// Keep context file for viewing output later (deleted only when card is removed)
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_complete",
|
|
featureId: feature.id,
|
|
passes: result.passes,
|
|
message: result.message,
|
|
});
|
|
} catch (error) {
|
|
console.error(`[AutoMode] Error running feature ${featureId}:`, error);
|
|
|
|
// Write error to context file
|
|
try {
|
|
await contextManager.writeToContextFile(
|
|
projectPath,
|
|
featureId,
|
|
`\n\n❌ ERROR: ${error.message}\n\n${error.stack || ''}\n`
|
|
);
|
|
} catch (contextError) {
|
|
console.error("[AutoMode] Failed to write error to context:", contextError);
|
|
}
|
|
|
|
// Update feature status to waiting_approval so user can review the error
|
|
try {
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
"waiting_approval",
|
|
projectPath,
|
|
{ error: error.message }
|
|
);
|
|
} catch (statusError) {
|
|
console.error("[AutoMode] Failed to update feature status after error:", statusError);
|
|
}
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_error",
|
|
error: error.message,
|
|
featureId: featureId,
|
|
});
|
|
} finally {
|
|
// Clean up this feature's execution
|
|
this.runningFeatures.delete(featureId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Analyze a new project - scans codebase and updates app_spec.txt
|
|
* This is triggered when opening a project for the first time
|
|
*/
|
|
async analyzeProject({ projectPath, sendToRenderer }) {
|
|
console.log(`[AutoMode] Analyzing project at: ${projectPath}`);
|
|
|
|
const analysisId = `project-analysis-${Date.now()}`;
|
|
|
|
// Check if already analyzing this project
|
|
if (this.runningFeatures.has(analysisId)) {
|
|
throw new Error("Project analysis is already running");
|
|
}
|
|
|
|
// Register as running
|
|
const execution = this.createExecutionContext(analysisId);
|
|
execution.projectPath = projectPath;
|
|
execution.sendToRenderer = sendToRenderer;
|
|
this.runningFeatures.set(analysisId, execution);
|
|
|
|
try {
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_start",
|
|
featureId: analysisId,
|
|
feature: {
|
|
id: analysisId,
|
|
category: "Project Analysis",
|
|
description: "Analyzing project structure and tech stack",
|
|
},
|
|
});
|
|
|
|
// Perform the analysis
|
|
const result = await projectAnalyzer.runProjectAnalysis(
|
|
projectPath,
|
|
analysisId,
|
|
sendToRenderer,
|
|
execution
|
|
);
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_complete",
|
|
featureId: analysisId,
|
|
passes: result.success,
|
|
message: result.message,
|
|
});
|
|
|
|
return { success: true, message: result.message };
|
|
} catch (error) {
|
|
console.error("[AutoMode] Error analyzing project:", error);
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_error",
|
|
error: error.message,
|
|
featureId: analysisId,
|
|
});
|
|
throw error;
|
|
} finally {
|
|
this.runningFeatures.delete(analysisId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop a specific feature by ID
|
|
*/
|
|
async stopFeature({ featureId }) {
|
|
if (!this.runningFeatures.has(featureId)) {
|
|
return { success: false, error: `Feature ${featureId} is not running` };
|
|
}
|
|
|
|
console.log(`[AutoMode] Stopping feature: ${featureId}`);
|
|
|
|
const execution = this.runningFeatures.get(featureId);
|
|
if (execution && execution.abortController) {
|
|
execution.abortController.abort();
|
|
}
|
|
|
|
// Clean up
|
|
this.runningFeatures.delete(featureId);
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Follow-up on a feature with additional prompt
|
|
* This continues work on a feature that's in waiting_approval status
|
|
*/
|
|
async followUpFeature({
|
|
projectPath,
|
|
featureId,
|
|
prompt,
|
|
imagePaths,
|
|
sendToRenderer,
|
|
}) {
|
|
// Check if this feature is already running
|
|
if (this.runningFeatures.has(featureId)) {
|
|
throw new Error(`Feature ${featureId} is already running`);
|
|
}
|
|
|
|
console.log(
|
|
`[AutoMode] Follow-up on feature: ${featureId} with prompt: ${prompt}`
|
|
);
|
|
|
|
// Register this feature as running
|
|
const execution = this.createExecutionContext(featureId);
|
|
execution.projectPath = projectPath;
|
|
execution.sendToRenderer = sendToRenderer;
|
|
this.runningFeatures.set(featureId, execution);
|
|
|
|
// Start the async work in the background (don't await)
|
|
// This allows the API to return immediately so the modal can close
|
|
this.runFollowUpWork({
|
|
projectPath,
|
|
featureId,
|
|
prompt,
|
|
imagePaths,
|
|
sendToRenderer,
|
|
execution,
|
|
}).catch((error) => {
|
|
console.error("[AutoMode] Follow-up work error:", error);
|
|
this.runningFeatures.delete(featureId);
|
|
});
|
|
|
|
// Return immediately so the frontend can close the modal
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Internal method to run follow-up work asynchronously
|
|
*/
|
|
async runFollowUpWork({
|
|
projectPath,
|
|
featureId,
|
|
prompt,
|
|
imagePaths,
|
|
sendToRenderer,
|
|
execution,
|
|
}) {
|
|
try {
|
|
// Load features
|
|
const features = await featureLoader.loadFeatures(projectPath);
|
|
const feature = features.find((f) => f.id === featureId);
|
|
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
console.log(`[AutoMode] Following up on feature: ${feature.description}`);
|
|
|
|
// Update status to in_progress
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
"in_progress",
|
|
projectPath
|
|
);
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_start",
|
|
featureId: feature.id,
|
|
feature: feature,
|
|
});
|
|
|
|
// Read existing context and append follow-up prompt
|
|
const previousContext = await contextManager.readContextFile(
|
|
projectPath,
|
|
featureId
|
|
);
|
|
|
|
// Append follow-up prompt to context
|
|
const followUpContext = `${previousContext}\n\n## Follow-up Instructions\n\n${prompt}`;
|
|
await contextManager.writeToContextFile(
|
|
projectPath,
|
|
featureId,
|
|
`\n\n## Follow-up Instructions\n\n${prompt}`
|
|
);
|
|
|
|
// Resume implementation with follow-up context and optional images
|
|
const result = await featureExecutor.resumeFeatureWithContext(
|
|
{ ...feature, followUpPrompt: prompt, followUpImages: imagePaths },
|
|
projectPath,
|
|
sendToRenderer,
|
|
followUpContext,
|
|
execution
|
|
);
|
|
|
|
// For skipTests features, go to waiting_approval on success instead of verified
|
|
// On failure, go to waiting_approval so user can review and decide next steps
|
|
const newStatus = result.passes
|
|
? feature.skipTests
|
|
? "waiting_approval"
|
|
: "verified"
|
|
: "waiting_approval";
|
|
|
|
await featureLoader.updateFeatureStatus(
|
|
feature.id,
|
|
newStatus,
|
|
projectPath
|
|
);
|
|
|
|
// Keep context file for viewing output later (deleted only when card is removed)
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_complete",
|
|
featureId: feature.id,
|
|
passes: result.passes,
|
|
message: result.message,
|
|
});
|
|
} catch (error) {
|
|
console.error("[AutoMode] Error in follow-up:", error);
|
|
|
|
// Write error to context file
|
|
try {
|
|
await contextManager.writeToContextFile(
|
|
projectPath,
|
|
featureId,
|
|
`\n\n❌ ERROR: ${error.message}\n\n${error.stack || ''}\n`
|
|
);
|
|
} catch (contextError) {
|
|
console.error("[AutoMode] Failed to write error to context:", contextError);
|
|
}
|
|
|
|
// Update feature status to waiting_approval so user can review the error
|
|
try {
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
"waiting_approval",
|
|
projectPath,
|
|
{ error: error.message }
|
|
);
|
|
} catch (statusError) {
|
|
console.error("[AutoMode] Failed to update feature status after error:", statusError);
|
|
}
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_error",
|
|
error: error.message,
|
|
featureId: featureId,
|
|
});
|
|
} finally {
|
|
this.runningFeatures.delete(featureId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Commit changes for a feature without doing additional work
|
|
* This marks the feature as verified and commits the changes
|
|
*/
|
|
async commitFeature({ projectPath, featureId, sendToRenderer }) {
|
|
console.log(`[AutoMode] Committing feature: ${featureId}`);
|
|
|
|
// Register briefly as running for the commit operation
|
|
const execution = this.createExecutionContext(featureId);
|
|
execution.projectPath = projectPath;
|
|
execution.sendToRenderer = sendToRenderer;
|
|
this.runningFeatures.set(featureId, execution);
|
|
|
|
try {
|
|
// Load feature to get description for commit message
|
|
const features = await featureLoader.loadFeatures(projectPath);
|
|
const feature = features.find((f) => f.id === featureId);
|
|
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_start",
|
|
featureId: feature.id,
|
|
feature: { ...feature, description: "Committing changes..." },
|
|
});
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_phase",
|
|
featureId,
|
|
phase: "action",
|
|
message: "Committing changes to git...",
|
|
});
|
|
|
|
// Run git commit via the agent
|
|
await featureExecutor.commitChangesOnly(
|
|
feature,
|
|
projectPath,
|
|
sendToRenderer,
|
|
execution
|
|
);
|
|
|
|
// Update status to verified
|
|
await featureLoader.updateFeatureStatus(
|
|
featureId,
|
|
"verified",
|
|
projectPath
|
|
);
|
|
|
|
// Keep context file for viewing output later (deleted only when card is removed)
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_complete",
|
|
featureId: feature.id,
|
|
passes: true,
|
|
message: "Changes committed successfully",
|
|
});
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("[AutoMode] Error committing feature:", error);
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_error",
|
|
error: error.message,
|
|
featureId: featureId,
|
|
});
|
|
throw error;
|
|
} finally {
|
|
this.runningFeatures.delete(featureId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sleep helper
|
|
*/
|
|
sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Revert feature changes by removing the worktree
|
|
* This effectively discards all changes made by the agent
|
|
*/
|
|
async revertFeature({ projectPath, featureId, sendToRenderer }) {
|
|
console.log(`[AutoMode] Reverting feature: ${featureId}`);
|
|
|
|
try {
|
|
// Stop the feature if it's running
|
|
if (this.runningFeatures.has(featureId)) {
|
|
await this.stopFeature({ featureId });
|
|
}
|
|
|
|
// Remove the worktree and delete the branch
|
|
const result = await worktreeManager.removeWorktree(projectPath, featureId, true);
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error || "Failed to remove worktree");
|
|
}
|
|
|
|
// Clear worktree info from feature
|
|
await featureLoader.updateFeatureWorktree(featureId, projectPath, null, null);
|
|
|
|
// Update feature status back to backlog
|
|
await featureLoader.updateFeatureStatus(featureId, "backlog", projectPath);
|
|
|
|
// Delete context file
|
|
await contextManager.deleteContextFile(projectPath, featureId);
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_complete",
|
|
featureId: featureId,
|
|
passes: false,
|
|
message: "Feature reverted - all changes discarded",
|
|
});
|
|
|
|
console.log(`[AutoMode] Feature ${featureId} reverted successfully`);
|
|
return { success: true, removedPath: result.removedPath };
|
|
} catch (error) {
|
|
console.error("[AutoMode] Error reverting feature:", error);
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_error",
|
|
error: error.message,
|
|
featureId: featureId,
|
|
});
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge feature worktree changes back to main branch
|
|
*/
|
|
async mergeFeature({ projectPath, featureId, options = {}, sendToRenderer }) {
|
|
console.log(`[AutoMode] Merging feature: ${featureId}`);
|
|
|
|
try {
|
|
// Load feature to get worktree info
|
|
const features = await featureLoader.loadFeatures(projectPath);
|
|
const feature = features.find((f) => f.id === featureId);
|
|
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_progress",
|
|
featureId: featureId,
|
|
content: "Merging feature branch into main...\n",
|
|
});
|
|
|
|
// Merge the worktree
|
|
const result = await worktreeManager.mergeWorktree(projectPath, featureId, {
|
|
...options,
|
|
cleanup: true, // Remove worktree after successful merge
|
|
});
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error || "Failed to merge worktree");
|
|
}
|
|
|
|
// Clear worktree info from feature
|
|
await featureLoader.updateFeatureWorktree(featureId, projectPath, null, null);
|
|
|
|
// Update feature status to verified
|
|
await featureLoader.updateFeatureStatus(featureId, "verified", projectPath);
|
|
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_feature_complete",
|
|
featureId: featureId,
|
|
passes: true,
|
|
message: `Feature merged into ${result.intoBranch}`,
|
|
});
|
|
|
|
console.log(`[AutoMode] Feature ${featureId} merged successfully`);
|
|
return { success: true, mergedBranch: result.mergedBranch };
|
|
} catch (error) {
|
|
console.error("[AutoMode] Error merging feature:", error);
|
|
this.emitEvent(projectPath, sendToRenderer, {
|
|
type: "auto_mode_error",
|
|
error: error.message,
|
|
featureId: featureId,
|
|
});
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get worktree info for a feature
|
|
*/
|
|
async getWorktreeInfo({ projectPath, featureId }) {
|
|
return await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
|
}
|
|
|
|
/**
|
|
* Get worktree status (changed files, commits, etc.)
|
|
*/
|
|
async getWorktreeStatus({ projectPath, featureId }) {
|
|
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
|
if (!worktreeInfo.success) {
|
|
return { success: false, error: "Worktree not found" };
|
|
}
|
|
return await worktreeManager.getWorktreeStatus(worktreeInfo.worktreePath);
|
|
}
|
|
|
|
/**
|
|
* List all feature worktrees
|
|
*/
|
|
async listWorktrees({ projectPath }) {
|
|
const worktrees = await worktreeManager.getAllFeatureWorktrees(projectPath);
|
|
return { success: true, worktrees };
|
|
}
|
|
|
|
/**
|
|
* Get file diffs for a feature worktree
|
|
*/
|
|
async getFileDiffs({ projectPath, featureId }) {
|
|
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
|
if (!worktreeInfo.success) {
|
|
return { success: false, error: "Worktree not found" };
|
|
}
|
|
return await worktreeManager.getFileDiffs(worktreeInfo.worktreePath);
|
|
}
|
|
|
|
/**
|
|
* Get diff for a specific file in a feature worktree
|
|
*/
|
|
async getFileDiff({ projectPath, featureId, filePath }) {
|
|
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
|
if (!worktreeInfo.success) {
|
|
return { success: false, error: "Worktree not found" };
|
|
}
|
|
return await worktreeManager.getFileDiff(worktreeInfo.worktreePath, filePath);
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
module.exports = new AutoModeService();
|