feat: enhance UI components and branch management

- Added new RadioGroup and Switch components for better UI interaction.
- Introduced BranchSelector for improved branch selection in feature dialogs.
- Updated Autocomplete and BranchAutocomplete components to handle error states.
- Refactored feature management to archive verified features instead of deleting them.
- Enhanced worktree handling by removing worktreePath from features, relying on branchName instead.
- Improved auto mode functionality by integrating branch management and worktree updates.
- Cleaned up unused code and optimized existing logic for better performance.
This commit is contained in:
Cody Seibert
2025-12-17 22:29:39 -05:00
parent cffdec91f1
commit 0549b8085a
45 changed files with 1669 additions and 1346 deletions

View File

@@ -6,8 +6,6 @@
import { Router } from "express";
import type { AutoModeService } from "../../services/auto-mode-service.js";
import { createStartHandler } from "./routes/start.js";
import { createStopHandler } from "./routes/stop.js";
import { createStopFeatureHandler } from "./routes/stop-feature.js";
import { createStatusHandler } from "./routes/status.js";
import { createRunFeatureHandler } from "./routes/run-feature.js";
@@ -21,8 +19,6 @@ import { createCommitFeatureHandler } from "./routes/commit-feature.js";
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
router.post("/start", createStartHandler(autoModeService));
router.post("/stop", createStopHandler(autoModeService));
router.post("/stop-feature", createStopFeatureHandler(autoModeService));
router.post("/status", createStatusHandler(autoModeService));
router.post("/run-feature", createRunFeatureHandler(autoModeService));

View File

