mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: implement cursor model migration and enhance auto mode functionality
This commit introduces significant updates to the cursor model handling and auto mode features. The cursor model IDs have been standardized to a canonical format, ensuring backward compatibility while migrating legacy IDs. New endpoints for starting and stopping the auto mode loop have been added, allowing for better control over project-specific auto mode operations. Key changes: - Updated cursor model IDs to use the 'cursor-' prefix for consistency. - Added new API endpoints: `/start` and `/stop` for managing auto mode. - Enhanced the status endpoint to provide detailed project-specific auto mode information. - Improved error handling and logging throughout the auto mode service. - Migrated legacy model IDs to their canonical counterparts in various components. This update aims to streamline the user experience and ensure a smooth transition for existing users while providing new functionalities.
This commit is contained in:
@@ -44,7 +44,7 @@ export class CursorConfigManager {
|
||||
|
||||
// Return default config with all available models
|
||||
return {
|
||||
defaultModel: 'auto',
|
||||
defaultModel: 'cursor-auto',
|
||||
models: getAllCursorModelIds(),
|
||||
};
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export class CursorConfigManager {
|
||||
* Get the default model
|
||||
*/
|
||||
getDefaultModel(): CursorModelId {
|
||||
return this.config.defaultModel || 'auto';
|
||||
return this.config.defaultModel || 'cursor-auto';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +93,7 @@ export class CursorConfigManager {
|
||||
* Get enabled models
|
||||
*/
|
||||
getEnabledModels(): CursorModelId[] {
|
||||
return this.config.models || ['auto'];
|
||||
return this.config.models || ['cursor-auto'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,7 +174,7 @@ export class CursorConfigManager {
|
||||
*/
|
||||
reset(): void {
|
||||
this.config = {
|
||||
defaultModel: 'auto',
|
||||
defaultModel: 'cursor-auto',
|
||||
models: getAllCursorModelIds(),
|
||||
};
|
||||
this.saveConfig();
|
||||
|
||||
@@ -10,6 +10,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createStopFeatureHandler } from './routes/stop-feature.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createRunFeatureHandler } from './routes/run-feature.js';
|
||||
import { createStartHandler } from './routes/start.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createVerifyFeatureHandler } from './routes/verify-feature.js';
|
||||
import { createResumeFeatureHandler } from './routes/resume-feature.js';
|
||||
import { createContextExistsHandler } from './routes/context-exists.js';
|
||||
@@ -22,6 +24,10 @@ import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Auto loop control routes
|
||||
router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService));
|
||||
router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService));
|
||||
|
||||
router.post('/stop-feature', createStopFeatureHandler(autoModeService));
|
||||
router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService));
|
||||
router.post(
|
||||
|
||||
54
apps/server/src/routes/auto-mode/routes/start.ts
Normal file
54
apps/server/src/routes/auto-mode/routes/start.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /start endpoint - Start auto mode loop for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createStartHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, maxConcurrency } = req.body as {
|
||||
projectPath: string;
|
||||
maxConcurrency?: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already running
|
||||
if (autoModeService.isAutoLoopRunningForProject(projectPath)) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Auto mode is already running for this project',
|
||||
alreadyRunning: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the auto loop for this project
|
||||
await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3);
|
||||
|
||||
logger.info(
|
||||
`Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Start auto mode failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* POST /status endpoint - Get auto mode status
|
||||
*
|
||||
* If projectPath is provided, returns per-project status including autoloop state.
|
||||
* If no projectPath, returns global status for backward compatibility.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -9,10 +12,30 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath?: string };
|
||||
|
||||
// If projectPath is provided, return per-project status
|
||||
if (projectPath) {
|
||||
const projectStatus = autoModeService.getStatusForProject(projectPath);
|
||||
res.json({
|
||||
success: true,
|
||||
isRunning: projectStatus.runningCount > 0,
|
||||
isAutoLoopRunning: projectStatus.isAutoLoopRunning,
|
||||
runningFeatures: projectStatus.runningFeatures,
|
||||
runningCount: projectStatus.runningCount,
|
||||
maxConcurrency: projectStatus.maxConcurrency,
|
||||
projectPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to global status for backward compatibility
|
||||
const status = autoModeService.getStatus();
|
||||
const activeProjects = autoModeService.getActiveAutoLoopProjects();
|
||||
res.json({
|
||||
success: true,
|
||||
...status,
|
||||
activeAutoLoopProjects: activeProjects,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get status failed');
|
||||
|
||||
54
apps/server/src/routes/auto-mode/routes/stop.ts
Normal file
54
apps/server/src/routes/auto-mode/routes/stop.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop auto mode loop for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createStopHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as {
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if running
|
||||
if (!autoModeService.isAutoLoopRunningForProject(projectPath)) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Auto mode is not running for this project',
|
||||
wasRunning: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the auto loop for this project
|
||||
const runningCount = await autoModeService.stopAutoLoopForProject(projectPath);
|
||||
|
||||
logger.info(
|
||||
`Stopped auto loop for project: ${projectPath}, ${runningCount} features still running`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Auto mode stopped',
|
||||
runningFeaturesCount: runningCount,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Stop auto mode failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -100,11 +100,60 @@ export function getAbortController(): AbortController | null {
|
||||
return currentAbortController;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
/**
|
||||
* Map SDK/CLI errors to user-friendly messages
|
||||
*/
|
||||
export function mapBacklogPlanError(rawMessage: string): string {
|
||||
// Claude Code spawn failures
|
||||
if (
|
||||
rawMessage.includes('Failed to spawn Claude Code process') ||
|
||||
rawMessage.includes('spawn node ENOENT') ||
|
||||
rawMessage.includes('Claude Code executable not found') ||
|
||||
rawMessage.includes('Claude Code native binary not found')
|
||||
) {
|
||||
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
|
||||
}
|
||||
return String(error);
|
||||
|
||||
// Claude Code process crash
|
||||
if (rawMessage.includes('Claude Code process exited')) {
|
||||
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) {
|
||||
return 'Rate limited. Please wait a moment and try again.';
|
||||
}
|
||||
|
||||
// Network errors
|
||||
if (
|
||||
rawMessage.toLowerCase().includes('network') ||
|
||||
rawMessage.toLowerCase().includes('econnrefused') ||
|
||||
rawMessage.toLowerCase().includes('timeout')
|
||||
) {
|
||||
return 'Network error. Check your internet connection and try again.';
|
||||
}
|
||||
|
||||
// Authentication errors
|
||||
if (
|
||||
rawMessage.toLowerCase().includes('not authenticated') ||
|
||||
rawMessage.toLowerCase().includes('unauthorized') ||
|
||||
rawMessage.includes('401')
|
||||
) {
|
||||
return 'Authentication failed. Please check your API key or run `claude login` to authenticate.';
|
||||
}
|
||||
|
||||
// Return original message for unknown errors
|
||||
return rawMessage;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
let rawMessage: string;
|
||||
if (error instanceof Error) {
|
||||
rawMessage = error.message;
|
||||
} else {
|
||||
rawMessage = String(error);
|
||||
}
|
||||
return mapBacklogPlanError(rawMessage);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context: string): void {
|
||||
|
||||
@@ -53,13 +53,12 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
// Note: generateBacklogPlan handles its own error event emission,
|
||||
// so we only log here to avoid duplicate error toasts
|
||||
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
|
||||
.catch((error) => {
|
||||
// Just log - error event already emitted by generateBacklogPlan
|
||||
logError(error, 'Generate backlog plan failed (background)');
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_error',
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
|
||||
@@ -235,6 +235,17 @@ interface AutoModeConfig {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-project autoloop state for multi-project support
|
||||
*/
|
||||
interface ProjectAutoLoopState {
|
||||
abortController: AbortController;
|
||||
config: AutoModeConfig;
|
||||
isRunning: boolean;
|
||||
consecutiveFailures: { timestamp: number; error: string }[];
|
||||
pausedDueToFailures: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution state for recovery after server restart
|
||||
* Tracks which features were running and auto-loop configuration
|
||||
@@ -267,12 +278,15 @@ export class AutoModeService {
|
||||
private runningFeatures = new Map<string, RunningFeature>();
|
||||
private autoLoop: AutoLoopState | null = null;
|
||||
private featureLoader = new FeatureLoader();
|
||||
// Per-project autoloop state (supports multiple concurrent projects)
|
||||
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
|
||||
// Legacy single-project properties (kept for backward compatibility during transition)
|
||||
private autoLoopRunning = false;
|
||||
private autoLoopAbortController: AbortController | null = null;
|
||||
private config: AutoModeConfig | null = null;
|
||||
private pendingApprovals = new Map<string, PendingApproval>();
|
||||
private settingsService: SettingsService | null = null;
|
||||
// Track consecutive failures to detect quota/API issues
|
||||
// Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject)
|
||||
private consecutiveFailures: { timestamp: number; error: string }[] = [];
|
||||
private pausedDueToFailures = false;
|
||||
|
||||
@@ -284,6 +298,44 @@ export class AutoModeService {
|
||||
/**
|
||||
* Track a failure and check if we should pause due to consecutive failures.
|
||||
* This handles cases where the SDK doesn't return useful error messages.
|
||||
* @param projectPath - The project to track failure for
|
||||
* @param errorInfo - Error information
|
||||
*/
|
||||
private trackFailureAndCheckPauseForProject(
|
||||
projectPath: string,
|
||||
errorInfo: { type: string; message: string }
|
||||
): boolean {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (!projectState) {
|
||||
// Fall back to legacy global tracking
|
||||
return this.trackFailureAndCheckPause(errorInfo);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Add this failure
|
||||
projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
|
||||
|
||||
// Remove old failures outside the window
|
||||
projectState.consecutiveFailures = projectState.consecutiveFailures.filter(
|
||||
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
||||
);
|
||||
|
||||
// Check if we've hit the threshold
|
||||
if (projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
||||
return true; // Should pause
|
||||
}
|
||||
|
||||
// Also immediately pause for known quota/rate limit errors
|
||||
if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a failure and check if we should pause due to consecutive failures (legacy global).
|
||||
*/
|
||||
private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean {
|
||||
const now = Date.now();
|
||||
@@ -311,7 +363,49 @@ export class AutoModeService {
|
||||
|
||||
/**
|
||||
* Signal that we should pause due to repeated failures or quota exhaustion.
|
||||
* This will pause the auto loop to prevent repeated failures.
|
||||
* This will pause the auto loop for a specific project.
|
||||
* @param projectPath - The project to pause
|
||||
* @param errorInfo - Error information
|
||||
*/
|
||||
private signalShouldPauseForProject(
|
||||
projectPath: string,
|
||||
errorInfo: { type: string; message: string }
|
||||
): void {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (!projectState) {
|
||||
// Fall back to legacy global pause
|
||||
this.signalShouldPause(errorInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectState.pausedDueToFailures) {
|
||||
return; // Already paused
|
||||
}
|
||||
|
||||
projectState.pausedDueToFailures = true;
|
||||
const failureCount = projectState.consecutiveFailures.length;
|
||||
logger.info(
|
||||
`Pausing auto loop for ${projectPath} after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
|
||||
);
|
||||
|
||||
// Emit event to notify UI
|
||||
this.emitAutoModeEvent('auto_mode_paused_failures', {
|
||||
message:
|
||||
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
||||
? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
|
||||
: 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
|
||||
errorType: errorInfo.type,
|
||||
originalError: errorInfo.message,
|
||||
failureCount,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Stop the auto loop for this project
|
||||
this.stopAutoLoopForProject(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that we should pause due to repeated failures or quota exhaustion (legacy global).
|
||||
*/
|
||||
private signalShouldPause(errorInfo: { type: string; message: string }): void {
|
||||
if (this.pausedDueToFailures) {
|
||||
@@ -341,7 +435,19 @@ export class AutoModeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failure tracking (called when user manually restarts auto mode)
|
||||
* Reset failure tracking for a specific project
|
||||
* @param projectPath - The project to reset failure tracking for
|
||||
*/
|
||||
private resetFailureTrackingForProject(projectPath: string): void {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (projectState) {
|
||||
projectState.consecutiveFailures = [];
|
||||
projectState.pausedDueToFailures = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failure tracking (called when user manually restarts auto mode) - legacy global
|
||||
*/
|
||||
private resetFailureTracking(): void {
|
||||
this.consecutiveFailures = [];
|
||||
@@ -349,16 +455,255 @@ export class AutoModeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful feature completion to reset consecutive failure count
|
||||
* Record a successful feature completion to reset consecutive failure count for a project
|
||||
* @param projectPath - The project to record success for
|
||||
*/
|
||||
private recordSuccessForProject(projectPath: string): void {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (projectState) {
|
||||
projectState.consecutiveFailures = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful feature completion to reset consecutive failure count - legacy global
|
||||
*/
|
||||
private recordSuccess(): void {
|
||||
this.consecutiveFailures = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the auto mode loop for a specific project (supports multiple concurrent projects)
|
||||
* @param projectPath - The project to start auto mode for
|
||||
* @param maxConcurrency - Maximum concurrent features (default: 3)
|
||||
*/
|
||||
async startAutoLoopForProject(projectPath: string, maxConcurrency = 3): Promise<void> {
|
||||
// Check if this project already has an active autoloop
|
||||
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
|
||||
const abortController = new AbortController();
|
||||
const config: AutoModeConfig = {
|
||||
maxConcurrency,
|
||||
useWorktrees: true,
|
||||
projectPath,
|
||||
};
|
||||
|
||||
const projectState: ProjectAutoLoopState = {
|
||||
abortController,
|
||||
config,
|
||||
isRunning: true,
|
||||
consecutiveFailures: [],
|
||||
pausedDueToFailures: false,
|
||||
};
|
||||
|
||||
this.autoLoopsByProject.set(projectPath, projectState);
|
||||
|
||||
logger.info(
|
||||
`Starting auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency}`
|
||||
);
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_started', {
|
||||
message: `Auto mode started with max ${maxConcurrency} concurrent features`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Save execution state for recovery after restart
|
||||
await this.saveExecutionStateForProject(projectPath, maxConcurrency);
|
||||
|
||||
// Run the loop in the background
|
||||
this.runAutoLoopForProject(projectPath).catch((error) => {
|
||||
logger.error(`Loop error for ${projectPath}:`, error);
|
||||
const errorInfo = classifyError(error);
|
||||
this.emitAutoModeEvent('auto_mode_error', {
|
||||
error: errorInfo.message,
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the auto loop for a specific project
|
||||
*/
|
||||
private async runAutoLoopForProject(projectPath: string): Promise<void> {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (!projectState) {
|
||||
logger.warn(`No project state found for ${projectPath}, stopping loop`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[AutoLoop] Starting loop for ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}`
|
||||
);
|
||||
let iterationCount = 0;
|
||||
|
||||
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
||||
iterationCount++;
|
||||
try {
|
||||
// Count running features for THIS project only
|
||||
const projectRunningCount = this.getRunningCountForProject(projectPath);
|
||||
|
||||
// Check if we have capacity for this project
|
||||
if (projectRunningCount >= projectState.config.maxConcurrency) {
|
||||
logger.debug(
|
||||
`[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...`
|
||||
);
|
||||
await this.sleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load pending features for this project
|
||||
const pendingFeatures = await this.loadPendingFeatures(projectPath);
|
||||
|
||||
logger.debug(
|
||||
`[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount} running`
|
||||
);
|
||||
|
||||
if (pendingFeatures.length === 0) {
|
||||
this.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath,
|
||||
});
|
||||
logger.info(`[AutoLoop] No pending features, sleeping for 10s...`);
|
||||
await this.sleep(10000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find a feature not currently running
|
||||
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
|
||||
|
||||
if (nextFeature) {
|
||||
logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`);
|
||||
// Start feature execution in background
|
||||
this.executeFeature(
|
||||
projectPath,
|
||||
nextFeature.id,
|
||||
projectState.config.useWorktrees,
|
||||
true
|
||||
).catch((error) => {
|
||||
logger.error(`Feature ${nextFeature.id} error:`, error);
|
||||
});
|
||||
} else {
|
||||
logger.debug(`[AutoLoop] All pending features are already running`);
|
||||
}
|
||||
|
||||
await this.sleep(2000);
|
||||
} catch (error) {
|
||||
logger.error(`[AutoLoop] Loop iteration error for ${projectPath}:`, error);
|
||||
await this.sleep(5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as not running when loop exits
|
||||
projectState.isRunning = false;
|
||||
logger.info(
|
||||
`[AutoLoop] Loop stopped for project: ${projectPath} after ${iterationCount} iterations`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of running features for a specific project
|
||||
*/
|
||||
private getRunningCountForProject(projectPath: string): number {
|
||||
let count = 0;
|
||||
for (const [, feature] of this.runningFeatures) {
|
||||
if (feature.projectPath === projectPath) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the auto mode loop for a specific project
|
||||
* @param projectPath - The project to stop auto mode for
|
||||
*/
|
||||
async stopAutoLoopForProject(projectPath: string): Promise<number> {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (!projectState) {
|
||||
logger.warn(`No auto loop running for project: ${projectPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const wasRunning = projectState.isRunning;
|
||||
projectState.isRunning = false;
|
||||
projectState.abortController.abort();
|
||||
|
||||
// Clear execution state when auto-loop is explicitly stopped
|
||||
await this.clearExecutionState(projectPath);
|
||||
|
||||
// Emit stop event
|
||||
if (wasRunning) {
|
||||
this.emitAutoModeEvent('auto_mode_stopped', {
|
||||
message: 'Auto mode stopped',
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from map
|
||||
this.autoLoopsByProject.delete(projectPath);
|
||||
|
||||
return this.getRunningCountForProject(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto mode is running for a specific project
|
||||
*/
|
||||
isAutoLoopRunningForProject(projectPath: string): boolean {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
return projectState?.isRunning ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auto loop config for a specific project
|
||||
*/
|
||||
getAutoLoopConfigForProject(projectPath: string): AutoModeConfig | null {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
return projectState?.config ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save execution state for a specific project
|
||||
*/
|
||||
private async saveExecutionStateForProject(
|
||||
projectPath: string,
|
||||
maxConcurrency: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const statePath = getExecutionStatePath(projectPath);
|
||||
const runningFeatureIds = Array.from(this.runningFeatures.entries())
|
||||
.filter(([, f]) => f.projectPath === projectPath)
|
||||
.map(([id]) => id);
|
||||
|
||||
const state: ExecutionState = {
|
||||
version: 1,
|
||||
autoLoopWasRunning: true,
|
||||
maxConcurrency,
|
||||
projectPath,
|
||||
runningFeatureIds,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
||||
logger.info(
|
||||
`Saved execution state for ${projectPath}: ${runningFeatureIds.length} running features`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save execution state for ${projectPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the auto mode loop - continuously picks and executes pending features
|
||||
* @deprecated Use startAutoLoopForProject instead for multi-project support
|
||||
*/
|
||||
async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise<void> {
|
||||
// For backward compatibility, delegate to the new per-project method
|
||||
// But also maintain legacy state for existing code that might check it
|
||||
if (this.autoLoopRunning) {
|
||||
throw new Error('Auto mode is already running');
|
||||
}
|
||||
@@ -396,6 +741,9 @@ export class AutoModeService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use runAutoLoopForProject instead
|
||||
*/
|
||||
private async runAutoLoop(): Promise<void> {
|
||||
while (
|
||||
this.autoLoopRunning &&
|
||||
@@ -448,6 +796,7 @@ export class AutoModeService {
|
||||
|
||||
/**
|
||||
* Stop the auto mode loop
|
||||
* @deprecated Use stopAutoLoopForProject instead for multi-project support
|
||||
*/
|
||||
async stopAutoLoop(): Promise<number> {
|
||||
const wasRunning = this.autoLoopRunning;
|
||||
@@ -1777,6 +2126,46 @@ Format your response as a structured markdown document.`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status for a specific project
|
||||
* @param projectPath - The project to get status for
|
||||
*/
|
||||
getStatusForProject(projectPath: string): {
|
||||
isAutoLoopRunning: boolean;
|
||||
runningFeatures: string[];
|
||||
runningCount: number;
|
||||
maxConcurrency: number;
|
||||
} {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
const runningFeatures: string[] = [];
|
||||
|
||||
for (const [featureId, feature] of this.runningFeatures) {
|
||||
if (feature.projectPath === projectPath) {
|
||||
runningFeatures.push(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isAutoLoopRunning: projectState?.isRunning ?? false,
|
||||
runningFeatures,
|
||||
runningCount: runningFeatures.length,
|
||||
maxConcurrency: projectState?.config.maxConcurrency ?? 3,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all projects that have auto mode running
|
||||
*/
|
||||
getActiveAutoLoopProjects(): string[] {
|
||||
const activeProjects: string[] = [];
|
||||
for (const [projectPath, state] of this.autoLoopsByProject) {
|
||||
if (state.isRunning) {
|
||||
activeProjects.push(projectPath);
|
||||
}
|
||||
}
|
||||
return activeProjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed info about all running agents
|
||||
*/
|
||||
@@ -2254,6 +2643,10 @@ Format your response as a structured markdown document.`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status`
|
||||
);
|
||||
|
||||
// Apply dependency-aware ordering
|
||||
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
||||
|
||||
@@ -2266,8 +2659,13 @@ Format your response as a structured markdown document.`;
|
||||
areDependenciesSatisfied(feature, allFeatures, { skipVerification })
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})`
|
||||
);
|
||||
|
||||
return readyFeatures;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.error(`[loadPendingFeatures] Error loading features:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
} from '../types/settings.js';
|
||||
import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsService');
|
||||
|
||||
@@ -127,10 +128,14 @@ export class SettingsService {
|
||||
// Migrate legacy enhancementModel/validationModel to phaseModels
|
||||
const migratedPhaseModels = this.migratePhaseModels(settings);
|
||||
|
||||
// Migrate model IDs to canonical format
|
||||
const migratedModelSettings = this.migrateModelSettings(settings);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
let result: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...settings,
|
||||
...migratedModelSettings,
|
||||
keyboardShortcuts: {
|
||||
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
...settings.keyboardShortcuts,
|
||||
@@ -226,19 +231,70 @@ export class SettingsService {
|
||||
* Convert a phase model value to PhaseModelEntry format
|
||||
*
|
||||
* Handles migration from string format (v2) to object format (v3).
|
||||
* - String values like 'sonnet' become { model: 'sonnet' }
|
||||
* - Object values are returned as-is (with type assertion)
|
||||
* Also migrates legacy model IDs to canonical prefixed format.
|
||||
* - String values like 'sonnet' become { model: 'claude-sonnet' }
|
||||
* - Object values have their model ID migrated if needed
|
||||
*
|
||||
* @param value - Phase model value (string or PhaseModelEntry)
|
||||
* @returns PhaseModelEntry object
|
||||
* @returns PhaseModelEntry object with canonical model ID
|
||||
*/
|
||||
private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry {
|
||||
if (typeof value === 'string') {
|
||||
// v2 format: just a model string
|
||||
return { model: value as PhaseModelEntry['model'] };
|
||||
// v2 format: just a model string - migrate to canonical ID
|
||||
return { model: migrateModelId(value) as PhaseModelEntry['model'] };
|
||||
}
|
||||
// v3 format: already a PhaseModelEntry object
|
||||
return value;
|
||||
// v3 format: PhaseModelEntry object - migrate model ID if needed
|
||||
return {
|
||||
...value,
|
||||
model: migrateModelId(value.model) as PhaseModelEntry['model'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate model-related settings to canonical format
|
||||
*
|
||||
* Migrates:
|
||||
* - enabledCursorModels: legacy IDs to cursor- prefixed
|
||||
* - enabledOpencodeModels: legacy slash format to dash format
|
||||
* - cursorDefaultModel: legacy ID to cursor- prefixed
|
||||
*
|
||||
* @param settings - Settings to migrate
|
||||
* @returns Settings with migrated model IDs
|
||||
*/
|
||||
private migrateModelSettings(settings: Partial<GlobalSettings>): Partial<GlobalSettings> {
|
||||
const migrated: Partial<GlobalSettings> = { ...settings };
|
||||
|
||||
// Migrate Cursor models
|
||||
if (settings.enabledCursorModels) {
|
||||
migrated.enabledCursorModels = migrateCursorModelIds(
|
||||
settings.enabledCursorModels as string[]
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate Cursor default model
|
||||
if (settings.cursorDefaultModel) {
|
||||
const migratedDefault = migrateCursorModelIds([settings.cursorDefaultModel as string]);
|
||||
if (migratedDefault.length > 0) {
|
||||
migrated.cursorDefaultModel = migratedDefault[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate OpenCode models
|
||||
if (settings.enabledOpencodeModels) {
|
||||
migrated.enabledOpencodeModels = migrateOpencodeModelIds(
|
||||
settings.enabledOpencodeModels as string[]
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate OpenCode default model
|
||||
if (settings.opencodeDefaultModel) {
|
||||
const migratedDefault = migrateOpencodeModelIds([settings.opencodeDefaultModel as string]);
|
||||
if (migratedDefault.length > 0) {
|
||||
migrated.opencodeDefaultModel = migratedDefault[0];
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('model-resolver.ts', () => {
|
||||
const result = resolveModelString('opus');
|
||||
expect(result).toBe('claude-opus-4-5-20251101');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Resolved Claude model alias: "opus"')
|
||||
expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"')
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ describe('cursor-config-manager.ts', () => {
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.defaultModel).toBe('auto');
|
||||
expect(config.models).toContain('auto');
|
||||
expect(config.defaultModel).toBe('cursor-auto');
|
||||
expect(config.models).toContain('cursor-auto');
|
||||
});
|
||||
|
||||
it('should use default config if file read fails', () => {
|
||||
@@ -62,7 +62,7 @@ describe('cursor-config-manager.ts', () => {
|
||||
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
expect(manager.getDefaultModel()).toBe('auto');
|
||||
expect(manager.getDefaultModel()).toBe('cursor-auto');
|
||||
});
|
||||
|
||||
it('should use default config if JSON parse fails', () => {
|
||||
@@ -71,7 +71,7 @@ describe('cursor-config-manager.ts', () => {
|
||||
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
expect(manager.getDefaultModel()).toBe('auto');
|
||||
expect(manager.getDefaultModel()).toBe('cursor-auto');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('cursor-config-manager.ts', () => {
|
||||
});
|
||||
|
||||
it('should return default model', () => {
|
||||
expect(manager.getDefaultModel()).toBe('auto');
|
||||
expect(manager.getDefaultModel()).toBe('cursor-auto');
|
||||
});
|
||||
|
||||
it('should set and persist default model', () => {
|
||||
@@ -103,13 +103,13 @@ describe('cursor-config-manager.ts', () => {
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return auto if defaultModel is undefined', () => {
|
||||
it('should return cursor-auto if defaultModel is undefined', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['auto'] }));
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['cursor-auto'] }));
|
||||
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
expect(manager.getDefaultModel()).toBe('auto');
|
||||
expect(manager.getDefaultModel()).toBe('cursor-auto');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('cursor-config-manager.ts', () => {
|
||||
it('should return enabled models', () => {
|
||||
const models = manager.getEnabledModels();
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models).toContain('auto');
|
||||
expect(models).toContain('cursor-auto');
|
||||
});
|
||||
|
||||
it('should set enabled models', () => {
|
||||
@@ -131,13 +131,13 @@ describe('cursor-config-manager.ts', () => {
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return [auto] if models is undefined', () => {
|
||||
it('should return [cursor-auto] if models is undefined', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' }));
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' }));
|
||||
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
expect(manager.getEnabledModels()).toEqual(['auto']);
|
||||
expect(manager.getEnabledModels()).toEqual(['cursor-auto']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,8 +146,8 @@ describe('cursor-config-manager.ts', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify({
|
||||
defaultModel: 'auto',
|
||||
models: ['auto'],
|
||||
defaultModel: 'cursor-auto',
|
||||
models: ['cursor-auto'],
|
||||
})
|
||||
);
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
@@ -161,14 +161,14 @@ describe('cursor-config-manager.ts', () => {
|
||||
});
|
||||
|
||||
it('should not add duplicate models', () => {
|
||||
manager.addModel('auto');
|
||||
manager.addModel('cursor-auto');
|
||||
|
||||
// Should not save if model already exists
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initialize models array if undefined', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' }));
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' }));
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
manager.addModel('claude-3-5-sonnet');
|
||||
@@ -293,7 +293,7 @@ describe('cursor-config-manager.ts', () => {
|
||||
it('should reset to default values', () => {
|
||||
manager.reset();
|
||||
|
||||
expect(manager.getDefaultModel()).toBe('auto');
|
||||
expect(manager.getDefaultModel()).toBe('cursor-auto');
|
||||
expect(manager.getMcpServers()).toEqual([]);
|
||||
expect(manager.getRules()).toEqual([]);
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
|
||||
@@ -647,9 +647,10 @@ describe('settings-service.ts', () => {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Verify all phase models are now PhaseModelEntry objects
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
|
||||
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
|
||||
// Legacy aliases are migrated to canonical IDs
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
|
||||
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' });
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
|
||||
expect(settings.version).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
@@ -675,16 +676,17 @@ describe('settings-service.ts', () => {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Verify PhaseModelEntry objects are preserved with thinkingLevel
|
||||
// Legacy aliases are migrated to canonical IDs
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({
|
||||
model: 'sonnet',
|
||||
model: 'claude-sonnet',
|
||||
thinkingLevel: 'high',
|
||||
});
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({
|
||||
model: 'opus',
|
||||
model: 'claude-opus',
|
||||
thinkingLevel: 'ultrathink',
|
||||
});
|
||||
expect(settings.phaseModels.backlogPlanningModel).toEqual({
|
||||
model: 'sonnet',
|
||||
model: 'claude-sonnet',
|
||||
thinkingLevel: 'medium',
|
||||
});
|
||||
});
|
||||
@@ -710,15 +712,15 @@ describe('settings-service.ts', () => {
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Strings should be converted to objects
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
|
||||
expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'haiku' });
|
||||
// Objects should be preserved
|
||||
// Strings should be converted to objects with canonical IDs
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
|
||||
expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'claude-haiku' });
|
||||
// Objects should be preserved with migrated IDs
|
||||
expect(settings.phaseModels.fileDescriptionModel).toEqual({
|
||||
model: 'haiku',
|
||||
model: 'claude-haiku',
|
||||
thinkingLevel: 'low',
|
||||
});
|
||||
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
|
||||
expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' });
|
||||
});
|
||||
|
||||
it('should migrate legacy enhancementModel/validationModel fields', async () => {
|
||||
@@ -735,11 +737,11 @@ describe('settings-service.ts', () => {
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Legacy fields should be migrated to phaseModels
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'haiku' });
|
||||
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
|
||||
// Other fields should use defaults
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
|
||||
// Legacy fields should be migrated to phaseModels with canonical IDs
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-haiku' });
|
||||
expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' });
|
||||
// Other fields should use defaults (canonical IDs)
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
|
||||
});
|
||||
|
||||
it('should use default phase models when none are configured', async () => {
|
||||
@@ -753,10 +755,10 @@ describe('settings-service.ts', () => {
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Should use DEFAULT_PHASE_MODELS
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
|
||||
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
|
||||
// Should use DEFAULT_PHASE_MODELS (with canonical IDs)
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
|
||||
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' });
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
|
||||
});
|
||||
|
||||
it('should deep merge phaseModels on update', async () => {
|
||||
@@ -776,13 +778,13 @@ describe('settings-service.ts', () => {
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Both should be preserved
|
||||
// Both should be preserved (models migrated to canonical format)
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({
|
||||
model: 'sonnet',
|
||||
model: 'claude-sonnet',
|
||||
thinkingLevel: 'high',
|
||||
});
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({
|
||||
model: 'opus',
|
||||
model: 'claude-opus',
|
||||
thinkingLevel: 'ultrathink',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -536,7 +536,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
if (modelStr.includes('grok')) {
|
||||
return 'grok';
|
||||
}
|
||||
if (modelStr.includes('cursor') || modelStr === 'auto' || modelStr === 'composer-1') {
|
||||
// Cursor models - canonical format includes 'cursor-' prefix
|
||||
// Also support legacy IDs for backward compatibility
|
||||
if (
|
||||
modelStr.includes('cursor') ||
|
||||
modelStr === 'auto' ||
|
||||
modelStr === 'composer-1' ||
|
||||
modelStr === 'cursor-auto' ||
|
||||
modelStr === 'cursor-composer-1'
|
||||
) {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function AgentView() {
|
||||
return () => window.removeEventListener('resize', updateVisibility);
|
||||
}, []);
|
||||
|
||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
|
||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
|
||||
|
||||
// Input ref for auto-focus
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -856,68 +856,9 @@ export function BoardView() {
|
||||
[handleAddFeature, handleStartImplementation]
|
||||
);
|
||||
|
||||
// Client-side auto mode: periodically check for backlog items and move them to in-progress
|
||||
// Use a ref to track the latest auto mode state so async operations always check the current value
|
||||
const autoModeRunningRef = useRef(autoMode.isRunning);
|
||||
useEffect(() => {
|
||||
autoModeRunningRef.current = autoMode.isRunning;
|
||||
}, [autoMode.isRunning]);
|
||||
|
||||
// Use a ref to track the latest features to avoid effect re-runs when features change
|
||||
const hookFeaturesRef = useRef(hookFeatures);
|
||||
useEffect(() => {
|
||||
hookFeaturesRef.current = hookFeatures;
|
||||
}, [hookFeatures]);
|
||||
|
||||
// Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef
|
||||
const runningAutoTasksRef = useRef(runningAutoTasks);
|
||||
useEffect(() => {
|
||||
runningAutoTasksRef.current = runningAutoTasks;
|
||||
}, [runningAutoTasks]);
|
||||
|
||||
// Keep latest start handler without retriggering the auto mode effect
|
||||
const handleStartImplementationRef = useRef(handleStartImplementation);
|
||||
useEffect(() => {
|
||||
handleStartImplementationRef.current = handleStartImplementation;
|
||||
}, [handleStartImplementation]);
|
||||
|
||||
// Track features that are pending (started but not yet confirmed running)
|
||||
const pendingFeaturesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Listen to auto mode events to remove features from pending when they start running
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Only process events for the current project
|
||||
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
|
||||
if (eventProjectPath && eventProjectPath !== currentProject.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'auto_mode_feature_start':
|
||||
// Feature is now confirmed running - remove from pending
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_feature_complete':
|
||||
case 'auto_mode_error':
|
||||
// Feature completed or errored - remove from pending if still there
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [currentProject]);
|
||||
// NOTE: Auto mode polling loop has been moved to the backend.
|
||||
// The frontend now just toggles the backend's auto loop via API calls.
|
||||
// See use-auto-mode.ts for the start/stop logic that calls the backend.
|
||||
|
||||
// Listen for backlog plan events (for background generation)
|
||||
useEffect(() => {
|
||||
@@ -976,219 +917,6 @@ export function BoardView() {
|
||||
};
|
||||
}, [currentProject, pendingBacklogPlan]);
|
||||
|
||||
useEffect(() => {
|
||||
logger.info(
|
||||
'[AutoMode] Effect triggered - isRunning:',
|
||||
autoMode.isRunning,
|
||||
'hasProject:',
|
||||
!!currentProject
|
||||
);
|
||||
if (!autoMode.isRunning || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path);
|
||||
let isChecking = false;
|
||||
let isActive = true; // Track if this effect is still active
|
||||
|
||||
const checkAndStartFeatures = async () => {
|
||||
// Check if auto mode is still running and effect is still active
|
||||
// Use ref to get the latest value, not the closure value
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent executions
|
||||
if (isChecking) {
|
||||
return;
|
||||
}
|
||||
|
||||
isChecking = true;
|
||||
try {
|
||||
// Double-check auto mode is still running before proceeding
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
logger.debug(
|
||||
'[AutoMode] Skipping check - isActive:',
|
||||
isActive,
|
||||
'autoModeRunning:',
|
||||
autoModeRunningRef.current,
|
||||
'hasProject:',
|
||||
!!currentProject
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Count currently running tasks + pending features
|
||||
// Use ref to get the latest running tasks without causing effect re-runs
|
||||
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
|
||||
const availableSlots = maxConcurrency - currentRunning;
|
||||
logger.debug(
|
||||
'[AutoMode] Checking features - running:',
|
||||
currentRunning,
|
||||
'available slots:',
|
||||
availableSlots
|
||||
);
|
||||
|
||||
// No available slots, skip check
|
||||
if (availableSlots <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This logic mirrors use-board-column-features.ts for consistency.
|
||||
// HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree,
|
||||
// so we fall back to "all backlog features" when none are visible in the current view.
|
||||
// Use ref to get the latest features without causing effect re-runs
|
||||
const currentFeatures = hookFeaturesRef.current;
|
||||
const backlogFeaturesInView = currentFeatures.filter((f) => {
|
||||
if (f.status !== 'backlog') return false;
|
||||
|
||||
const featureBranch = f.branchName;
|
||||
|
||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||
if (!featureBranch) {
|
||||
// No branch assigned - show only when viewing primary worktree
|
||||
const isViewingPrimary = currentWorktreePath === null;
|
||||
return isViewingPrimary;
|
||||
}
|
||||
|
||||
if (currentWorktreeBranch === null) {
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// Show features assigned to primary worktree's branch
|
||||
return currentProject.path
|
||||
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
|
||||
: false;
|
||||
}
|
||||
|
||||
// Match by branch name
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
});
|
||||
|
||||
const backlogFeatures =
|
||||
backlogFeaturesInView.length > 0
|
||||
? backlogFeaturesInView
|
||||
: currentFeatures.filter((f) => f.status === 'backlog');
|
||||
|
||||
logger.debug(
|
||||
'[AutoMode] Features - total:',
|
||||
currentFeatures.length,
|
||||
'backlog in view:',
|
||||
backlogFeaturesInView.length,
|
||||
'backlog total:',
|
||||
backlogFeatures.length
|
||||
);
|
||||
|
||||
if (backlogFeatures.length === 0) {
|
||||
logger.debug(
|
||||
'[AutoMode] No backlog features found, statuses:',
|
||||
currentFeatures.map((f) => f.status).join(', ')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by priority (lower number = higher priority, priority 1 is highest)
|
||||
const sortedBacklog = [...backlogFeatures].sort(
|
||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||
);
|
||||
|
||||
// Filter out features with blocking dependencies if dependency blocking is enabled
|
||||
// NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we
|
||||
// should NOT exclude blocked features in that mode.
|
||||
const eligibleFeatures =
|
||||
enableDependencyBlocking && !skipVerificationInAutoMode
|
||||
? sortedBacklog.filter((f) => {
|
||||
const blockingDeps = getBlockingDependencies(f, currentFeatures);
|
||||
if (blockingDeps.length > 0) {
|
||||
logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps);
|
||||
}
|
||||
return blockingDeps.length === 0;
|
||||
})
|
||||
: sortedBacklog;
|
||||
|
||||
logger.debug(
|
||||
'[AutoMode] Eligible features after dep check:',
|
||||
eligibleFeatures.length,
|
||||
'dependency blocking enabled:',
|
||||
enableDependencyBlocking
|
||||
);
|
||||
|
||||
// Start features up to available slots
|
||||
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
|
||||
const startImplementation = handleStartImplementationRef.current;
|
||||
if (!startImplementation) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'[AutoMode] Starting',
|
||||
featuresToStart.length,
|
||||
'features:',
|
||||
featuresToStart.map((f) => f.id).join(', ')
|
||||
);
|
||||
|
||||
for (const feature of featuresToStart) {
|
||||
// Check again before starting each feature
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
|
||||
// If feature has no branchName, assign it to the primary branch so it can run consistently
|
||||
// even when the user is viewing a non-primary worktree.
|
||||
if (!feature.branchName) {
|
||||
const primaryBranch =
|
||||
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
|
||||
'main';
|
||||
await persistFeatureUpdate(feature.id, {
|
||||
branchName: primaryBranch,
|
||||
});
|
||||
}
|
||||
|
||||
// Final check before starting implementation
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the implementation - server will derive workDir from feature.branchName
|
||||
const started = await startImplementation(feature);
|
||||
|
||||
// If successfully started, track it as pending until we receive the start event
|
||||
if (started) {
|
||||
pendingFeaturesRef.current.add(feature.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isChecking = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately, then every 3 seconds
|
||||
checkAndStartFeatures();
|
||||
const interval = setInterval(checkAndStartFeatures, 3000);
|
||||
|
||||
return () => {
|
||||
// Mark as inactive to prevent any pending async operations from continuing
|
||||
isActive = false;
|
||||
clearInterval(interval);
|
||||
// Clear pending features when effect unmounts or dependencies change
|
||||
pendingFeaturesRef.current.clear();
|
||||
};
|
||||
}, [
|
||||
autoMode.isRunning,
|
||||
currentProject,
|
||||
// runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs
|
||||
// that would clear pendingFeaturesRef and cause concurrency issues
|
||||
maxConcurrency,
|
||||
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
|
||||
currentWorktreeBranch,
|
||||
currentWorktreePath,
|
||||
getPrimaryWorktreeBranch,
|
||||
isPrimaryWorktreeBranch,
|
||||
enableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
persistFeatureUpdate,
|
||||
]);
|
||||
|
||||
// Use keyboard shortcuts hook (after actions hook)
|
||||
useBoardKeyboardShortcuts({
|
||||
features: hookFeatures,
|
||||
@@ -1403,9 +1131,13 @@ export function BoardView() {
|
||||
isAutoModeRunning={autoMode.isRunning}
|
||||
onAutoModeToggle={(enabled) => {
|
||||
if (enabled) {
|
||||
autoMode.start();
|
||||
autoMode.start().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to start:', error);
|
||||
});
|
||||
} else {
|
||||
autoMode.stop();
|
||||
autoMode.stop().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to stop:', error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||
|
||||
@@ -170,7 +170,7 @@ export function AddFeatureDialog({
|
||||
const [priority, setPriority] = useState(2);
|
||||
|
||||
// Model selection state
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
|
||||
|
||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { toast } from 'sonner';
|
||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||
import { migrateModelId } from '@automaker/types';
|
||||
import {
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
@@ -107,9 +108,9 @@ export function EditFeatureDialog({
|
||||
feature?.requirePlanApproval ?? false
|
||||
);
|
||||
|
||||
// Model selection state
|
||||
// Model selection state - migrate legacy model IDs to canonical format
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
|
||||
model: (feature?.model as ModelAlias) || 'opus',
|
||||
model: migrateModelId(feature?.model) || 'claude-opus',
|
||||
thinkingLevel: feature?.thinkingLevel || 'none',
|
||||
reasoningEffort: feature?.reasoningEffort || 'none',
|
||||
}));
|
||||
@@ -157,9 +158,9 @@ export function EditFeatureDialog({
|
||||
setDescriptionChangeSource(null);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory(feature.descriptionHistory ?? []);
|
||||
// Reset model entry
|
||||
// Reset model entry - migrate legacy model IDs
|
||||
setModelEntry({
|
||||
model: (feature.model as ModelAlias) || 'opus',
|
||||
model: migrateModelId(feature.model) || 'claude-opus',
|
||||
thinkingLevel: feature.thinkingLevel || 'none',
|
||||
reasoningEffort: feature.reasoningEffort || 'none',
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ export function MassEditDialog({
|
||||
});
|
||||
|
||||
// Field values
|
||||
const [model, setModel] = useState<ModelAlias>('sonnet');
|
||||
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
@@ -160,7 +160,7 @@ export function MassEditDialog({
|
||||
skipTests: false,
|
||||
branchName: false,
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
export type ModelOption = {
|
||||
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
|
||||
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
@@ -17,23 +17,27 @@ export type ModelOption = {
|
||||
hasThinking?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude models with canonical prefixed IDs
|
||||
* UI displays short labels but stores full canonical IDs
|
||||
*/
|
||||
export const CLAUDE_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: 'haiku',
|
||||
id: 'claude-haiku', // Canonical prefixed ID
|
||||
label: 'Claude Haiku',
|
||||
description: 'Fast and efficient for simple tasks.',
|
||||
badge: 'Speed',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'sonnet',
|
||||
id: 'claude-sonnet', // Canonical prefixed ID
|
||||
label: 'Claude Sonnet',
|
||||
description: 'Balanced performance with strong reasoning.',
|
||||
badge: 'Balanced',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'opus',
|
||||
id: 'claude-opus', // Canonical prefixed ID
|
||||
label: 'Claude Opus',
|
||||
description: 'Most capable model for complex work.',
|
||||
badge: 'Premium',
|
||||
@@ -43,11 +47,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
|
||||
|
||||
/**
|
||||
* Cursor models derived from CURSOR_MODEL_MAP
|
||||
* ID is prefixed with "cursor-" for ProviderFactory routing (if not already prefixed)
|
||||
* IDs already have 'cursor-' prefix in the canonical format
|
||||
*/
|
||||
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id: id.startsWith('cursor-') ? id : `cursor-${id}`,
|
||||
id, // Already prefixed in canonical format
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
provider: 'cursor' as ModelProvider,
|
||||
|
||||
@@ -70,22 +70,30 @@ export function ModelSelector({
|
||||
|
||||
// Filter Cursor models based on enabled models from global settings
|
||||
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix
|
||||
return enabledCursorModels.includes(model.id as any);
|
||||
// enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix
|
||||
// (e.g., 'auto', 'sonnet-4.5' without prefix, but 'cursor-gpt-5.2' with prefix)
|
||||
// CURSOR_MODELS always has the "cursor-" prefix added in model-constants.ts
|
||||
// Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models)
|
||||
const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id;
|
||||
return (
|
||||
enabledCursorModels.includes(model.id as any) ||
|
||||
enabledCursorModels.includes(unprefixedId as any)
|
||||
);
|
||||
});
|
||||
|
||||
const handleProviderChange = (provider: ModelProvider) => {
|
||||
if (provider === 'cursor' && selectedProvider !== 'cursor') {
|
||||
// Switch to Cursor's default model (from global settings)
|
||||
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
|
||||
// cursorDefaultModel is now canonical (e.g., 'cursor-auto'), so use directly
|
||||
onModelSelect(cursorDefaultModel);
|
||||
} else if (provider === 'codex' && selectedProvider !== 'codex') {
|
||||
// Switch to Codex's default model (use isDefault flag from dynamic models)
|
||||
const defaultModel = codexModels.find((m) => m.isDefault);
|
||||
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
|
||||
onModelSelect(defaultModelId);
|
||||
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
||||
// Switch to Claude's default model
|
||||
onModelSelect('sonnet');
|
||||
// Switch to Claude's default model (canonical format)
|
||||
onModelSelect('claude-sonnet');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -279,8 +279,8 @@ export function PhaseModelSelector({
|
||||
}, [codexModels]);
|
||||
|
||||
// Filter Cursor models to only show enabled ones
|
||||
// With canonical IDs, both CURSOR_MODELS and enabledCursorModels use prefixed format
|
||||
const availableCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix
|
||||
return enabledCursorModels.includes(model.id as CursorModelId);
|
||||
});
|
||||
|
||||
@@ -300,6 +300,7 @@ export function PhaseModelSelector({
|
||||
};
|
||||
}
|
||||
|
||||
// With canonical IDs, direct comparison works
|
||||
const cursorModel = availableCursorModels.find((m) => m.id === selectedModel);
|
||||
if (cursorModel) return { ...cursorModel, icon: CursorIcon };
|
||||
|
||||
@@ -352,7 +353,7 @@ export function PhaseModelSelector({
|
||||
const seenGroups = new Set<string>();
|
||||
|
||||
availableCursorModels.forEach((model) => {
|
||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||
const cursorId = model.id as CursorModelId;
|
||||
|
||||
// Check if this model is standalone
|
||||
if (STANDALONE_CURSOR_MODELS.includes(cursorId)) {
|
||||
@@ -908,8 +909,8 @@ export function PhaseModelSelector({
|
||||
|
||||
// Render Cursor model item (no thinking level needed)
|
||||
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
|
||||
const modelValue = stripProviderPrefix(model.id);
|
||||
const isSelected = selectedModel === modelValue;
|
||||
// With canonical IDs, store the full prefixed ID
|
||||
const isSelected = selectedModel === model.id;
|
||||
const isFavorite = favoriteModels.includes(model.id);
|
||||
|
||||
return (
|
||||
@@ -917,7 +918,7 @@ export function PhaseModelSelector({
|
||||
key={model.id}
|
||||
value={model.label}
|
||||
onSelect={() => {
|
||||
onChange({ model: modelValue as CursorModelId });
|
||||
onChange({ model: model.id as CursorModelId });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group flex items-center justify-between py-2"
|
||||
@@ -1458,7 +1459,7 @@ export function PhaseModelSelector({
|
||||
return favorites.map((model) => {
|
||||
// Check if this favorite is part of a grouped model
|
||||
if (model.provider === 'cursor') {
|
||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||
const cursorId = model.id as CursorModelId;
|
||||
const group = getModelGroup(cursorId);
|
||||
if (group) {
|
||||
// Skip if we already rendered this group
|
||||
|
||||
@@ -92,7 +92,8 @@ export function CursorModelConfiguration({
|
||||
<div className="grid gap-3">
|
||||
{availableModels.map((model) => {
|
||||
const isEnabled = enabledCursorModels.includes(model.id);
|
||||
const isAuto = model.id === 'auto';
|
||||
// With canonical IDs, 'auto' becomes 'cursor-auto'
|
||||
const isAuto = model.id === 'cursor-auto';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -94,21 +94,33 @@ export function useAutoMode() {
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
|
||||
// Restore auto-mode toggle after a renderer refresh (e.g. dev HMR reload).
|
||||
// This is intentionally session-scoped to avoid auto-running features after a full app restart.
|
||||
// On mount, query backend for current auto loop status and sync UI state.
|
||||
// This handles cases where the backend is still running after a page refresh.
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const session = readAutoModeSession();
|
||||
const desired = session[currentProject.path];
|
||||
if (typeof desired !== 'boolean') return;
|
||||
const syncWithBackend = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
if (desired !== isAutoModeRunning) {
|
||||
logger.info(
|
||||
`[AutoMode] Restoring session state for ${currentProject.path}: ${desired ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(currentProject.id, desired);
|
||||
}
|
||||
const result = await api.autoMode.status(currentProject.path);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(currentProject.id, backendIsRunning);
|
||||
setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
};
|
||||
|
||||
syncWithBackend();
|
||||
}, [currentProject, isAutoModeRunning, setAutoModeRunning]);
|
||||
|
||||
// Handle auto mode events - listen globally for all projects
|
||||
@@ -139,6 +151,22 @@ export function useAutoMode() {
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'auto_mode_started':
|
||||
// Backend started auto loop - update UI state
|
||||
logger.info('[AutoMode] Backend started auto loop for project');
|
||||
if (eventProjectId) {
|
||||
setAutoModeRunning(eventProjectId, true);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_stopped':
|
||||
// Backend stopped auto loop - update UI state
|
||||
logger.info('[AutoMode] Backend stopped auto loop for project');
|
||||
if (eventProjectId) {
|
||||
setAutoModeRunning(eventProjectId, false);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_feature_start':
|
||||
if (event.featureId) {
|
||||
addRunningTask(eventProjectId, event.featureId);
|
||||
@@ -374,35 +402,92 @@ export function useAutoMode() {
|
||||
addAutoModeActivity,
|
||||
getProjectIdFromPath,
|
||||
setPendingPlanApproval,
|
||||
setAutoModeRunning,
|
||||
currentProject?.path,
|
||||
]);
|
||||
|
||||
// Start auto mode - UI only, feature pickup is handled in board-view.tsx
|
||||
const start = useCallback(() => {
|
||||
// Start auto mode - calls backend to start the auto loop
|
||||
const start = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
logger.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
logger.debug(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.start) {
|
||||
throw new Error('Start auto mode API not available');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}`
|
||||
);
|
||||
|
||||
// Optimistically update UI state (backend will confirm via event)
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
|
||||
// Call backend to start the auto loop
|
||||
const result = await api.autoMode.start(currentProject.path, maxConcurrency);
|
||||
|
||||
if (!result.success) {
|
||||
// Revert UI state on failure
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
logger.error('Failed to start auto mode:', result.error);
|
||||
throw new Error(result.error || 'Failed to start auto mode');
|
||||
}
|
||||
|
||||
logger.debug(`[AutoMode] Started successfully`);
|
||||
} catch (error) {
|
||||
// Revert UI state on error
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
logger.error('Error starting auto mode:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning, maxConcurrency]);
|
||||
|
||||
// Stop auto mode - UI only, running tasks continue until natural completion
|
||||
const stop = useCallback(() => {
|
||||
// Stop auto mode - calls backend to stop the auto loop
|
||||
const stop = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
logger.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
// NOTE: We intentionally do NOT clear running tasks here.
|
||||
// Stopping auto mode only turns off the toggle to prevent new features
|
||||
// from being picked up. Running tasks will complete naturally and be
|
||||
// removed via the auto_mode_feature_complete event.
|
||||
logger.info('Stopped - running tasks will continue');
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.stop) {
|
||||
throw new Error('Stop auto mode API not available');
|
||||
}
|
||||
|
||||
logger.info(`[AutoMode] Stopping auto loop for ${currentProject.path}`);
|
||||
|
||||
// Optimistically update UI state (backend will confirm via event)
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
|
||||
// Call backend to stop the auto loop
|
||||
const result = await api.autoMode.stop(currentProject.path);
|
||||
|
||||
if (!result.success) {
|
||||
// Revert UI state on failure
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
logger.error('Failed to stop auto mode:', result.error);
|
||||
throw new Error(result.error || 'Failed to stop auto mode');
|
||||
}
|
||||
|
||||
// NOTE: Running tasks will continue until natural completion.
|
||||
// The backend stops picking up new features but doesn't abort running ones.
|
||||
logger.info('Stopped - running tasks will continue');
|
||||
} catch (error) {
|
||||
// Revert UI state on error
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
logger.error('Error stopping auto mode:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning]);
|
||||
|
||||
// Stop a specific feature
|
||||
|
||||
@@ -31,7 +31,11 @@ import { useSetupStore } from '@/store/setup-store';
|
||||
import {
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
getAllOpencodeModelIds,
|
||||
getAllCursorModelIds,
|
||||
migrateCursorModelIds,
|
||||
migratePhaseModelEntry,
|
||||
type GlobalSettings,
|
||||
type CursorModelId,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsMigration');
|
||||
@@ -566,6 +570,19 @@ export function useSettingsMigration(): MigrationState {
|
||||
*/
|
||||
export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
const current = useAppStore.getState();
|
||||
|
||||
// Migrate Cursor models to canonical format
|
||||
// IMPORTANT: Always use ALL available Cursor models to ensure new models are visible
|
||||
// Users who had old settings with a subset of models should still see all available models
|
||||
const allCursorModels = getAllCursorModelIds();
|
||||
const migratedCursorDefault = migrateCursorModelIds([
|
||||
settings.cursorDefaultModel ?? current.cursorDefaultModel ?? 'cursor-auto',
|
||||
])[0];
|
||||
const validCursorModelIds = new Set(allCursorModels);
|
||||
const sanitizedCursorDefaultModel = validCursorModelIds.has(migratedCursorDefault)
|
||||
? migratedCursorDefault
|
||||
: ('cursor-auto' as CursorModelId);
|
||||
|
||||
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
|
||||
const incomingEnabledOpencodeModels =
|
||||
settings.enabledOpencodeModels ?? current.enabledOpencodeModels;
|
||||
@@ -631,15 +648,17 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
useWorktrees: settings.useWorktrees ?? true,
|
||||
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
|
||||
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
|
||||
defaultFeatureModel: settings.defaultFeatureModel ?? { model: 'opus' },
|
||||
defaultFeatureModel: migratePhaseModelEntry(settings.defaultFeatureModel) ?? {
|
||||
model: 'claude-opus',
|
||||
},
|
||||
muteDoneSound: settings.muteDoneSound ?? false,
|
||||
serverLogLevel: settings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: settings.enableRequestLogging ?? true,
|
||||
enhancementModel: settings.enhancementModel ?? 'sonnet',
|
||||
validationModel: settings.validationModel ?? 'opus',
|
||||
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
|
||||
validationModel: settings.validationModel ?? 'claude-opus',
|
||||
phaseModels: settings.phaseModels ?? current.phaseModels,
|
||||
enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels,
|
||||
cursorDefaultModel: settings.cursorDefaultModel ?? 'auto',
|
||||
enabledCursorModels: allCursorModels, // Always use ALL cursor models
|
||||
cursorDefaultModel: sanitizedCursorDefaultModel,
|
||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
|
||||
@@ -22,7 +22,13 @@ import { waitForMigrationComplete, resetMigrationState } from './use-settings-mi
|
||||
import {
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
getAllOpencodeModelIds,
|
||||
getAllCursorModelIds,
|
||||
migrateCursorModelIds,
|
||||
migrateOpencodeModelIds,
|
||||
migratePhaseModelEntry,
|
||||
type GlobalSettings,
|
||||
type CursorModelId,
|
||||
type OpencodeModelId,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsSync');
|
||||
@@ -501,17 +507,35 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
|
||||
const serverSettings = result.settings as unknown as GlobalSettings;
|
||||
const currentAppState = useAppStore.getState();
|
||||
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
|
||||
const incomingEnabledOpencodeModels =
|
||||
serverSettings.enabledOpencodeModels ?? currentAppState.enabledOpencodeModels;
|
||||
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(
|
||||
serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel
|
||||
)
|
||||
? (serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel)
|
||||
: DEFAULT_OPENCODE_MODEL;
|
||||
const sanitizedEnabledOpencodeModels = Array.from(
|
||||
new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId)))
|
||||
|
||||
// Cursor models - ALWAYS use ALL available models to ensure new models are visible
|
||||
const allCursorModels = getAllCursorModelIds();
|
||||
const validCursorModelIds = new Set(allCursorModels);
|
||||
|
||||
// Migrate Cursor default model
|
||||
const migratedCursorDefault = migrateCursorModelIds([
|
||||
serverSettings.cursorDefaultModel ?? 'cursor-auto',
|
||||
])[0];
|
||||
const sanitizedCursorDefault = validCursorModelIds.has(migratedCursorDefault)
|
||||
? migratedCursorDefault
|
||||
: ('cursor-auto' as CursorModelId);
|
||||
|
||||
// Migrate OpenCode models to canonical format
|
||||
const migratedOpencodeModels = migrateOpencodeModelIds(
|
||||
serverSettings.enabledOpencodeModels ?? []
|
||||
);
|
||||
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
|
||||
const sanitizedEnabledOpencodeModels = migratedOpencodeModels.filter((id) =>
|
||||
validOpencodeModelIds.has(id)
|
||||
);
|
||||
|
||||
// Migrate OpenCode default model
|
||||
const migratedOpencodeDefault = migrateOpencodeModelIds([
|
||||
serverSettings.opencodeDefaultModel ?? DEFAULT_OPENCODE_MODEL,
|
||||
])[0];
|
||||
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(migratedOpencodeDefault)
|
||||
? migratedOpencodeDefault
|
||||
: DEFAULT_OPENCODE_MODEL;
|
||||
|
||||
if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) {
|
||||
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
|
||||
@@ -523,6 +547,37 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
(modelId) => !modelId.startsWith('amazon-bedrock/')
|
||||
);
|
||||
|
||||
// Migrate phase models to canonical format
|
||||
const migratedPhaseModels = serverSettings.phaseModels
|
||||
? {
|
||||
enhancementModel: migratePhaseModelEntry(serverSettings.phaseModels.enhancementModel),
|
||||
fileDescriptionModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.fileDescriptionModel
|
||||
),
|
||||
imageDescriptionModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.imageDescriptionModel
|
||||
),
|
||||
validationModel: migratePhaseModelEntry(serverSettings.phaseModels.validationModel),
|
||||
specGenerationModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.specGenerationModel
|
||||
),
|
||||
featureGenerationModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.featureGenerationModel
|
||||
),
|
||||
backlogPlanningModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.backlogPlanningModel
|
||||
),
|
||||
projectAnalysisModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.projectAnalysisModel
|
||||
),
|
||||
suggestionsModel: migratePhaseModelEntry(serverSettings.phaseModels.suggestionsModel),
|
||||
memoryExtractionModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.memoryExtractionModel
|
||||
),
|
||||
commitMessageModel: migratePhaseModelEntry(serverSettings.phaseModels.commitMessageModel),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Save theme to localStorage for fallback when server settings aren't available
|
||||
if (serverSettings.theme) {
|
||||
setItem(THEME_STORAGE_KEY, serverSettings.theme);
|
||||
@@ -539,15 +594,17 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
useWorktrees: serverSettings.useWorktrees,
|
||||
defaultPlanningMode: serverSettings.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
|
||||
defaultFeatureModel: serverSettings.defaultFeatureModel ?? { model: 'opus' },
|
||||
defaultFeatureModel: serverSettings.defaultFeatureModel
|
||||
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
|
||||
: { model: 'claude-opus' },
|
||||
muteDoneSound: serverSettings.muteDoneSound,
|
||||
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||
enhancementModel: serverSettings.enhancementModel,
|
||||
validationModel: serverSettings.validationModel,
|
||||
phaseModels: serverSettings.phaseModels,
|
||||
enabledCursorModels: serverSettings.enabledCursorModels,
|
||||
cursorDefaultModel: serverSettings.cursorDefaultModel,
|
||||
phaseModels: migratedPhaseModels ?? serverSettings.phaseModels,
|
||||
enabledCursorModels: allCursorModels, // Always use ALL cursor models
|
||||
cursorDefaultModel: sanitizedCursorDefault,
|
||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
|
||||
@@ -495,10 +495,12 @@ export interface AutoModeAPI {
|
||||
status: (projectPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
isAutoLoopRunning?: boolean;
|
||||
currentFeatureId?: string | null;
|
||||
runningFeatures?: string[];
|
||||
runningProjects?: string[];
|
||||
runningCount?: number;
|
||||
maxConcurrency?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
runFeature: (
|
||||
@@ -3226,7 +3228,7 @@ function createMockGitHubAPI(): GitHubAPI {
|
||||
estimatedComplexity: 'moderate' as const,
|
||||
},
|
||||
projectPath,
|
||||
model: model || 'sonnet',
|
||||
model: model || 'claude-sonnet',
|
||||
})
|
||||
);
|
||||
}, 2000);
|
||||
|
||||
@@ -1393,12 +1393,12 @@ const initialState: AppState = {
|
||||
muteDoneSound: false, // Default to sound enabled (not muted)
|
||||
serverLogLevel: 'info', // Default to info level for server logs
|
||||
enableRequestLogging: true, // Default to enabled for HTTP request logging
|
||||
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
|
||||
validationModel: 'opus', // Default to opus for GitHub issue validation
|
||||
enhancementModel: 'claude-sonnet', // Default to sonnet for feature enhancement
|
||||
validationModel: 'claude-opus', // Default to opus for GitHub issue validation
|
||||
phaseModels: DEFAULT_PHASE_MODELS, // Phase-specific model configuration
|
||||
favoriteModels: [],
|
||||
enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default
|
||||
cursorDefaultModel: 'auto', // Default to auto selection
|
||||
cursorDefaultModel: 'cursor-auto', // Default to auto selection
|
||||
enabledCodexModels: getAllCodexModelIds(), // All Codex models enabled by default
|
||||
codexDefaultModel: 'codex-gpt-5.2-codex', // Default to GPT-5.2-Codex
|
||||
codexAutoLoadAgents: false, // Default to disabled (user must opt-in)
|
||||
|
||||
Reference in New Issue
Block a user