diff --git a/apps/server/src/services/auto-mode/compat.ts b/apps/server/src/services/auto-mode/compat.ts index 1e37ecaf..011c654e 100644 --- a/apps/server/src/services/auto-mode/compat.ts +++ b/apps/server/src/services/auto-mode/compat.ts @@ -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(); 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; } // =========================================================================== diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index d8be006d..4f00337e 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -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', + }); }); } }} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index f3aebce6..469e3f9c 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -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 diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index cb683417..c6dba5b3 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -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]);