@@ -12,13 +12,14 @@ const logger = createLogger("AutoMode");
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as {
projectPath: string;
featureId: string;
prompt: string;
imagePaths?: string[];
worktreePath?: string;
};
const { projectPath, featureId, prompt, imagePaths, useWorktrees } =
req.body as {
projectPath: string;
featureId: string;
prompt: string;
imagePaths?: string[];
useWorktrees?: boolean;
};
if (!projectPath || !featureId || !prompt) {
res.status(400).json({
@@ -28,14 +29,25 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return;
}
// Start follow-up in background, using the feature's worktreePath for correct branch
// Start follow-up in background
// followUpFeature derives workDir from feature.branchName
autoModeService
.followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath)
.followUpFeature(
projectPath,
featureId,
prompt,
imagePaths,
useWorktrees ?? true
)
.catch((error) => {
logger.error(
`[AutoMode] Follow up feature ${featureId} error:`,
error
);
})
.finally(() => {
// Release the starting slot when follow-up completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });

View File

@@ -19,12 +19,10 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
};
if (!projectPath || !featureId) {
res
.status(400)
.json({
success: false,
error: "projectPath and featureId are required",
});
res.status(400).json({
success: false,
error: "projectPath and featureId are required",
});
return;
}
@@ -34,7 +32,8 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
.resumeFeature(projectPath, featureId, useWorktrees ?? false)
.catch((error) => {
logger.error(`[AutoMode] Resume feature ${featureId} error:`, error);
});
})
.finally(() => {});
res.json({ success: true });
} catch (error) {

View File

@@ -12,30 +12,30 @@ const logger = createLogger("AutoMode");
export function createRunFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, useWorktrees, worktreePath } = req.body as {
const { projectPath, featureId, useWorktrees } = req.body as {
projectPath: string;
featureId: string;
useWorktrees?: boolean;
worktreePath?: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({
success: false,
error: "projectPath and featureId are required",
});
res.status(400).json({
success: false,
error: "projectPath and featureId are required",
});
return;
}
// Start execution in background
// If worktreePath is provided, use it directly; otherwise let the service decide
// Default to false - worktrees should only be used when explicitly enabled
// executeFeature derives workDir from feature.branchName
autoModeService
.executeFeature(projectPath, featureId, useWorktrees ?? false, false, worktreePath)
.executeFeature(projectPath, featureId, useWorktrees ?? false, false)
.catch((error) => {
logger.error(`[AutoMode] Feature ${featureId} error:`, error);
})
.finally(() => {
// Release the starting slot when execution completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });

View File

@@ -1,31 +0,0 @@
/**
* POST /start endpoint - Start auto mode loop
*/
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createStartHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, maxConcurrency } = req.body as {
projectPath: string;
maxConcurrency?: number;
};
if (!projectPath) {
res
.status(400)
.json({ success: false, error: "projectPath is required" });
return;
}
await autoModeService.startAutoLoop(projectPath, maxConcurrency || 3);
res.json({ success: true });
} catch (error) {
logError(error, "Start auto loop failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,19 +0,0 @@
/**
* POST /stop endpoint - Stop auto mode loop
*/
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createStopHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const runningCount = await autoModeService.stopAutoLoop();
res.json({ success: true, runningFeatures: runningCount });
} catch (error) {
logError(error, "Stop auto loop failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -16,7 +16,6 @@ export function createIndexHandler(autoModeService: AutoModeService) {
success: true,
runningAgents,
totalCount: runningAgents.length,
autoLoopRunning: status.autoLoopRunning,
});
} catch (error) {
logError(error, "Get running agents failed");

View File

@@ -8,6 +8,7 @@
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { existsSync } from "fs";
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js";
const execAsync = promisify(exec);
@@ -58,10 +59,12 @@ export function createListHandler() {
});
const worktrees: WorktreeInfo[] = [];
const removedWorktrees: Array<{ path: string; branch: string }> = [];
const lines = stdout.split("\n");
let current: { path?: string; branch?: string } = {};
let isFirst = true;
// First pass: detect removed worktrees
for (const line of lines) {
if (line.startsWith("worktree ")) {
current.path = normalizePath(line.slice(9));
@@ -69,19 +72,40 @@ export function createListHandler() {
current.branch = line.slice(7).replace("refs/heads/", "");
} else if (line === "") {
if (current.path && current.branch) {
worktrees.push({
path: current.path,
branch: current.branch,
isMain: isFirst,
isCurrent: current.branch === currentBranch,
hasWorktree: true,
});
isFirst = false;
const isMainWorktree = isFirst;
// Check if the worktree directory actually exists
// Skip checking/pruning the main worktree (projectPath itself)
if (!isMainWorktree && !existsSync(current.path)) {
// Worktree directory doesn't exist - it was manually deleted
removedWorktrees.push({
path: current.path,
branch: current.branch,
});
} else {
// Worktree exists (or is main worktree), add it to the list
worktrees.push({
path: current.path,
branch: current.branch,
isMain: isMainWorktree,
isCurrent: current.branch === currentBranch,
hasWorktree: true,
});
isFirst = false;
}
}
current = {};
}
}
// Prune removed worktrees from git (only if any were detected)
if (removedWorktrees.length > 0) {
try {
await execAsync("git worktree prune", { cwd: projectPath });
} catch {
// Prune failed, but we'll still report the removed worktrees
}
}
// If includeDetails is requested, fetch change status for each worktree
if (includeDetails) {
for (const worktree of worktrees) {
@@ -103,7 +127,11 @@ export function createListHandler() {
}
}
res.json({ success: true, worktrees });
res.json({
success: true,
worktrees,
removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
});
} catch (error) {
logError(error, "List worktrees failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -20,14 +20,8 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { createAutoModeOptions } from "../lib/sdk-options.js";
import { isAbortError, classifyError } from "../lib/error-handler.js";
import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js";
import type { Feature } from "./feature-loader.js";
import {
getFeatureDir,
getFeaturesDir,
getAutomakerDir,
getWorktreesDir,
} from "../lib/automaker-paths.js";
import { getFeatureDir, getAutomakerDir } from "../lib/automaker-paths.js";
const execAsync = promisify(exec);
@@ -41,196 +35,43 @@ interface RunningFeature {
startTime: number;
}
interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
projectPath: string;
}
export class AutoModeService {
private events: EventEmitter;
private runningFeatures = new Map<string, RunningFeature>();
private autoLoopRunning = false;
private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null;
constructor(events: EventEmitter) {
this.events = events;
}
/**
* Start the auto mode loop - continuously picks and executes pending features
*/
async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise<void> {
if (this.autoLoopRunning) {
throw new Error("Auto mode is already running");
}
this.autoLoopRunning = true;
this.autoLoopAbortController = new AbortController();
this.config = {
maxConcurrency,
useWorktrees: true,
projectPath,
};
this.emitAutoModeEvent("auto_mode_started", {
message: `Auto mode started with max ${maxConcurrency} concurrent features`,
projectPath,
});
// Run the loop in the background
this.runAutoLoop().catch((error) => {
console.error("[AutoMode] Loop error:", error);
this.emitAutoModeEvent("auto_mode_error", {
error: error.message,
});
});
}
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 || 3)) {
await this.sleep(5000);
continue;
}
// Load pending features
const pendingFeatures = await this.loadPendingFeatures(
this.config!.projectPath
);
if (pendingFeatures.length === 0) {
this.emitAutoModeEvent("auto_mode_idle", {
message: "No pending features - auto mode idle",
projectPath: this.config!.projectPath,
});
await this.sleep(10000);
continue;
}
// Find a feature not currently running
const nextFeature = pendingFeatures.find(
(f) => !this.runningFeatures.has(f.id)
);
if (nextFeature) {
// Start feature execution in background
this.executeFeature(
this.config!.projectPath,
nextFeature.id,
this.config!.useWorktrees,
true
).catch((error) => {
console.error(`[AutoMode] Feature ${nextFeature.id} error:`, error);
});
}
await this.sleep(2000);
} catch (error) {
console.error("[AutoMode] Loop iteration error:", error);
await this.sleep(5000);
}
}
this.autoLoopRunning = false;
}
/**
* Stop the auto mode loop
*/
async stopAutoLoop(): Promise<number> {
const wasRunning = this.autoLoopRunning;
this.autoLoopRunning = false;
if (this.autoLoopAbortController) {
this.autoLoopAbortController.abort();
this.autoLoopAbortController = null;
}
// Emit stop event immediately when user explicitly stops
if (wasRunning) {
this.emitAutoModeEvent("auto_mode_stopped", {
message: "Auto mode stopped",
projectPath: this.config?.projectPath,
});
}
return this.runningFeatures.size;
}
/**
* 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
* @param providedWorktreePath - Optional: use this worktree path instead of creating a new one
*/
async executeFeature(
projectPath: string,
featureId: string,
useWorktrees = false,
isAutoMode = false,
providedWorktreePath?: string
isAutoMode = false
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
}
const abortController = new AbortController();
const branchName = `feature/${featureId}`;
let worktreePath: string | null = null;
// Use provided worktree path if given, otherwise setup new worktree if enabled
if (providedWorktreePath) {
// Resolve to absolute path - critical for cross-platform compatibility
// On Windows, relative paths or paths with forward slashes may not work correctly with cwd
// On all platforms, absolute paths ensure commands execute in the correct directory
try {
// Resolve relative paths relative to projectPath, absolute paths as-is
const resolvedPath = path.isAbsolute(providedWorktreePath)
? path.resolve(providedWorktreePath)
: path.resolve(projectPath, providedWorktreePath);
// Verify the path exists before using it
await fs.access(resolvedPath);
worktreePath = resolvedPath;
console.log(`[AutoMode] Using provided worktree path (resolved): ${worktreePath}`);
} catch (error) {
console.error(`[AutoMode] Provided worktree path invalid or doesn't exist: ${providedWorktreePath}`, error);
// Fall through to create new worktree or use project path
}
}
if (!worktreePath && useWorktrees) {
// No specific worktree provided, create a new one for this feature
worktreePath = await this.setupWorktree(
projectPath,
featureId,
branchName
// Check if feature has existing context - if so, resume instead of starting fresh
const hasExistingContext = await this.contextExists(projectPath, featureId);
if (hasExistingContext) {
console.log(
`[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh`
);
return this.resumeFeature(projectPath, featureId, useWorktrees);
}
// Ensure workDir is always an absolute path for cross-platform compatibility
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
const abortController = new AbortController();
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath,
branchName,
abortController,
isAutoMode,
startTime: Date.now(),
});
// Emit feature start event
// Emit feature start event early
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
@@ -242,12 +83,53 @@ export class AutoModeService {
});
try {
// Load feature details
// Load feature details FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Derive workDir from feature.branchName
// If no branchName, use the project path directly
let worktreePath: string | null = null;
const branchName = feature.branchName || null;
if (useWorktrees && branchName) {
// Try to find existing worktree for this branch
worktreePath = await this.findExistingWorktreeForBranch(
projectPath,
branchName
);
if (!worktreePath) {
// Create worktree for this branch
worktreePath = await this.setupWorktree(
projectPath,
featureId,
branchName
);
}
console.log(
`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`
);
}
// Ensure workDir is always an absolute path for cross-platform compatibility
const workDir = worktreePath
? path.resolve(worktreePath)
: path.resolve(projectPath);
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath,
branchName,
abortController,
isAutoMode,
startTime: Date.now(),
});
// Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
@@ -262,7 +144,7 @@ export class AutoModeService {
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
console.log(
`[AutoMode] Executing feature ${featureId} with model: ${model}`
`[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}`
);
// Run the agent with the feature's model and images
@@ -271,6 +153,7 @@ export class AutoModeService {
featureId,
prompt,
abortController,
projectPath,
imagePaths,
model
);
@@ -371,7 +254,7 @@ export class AutoModeService {
featureId: string,
prompt: string,
imagePaths?: string[],
providedWorktreePath?: string
useWorktrees = true
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
@@ -379,32 +262,29 @@ export class AutoModeService {
const abortController = new AbortController();
// Use the provided worktreePath (from the feature's assigned branch)
// Fall back to project path if not provided
// Load feature info for context FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId);
// Derive workDir from feature.branchName
let workDir = path.resolve(projectPath);
let worktreePath: string | null = null;
const branchName = feature?.branchName || null;
if (providedWorktreePath) {
try {
// Resolve to absolute path - critical for cross-platform compatibility
// On Windows, relative paths or paths with forward slashes may not work correctly with cwd
// On all platforms, absolute paths ensure commands execute in the correct directory
const resolvedPath = path.isAbsolute(providedWorktreePath)
? path.resolve(providedWorktreePath)
: path.resolve(projectPath, providedWorktreePath);
await fs.access(resolvedPath);
workDir = resolvedPath;
worktreePath = resolvedPath;
} catch {
// Worktree path provided but doesn't exist, use project path
console.log(`[AutoMode] Provided worktreePath doesn't exist: ${providedWorktreePath}, using project path`);
if (useWorktrees && branchName) {
// Try to find existing worktree for this branch
worktreePath = await this.findExistingWorktreeForBranch(
projectPath,
branchName
);
if (worktreePath) {
workDir = worktreePath;
console.log(
`[AutoMode] Follow-up using worktree for branch "${branchName}": ${workDir}`
);
}
}
// Load feature info for context
const feature = await this.loadFeature(projectPath, featureId);
// Load previous agent output if it exists
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
@@ -441,7 +321,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId,
projectPath,
worktreePath,
branchName: worktreePath ? path.basename(worktreePath) : null,
branchName,
abortController,
isAutoMode: false,
startTime: Date.now(),
@@ -537,6 +417,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId,
fullPrompt,
abortController,
projectPath,
allImagePaths.length > 0 ? allImagePaths : imagePaths,
model,
previousContext || undefined
@@ -653,17 +534,25 @@ Address the follow-up instructions above. Review the previous work and make the
workDir = providedWorktreePath;
console.log(`[AutoMode] Committing in provided worktree: ${workDir}`);
} catch {
console.log(`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`);
console.log(
`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`
);
}
} else {
// Fallback: try to find worktree at legacy location
const legacyWorktreePath = path.join(projectPath, ".worktrees", featureId);
const legacyWorktreePath = path.join(
projectPath,
".worktrees",
featureId
);
try {
await fs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`);
} catch {
console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`);
console.log(
`[AutoMode] No worktree found, committing in project path: ${workDir}`
);
}
}
@@ -816,13 +705,11 @@ Format your response as a structured markdown document.`;
*/
getStatus(): {
isRunning: boolean;
autoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
} {
return {
isRunning: this.autoLoopRunning || this.runningFeatures.size > 0,
autoLoopRunning: this.autoLoopRunning,
isRunning: this.runningFeatures.size > 0,
runningFeatures: Array.from(this.runningFeatures.keys()),
runningCount: this.runningFeatures.size,
};
@@ -905,10 +792,15 @@ Format your response as a structured markdown document.`;
branchName: string
): Promise<string> {
// First, check if git already has a worktree for this branch (anywhere)
const existingWorktree = await this.findExistingWorktreeForBranch(projectPath, branchName);
const existingWorktree = await this.findExistingWorktreeForBranch(
projectPath,
branchName
);
if (existingWorktree) {
// Path is already resolved to absolute in findExistingWorktreeForBranch
console.log(`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`);
console.log(
`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`
);
return existingWorktree;
}
@@ -992,56 +884,6 @@ Format your response as a structured markdown document.`;
}
}
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
// Features are stored in .automaker directory
const featuresDir = getFeaturesDir(projectPath);
try {
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const allFeatures: Feature[] = [];
const pendingFeatures: Feature[] = [];
// Load all features (for dependency checking)
for (const entry of entries) {
if (entry.isDirectory()) {
const featurePath = path.join(
featuresDir,
entry.name,
"feature.json"
);
try {
const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data);
allFeatures.push(feature);
// Track pending features separately
if (
feature.status === "pending" ||
feature.status === "ready" ||
feature.status === "backlog"
) {
pendingFeatures.push(feature);
}
} catch {
// Skip invalid features
}
}
}
// Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Filter to only features with satisfied dependencies
const readyFeatures = orderedFeatures.filter(feature =>
areDependenciesSatisfied(feature, allFeatures)
);
return readyFeatures;
} catch {
return [];
}
}
/**
* Extract a title from feature description (first line or truncated)
*/
@@ -1060,31 +902,6 @@ Format your response as a structured markdown document.`;
return firstLine.substring(0, 57) + "...";
}
/**
* Extract image paths from feature's imagePaths array
* Handles both string paths and objects with path property
*/
private extractImagePaths(
imagePaths:
| Array<string | { path: string; [key: string]: unknown }>
| undefined,
projectPath: string
): string[] {
if (!imagePaths || imagePaths.length === 0) {
return [];
}
return imagePaths
.map((imgPath) => {
const pathStr = typeof imgPath === "string" ? imgPath : imgPath.path;
// Resolve relative paths to absolute paths
return path.isAbsolute(pathStr)
? pathStr
: path.join(projectPath, pathStr);
})
.filter((p) => p); // Filter out any empty paths
}
private buildFeaturePrompt(feature: Feature): string {
const title = this.extractTitleFromDescription(feature.description);
@@ -1164,6 +981,7 @@ This helps parse your summary correctly in the output logs.`;
featureId: string,
prompt: string,
abortController: AbortController,
projectPath: string,
imagePaths?: string[],
model?: string,
previousContent?: string
@@ -1171,7 +989,9 @@ This helps parse your summary correctly in the output logs.`;
// 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") {
console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`);
console.log(
`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`
);
// Simulate some work being done
await this.sleep(500);
@@ -1203,8 +1023,7 @@ This helps parse your summary correctly in the output logs.`;
await this.sleep(200);
// Save mock agent output
const configProjectPath = this.config?.projectPath || workDir;
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const featureDirForOutput = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
const mockOutput = `# Mock Agent Output
@@ -1222,7 +1041,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, mockOutput);
console.log(`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`);
console.log(
`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`
);
return;
}
@@ -1273,10 +1094,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
: "";
// Agent output goes to .automaker directory
// Note: We use the original projectPath here (from config), not workDir
// because workDir might be a worktree path
const configProjectPath = this.config?.projectPath || workDir;
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
// 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");
// Incremental file writing state
@@ -1290,7 +1109,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
await fs.writeFile(outputPath, responseText);
} catch (error) {
// Log but don't crash - file write errors shouldn't stop execution
console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error);
console.error(
`[AutoMode] Failed to write agent output for ${featureId}:`,
error
);
}
};
@@ -1309,11 +1131,11 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
for (const block of msg.message.content) {
if (block.type === "text") {
// Add separator before new text if we already have content and it doesn't end with newlines
if (responseText.length > 0 && !responseText.endsWith('\n\n')) {
if (responseText.endsWith('\n')) {
responseText += '\n';
if (responseText.length > 0 && !responseText.endsWith("\n\n")) {
if (responseText.endsWith("\n")) {
responseText += "\n";
} else {
responseText += '\n\n';
responseText += "\n\n";
}
}
responseText += block.text || "";
@@ -1347,12 +1169,16 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
});
// Also add to file output for persistence
if (responseText.length > 0 && !responseText.endsWith('\n')) {
responseText += '\n';
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`;
responseText += `Input: ${JSON.stringify(
block.input,
null,
2
)}\n`;
}
scheduleWrite();
}
@@ -1382,12 +1208,68 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
context: string,
useWorktrees: boolean
): Promise<void> {
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
}
const prompt = `## Continuing Feature Implementation
const abortController = new AbortController();
// Emit feature start event early
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: {
id: featureId,
title: "Resuming...",
description: "Feature is resuming from previous context",
},
});
try {
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Derive workDir from feature.branchName
let worktreePath: string | null = null;
const branchName = feature.branchName || null;
if (useWorktrees && branchName) {
worktreePath = await this.findExistingWorktreeForBranch(
projectPath,
branchName
);
if (!worktreePath) {
worktreePath = await this.setupWorktree(
projectPath,
featureId,
branchName
);
}
console.log(
`[AutoMode] Resuming in worktree for branch "${branchName}": ${worktreePath}`
);
}
const workDir = worktreePath
? path.resolve(worktreePath)
: path.resolve(projectPath);
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath,
branchName,
abortController,
isAutoMode: false,
startTime: Date.now(),
});
// Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
const prompt = `## Continuing Feature Implementation
${this.buildFeaturePrompt(feature)}
@@ -1399,7 +1281,67 @@ ${context}
## Instructions
Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`;
return this.executeFeature(projectPath, featureId, useWorktrees, false);
// Extract image paths from feature
const imagePaths = feature.imagePaths?.map((img) =>
typeof img === "string" ? img : img.path
);
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
console.log(
`[AutoMode] Resuming feature ${featureId} with model: ${model} in ${workDir}`
);
// Run the agent with context
await this.runAgent(
workDir,
featureId,
prompt,
abortController,
projectPath,
imagePaths,
model,
context // Pass previous context for proper file output
);
// Mark as waiting_approval for user review
await this.updateFeatureStatus(
projectPath,
featureId,
"waiting_approval"
);
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: true,
message: `Feature resumed and completed in ${Math.round(
(Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000
)}s`,
projectPath,
});
} catch (error) {
const errorInfo = classifyError(error);
if (errorInfo.isAbort) {
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: false,
message: "Feature stopped by user",
projectPath,
});
} else {
console.error(`[AutoMode] Feature ${featureId} resume failed:`, error);
await this.updateFeatureStatus(projectPath, featureId, "backlog");
this.emitAutoModeEvent("auto_mode_error", {
featureId,
error: errorInfo.message,
errorType: errorInfo.isAuth ? "authentication" : "execution",
projectPath,
});
}
} finally {
this.runningFeatures.delete(featureId);
}
}
/**
@@ -1418,7 +1360,28 @@ Review the previous work and continue the implementation. If the feature appears
});
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
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 }
);
}
});
}
}

View File

@@ -24,6 +24,8 @@ export interface Feature {
spec?: string;
model?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
[key: string]: unknown;
}