feat: enhance auto mode functionality with worktree support

- Updated auto mode handlers to support branch-specific operations, allowing for better management of features across different worktrees.
- Introduced normalization of branch names to handle undefined values gracefully.
- Enhanced status and response messages to reflect the current worktree context.
- Updated the auto mode service to manage state and concurrency settings per worktree, improving user experience and flexibility.
- Added UI elements to display current max concurrency for auto mode in both board and mobile views.

This update aims to streamline the auto mode experience, making it more intuitive for users working with multiple branches and worktrees.
This commit is contained in:
webdevcody
2026-01-19 17:17:40 -05:00
parent 63b8eb0991
commit 82e22b4362
25 changed files with 2693 additions and 268 deletions

View File

@@ -12,8 +12,9 @@ const logger = createLogger('AutoMode');
export function createStartHandler(autoModeService: AutoModeService) { export function createStartHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, maxConcurrency } = req.body as { const { projectPath, branchName, maxConcurrency } = req.body as {
projectPath: string; projectPath: string;
branchName?: string | null;
maxConcurrency?: number; maxConcurrency?: number;
}; };
@@ -25,26 +26,38 @@ export function createStartHandler(autoModeService: AutoModeService) {
return; return;
} }
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const worktreeDesc = normalizedBranchName
? `worktree ${normalizedBranchName}`
: 'main worktree';
// Check if already running // Check if already running
if (autoModeService.isAutoLoopRunningForProject(projectPath)) { if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
res.json({ res.json({
success: true, success: true,
message: 'Auto mode is already running for this project', message: `Auto mode is already running for ${worktreeDesc}`,
alreadyRunning: true, alreadyRunning: true,
branchName: normalizedBranchName,
}); });
return; return;
} }
// Start the auto loop for this project // Start the auto loop for this project/worktree
await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3); const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject(
projectPath,
normalizedBranchName,
maxConcurrency
);
logger.info( logger.info(
`Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}` `Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
); );
res.json({ res.json({
success: true, success: true,
message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`, message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
branchName: normalizedBranchName,
}); });
} catch (error) { } catch (error) {
logError(error, 'Start auto mode failed'); logError(error, 'Start auto mode failed');

View File

@@ -12,11 +12,19 @@ import { getErrorMessage, logError } from '../common.js';
export function createStatusHandler(autoModeService: AutoModeService) { export function createStatusHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath } = req.body as { projectPath?: string }; const { projectPath, branchName } = req.body as {
projectPath?: string;
branchName?: string | null;
};
// If projectPath is provided, return per-project status // If projectPath is provided, return per-project/worktree status
if (projectPath) { if (projectPath) {
const projectStatus = autoModeService.getStatusForProject(projectPath); // Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const projectStatus = autoModeService.getStatusForProject(
projectPath,
normalizedBranchName
);
res.json({ res.json({
success: true, success: true,
isRunning: projectStatus.runningCount > 0, isRunning: projectStatus.runningCount > 0,
@@ -25,6 +33,7 @@ export function createStatusHandler(autoModeService: AutoModeService) {
runningCount: projectStatus.runningCount, runningCount: projectStatus.runningCount,
maxConcurrency: projectStatus.maxConcurrency, maxConcurrency: projectStatus.maxConcurrency,
projectPath, projectPath,
branchName: normalizedBranchName,
}); });
return; return;
} }
@@ -32,10 +41,12 @@ export function createStatusHandler(autoModeService: AutoModeService) {
// Fall back to global status for backward compatibility // Fall back to global status for backward compatibility
const status = autoModeService.getStatus(); const status = autoModeService.getStatus();
const activeProjects = autoModeService.getActiveAutoLoopProjects(); const activeProjects = autoModeService.getActiveAutoLoopProjects();
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
res.json({ res.json({
success: true, success: true,
...status, ...status,
activeAutoLoopProjects: activeProjects, activeAutoLoopProjects: activeProjects,
activeAutoLoopWorktrees: activeWorktrees,
}); });
} catch (error) { } catch (error) {
logError(error, 'Get status failed'); logError(error, 'Get status failed');

View File

@@ -12,8 +12,9 @@ const logger = createLogger('AutoMode');
export function createStopHandler(autoModeService: AutoModeService) { export function createStopHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath } = req.body as { const { projectPath, branchName } = req.body as {
projectPath: string; projectPath: string;
branchName?: string | null;
}; };
if (!projectPath) { if (!projectPath) {
@@ -24,27 +25,38 @@ export function createStopHandler(autoModeService: AutoModeService) {
return; return;
} }
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const worktreeDesc = normalizedBranchName
? `worktree ${normalizedBranchName}`
: 'main worktree';
// Check if running // Check if running
if (!autoModeService.isAutoLoopRunningForProject(projectPath)) { if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
res.json({ res.json({
success: true, success: true,
message: 'Auto mode is not running for this project', message: `Auto mode is not running for ${worktreeDesc}`,
wasRunning: false, wasRunning: false,
branchName: normalizedBranchName,
}); });
return; return;
} }
// Stop the auto loop for this project // Stop the auto loop for this project/worktree
const runningCount = await autoModeService.stopAutoLoopForProject(projectPath); const runningCount = await autoModeService.stopAutoLoopForProject(
projectPath,
normalizedBranchName
);
logger.info( logger.info(
`Stopped auto loop for project: ${projectPath}, ${runningCount} features still running` `Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running`
); );
res.json({ res.json({
success: true, success: true,
message: 'Auto mode stopped', message: 'Auto mode stopped',
runningFeaturesCount: runningCount, runningFeaturesCount: runningCount,
branchName: normalizedBranchName,
}); });
} catch (error) { } catch (error) {
logError(error, 'Stop auto mode failed'); logError(error, 'Stop auto mode failed');

View File

@@ -21,7 +21,12 @@ import type {
ThinkingLevel, ThinkingLevel,
PlanningMode, PlanningMode,
} from '@automaker/types'; } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isClaudeModel, stripProviderPrefix } from '@automaker/types'; import {
DEFAULT_PHASE_MODELS,
DEFAULT_MAX_CONCURRENCY,
isClaudeModel,
stripProviderPrefix,
} from '@automaker/types';
import { import {
buildPromptWithImages, buildPromptWithImages,
classifyError, classifyError,
@@ -233,10 +238,20 @@ interface AutoModeConfig {
maxConcurrency: number; maxConcurrency: number;
useWorktrees: boolean; useWorktrees: boolean;
projectPath: string; projectPath: string;
branchName: string | null; // null = main worktree
} }
/** /**
* Per-project autoloop state for multi-project support * 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 {
return `${projectPath}::${branchName ?? '__main__'}`;
}
/**
* Per-worktree autoloop state for multi-project/worktree support
*/ */
interface ProjectAutoLoopState { interface ProjectAutoLoopState {
abortController: AbortController; abortController: AbortController;
@@ -244,6 +259,8 @@ interface ProjectAutoLoopState {
isRunning: boolean; isRunning: boolean;
consecutiveFailures: { timestamp: number; error: string }[]; consecutiveFailures: { timestamp: number; error: string }[];
pausedDueToFailures: boolean; pausedDueToFailures: boolean;
hasEmittedIdleEvent: boolean;
branchName: string | null; // null = main worktree
} }
/** /**
@@ -255,6 +272,7 @@ interface ExecutionState {
autoLoopWasRunning: boolean; autoLoopWasRunning: boolean;
maxConcurrency: number; maxConcurrency: number;
projectPath: string; projectPath: string;
branchName: string | null; // null = main worktree
runningFeatureIds: string[]; runningFeatureIds: string[];
savedAt: string; savedAt: string;
} }
@@ -263,8 +281,9 @@ interface ExecutionState {
const DEFAULT_EXECUTION_STATE: ExecutionState = { const DEFAULT_EXECUTION_STATE: ExecutionState = {
version: 1, version: 1,
autoLoopWasRunning: false, autoLoopWasRunning: false,
maxConcurrency: 3, maxConcurrency: DEFAULT_MAX_CONCURRENCY,
projectPath: '', projectPath: '',
branchName: null,
runningFeatureIds: [], runningFeatureIds: [],
savedAt: '', savedAt: '',
}; };
@@ -289,6 +308,8 @@ export class AutoModeService {
// Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject) // Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject)
private consecutiveFailures: { timestamp: number; error: string }[] = []; private consecutiveFailures: { timestamp: number; error: string }[] = [];
private pausedDueToFailures = false; private pausedDueToFailures = false;
// Track if idle event has been emitted (legacy, now per-project in autoLoopsByProject)
private hasEmittedIdleEvent = false;
constructor(events: EventEmitter, settingsService?: SettingsService) { constructor(events: EventEmitter, settingsService?: SettingsService) {
this.events = events; this.events = events;
@@ -472,24 +493,81 @@ export class AutoModeService {
this.consecutiveFailures = []; this.consecutiveFailures = [];
} }
/** private async resolveMaxConcurrency(
* Start the auto mode loop for a specific project (supports multiple concurrent projects) projectPath: string,
* @param projectPath - The project to start auto mode for branchName: string | null,
* @param maxConcurrency - Maximum concurrent features (default: 3) provided?: number
*/ ): Promise<number> {
async startAutoLoopForProject(projectPath: string, maxConcurrency = 3): Promise<void> { if (typeof provided === 'number' && Number.isFinite(provided)) {
// Check if this project already has an active autoloop return provided;
const existingState = this.autoLoopsByProject.get(projectPath);
if (existingState?.isRunning) {
throw new Error(`Auto mode is already running for project: ${projectPath}`);
} }
// Create new project autoloop state 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 as unknown as Record<string, unknown>)
.autoModeByWorktree;
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
const key = `${projectId}::${branchName ?? '__main__'}`;
const entry = (autoModeByWorktree as Record<string, unknown>)[key] as
| { maxConcurrency?: number }
| undefined;
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 abortController = new AbortController();
const config: AutoModeConfig = { const config: AutoModeConfig = {
maxConcurrency, maxConcurrency: resolvedMaxConcurrency,
useWorktrees: true, useWorktrees: true,
projectPath, projectPath,
branchName,
}; };
const projectState: ProjectAutoLoopState = { const projectState: ProjectAutoLoopState = {
@@ -498,56 +576,68 @@ export class AutoModeService {
isRunning: true, isRunning: true,
consecutiveFailures: [], consecutiveFailures: [],
pausedDueToFailures: false, pausedDueToFailures: false,
hasEmittedIdleEvent: false,
branchName,
}; };
this.autoLoopsByProject.set(projectPath, projectState); this.autoLoopsByProject.set(worktreeKey, projectState);
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info( logger.info(
`Starting auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency}` `Starting auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
); );
this.emitAutoModeEvent('auto_mode_started', { this.emitAutoModeEvent('auto_mode_started', {
message: `Auto mode started with max ${maxConcurrency} concurrent features`, message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
projectPath, projectPath,
branchName,
}); });
// Save execution state for recovery after restart // Save execution state for recovery after restart
await this.saveExecutionStateForProject(projectPath, maxConcurrency); await this.saveExecutionStateForProject(projectPath, branchName, resolvedMaxConcurrency);
// Run the loop in the background // Run the loop in the background
this.runAutoLoopForProject(projectPath).catch((error) => { this.runAutoLoopForProject(worktreeKey).catch((error) => {
logger.error(`Loop error for ${projectPath}:`, error); const worktreeDescErr = branchName ? `worktree ${branchName}` : 'main worktree';
logger.error(`Loop error for ${worktreeDescErr} in ${projectPath}:`, error);
const errorInfo = classifyError(error); const errorInfo = classifyError(error);
this.emitAutoModeEvent('auto_mode_error', { this.emitAutoModeEvent('auto_mode_error', {
error: errorInfo.message, error: errorInfo.message,
errorType: errorInfo.type, errorType: errorInfo.type,
projectPath, projectPath,
branchName,
}); });
}); });
return resolvedMaxConcurrency;
} }
/** /**
* Run the auto loop for a specific project * Run the auto loop for a specific project/worktree
* @param worktreeKey - The worktree key (projectPath::branchName or projectPath::__main__)
*/ */
private async runAutoLoopForProject(projectPath: string): Promise<void> { private async runAutoLoopForProject(worktreeKey: string): Promise<void> {
const projectState = this.autoLoopsByProject.get(projectPath); const projectState = this.autoLoopsByProject.get(worktreeKey);
if (!projectState) { if (!projectState) {
logger.warn(`No project state found for ${projectPath}, stopping loop`); logger.warn(`No project state found for ${worktreeKey}, stopping loop`);
return; return;
} }
const { projectPath, branchName } = projectState.config;
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info( logger.info(
`[AutoLoop] Starting loop for ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}` `[AutoLoop] Starting loop for ${worktreeDesc} in ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}`
); );
let iterationCount = 0; let iterationCount = 0;
while (projectState.isRunning && !projectState.abortController.signal.aborted) { while (projectState.isRunning && !projectState.abortController.signal.aborted) {
iterationCount++; iterationCount++;
try { try {
// Count running features for THIS project only // Count running features for THIS project/worktree only
const projectRunningCount = this.getRunningCountForProject(projectPath); const projectRunningCount = this.getRunningCountForWorktree(projectPath, branchName);
// Check if we have capacity for this project // Check if we have capacity for this project/worktree
if (projectRunningCount >= projectState.config.maxConcurrency) { if (projectRunningCount >= projectState.config.maxConcurrency) {
logger.debug( logger.debug(
`[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...` `[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...`
@@ -556,19 +646,32 @@ export class AutoModeService {
continue; continue;
} }
// Load pending features for this project // Load pending features for this project/worktree
const pendingFeatures = await this.loadPendingFeatures(projectPath); const pendingFeatures = await this.loadPendingFeatures(projectPath, branchName);
logger.debug( logger.info(
`[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount} running` `[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount}/${projectState.config.maxConcurrency} running for ${worktreeDesc}`
); );
if (pendingFeatures.length === 0) { if (pendingFeatures.length === 0) {
this.emitAutoModeEvent('auto_mode_idle', { // Emit idle event only once when backlog is empty AND no features are running
message: 'No pending features - auto mode idle', if (projectRunningCount === 0 && !projectState.hasEmittedIdleEvent) {
projectPath, this.emitAutoModeEvent('auto_mode_idle', {
}); message: 'No pending features - auto mode idle',
logger.info(`[AutoLoop] No pending features, sleeping for 10s...`); 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); await this.sleep(10000);
continue; continue;
} }
@@ -578,6 +681,8 @@ export class AutoModeService {
if (nextFeature) { if (nextFeature) {
logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`); 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 // Start feature execution in background
this.executeFeature( this.executeFeature(
projectPath, projectPath,
@@ -619,13 +724,47 @@ export class AutoModeService {
} }
/** /**
* Stop the auto mode loop for a specific project * Get count of running features for a specific worktree
* @param projectPath - The project to stop auto mode for * @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree (features without branchName or with "main")
*/ */
async stopAutoLoopForProject(projectPath: string): Promise<number> { private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
const projectState = this.autoLoopsByProject.get(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 === "main"
if (
feature.projectPath === projectPath &&
(featureBranch === null || featureBranch === 'main')
) {
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) { if (!projectState) {
logger.warn(`No auto loop running for project: ${projectPath}`); const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.warn(`No auto loop running for ${worktreeDesc} in project: ${projectPath}`);
return 0; return 0;
} }
@@ -634,43 +773,57 @@ export class AutoModeService {
projectState.abortController.abort(); projectState.abortController.abort();
// Clear execution state when auto-loop is explicitly stopped // Clear execution state when auto-loop is explicitly stopped
await this.clearExecutionState(projectPath); await this.clearExecutionState(projectPath, branchName);
// Emit stop event // Emit stop event
if (wasRunning) { if (wasRunning) {
this.emitAutoModeEvent('auto_mode_stopped', { this.emitAutoModeEvent('auto_mode_stopped', {
message: 'Auto mode stopped', message: 'Auto mode stopped',
projectPath, projectPath,
branchName,
}); });
} }
// Remove from map // Remove from map
this.autoLoopsByProject.delete(projectPath); this.autoLoopsByProject.delete(worktreeKey);
return this.getRunningCountForProject(projectPath); return this.getRunningCountForWorktree(projectPath, branchName);
} }
/** /**
* Check if auto mode is running for a specific project * 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): boolean { isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean {
const projectState = this.autoLoopsByProject.get(projectPath); const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
const projectState = this.autoLoopsByProject.get(worktreeKey);
return projectState?.isRunning ?? false; return projectState?.isRunning ?? false;
} }
/** /**
* Get auto loop config for a specific project * 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): AutoModeConfig | null { getAutoLoopConfigForProject(
const projectState = this.autoLoopsByProject.get(projectPath); projectPath: string,
branchName: string | null = null
): AutoModeConfig | null {
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
const projectState = this.autoLoopsByProject.get(worktreeKey);
return projectState?.config ?? null; return projectState?.config ?? null;
} }
/** /**
* Save execution state for a specific project * 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( private async saveExecutionStateForProject(
projectPath: string, projectPath: string,
branchName: string | null,
maxConcurrency: number maxConcurrency: number
): Promise<void> { ): Promise<void> {
try { try {
@@ -685,15 +838,18 @@ export class AutoModeService {
autoLoopWasRunning: true, autoLoopWasRunning: true,
maxConcurrency, maxConcurrency,
projectPath, projectPath,
branchName,
runningFeatureIds, runningFeatureIds,
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
}; };
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info( logger.info(
`Saved execution state for ${projectPath}: ${runningFeatureIds.length} running features` `Saved execution state for ${worktreeDesc} in ${projectPath}: ${runningFeatureIds.length} running features`
); );
} catch (error) { } catch (error) {
logger.error(`Failed to save execution state for ${projectPath}:`, error); const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.error(`Failed to save execution state for ${worktreeDesc} in ${projectPath}:`, error);
} }
} }
@@ -701,7 +857,10 @@ export class AutoModeService {
* Start the auto mode loop - continuously picks and executes pending features * Start the auto mode loop - continuously picks and executes pending features
* @deprecated Use startAutoLoopForProject instead for multi-project support * @deprecated Use startAutoLoopForProject instead for multi-project support
*/ */
async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise<void> { async startAutoLoop(
projectPath: string,
maxConcurrency = DEFAULT_MAX_CONCURRENCY
): Promise<void> {
// For backward compatibility, delegate to the new per-project method // For backward compatibility, delegate to the new per-project method
// But also maintain legacy state for existing code that might check it // But also maintain legacy state for existing code that might check it
if (this.autoLoopRunning) { if (this.autoLoopRunning) {
@@ -717,6 +876,7 @@ export class AutoModeService {
maxConcurrency, maxConcurrency,
useWorktrees: true, useWorktrees: true,
projectPath, projectPath,
branchName: null,
}; };
this.emitAutoModeEvent('auto_mode_started', { this.emitAutoModeEvent('auto_mode_started', {
@@ -752,7 +912,7 @@ export class AutoModeService {
) { ) {
try { try {
// Check if we have capacity // Check if we have capacity
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) { if (this.runningFeatures.size >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) {
await this.sleep(5000); await this.sleep(5000);
continue; continue;
} }
@@ -761,10 +921,22 @@ export class AutoModeService {
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath);
if (pendingFeatures.length === 0) { if (pendingFeatures.length === 0) {
this.emitAutoModeEvent('auto_mode_idle', { // Emit idle event only once when backlog is empty AND no features are running
message: 'No pending features - auto mode idle', const runningCount = this.runningFeatures.size;
projectPath: this.config!.projectPath, 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); await this.sleep(10000);
continue; continue;
} }
@@ -773,6 +945,8 @@ export class AutoModeService {
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
if (nextFeature) { if (nextFeature) {
// Reset idle event flag since we're doing work again
this.hasEmittedIdleEvent = false;
// Start feature execution in background // Start feature execution in background
this.executeFeature( this.executeFeature(
this.config!.projectPath, this.config!.projectPath,
@@ -862,6 +1036,9 @@ export class AutoModeService {
await this.saveExecutionState(projectPath); 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 { try {
// Validate that project path is allowed using centralized validation // Validate that project path is allowed using centralized validation
validateWorkingDirectory(projectPath); validateWorkingDirectory(projectPath);
@@ -880,18 +1057,8 @@ export class AutoModeService {
} }
} }
// Emit feature start event early
this.emitAutoModeEvent('auto_mode_feature_start', {
featureId,
projectPath,
feature: {
id: featureId,
title: 'Loading...',
description: 'Feature is starting',
},
});
// Load feature details FIRST to get branchName // Load feature details FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId); feature = await this.loadFeature(projectPath, featureId);
if (!feature) { if (!feature) {
throw new Error(`Feature ${featureId} not found`); throw new Error(`Feature ${featureId} not found`);
} }
@@ -924,9 +1091,22 @@ export class AutoModeService {
tempRunningFeature.worktreePath = worktreePath; tempRunningFeature.worktreePath = worktreePath;
tempRunningFeature.branchName = branchName ?? null; tempRunningFeature.branchName = branchName ?? null;
// Update feature status to in_progress // 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'); 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 // Load autoLoadClaudeMd setting to determine context loading strategy
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath, projectPath,
@@ -1070,6 +1250,8 @@ export class AutoModeService {
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true, passes: true,
message: `Feature completed in ${Math.round( message: `Feature completed in ${Math.round(
(Date.now() - tempRunningFeature.startTime) / 1000 (Date.now() - tempRunningFeature.startTime) / 1000
@@ -1084,6 +1266,8 @@ export class AutoModeService {
if (errorInfo.isAbort) { if (errorInfo.isAbort) {
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: false, passes: false,
message: 'Feature stopped by user', message: 'Feature stopped by user',
projectPath, projectPath,
@@ -1093,6 +1277,8 @@ export class AutoModeService {
await this.updateFeatureStatus(projectPath, featureId, 'backlog'); await this.updateFeatureStatus(projectPath, featureId, 'backlog');
this.emitAutoModeEvent('auto_mode_error', { this.emitAutoModeEvent('auto_mode_error', {
featureId, featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
error: errorInfo.message, error: errorInfo.message,
errorType: errorInfo.type, errorType: errorInfo.type,
projectPath, projectPath,
@@ -1413,6 +1599,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true, passes: true,
message: message:
'Pipeline step no longer exists - feature completed without remaining pipeline steps', 'Pipeline step no longer exists - feature completed without remaining pipeline steps',
@@ -1526,6 +1714,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
this.emitAutoModeEvent('auto_mode_feature_start', { this.emitAutoModeEvent('auto_mode_feature_start', {
featureId, featureId,
projectPath, projectPath,
branchName: branchName ?? null,
feature: { feature: {
id: featureId, id: featureId,
title: feature.title || 'Resuming Pipeline', title: feature.title || 'Resuming Pipeline',
@@ -1535,8 +1724,9 @@ Complete the pipeline step instructions above. Review the previous work and appl
this.emitAutoModeEvent('auto_mode_progress', { this.emitAutoModeEvent('auto_mode_progress', {
featureId, featureId,
content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`,
projectPath, projectPath,
branchName: branchName ?? null,
content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`,
}); });
// Load autoLoadClaudeMd setting // Load autoLoadClaudeMd setting
@@ -1565,6 +1755,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: true, passes: true,
message: 'Pipeline resumed and completed successfully', message: 'Pipeline resumed and completed successfully',
projectPath, projectPath,
@@ -1575,6 +1767,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
if (errorInfo.isAbort) { if (errorInfo.isAbort) {
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
passes: false, passes: false,
message: 'Pipeline resume stopped by user', message: 'Pipeline resume stopped by user',
projectPath, projectPath,
@@ -1584,6 +1778,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
await this.updateFeatureStatus(projectPath, featureId, 'backlog'); await this.updateFeatureStatus(projectPath, featureId, 'backlog');
this.emitAutoModeEvent('auto_mode_error', { this.emitAutoModeEvent('auto_mode_error', {
featureId, featureId,
featureName: feature.title,
branchName: feature.branchName ?? null,
error: errorInfo.message, error: errorInfo.message,
errorType: errorInfo.type, errorType: errorInfo.type,
projectPath, projectPath,
@@ -1705,22 +1901,25 @@ Address the follow-up instructions above. Review the previous work and make the
provider, provider,
}); });
this.emitAutoModeEvent('auto_mode_feature_start', {
featureId,
projectPath,
feature: feature || {
id: featureId,
title: 'Follow-up',
description: prompt.substring(0, 100),
},
model,
provider,
});
try { try {
// Update feature status to in_progress // 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'); 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 // Copy follow-up images to feature folder
const copiedImagePaths: string[] = []; const copiedImagePaths: string[] = [];
if (imagePaths && imagePaths.length > 0) { if (imagePaths && imagePaths.length > 0) {
@@ -1814,6 +2013,8 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature?.title,
branchName: branchName ?? null,
passes: true, passes: true,
message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`, message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
projectPath, projectPath,
@@ -1825,6 +2026,8 @@ Address the follow-up instructions above. Review the previous work and make the
if (!errorInfo.isCancellation) { if (!errorInfo.isCancellation) {
this.emitAutoModeEvent('auto_mode_error', { this.emitAutoModeEvent('auto_mode_error', {
featureId, featureId,
featureName: feature?.title,
branchName: branchName ?? null,
error: errorInfo.message, error: errorInfo.message,
errorType: errorInfo.type, errorType: errorInfo.type,
projectPath, projectPath,
@@ -1852,6 +2055,9 @@ Address the follow-up instructions above. Review the previous work and make the
* Verify a feature's implementation * Verify a feature's implementation
*/ */
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> { 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 // Worktrees are in project dir
const worktreePath = path.join(projectPath, '.worktrees', featureId); const worktreePath = path.join(projectPath, '.worktrees', featureId);
let workDir = projectPath; let workDir = projectPath;
@@ -1898,6 +2104,8 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: allPassed, passes: allPassed,
message: allPassed message: allPassed
? 'All verification checks passed' ? 'All verification checks passed'
@@ -1974,6 +2182,8 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature?.title,
branchName: feature?.branchName ?? null,
passes: true, passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`, message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath, projectPath,
@@ -2012,6 +2222,7 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent('auto_mode_feature_start', { this.emitAutoModeEvent('auto_mode_feature_start', {
featureId: analysisFeatureId, featureId: analysisFeatureId,
projectPath, projectPath,
branchName: null, // Project analysis is not worktree-specific
feature: { feature: {
id: analysisFeatureId, id: analysisFeatureId,
title: 'Project Analysis', title: 'Project Analysis',
@@ -2096,6 +2307,8 @@ Format your response as a structured markdown document.`;
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId: analysisFeatureId, featureId: analysisFeatureId,
featureName: 'Project Analysis',
branchName: null, // Project analysis is not worktree-specific
passes: true, passes: true,
message: 'Project analysis completed', message: 'Project analysis completed',
projectPath, projectPath,
@@ -2104,6 +2317,8 @@ Format your response as a structured markdown document.`;
const errorInfo = classifyError(error); const errorInfo = classifyError(error);
this.emitAutoModeEvent('auto_mode_error', { this.emitAutoModeEvent('auto_mode_error', {
featureId: analysisFeatureId, featureId: analysisFeatureId,
featureName: 'Project Analysis',
branchName: null, // Project analysis is not worktree-specific
error: errorInfo.message, error: errorInfo.message,
errorType: errorInfo.type, errorType: errorInfo.type,
projectPath, projectPath,
@@ -2127,20 +2342,27 @@ Format your response as a structured markdown document.`;
} }
/** /**
* Get status for a specific project * Get status for a specific project/worktree
* @param projectPath - The project to get status for * @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree
*/ */
getStatusForProject(projectPath: string): { getStatusForProject(
projectPath: string,
branchName: string | null = null
): {
isAutoLoopRunning: boolean; isAutoLoopRunning: boolean;
runningFeatures: string[]; runningFeatures: string[];
runningCount: number; runningCount: number;
maxConcurrency: number; maxConcurrency: number;
branchName: string | null;
} { } {
const projectState = this.autoLoopsByProject.get(projectPath); const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
const projectState = this.autoLoopsByProject.get(worktreeKey);
const runningFeatures: string[] = []; const runningFeatures: string[] = [];
for (const [featureId, feature] of this.runningFeatures) { for (const [featureId, feature] of this.runningFeatures) {
if (feature.projectPath === projectPath) { // Filter by project path AND branchName to get worktree-specific features
if (feature.projectPath === projectPath && feature.branchName === branchName) {
runningFeatures.push(featureId); runningFeatures.push(featureId);
} }
} }
@@ -2149,21 +2371,39 @@ Format your response as a structured markdown document.`;
isAutoLoopRunning: projectState?.isRunning ?? false, isAutoLoopRunning: projectState?.isRunning ?? false,
runningFeatures, runningFeatures,
runningCount: runningFeatures.length, runningCount: runningFeatures.length,
maxConcurrency: projectState?.config.maxConcurrency ?? 3, maxConcurrency: projectState?.config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
branchName,
}; };
} }
/** /**
* Get all projects that have auto mode running * Get all active auto loop worktrees with their project paths and branch names
*/ */
getActiveAutoLoopProjects(): string[] { getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
const activeProjects: string[] = []; const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = [];
for (const [projectPath, state] of this.autoLoopsByProject) { for (const [, state] of this.autoLoopsByProject) {
if (state.isRunning) { if (state.isRunning) {
activeProjects.push(projectPath); activeWorktrees.push({
projectPath: state.config.projectPath,
branchName: state.branchName,
});
} }
} }
return activeProjects; 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);
} }
/** /**
@@ -2600,7 +2840,15 @@ Format your response as a structured markdown document.`;
} }
} }
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> { /**
* 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 // Features are stored in .automaker directory
const featuresDir = getFeaturesDir(projectPath); const featuresDir = getFeaturesDir(projectPath);
@@ -2632,21 +2880,60 @@ Format your response as a structured markdown document.`;
allFeatures.push(feature); allFeatures.push(feature);
// Track pending features separately // Track pending features separately, filtered by worktree/branch
if ( if (
feature.status === 'pending' || feature.status === 'pending' ||
feature.status === 'ready' || feature.status === 'ready' ||
feature.status === 'backlog' feature.status === 'backlog'
) { ) {
pendingFeatures.push(feature); // Filter by branchName:
// - If branchName is null (main worktree), include features with branchName === null OR branchName === "main"
// - 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 === "main"
// This handles both correct (null) and legacy ("main") cases
if (featureBranch === null || featureBranch === 'main') {
pendingFeatures.push(feature);
} else {
logger.debug(
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}) 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}`
);
}
}
} }
} }
} }
logger.debug( const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status` logger.info(
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status 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'
);
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 // Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures); const { orderedFeatures } = resolveDependencies(pendingFeatures);
@@ -2655,11 +2942,41 @@ Format your response as a structured markdown document.`;
const skipVerification = settings?.skipVerificationInAutoMode ?? false; const skipVerification = settings?.skipVerificationInAutoMode ?? false;
// Filter to only features with satisfied dependencies // Filter to only features with satisfied dependencies
const readyFeatures = orderedFeatures.filter((feature: Feature) => const readyFeatures: Feature[] = [];
areDependenciesSatisfied(feature, allFeatures, { skipVerification }) const blockedFeatures: Array<{ feature: Feature; reason: string }> = [];
);
logger.debug( 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})` `[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})`
); );
@@ -3818,8 +4135,9 @@ After generating the revised spec, output:
const state: ExecutionState = { const state: ExecutionState = {
version: 1, version: 1,
autoLoopWasRunning: this.autoLoopRunning, autoLoopWasRunning: this.autoLoopRunning,
maxConcurrency: this.config?.maxConcurrency ?? 3, maxConcurrency: this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
projectPath, projectPath,
branchName: null, // Legacy global auto mode uses main worktree
runningFeatureIds: Array.from(this.runningFeatures.keys()), runningFeatureIds: Array.from(this.runningFeatures.keys()),
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
}; };
@@ -3850,11 +4168,15 @@ After generating the revised spec, output:
/** /**
* Clear execution state (called on successful shutdown or when auto-loop stops) * Clear execution state (called on successful shutdown or when auto-loop stops)
*/ */
private async clearExecutionState(projectPath: string): Promise<void> { private async clearExecutionState(
projectPath: string,
branchName: string | null = null
): Promise<void> {
try { try {
const statePath = getExecutionStatePath(projectPath); const statePath = getExecutionStatePath(projectPath);
await secureFs.unlink(statePath); await secureFs.unlink(statePath);
logger.info('Cleared execution state'); const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(`Cleared execution state for ${worktreeDesc}`);
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Failed to clear execution state:', error); logger.error('Failed to clear execution state:', error);

View File

@@ -57,6 +57,7 @@ interface HookContext {
interface AutoModeEventPayload { interface AutoModeEventPayload {
type?: string; type?: string;
featureId?: string; featureId?: string;
featureName?: string;
passes?: boolean; passes?: boolean;
message?: string; message?: string;
error?: string; error?: string;
@@ -152,6 +153,7 @@ export class EventHookService {
// Build context for variable substitution // Build context for variable substitution
const context: HookContext = { const context: HookContext = {
featureId: payload.featureId, featureId: payload.featureId,
featureName: payload.featureName,
projectPath: payload.projectPath, projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined, projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message, error: payload.error || payload.message,

View File

@@ -41,7 +41,12 @@ import {
CREDENTIALS_VERSION, CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION, PROJECT_SETTINGS_VERSION,
} from '../types/settings.js'; } from '../types/settings.js';
import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types'; import {
DEFAULT_MAX_CONCURRENCY,
migrateModelId,
migrateCursorModelIds,
migrateOpencodeModelIds,
} from '@automaker/types';
const logger = createLogger('SettingsService'); const logger = createLogger('SettingsService');
@@ -682,7 +687,7 @@ export class SettingsService {
theme: (appState.theme as GlobalSettings['theme']) || 'dark', theme: (appState.theme as GlobalSettings['theme']) || 'dark',
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true, sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false, chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
maxConcurrency: (appState.maxConcurrency as number) || 3, maxConcurrency: (appState.maxConcurrency as number) || DEFAULT_MAX_CONCURRENCY,
defaultSkipTests: defaultSkipTests:
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true, appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
enableDependencyBlocking: enableDependencyBlocking:

View File

@@ -88,8 +88,8 @@ const logger = createLogger('Board');
export function BoardView() { export function BoardView() {
const { const {
currentProject, currentProject,
maxConcurrency, maxConcurrency: legacyMaxConcurrency,
setMaxConcurrency, setMaxConcurrency: legacySetMaxConcurrency,
defaultSkipTests, defaultSkipTests,
specCreatingForProject, specCreatingForProject,
setSpecCreatingForProject, setSpecCreatingForProject,
@@ -261,11 +261,6 @@ export function BoardView() {
loadPipelineConfig(); loadPipelineConfig();
}, [currentProject?.path, setPipelineConfig]); }, [currentProject?.path, setPipelineConfig]);
// Auto mode hook
const autoMode = useAutoMode();
// Get runningTasks from the hook (scoped to current project)
const runningAutoTasks = autoMode.runningTasks;
// Window state hook for compact dialog mode // Window state hook for compact dialog mode
const { isMaximized } = useWindowState(); const { isMaximized } = useWindowState();
@@ -374,14 +369,6 @@ export function BoardView() {
[hookFeatures, updateFeature, persistFeatureUpdate] [hookFeatures, updateFeature, persistFeatureUpdate]
); );
// Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path) for filtering features // Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch // This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
@@ -407,6 +394,16 @@ export function BoardView() {
} }
}, [worktrees, currentWorktreePath]); }, [worktrees, currentWorktreePath]);
// Auto mode hook - pass current worktree to get worktree-specific state
// Must be after selectedWorktree is defined
const autoMode = useAutoMode(selectedWorktree ?? undefined);
// Get runningTasks from the hook (scoped to current project/worktree)
const runningAutoTasks = autoMode.runningTasks;
// Get worktree-specific maxConcurrency from the hook
const maxConcurrency = autoMode.maxConcurrency;
// Get worktree-specific setter
const setMaxConcurrencyForWorktree = useAppStore((state) => state.setMaxConcurrencyForWorktree);
// Get the current branch from the selected worktree (not from store which may be stale) // Get the current branch from the selected worktree (not from store which may be stale)
const currentWorktreeBranch = selectedWorktree?.branch ?? null; const currentWorktreeBranch = selectedWorktree?.branch ?? null;
@@ -415,6 +412,15 @@ export function BoardView() {
const selectedWorktreeBranch = const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main'; currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Get in-progress features for keyboard shortcuts (needed before actions hook)
// Must be after runningAutoTasks is defined
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Calculate unarchived card counts per branch // Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => { const branchCardCounts = useMemo(() => {
// Use primary worktree branch as default for features without branchName // Use primary worktree branch as default for features without branchName
@@ -512,14 +518,14 @@ export function BoardView() {
try { try {
// Determine final branch name based on work mode: // Determine final branch name based on work mode:
// - 'current': Empty string to clear branch assignment (work on main/current branch) // - 'current': Use selected worktree branch if available, otherwise undefined (work on main)
// - 'auto': Auto-generate branch name based on current branch // - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name // - 'custom': Use the provided branch name
let finalBranchName: string | undefined; let finalBranchName: string | undefined;
if (workMode === 'current') { if (workMode === 'current') {
// Empty string clears the branch assignment, moving features to main/current branch // If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
finalBranchName = ''; finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') { } else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp // Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths // Always use primary branch to avoid nested feature/feature/... paths
@@ -605,6 +611,7 @@ export function BoardView() {
exitSelectionMode, exitSelectionMode,
getPrimaryWorktreeBranch, getPrimaryWorktreeBranch,
addAndSelectWorktree, addAndSelectWorktree,
currentWorktreeBranch,
setWorktreeRefreshKey, setWorktreeRefreshKey,
] ]
); );
@@ -1127,7 +1134,21 @@ export function BoardView() {
projectPath={currentProject.path} projectPath={currentProject.path}
maxConcurrency={maxConcurrency} maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length} runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency} onConcurrencyChange={(newMaxConcurrency) => {
if (currentProject && selectedWorktree) {
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
// Also update backend if auto mode is running
if (autoMode.isRunning) {
// Restart auto mode with new concurrency (backend will handle this)
autoMode.stop().then(() => {
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
});
});
}
}
}}
isAutoModeRunning={autoMode.isRunning} isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => { onAutoModeToggle={(enabled) => {
if (enabled) { if (enabled) {

View File

@@ -182,6 +182,13 @@ export function BoardHeader({
> >
Auto Mode Auto Mode
</Label> </Label>
<span
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
data-testid="auto-mode-max-concurrency"
title="Max concurrent agents"
>
{maxConcurrency}
</span>
<Switch <Switch
id="auto-mode-toggle" id="auto-mode-toggle"
checked={isAutoModeRunning} checked={isAutoModeRunning}

View File

@@ -80,6 +80,13 @@ export function HeaderMobileMenu({
)} )}
/> />
<span className="text-sm font-medium">Auto Mode</span> <span className="text-sm font-medium">Auto Mode</span>
<span
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
data-testid="mobile-auto-mode-max-concurrency"
title="Max concurrent agents"
>
{maxConcurrency}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch

View File

@@ -118,14 +118,14 @@ export function useBoardActions({
const workMode = featureData.workMode || 'current'; const workMode = featureData.workMode || 'current';
// Determine final branch name based on work mode: // Determine final branch name based on work mode:
// - 'current': No branch name, work on current branch (no worktree) // - 'current': Use selected worktree branch if available, otherwise undefined (work on main)
// - 'auto': Auto-generate branch name based on current branch // - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name // - 'custom': Use the provided branch name
let finalBranchName: string | undefined; let finalBranchName: string | undefined;
if (workMode === 'current') { if (workMode === 'current') {
// No worktree isolation - work directly on current branch // If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
finalBranchName = undefined; finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') { } else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp // Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths // Always use primary branch to avoid nested feature/feature/... paths
@@ -249,6 +249,7 @@ export function useBoardActions({
onWorktreeAutoSelect, onWorktreeAutoSelect,
getPrimaryWorktreeBranch, getPrimaryWorktreeBranch,
features, features,
currentWorktreeBranch,
] ]
); );
@@ -282,7 +283,8 @@ export function useBoardActions({
let finalBranchName: string | undefined; let finalBranchName: string | undefined;
if (workMode === 'current') { if (workMode === 'current') {
finalBranchName = undefined; // If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') { } else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp // Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths // Always use primary branch to avoid nested feature/feature/... paths
@@ -397,6 +399,7 @@ export function useBoardActions({
onWorktreeCreated, onWorktreeCreated,
getPrimaryWorktreeBranch, getPrimaryWorktreeBranch,
features, features,
currentWorktreeBranch,
] ]
); );

View File

@@ -97,8 +97,25 @@ export function useBoardColumnFeatures({
// Historically, we forced "running" features into in_progress so they never disappeared // Historically, we forced "running" features into in_progress so they never disappeared
// during stale reload windows. With pipelines, a feature can legitimately be running while // during stale reload windows. With pipelines, a feature can legitimately be running while
// its status is `pipeline_*`, so we must respect that status to render it in the right column. // its status is `pipeline_*`, so we must respect that status to render it in the right column.
// NOTE: runningAutoTasks is already worktree-scoped, so if a feature is in runningAutoTasks,
// it's already running for the current worktree. However, we still need to check matchesWorktree
// to ensure the feature's branchName matches the current worktree's branch.
if (isRunning) { if (isRunning) {
if (!matchesWorktree) return; // If feature is running but doesn't match worktree, it might be a timing issue where
// the feature was started for a different worktree. Still show it if it's running to
// prevent disappearing features, but log a warning.
if (!matchesWorktree) {
// This can happen if:
// 1. Feature was started for a different worktree (bug)
// 2. Timing issue where branchName hasn't been set yet
// 3. User switched worktrees while feature was starting
// Still show it in in_progress to prevent it from disappearing
console.debug(
`Feature ${f.id} is running but branchName (${featureBranch}) doesn't match current worktree branch (${effectiveBranch}) - showing anyway to prevent disappearing`
);
map.in_progress.push(f);
return;
}
if (status.startsWith('pipeline_')) { if (status.startsWith('pipeline_')) {
if (!map[status]) map[status] = []; if (!map[status]) map[status] = [];

View File

@@ -196,13 +196,30 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const { removeRunningTask } = useAppStore.getState(); const { removeRunningTask } = useAppStore.getState();
const projectId = currentProject.id; const projectId = currentProject.id;
const projectPath = currentProject.path;
const unsubscribe = api.autoMode.onEvent((event) => { const unsubscribe = api.autoMode.onEvent((event) => {
// Check if event is for the current project by matching projectPath
const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined;
if (eventProjectPath && eventProjectPath !== projectPath) {
// Event is for a different project, ignore it
logger.debug(
`Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})`
);
return;
}
// Use event's projectPath or projectId if available, otherwise use current project // Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project // Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId; const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === 'auto_mode_feature_complete') { if (event.type === 'auto_mode_feature_start') {
// Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected
logger.info(
`[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...`
);
loadFeatures();
} else if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed // Reload features when a feature is completed
logger.info('Feature completed, reloading features...'); logger.info('Feature completed, reloading features...');
loadFeatures(); loadFeatures();

View File

@@ -29,6 +29,7 @@ import {
Terminal, Terminal,
SquarePlus, SquarePlus,
SplitSquareHorizontal, SplitSquareHorizontal,
Zap,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -56,6 +57,8 @@ interface WorktreeActionsDropdownProps {
gitRepoStatus: GitRepoStatus; gitRepoStatus: GitRepoStatus;
/** When true, renders as a standalone button (not attached to another element) */ /** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean; standalone?: boolean;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void; onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void;
@@ -73,6 +76,7 @@ interface WorktreeActionsDropdownProps {
onOpenDevServerUrl: (worktree: WorktreeInfo) => void; onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void; onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void; onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean; hasInitScript: boolean;
} }
@@ -88,6 +92,7 @@ export function WorktreeActionsDropdown({
devServerInfo, devServerInfo,
gitRepoStatus, gitRepoStatus,
standalone = false, standalone = false,
isAutoModeRunning = false,
onOpenChange, onOpenChange,
onPull, onPull,
onPush, onPush,
@@ -105,6 +110,7 @@ export function WorktreeActionsDropdown({
onOpenDevServerUrl, onOpenDevServerUrl,
onViewDevServerLogs, onViewDevServerLogs,
onRunInitScript, onRunInitScript,
onToggleAutoMode,
hasInitScript, hasInitScript,
}: WorktreeActionsDropdownProps) { }: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu // Get available editors for the "Open In" submenu
@@ -214,6 +220,26 @@ export function WorktreeActionsDropdown({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}
{/* Auto Mode toggle */}
{onToggleAutoMode && (
<>
{isAutoModeRunning ? (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<span className="flex items-center mr-2">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<span className="ml-0.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
Stop Auto Mode
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<Zap className="w-3.5 h-3.5 mr-2" />
Start Auto Mode
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}> <TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem <DropdownMenuItem
onClick={() => canPerformGitOps && onPull(worktree)} onClick={() => canPerformGitOps && onPull(worktree)}

View File

@@ -29,6 +29,8 @@ interface WorktreeTabProps {
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
gitRepoStatus: GitRepoStatus; gitRepoStatus: GitRepoStatus;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
onSelectWorktree: (worktree: WorktreeInfo) => void; onSelectWorktree: (worktree: WorktreeInfo) => void;
onBranchDropdownOpenChange: (open: boolean) => void; onBranchDropdownOpenChange: (open: boolean) => void;
onActionsDropdownOpenChange: (open: boolean) => void; onActionsDropdownOpenChange: (open: boolean) => void;
@@ -51,6 +53,7 @@ interface WorktreeTabProps {
onOpenDevServerUrl: (worktree: WorktreeInfo) => void; onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void; onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void; onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean; hasInitScript: boolean;
} }
@@ -75,6 +78,7 @@ export function WorktreeTab({
aheadCount, aheadCount,
behindCount, behindCount,
gitRepoStatus, gitRepoStatus,
isAutoModeRunning = false,
onSelectWorktree, onSelectWorktree,
onBranchDropdownOpenChange, onBranchDropdownOpenChange,
onActionsDropdownOpenChange, onActionsDropdownOpenChange,
@@ -97,6 +101,7 @@ export function WorktreeTab({
onOpenDevServerUrl, onOpenDevServerUrl,
onViewDevServerLogs, onViewDevServerLogs,
onRunInitScript, onRunInitScript,
onToggleAutoMode,
hasInitScript, hasInitScript,
}: WorktreeTabProps) { }: WorktreeTabProps) {
let prBadge: JSX.Element | null = null; let prBadge: JSX.Element | null = null;
@@ -332,6 +337,26 @@ export function WorktreeTab({
</TooltipProvider> </TooltipProvider>
)} )}
{isAutoModeRunning && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'flex items-center justify-center h-7 px-1.5 rounded-none border-r-0',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-secondary/50'
)}
>
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Auto Mode Running</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<WorktreeActionsDropdown <WorktreeActionsDropdown
worktree={worktree} worktree={worktree}
isSelected={isSelected} isSelected={isSelected}
@@ -343,6 +368,7 @@ export function WorktreeTab({
isDevServerRunning={isDevServerRunning} isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo} devServerInfo={devServerInfo}
gitRepoStatus={gitRepoStatus} gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunning}
onOpenChange={onActionsDropdownOpenChange} onOpenChange={onActionsDropdownOpenChange}
onPull={onPull} onPull={onPull}
onPush={onPush} onPush={onPush}
@@ -360,6 +386,7 @@ export function WorktreeTab({
onOpenDevServerUrl={onOpenDevServerUrl} onOpenDevServerUrl={onOpenDevServerUrl}
onViewDevServerLogs={onViewDevServerLogs} onViewDevServerLogs={onViewDevServerLogs}
onRunInitScript={onRunInitScript} onRunInitScript={onRunInitScript}
onToggleAutoMode={onToggleAutoMode}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
/> />
</div> </div>

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react'; import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { cn, pathsEqual } from '@/lib/utils'; import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query'; import { useIsMobile } from '@/hooks/use-media-query';
@@ -21,6 +21,7 @@ import {
WorktreeActionsDropdown, WorktreeActionsDropdown,
BranchSwitchDropdown, BranchSwitchDropdown,
} from './components'; } from './components';
import { useAppStore } from '@/store/app-store';
export function WorktreePanel({ export function WorktreePanel({
projectPath, projectPath,
@@ -50,7 +51,6 @@ export function WorktreePanel({
const { const {
isStartingDevServer, isStartingDevServer,
getWorktreeKey,
isDevServerRunning, isDevServerRunning,
getDevServerInfo, getDevServerInfo,
handleStartDevServer, handleStartDevServer,
@@ -92,6 +92,67 @@ export function WorktreePanel({
features, features,
}); });
// Auto-mode state management using the store
// Use separate selectors to avoid creating new object references on each render
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const currentProject = useAppStore((state) => state.currentProject);
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
const getAutoModeWorktreeKey = useCallback(
(projectId: string, branchName: string | null): string => {
return `${projectId}::${branchName ?? '__main__'}`;
},
[]
);
// Helper to check if auto-mode is running for a specific worktree
const isAutoModeRunningForWorktree = useCallback(
(worktree: WorktreeInfo): boolean => {
if (!currentProject) return false;
const branchName = worktree.isMain ? null : worktree.branch;
const key = getAutoModeWorktreeKey(currentProject.id, branchName);
return autoModeByWorktree[key]?.isRunning ?? false;
},
[currentProject, autoModeByWorktree, getAutoModeWorktreeKey]
);
// Handler to toggle auto-mode for a worktree
const handleToggleAutoMode = useCallback(
async (worktree: WorktreeInfo) => {
if (!currentProject) return;
// Import the useAutoMode to get start/stop functions
// Since useAutoMode is a hook, we'll use the API client directly
const api = getHttpApiClient();
const branchName = worktree.isMain ? null : worktree.branch;
const isRunning = isAutoModeRunningForWorktree(worktree);
try {
if (isRunning) {
const result = await api.autoMode.stop(projectPath, branchName);
if (result.success) {
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode stopped for ${desc}`);
} else {
toast.error(result.error || 'Failed to stop Auto Mode');
}
} else {
const result = await api.autoMode.start(projectPath, branchName);
if (result.success) {
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode started for ${desc}`);
} else {
toast.error(result.error || 'Failed to start Auto Mode');
}
}
} catch (error) {
toast.error('Error toggling Auto Mode');
console.error('Auto mode toggle error:', error);
}
},
[currentProject, projectPath, isAutoModeRunningForWorktree]
);
// Track whether init script exists for the project // Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false); const [hasInitScript, setHasInitScript] = useState(false);
@@ -244,6 +305,7 @@ export function WorktreePanel({
isDevServerRunning={isDevServerRunning(selectedWorktree)} isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)} devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus} gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)} onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull} onPull={handlePull}
onPush={handlePush} onPush={handlePush}
@@ -261,6 +323,7 @@ export function WorktreePanel({
onOpenDevServerUrl={handleOpenDevServerUrl} onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs} onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript} onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
/> />
)} )}
@@ -328,6 +391,7 @@ export function WorktreePanel({
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
gitRepoStatus={gitRepoStatus} gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
onSelectWorktree={handleSelectWorktree} onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
@@ -350,6 +414,7 @@ export function WorktreePanel({
onOpenDevServerUrl={handleOpenDevServerUrl} onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs} onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript} onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
/> />
)} )}
@@ -388,6 +453,7 @@ export function WorktreePanel({
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
gitRepoStatus={gitRepoStatus} gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
onSelectWorktree={handleSelectWorktree} onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
@@ -410,6 +476,7 @@ export function WorktreePanel({
onOpenDevServerUrl={handleOpenDevServerUrl} onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs} onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript} onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript} hasInitScript={hasInitScript}
/> />
); );

View File

@@ -1,13 +1,24 @@
import { useEffect, useCallback, useMemo } from 'react'; import { useEffect, useCallback, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron'; import type { AutoModeEvent } from '@/types/electron';
import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
const logger = createLogger('AutoMode'); const logger = createLogger('AutoMode');
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByProjectPath'; const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
/**
* Generate a worktree key for session storage
* @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree
*/
function getWorktreeSessionKey(projectPath: string, branchName: string | null): string {
return `${projectPath}::${branchName ?? '__main__'}`;
}
function readAutoModeSession(): Record<string, boolean> { function readAutoModeSession(): Record<string, boolean> {
try { try {
@@ -31,9 +42,14 @@ function writeAutoModeSession(next: Record<string, boolean>): void {
} }
} }
function setAutoModeSessionForProjectPath(projectPath: string, running: boolean): void { function setAutoModeSessionForWorktree(
projectPath: string,
branchName: string | null,
running: boolean
): void {
const worktreeKey = getWorktreeSessionKey(projectPath, branchName);
const current = readAutoModeSession(); const current = readAutoModeSession();
const next = { ...current, [projectPath]: running }; const next = { ...current, [worktreeKey]: running };
writeAutoModeSession(next); writeAutoModeSession(next);
} }
@@ -45,33 +61,44 @@ function isPlanApprovalEvent(
} }
/** /**
* Hook for managing auto mode (scoped per project) * Hook for managing auto mode (scoped per worktree)
* @param worktree - Optional worktree info. If not provided, uses main worktree (branchName = null)
*/ */
export function useAutoMode() { export function useAutoMode(worktree?: WorktreeInfo) {
const { const {
autoModeByProject, autoModeByWorktree,
setAutoModeRunning, setAutoModeRunning,
addRunningTask, addRunningTask,
removeRunningTask, removeRunningTask,
currentProject, currentProject,
addAutoModeActivity, addAutoModeActivity,
maxConcurrency,
projects, projects,
setPendingPlanApproval, setPendingPlanApproval,
getWorktreeKey,
getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
} = useAppStore( } = useAppStore(
useShallow((state) => ({ useShallow((state) => ({
autoModeByProject: state.autoModeByProject, autoModeByWorktree: state.autoModeByWorktree,
setAutoModeRunning: state.setAutoModeRunning, setAutoModeRunning: state.setAutoModeRunning,
addRunningTask: state.addRunningTask, addRunningTask: state.addRunningTask,
removeRunningTask: state.removeRunningTask, removeRunningTask: state.removeRunningTask,
currentProject: state.currentProject, currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity, addAutoModeActivity: state.addAutoModeActivity,
maxConcurrency: state.maxConcurrency,
projects: state.projects, projects: state.projects,
setPendingPlanApproval: state.setPendingPlanApproval, setPendingPlanApproval: state.setPendingPlanApproval,
getWorktreeKey: state.getWorktreeKey,
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
})) }))
); );
// Derive branchName from worktree: main worktree uses null, feature worktrees use their branch
const branchName = useMemo(() => {
if (!worktree) return null;
return worktree.isMain ? null : worktree.branch;
}, [worktree]);
// Helper to look up project ID from path // Helper to look up project ID from path
const getProjectIdFromPath = useCallback( const getProjectIdFromPath = useCallback(
(path: string): string | undefined => { (path: string): string | undefined => {
@@ -81,15 +108,30 @@ export function useAutoMode() {
[projects] [projects]
); );
// Get project-specific auto mode state // Get worktree-specific auto mode state
const projectId = currentProject?.id; const projectId = currentProject?.id;
const projectAutoModeState = useMemo(() => { const worktreeAutoModeState = useMemo(() => {
if (!projectId) return { isRunning: false, runningTasks: [] }; if (!projectId)
return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] }; return {
}, [autoModeByProject, projectId]); isRunning: false,
runningTasks: [],
branchName: null,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
};
const key = getWorktreeKey(projectId, branchName);
return (
autoModeByWorktree[key] || {
isRunning: false,
runningTasks: [],
branchName,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
}
);
}, [autoModeByWorktree, projectId, branchName, getWorktreeKey]);
const isAutoModeRunning = projectAutoModeState.isRunning; const isAutoModeRunning = worktreeAutoModeState.isRunning;
const runningAutoTasks = projectAutoModeState.runningTasks; const runningAutoTasks = worktreeAutoModeState.runningTasks;
const maxConcurrency = worktreeAutoModeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
// Check if we can start a new task based on concurrency limit // Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency; const canStartNewTask = runningAutoTasks.length < maxConcurrency;
@@ -104,15 +146,17 @@ export function useAutoMode() {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.autoMode?.status) return; if (!api?.autoMode?.status) return;
const result = await api.autoMode.status(currentProject.path); const result = await api.autoMode.status(currentProject.path, branchName);
if (result.success && result.isAutoLoopRunning !== undefined) { if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning; const backendIsRunning = result.isAutoLoopRunning;
if (backendIsRunning !== isAutoModeRunning) { if (backendIsRunning !== isAutoModeRunning) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info( logger.info(
`[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` `[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
); );
setAutoModeRunning(currentProject.id, backendIsRunning); setAutoModeRunning(currentProject.id, branchName, backendIsRunning);
setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning); setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
} }
} }
} catch (error) { } catch (error) {
@@ -121,9 +165,9 @@ export function useAutoMode() {
}; };
syncWithBackend(); syncWithBackend();
}, [currentProject, isAutoModeRunning, setAutoModeRunning]); }, [currentProject, branchName, isAutoModeRunning, setAutoModeRunning]);
// Handle auto mode events - listen globally for all projects // Handle auto mode events - listen globally for all projects/worktrees
useEffect(() => { useEffect(() => {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.autoMode) return; if (!api?.autoMode) return;
@@ -131,8 +175,8 @@ export function useAutoMode() {
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
logger.info('Event:', event); logger.info('Event:', event);
// Events include projectPath from backend - use it to look up project ID // Events include projectPath and branchName from backend
// Fall back to current projectId if not provided in event // Use them to look up project ID and determine the worktree
let eventProjectId: string | undefined; let eventProjectId: string | undefined;
if ('projectPath' in event && event.projectPath) { if ('projectPath' in event && event.projectPath) {
eventProjectId = getProjectIdFromPath(event.projectPath); eventProjectId = getProjectIdFromPath(event.projectPath);
@@ -144,6 +188,10 @@ export function useAutoMode() {
eventProjectId = projectId; eventProjectId = projectId;
} }
// Extract branchName from event, defaulting to null (main worktree)
const eventBranchName: string | null =
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
// Skip event if we couldn't determine the project // Skip event if we couldn't determine the project
if (!eventProjectId) { if (!eventProjectId) {
logger.warn('Could not determine project for event:', event); logger.warn('Could not determine project for event:', event);
@@ -153,23 +201,34 @@ export function useAutoMode() {
switch (event.type) { switch (event.type) {
case 'auto_mode_started': case 'auto_mode_started':
// Backend started auto loop - update UI state // Backend started auto loop - update UI state
logger.info('[AutoMode] Backend started auto loop for project'); {
if (eventProjectId) { const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
setAutoModeRunning(eventProjectId, true); logger.info(`[AutoMode] Backend started auto loop for ${worktreeDesc}`);
if (eventProjectId) {
// Extract maxConcurrency from event if available, otherwise use current or default
const eventMaxConcurrency =
'maxConcurrency' in event && typeof event.maxConcurrency === 'number'
? event.maxConcurrency
: getMaxConcurrencyForWorktree(eventProjectId, eventBranchName);
setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency);
}
} }
break; break;
case 'auto_mode_stopped': case 'auto_mode_stopped':
// Backend stopped auto loop - update UI state // Backend stopped auto loop - update UI state
logger.info('[AutoMode] Backend stopped auto loop for project'); {
if (eventProjectId) { const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
setAutoModeRunning(eventProjectId, false); logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
if (eventProjectId) {
setAutoModeRunning(eventProjectId, eventBranchName, false);
}
} }
break; break;
case 'auto_mode_feature_start': case 'auto_mode_feature_start':
if (event.featureId) { if (event.featureId) {
addRunningTask(eventProjectId, event.featureId); addRunningTask(eventProjectId, eventBranchName, event.featureId);
addAutoModeActivity({ addAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'start', type: 'start',
@@ -182,7 +241,7 @@ export function useAutoMode() {
// Feature completed - remove from running tasks and UI will reload features on its own // Feature completed - remove from running tasks and UI will reload features on its own
if (event.featureId) { if (event.featureId) {
logger.info('Feature completed:', event.featureId, 'passes:', event.passes); logger.info('Feature completed:', event.featureId, 'passes:', event.passes);
removeRunningTask(eventProjectId, event.featureId); removeRunningTask(eventProjectId, eventBranchName, event.featureId);
addAutoModeActivity({ addAutoModeActivity({
featureId: event.featureId, featureId: event.featureId,
type: 'complete', type: 'complete',
@@ -202,7 +261,7 @@ export function useAutoMode() {
logger.info('Feature cancelled/aborted:', event.error); logger.info('Feature cancelled/aborted:', event.error);
// Remove from running tasks // Remove from running tasks
if (eventProjectId) { if (eventProjectId) {
removeRunningTask(eventProjectId, event.featureId); removeRunningTask(eventProjectId, eventBranchName, event.featureId);
} }
break; break;
} }
@@ -229,7 +288,7 @@ export function useAutoMode() {
// Remove the task from running since it failed // Remove the task from running since it failed
if (eventProjectId) { if (eventProjectId) {
removeRunningTask(eventProjectId, event.featureId); removeRunningTask(eventProjectId, eventBranchName, event.featureId);
} }
} }
break; break;
@@ -404,9 +463,11 @@ export function useAutoMode() {
setPendingPlanApproval, setPendingPlanApproval,
setAutoModeRunning, setAutoModeRunning,
currentProject?.path, currentProject?.path,
getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
]); ]);
// Start auto mode - calls backend to start the auto loop // Start auto mode - calls backend to start the auto loop for this worktree
const start = useCallback(async () => { const start = useCallback(async () => {
if (!currentProject) { if (!currentProject) {
logger.error('No project selected'); logger.error('No project selected');
@@ -419,36 +480,35 @@ export function useAutoMode() {
throw new Error('Start auto mode API not available'); throw new Error('Start auto mode API not available');
} }
logger.info( const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
`[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}` logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`);
);
// Optimistically update UI state (backend will confirm via event) // Optimistically update UI state (backend will confirm via event)
setAutoModeSessionForProjectPath(currentProject.path, true); setAutoModeSessionForWorktree(currentProject.path, branchName, true);
setAutoModeRunning(currentProject.id, true); setAutoModeRunning(currentProject.id, branchName, true);
// Call backend to start the auto loop // Call backend to start the auto loop (backend uses stored concurrency)
const result = await api.autoMode.start(currentProject.path, maxConcurrency); const result = await api.autoMode.start(currentProject.path, branchName);
if (!result.success) { if (!result.success) {
// Revert UI state on failure // Revert UI state on failure
setAutoModeSessionForProjectPath(currentProject.path, false); setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, false); setAutoModeRunning(currentProject.id, branchName, false);
logger.error('Failed to start auto mode:', result.error); logger.error('Failed to start auto mode:', result.error);
throw new Error(result.error || 'Failed to start auto mode'); throw new Error(result.error || 'Failed to start auto mode');
} }
logger.debug(`[AutoMode] Started successfully`); logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
} catch (error) { } catch (error) {
// Revert UI state on error // Revert UI state on error
setAutoModeSessionForProjectPath(currentProject.path, false); setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, false); setAutoModeRunning(currentProject.id, branchName, false);
logger.error('Error starting auto mode:', error); logger.error('Error starting auto mode:', error);
throw error; throw error;
} }
}, [currentProject, setAutoModeRunning, maxConcurrency]); }, [currentProject, branchName, setAutoModeRunning]);
// Stop auto mode - calls backend to stop the auto loop // Stop auto mode - calls backend to stop the auto loop for this worktree
const stop = useCallback(async () => { const stop = useCallback(async () => {
if (!currentProject) { if (!currentProject) {
logger.error('No project selected'); logger.error('No project selected');
@@ -461,34 +521,35 @@ export function useAutoMode() {
throw new Error('Stop auto mode API not available'); throw new Error('Stop auto mode API not available');
} }
logger.info(`[AutoMode] Stopping auto loop for ${currentProject.path}`); const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(`[AutoMode] Stopping auto loop for ${worktreeDesc} in ${currentProject.path}`);
// Optimistically update UI state (backend will confirm via event) // Optimistically update UI state (backend will confirm via event)
setAutoModeSessionForProjectPath(currentProject.path, false); setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, false); setAutoModeRunning(currentProject.id, branchName, false);
// Call backend to stop the auto loop // Call backend to stop the auto loop
const result = await api.autoMode.stop(currentProject.path); const result = await api.autoMode.stop(currentProject.path, branchName);
if (!result.success) { if (!result.success) {
// Revert UI state on failure // Revert UI state on failure
setAutoModeSessionForProjectPath(currentProject.path, true); setAutoModeSessionForWorktree(currentProject.path, branchName, true);
setAutoModeRunning(currentProject.id, true); setAutoModeRunning(currentProject.id, branchName, true);
logger.error('Failed to stop auto mode:', result.error); logger.error('Failed to stop auto mode:', result.error);
throw new Error(result.error || 'Failed to stop auto mode'); throw new Error(result.error || 'Failed to stop auto mode');
} }
// NOTE: Running tasks will continue until natural completion. // NOTE: Running tasks will continue until natural completion.
// The backend stops picking up new features but doesn't abort running ones. // The backend stops picking up new features but doesn't abort running ones.
logger.info('Stopped - running tasks will continue'); logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
} catch (error) { } catch (error) {
// Revert UI state on error // Revert UI state on error
setAutoModeSessionForProjectPath(currentProject.path, true); setAutoModeSessionForWorktree(currentProject.path, branchName, true);
setAutoModeRunning(currentProject.id, true); setAutoModeRunning(currentProject.id, branchName, true);
logger.error('Error stopping auto mode:', error); logger.error('Error stopping auto mode:', error);
throw error; throw error;
} }
}, [currentProject, setAutoModeRunning]); }, [currentProject, branchName, setAutoModeRunning]);
// Stop a specific feature // Stop a specific feature
const stopFeature = useCallback( const stopFeature = useCallback(
@@ -507,7 +568,7 @@ export function useAutoMode() {
const result = await api.autoMode.stopFeature(featureId); const result = await api.autoMode.stopFeature(featureId);
if (result.success) { if (result.success) {
removeRunningTask(currentProject.id, featureId); removeRunningTask(currentProject.id, branchName, featureId);
logger.info('Feature stopped successfully:', featureId); logger.info('Feature stopped successfully:', featureId);
addAutoModeActivity({ addAutoModeActivity({
featureId, featureId,
@@ -524,7 +585,7 @@ export function useAutoMode() {
throw error; throw error;
} }
}, },
[currentProject, removeRunningTask, addAutoModeActivity] [currentProject, branchName, removeRunningTask, addAutoModeActivity]
); );
return { return {
@@ -532,6 +593,7 @@ export function useAutoMode() {
runningTasks: runningAutoTasks, runningTasks: runningAutoTasks,
maxConcurrency, maxConcurrency,
canStartNewTask, canStartNewTask,
branchName,
start, start,
stop, stop,
stopFeature, stopFeature,

View File

@@ -30,6 +30,7 @@ import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
import { import {
DEFAULT_OPENCODE_MODEL, DEFAULT_OPENCODE_MODEL,
DEFAULT_MAX_CONCURRENCY,
getAllOpencodeModelIds, getAllOpencodeModelIds,
getAllCursorModelIds, getAllCursorModelIds,
migrateCursorModelIds, migrateCursorModelIds,
@@ -194,6 +195,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'], keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
mcpServers: state.mcpServers as GlobalSettings['mcpServers'], mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'], promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
projects: state.projects as GlobalSettings['projects'], projects: state.projects as GlobalSettings['projects'],
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'], trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null, currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
@@ -635,13 +637,39 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
setItem(THEME_STORAGE_KEY, storedTheme); setItem(THEME_STORAGE_KEY, storedTheme);
} }
// Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset)
const restoredAutoModeByWorktree: Record<
string,
{
isRunning: boolean;
runningTasks: string[];
branchName: string | null;
maxConcurrency: number;
}
> = {};
if ((settings as Record<string, unknown>).autoModeByWorktree) {
const persistedSettings = (settings as Record<string, unknown>).autoModeByWorktree as Record<
string,
{ maxConcurrency?: number; branchName?: string | null }
>;
for (const [key, value] of Object.entries(persistedSettings)) {
restoredAutoModeByWorktree[key] = {
isRunning: false, // Always start with auto mode off
runningTasks: [], // No running tasks on startup
branchName: value.branchName ?? null,
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
};
}
}
useAppStore.setState({ useAppStore.setState({
theme: settings.theme as unknown as import('@/store/app-store').ThemeMode, theme: settings.theme as unknown as import('@/store/app-store').ThemeMode,
fontFamilySans: settings.fontFamilySans ?? null, fontFamilySans: settings.fontFamilySans ?? null,
fontFamilyMono: settings.fontFamilyMono ?? null, fontFamilyMono: settings.fontFamilyMono ?? null,
sidebarOpen: settings.sidebarOpen ?? true, sidebarOpen: settings.sidebarOpen ?? true,
chatHistoryOpen: settings.chatHistoryOpen ?? false, chatHistoryOpen: settings.chatHistoryOpen ?? false,
maxConcurrency: settings.maxConcurrency ?? 3, maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
autoModeByWorktree: restoredAutoModeByWorktree,
defaultSkipTests: settings.defaultSkipTests ?? true, defaultSkipTests: settings.defaultSkipTests ?? true,
enableDependencyBlocking: settings.enableDependencyBlocking ?? true, enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false, skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
@@ -671,6 +699,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
}, },
mcpServers: settings.mcpServers ?? [], mcpServers: settings.mcpServers ?? [],
promptCustomization: settings.promptCustomization ?? {}, promptCustomization: settings.promptCustomization ?? {},
eventHooks: settings.eventHooks ?? [],
projects, projects,
currentProject, currentProject,
trashedProjects: settings.trashedProjects ?? [], trashedProjects: settings.trashedProjects ?? [],
@@ -705,6 +734,19 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
function buildSettingsUpdateFromStore(): Record<string, unknown> { function buildSettingsUpdateFromStore(): Record<string, unknown> {
const state = useAppStore.getState(); const state = useAppStore.getState();
const setupState = useSetupStore.getState(); const setupState = useSetupStore.getState();
// Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
const persistedAutoModeByWorktree: Record<
string,
{ maxConcurrency: number; branchName: string | null }
> = {};
for (const [key, value] of Object.entries(state.autoModeByWorktree)) {
persistedAutoModeByWorktree[key] = {
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
branchName: value.branchName,
};
}
return { return {
setupComplete: setupState.setupComplete, setupComplete: setupState.setupComplete,
isFirstRun: setupState.isFirstRun, isFirstRun: setupState.isFirstRun,
@@ -713,6 +755,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
sidebarOpen: state.sidebarOpen, sidebarOpen: state.sidebarOpen,
chatHistoryOpen: state.chatHistoryOpen, chatHistoryOpen: state.chatHistoryOpen,
maxConcurrency: state.maxConcurrency, maxConcurrency: state.maxConcurrency,
autoModeByWorktree: persistedAutoModeByWorktree,
defaultSkipTests: state.defaultSkipTests, defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking, enableDependencyBlocking: state.enableDependencyBlocking,
skipVerificationInAutoMode: state.skipVerificationInAutoMode, skipVerificationInAutoMode: state.skipVerificationInAutoMode,
@@ -732,6 +775,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
keyboardShortcuts: state.keyboardShortcuts, keyboardShortcuts: state.keyboardShortcuts,
mcpServers: state.mcpServers, mcpServers: state.mcpServers,
promptCustomization: state.promptCustomization, promptCustomization: state.promptCustomization,
eventHooks: state.eventHooks,
projects: state.projects, projects: state.projects,
trashedProjects: state.trashedProjects, trashedProjects: state.trashedProjects,
currentProjectId: state.currentProject?.id ?? null, currentProjectId: state.currentProject?.id ?? null,

View File

@@ -21,6 +21,7 @@ import { useAuthStore } from '@/store/auth-store';
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration'; import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
import { import {
DEFAULT_OPENCODE_MODEL, DEFAULT_OPENCODE_MODEL,
DEFAULT_MAX_CONCURRENCY,
getAllOpencodeModelIds, getAllOpencodeModelIds,
getAllCursorModelIds, getAllCursorModelIds,
migrateCursorModelIds, migrateCursorModelIds,
@@ -46,6 +47,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'sidebarOpen', 'sidebarOpen',
'chatHistoryOpen', 'chatHistoryOpen',
'maxConcurrency', 'maxConcurrency',
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
'defaultSkipTests', 'defaultSkipTests',
'enableDependencyBlocking', 'enableDependencyBlocking',
'skipVerificationInAutoMode', 'skipVerificationInAutoMode',
@@ -112,6 +114,19 @@ function getSettingsFieldValue(
if (field === 'openTerminalMode') { if (field === 'openTerminalMode') {
return appState.terminalState.openTerminalMode; return appState.terminalState.openTerminalMode;
} }
if (field === 'autoModeByWorktree') {
// Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
const autoModeByWorktree = appState.autoModeByWorktree;
const persistedSettings: Record<string, { maxConcurrency: number; branchName: string | null }> =
{};
for (const [key, value] of Object.entries(autoModeByWorktree)) {
persistedSettings[key] = {
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
branchName: value.branchName,
};
}
return persistedSettings;
}
return appState[field as keyof typeof appState]; return appState[field as keyof typeof appState];
} }
@@ -591,11 +606,37 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
setItem(THEME_STORAGE_KEY, serverSettings.theme); setItem(THEME_STORAGE_KEY, serverSettings.theme);
} }
// Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset)
const restoredAutoModeByWorktree: Record<
string,
{
isRunning: boolean;
runningTasks: string[];
branchName: string | null;
maxConcurrency: number;
}
> = {};
if (serverSettings.autoModeByWorktree) {
const persistedSettings = serverSettings.autoModeByWorktree as Record<
string,
{ maxConcurrency?: number; branchName?: string | null }
>;
for (const [key, value] of Object.entries(persistedSettings)) {
restoredAutoModeByWorktree[key] = {
isRunning: false, // Always start with auto mode off
runningTasks: [], // No running tasks on startup
branchName: value.branchName ?? null,
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
};
}
}
useAppStore.setState({ useAppStore.setState({
theme: serverSettings.theme as unknown as ThemeMode, theme: serverSettings.theme as unknown as ThemeMode,
sidebarOpen: serverSettings.sidebarOpen, sidebarOpen: serverSettings.sidebarOpen,
chatHistoryOpen: serverSettings.chatHistoryOpen, chatHistoryOpen: serverSettings.chatHistoryOpen,
maxConcurrency: serverSettings.maxConcurrency, maxConcurrency: serverSettings.maxConcurrency,
autoModeByWorktree: restoredAutoModeByWorktree,
defaultSkipTests: serverSettings.defaultSkipTests, defaultSkipTests: serverSettings.defaultSkipTests,
enableDependencyBlocking: serverSettings.enableDependencyBlocking, enableDependencyBlocking: serverSettings.enableDependencyBlocking,
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode, skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,

View File

@@ -28,6 +28,7 @@ import type {
UpdateIdeaInput, UpdateIdeaInput,
ConvertToFeatureOptions, ConvertToFeatureOptions,
} from '@automaker/types'; } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage'; import { getJSON, setJSON, removeItem } from './storage';
// Re-export issue validation types for use in components // Re-export issue validation types for use in components
@@ -486,13 +487,18 @@ export interface FeaturesAPI {
export interface AutoModeAPI { export interface AutoModeAPI {
start: ( start: (
projectPath: string, projectPath: string,
branchName?: string | null,
maxConcurrency?: number maxConcurrency?: number
) => Promise<{ success: boolean; error?: string }>; ) => Promise<{ success: boolean; error?: string }>;
stop: ( stop: (
projectPath: string projectPath: string,
branchName?: string | null
) => Promise<{ success: boolean; error?: string; runningFeatures?: number }>; ) => Promise<{ success: boolean; error?: string; runningFeatures?: number }>;
stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>; stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>;
status: (projectPath?: string) => Promise<{ status: (
projectPath?: string,
branchName?: string | null
) => Promise<{
success: boolean; success: boolean;
isRunning?: boolean; isRunning?: boolean;
isAutoLoopRunning?: boolean; isAutoLoopRunning?: boolean;
@@ -2060,7 +2066,9 @@ function createMockAutoModeAPI(): AutoModeAPI {
} }
mockAutoModeRunning = true; mockAutoModeRunning = true;
console.log(`[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || 3}`); console.log(
`[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || DEFAULT_MAX_CONCURRENCY}`
);
const featureId = 'auto-mode-0'; const featureId = 'auto-mode-0';
mockRunningFeatures.add(featureId); mockRunningFeatures.add(featureId);

View File

@@ -1667,11 +1667,13 @@ export class HttpApiClient implements ElectronAPI {
// Auto Mode API // Auto Mode API
autoMode: AutoModeAPI = { autoMode: AutoModeAPI = {
start: (projectPath: string, maxConcurrency?: number) => start: (projectPath: string, branchName?: string | null, maxConcurrency?: number) =>
this.post('/api/auto-mode/start', { projectPath, maxConcurrency }), this.post('/api/auto-mode/start', { projectPath, branchName, maxConcurrency }),
stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }), stop: (projectPath: string, branchName?: string | null) =>
this.post('/api/auto-mode/stop', { projectPath, branchName }),
stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }), stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }),
status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }), status: (projectPath?: string, branchName?: string | null) =>
this.post('/api/auto-mode/status', { projectPath, branchName }),
runFeature: ( runFeature: (
projectPath: string, projectPath: string,
featureId: string, featureId: string,

View File

@@ -38,6 +38,7 @@ import {
getAllOpencodeModelIds, getAllOpencodeModelIds,
DEFAULT_PHASE_MODELS, DEFAULT_PHASE_MODELS,
DEFAULT_OPENCODE_MODEL, DEFAULT_OPENCODE_MODEL,
DEFAULT_MAX_CONCURRENCY,
} from '@automaker/types'; } from '@automaker/types';
const logger = createLogger('AppStore'); const logger = createLogger('AppStore');
@@ -626,16 +627,18 @@ export interface AppState {
currentChatSession: ChatSession | null; currentChatSession: ChatSession | null;
chatHistoryOpen: boolean; chatHistoryOpen: boolean;
// Auto Mode (per-project state, keyed by project ID) // Auto Mode (per-worktree state, keyed by "${projectId}::${branchName ?? '__main__'}")
autoModeByProject: Record< autoModeByWorktree: Record<
string, string,
{ {
isRunning: boolean; isRunning: boolean;
runningTasks: string[]; // Feature IDs being worked on runningTasks: string[]; // Feature IDs being worked on
branchName: string | null; // null = main worktree
maxConcurrency?: number; // Maximum concurrent features for this worktree (defaults to 3)
} }
>; >;
autoModeActivityLog: AutoModeActivity[]; autoModeActivityLog: AutoModeActivity[];
maxConcurrency: number; // Maximum number of concurrent agent tasks maxConcurrency: number; // Legacy: Maximum number of concurrent agent tasks (deprecated, use per-worktree maxConcurrency)
// Kanban Card Display Settings // Kanban Card Display Settings
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
@@ -1057,18 +1060,36 @@ export interface AppActions {
setChatHistoryOpen: (open: boolean) => void; setChatHistoryOpen: (open: boolean) => void;
toggleChatHistory: () => void; toggleChatHistory: () => void;
// Auto Mode actions (per-project) // Auto Mode actions (per-worktree)
setAutoModeRunning: (projectId: string, running: boolean) => void; setAutoModeRunning: (
addRunningTask: (projectId: string, taskId: string) => void; projectId: string,
removeRunningTask: (projectId: string, taskId: string) => void; branchName: string | null,
clearRunningTasks: (projectId: string) => void; running: boolean,
getAutoModeState: (projectId: string) => { maxConcurrency?: number
) => void;
addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
clearRunningTasks: (projectId: string, branchName: string | null) => void;
getAutoModeState: (
projectId: string,
branchName: string | null
) => {
isRunning: boolean; isRunning: boolean;
runningTasks: string[]; runningTasks: string[];
branchName: string | null;
maxConcurrency?: number;
}; };
/** Helper to generate worktree key from projectId and branchName */
getWorktreeKey: (projectId: string, branchName: string | null) => string;
addAutoModeActivity: (activity: Omit<AutoModeActivity, 'id' | 'timestamp'>) => void; addAutoModeActivity: (activity: Omit<AutoModeActivity, 'id' | 'timestamp'>) => void;
clearAutoModeActivity: () => void; clearAutoModeActivity: () => void;
setMaxConcurrency: (max: number) => void; setMaxConcurrency: (max: number) => void; // Legacy: kept for backward compatibility
getMaxConcurrencyForWorktree: (projectId: string, branchName: string | null) => number;
setMaxConcurrencyForWorktree: (
projectId: string,
branchName: string | null,
maxConcurrency: number
) => void;
// Kanban Card Settings actions // Kanban Card Settings actions
setBoardViewMode: (mode: BoardViewMode) => void; setBoardViewMode: (mode: BoardViewMode) => void;
@@ -1387,9 +1408,9 @@ const initialState: AppState = {
chatSessions: [], chatSessions: [],
currentChatSession: null, currentChatSession: null,
chatHistoryOpen: false, chatHistoryOpen: false,
autoModeByProject: {}, autoModeByWorktree: {},
autoModeActivityLog: [], autoModeActivityLog: [],
maxConcurrency: 3, // Default to 3 concurrent agents maxConcurrency: DEFAULT_MAX_CONCURRENCY, // Default concurrent agents
boardViewMode: 'kanban', // Default to kanban view boardViewMode: 'kanban', // Default to kanban view
defaultSkipTests: true, // Default to manual verification (tests disabled) defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
@@ -2073,74 +2094,125 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }), toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }),
// Auto Mode actions (per-project) // Auto Mode actions (per-worktree)
setAutoModeRunning: (projectId, running) => { getWorktreeKey: (projectId, branchName) => {
const current = get().autoModeByProject; return `${projectId}::${branchName ?? '__main__'}`;
const projectState = current[projectId] || { },
setAutoModeRunning: (projectId, branchName, running, maxConcurrency?: number) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName);
const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || {
isRunning: false, isRunning: false,
runningTasks: [], runningTasks: [],
branchName,
maxConcurrency: maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
}; };
set({ set({
autoModeByProject: { autoModeByWorktree: {
...current, ...current,
[projectId]: { ...projectState, isRunning: running }, [worktreeKey]: {
...worktreeState,
isRunning: running,
branchName,
maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
},
}, },
}); });
}, },
addRunningTask: (projectId, taskId) => { addRunningTask: (projectId, branchName, taskId) => {
const current = get().autoModeByProject; const worktreeKey = get().getWorktreeKey(projectId, branchName);
const projectState = current[projectId] || { const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || {
isRunning: false, isRunning: false,
runningTasks: [], runningTasks: [],
branchName,
}; };
if (!projectState.runningTasks.includes(taskId)) { if (!worktreeState.runningTasks.includes(taskId)) {
set({ set({
autoModeByProject: { autoModeByWorktree: {
...current, ...current,
[projectId]: { [worktreeKey]: {
...projectState, ...worktreeState,
runningTasks: [...projectState.runningTasks, taskId], runningTasks: [...worktreeState.runningTasks, taskId],
branchName,
}, },
}, },
}); });
} }
}, },
removeRunningTask: (projectId, taskId) => { removeRunningTask: (projectId, branchName, taskId) => {
const current = get().autoModeByProject; const worktreeKey = get().getWorktreeKey(projectId, branchName);
const projectState = current[projectId] || { const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || {
isRunning: false, isRunning: false,
runningTasks: [], runningTasks: [],
branchName,
}; };
set({ set({
autoModeByProject: { autoModeByWorktree: {
...current, ...current,
[projectId]: { [worktreeKey]: {
...projectState, ...worktreeState,
runningTasks: projectState.runningTasks.filter((id) => id !== taskId), runningTasks: worktreeState.runningTasks.filter((id) => id !== taskId),
branchName,
}, },
}, },
}); });
}, },
clearRunningTasks: (projectId) => { clearRunningTasks: (projectId, branchName) => {
const current = get().autoModeByProject; const worktreeKey = get().getWorktreeKey(projectId, branchName);
const projectState = current[projectId] || { const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || {
isRunning: false, isRunning: false,
runningTasks: [], runningTasks: [],
branchName,
}; };
set({ set({
autoModeByProject: { autoModeByWorktree: {
...current, ...current,
[projectId]: { ...projectState, runningTasks: [] }, [worktreeKey]: { ...worktreeState, runningTasks: [], branchName },
}, },
}); });
}, },
getAutoModeState: (projectId) => { getAutoModeState: (projectId, branchName) => {
const projectState = get().autoModeByProject[projectId]; const worktreeKey = get().getWorktreeKey(projectId, branchName);
return projectState || { isRunning: false, runningTasks: [] }; const worktreeState = get().autoModeByWorktree[worktreeKey];
return (
worktreeState || {
isRunning: false,
runningTasks: [],
branchName,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
}
);
},
getMaxConcurrencyForWorktree: (projectId, branchName) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName);
const worktreeState = get().autoModeByWorktree[worktreeKey];
return worktreeState?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
},
setMaxConcurrencyForWorktree: (projectId, branchName, maxConcurrency) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName);
const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || {
isRunning: false,
runningTasks: [],
branchName,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
};
set({
autoModeByWorktree: {
...current,
[worktreeKey]: { ...worktreeState, maxConcurrency, branchName },
},
});
}, },
addAutoModeActivity: (activity) => { addAutoModeActivity: (activity) => {

View File

@@ -163,11 +163,30 @@ export interface SessionsAPI {
} }
export type AutoModeEvent = export type AutoModeEvent =
| {
type: 'auto_mode_started';
message: string;
projectPath?: string;
branchName?: string | null;
}
| {
type: 'auto_mode_stopped';
message: string;
projectPath?: string;
branchName?: string | null;
}
| {
type: 'auto_mode_idle';
message: string;
projectPath?: string;
branchName?: string | null;
}
| { | {
type: 'auto_mode_feature_start'; type: 'auto_mode_feature_start';
featureId: string; featureId: string;
projectId?: string; projectId?: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
feature: unknown; feature: unknown;
} }
| { | {
@@ -175,6 +194,7 @@ export type AutoModeEvent =
featureId: string; featureId: string;
projectId?: string; projectId?: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
content: string; content: string;
} }
| { | {
@@ -182,6 +202,7 @@ export type AutoModeEvent =
featureId: string; featureId: string;
projectId?: string; projectId?: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
tool: string; tool: string;
input: unknown; input: unknown;
} }
@@ -190,6 +211,7 @@ export type AutoModeEvent =
featureId: string; featureId: string;
projectId?: string; projectId?: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
passes: boolean; passes: boolean;
message: string; message: string;
} }
@@ -218,6 +240,7 @@ export type AutoModeEvent =
featureId?: string; featureId?: string;
projectId?: string; projectId?: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
} }
| { | {
type: 'auto_mode_phase'; type: 'auto_mode_phase';
@@ -389,18 +412,48 @@ export interface SpecRegenerationAPI {
} }
export interface AutoModeAPI { export interface AutoModeAPI {
start: (
projectPath: string,
branchName?: string | null,
maxConcurrency?: number
) => Promise<{
success: boolean;
message?: string;
alreadyRunning?: boolean;
branchName?: string | null;
error?: string;
}>;
stop: (
projectPath: string,
branchName?: string | null
) => Promise<{
success: boolean;
message?: string;
wasRunning?: boolean;
runningFeaturesCount?: number;
branchName?: string | null;
error?: string;
}>;
stopFeature: (featureId: string) => Promise<{ stopFeature: (featureId: string) => Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}>; }>;
status: (projectPath?: string) => Promise<{ status: (
projectPath?: string,
branchName?: string | null
) => Promise<{
success: boolean; success: boolean;
isRunning?: boolean; isRunning?: boolean;
isAutoLoopRunning?: boolean;
currentFeatureId?: string | null; currentFeatureId?: string | null;
runningFeatures?: string[]; runningFeatures?: string[];
runningProjects?: string[]; runningProjects?: string[];
runningCount?: number; runningCount?: number;
maxConcurrency?: number;
branchName?: string | null;
error?: string; error?: string;
}>; }>;

View File

@@ -168,6 +168,7 @@ export {
DEFAULT_GLOBAL_SETTINGS, DEFAULT_GLOBAL_SETTINGS,
DEFAULT_CREDENTIALS, DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS, DEFAULT_PROJECT_SETTINGS,
DEFAULT_MAX_CONCURRENCY,
SETTINGS_VERSION, SETTINGS_VERSION,
CREDENTIALS_VERSION, CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION, PROJECT_SETTINGS_VERSION,

View File

@@ -833,6 +833,9 @@ export const CREDENTIALS_VERSION = 1;
/** Current version of the project settings schema */ /** Current version of the project settings schema */
export const PROJECT_SETTINGS_VERSION = 1; export const PROJECT_SETTINGS_VERSION = 1;
/** Default maximum concurrent agents for auto mode */
export const DEFAULT_MAX_CONCURRENCY = 1;
/** Default keyboard shortcut bindings */ /** Default keyboard shortcut bindings */
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
board: 'K', board: 'K',
@@ -866,7 +869,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
theme: 'dark', theme: 'dark',
sidebarOpen: true, sidebarOpen: true,
chatHistoryOpen: false, chatHistoryOpen: false,
maxConcurrency: 3, maxConcurrency: DEFAULT_MAX_CONCURRENCY,
defaultSkipTests: true, defaultSkipTests: true,
enableDependencyBlocking: true, enableDependencyBlocking: true,
skipVerificationInAutoMode: false, skipVerificationInAutoMode: false,