feat(auto-mode): implement facade caching and enhance error handling

- Added caching for facades in AutoModeServiceCompat to persist auto loop state across API calls.
- Improved error handling in BoardView for starting and stopping auto mode, with user-friendly toast notifications.
- Updated WorktreePanel to manage auto mode state and concurrency limits more effectively.
- Enhanced useAutoMode hook to prevent state overwrites during transitions and synchronize UI with backend status.

This update optimizes performance and user experience in the auto mode feature.
This commit is contained in:
gsxdsm
2026-02-14 20:37:03 -08:00
parent bcc854234c
commit 0f0f5159d2
4 changed files with 70 additions and 14 deletions

View File

@@ -22,6 +22,7 @@ import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js
export class AutoModeServiceCompat {
private readonly globalService: GlobalAutoModeService;
private readonly facadeOptions: FacadeOptions;
private readonly facadeCache = new Map<string, AutoModeServiceFacade>();
constructor(
events: EventEmitter,
@@ -47,10 +48,17 @@ export class AutoModeServiceCompat {
}
/**
* Create a facade for a specific project
* Get or create a facade for a specific project.
* Facades are cached by project path so that auto loop state
* (stored in the facade's AutoLoopCoordinator) persists across API calls.
*/
createFacade(projectPath: string): AutoModeServiceFacade {
return AutoModeServiceFacade.create(projectPath, this.facadeOptions);
let facade = this.facadeCache.get(projectPath);
if (!facade) {
facade = AutoModeServiceFacade.create(projectPath, this.facadeOptions);
this.facadeCache.set(projectPath, facade);
}
return facade;
}
// ===========================================================================

View File

@@ -1362,10 +1362,16 @@ export function BoardView() {
if (enabled) {
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to start:', error);
toast.error('Failed to start auto mode', {
description: error instanceof Error ? error.message : 'Unknown error',
});
});
} else {
autoMode.stop().catch((error) => {
logger.error('[AutoMode] Failed to stop:', error);
toast.error('Failed to stop auto mode', {
description: error instanceof Error ? error.message : 'Unknown error',
});
});
}
}}

View File

@@ -112,6 +112,8 @@ export function WorktreePanel({
// Use separate selectors to avoid creating new object references on each render
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const currentProject = useAppStore((state) => state.currentProject);
const setAutoModeRunning = useAppStore((state) => state.setAutoModeRunning);
const getMaxConcurrencyForWorktree = useAppStore((state) => state.getMaxConcurrencyForWorktree);
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
const getAutoModeWorktreeKey = useCallback(
@@ -137,8 +139,6 @@ export function WorktreePanel({
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);
@@ -147,14 +147,17 @@ export function WorktreePanel({
if (isRunning) {
const result = await api.autoMode.stop(projectPath, branchName);
if (result.success) {
setAutoModeRunning(currentProject.id, branchName, false);
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);
const maxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName);
const result = await api.autoMode.start(projectPath, branchName, maxConcurrency);
if (result.success) {
setAutoModeRunning(currentProject.id, branchName, true, maxConcurrency);
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode started for ${desc}`);
} else {
@@ -166,7 +169,13 @@ export function WorktreePanel({
console.error('Auto mode toggle error:', error);
}
},
[currentProject, projectPath, isAutoModeRunningForWorktree]
[
currentProject,
projectPath,
isAutoModeRunningForWorktree,
setAutoModeRunning,
getMaxConcurrencyForWorktree,
]
);
// Check if init script exists for the project using React Query

View File

@@ -1,4 +1,4 @@
import { useEffect, useCallback, useMemo } from 'react';
import { useEffect, useCallback, useMemo, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { createLogger } from '@automaker/utils/logger';
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
@@ -11,6 +11,12 @@ import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
const logger = createLogger('AutoMode');
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const set = new Set(b);
return a.every((id) => set.has(id));
}
const AUTO_MODE_POLLING_INTERVAL = 30000;
/**
@@ -142,9 +148,16 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Ref to prevent refreshStatus from overwriting optimistic state during start/stop
const isTransitioningRef = useRef(false);
const refreshStatus = useCallback(async () => {
if (!currentProject) return;
// Skip sync when user is in the middle of start/stop - avoids race where
// refreshStatus runs before the API call completes and overwrites optimistic state
if (isTransitioningRef.current) return;
try {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
@@ -152,18 +165,28 @@ export function useAutoMode(worktree?: WorktreeInfo) {
const result = await api.autoMode.status(currentProject.path, branchName);
if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning;
const backendRunningFeatures = result.runningFeatures ?? [];
const needsSync =
backendIsRunning !== isAutoModeRunning ||
// Also sync when backend has runningFeatures we're missing (handles missed WebSocket events)
(backendIsRunning &&
Array.isArray(backendRunningFeatures) &&
backendRunningFeatures.length > 0 &&
!arraysEqual(backendRunningFeatures, runningAutoTasks));
if (backendIsRunning !== isAutoModeRunning) {
if (needsSync) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
);
if (backendIsRunning !== isAutoModeRunning) {
logger.info(
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
);
}
setAutoModeRunning(
currentProject.id,
branchName,
backendIsRunning,
result.maxConcurrency,
result.runningFeatures
backendRunningFeatures
);
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
}
@@ -171,7 +194,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} catch (error) {
logger.error('Error syncing auto mode state with backend:', error);
}
}, [branchName, currentProject, isAutoModeRunning, setAutoModeRunning]);
}, [branchName, currentProject, isAutoModeRunning, runningAutoTasks, setAutoModeRunning]);
// 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.
@@ -558,6 +581,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
return;
}
isTransitioningRef.current = true;
try {
const api = getElectronAPI();
if (!api?.autoMode?.start) {
@@ -588,14 +612,18 @@ export function useAutoMode(worktree?: WorktreeInfo) {
}
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
// Sync with backend after success (gets runningFeatures if events were delayed)
queueMicrotask(() => void refreshStatus());
} catch (error) {
// Revert UI state on error
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, branchName, false);
logger.error('Error starting auto mode:', error);
throw error;
} finally {
isTransitioningRef.current = false;
}
}, [currentProject, branchName, setAutoModeRunning]);
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]);
// Stop auto mode - calls backend to stop the auto loop for this worktree
const stop = useCallback(async () => {
@@ -604,6 +632,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
return;
}
isTransitioningRef.current = true;
try {
const api = getElectronAPI();
if (!api?.autoMode?.stop) {
@@ -631,12 +660,16 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// NOTE: Running tasks will continue until natural completion.
// The backend stops picking up new features but doesn't abort running ones.
logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
// Sync with backend after success
queueMicrotask(() => void refreshStatus());
} catch (error) {
// Revert UI state on error
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
setAutoModeRunning(currentProject.id, branchName, true);
logger.error('Error stopping auto mode:', error);
throw error;
} finally {
isTransitioningRef.current = false;
}
}, [currentProject, branchName, setAutoModeRunning]);