mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-23 12:03:07 +00:00
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:
@@ -22,6 +22,7 @@ import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js
|
|||||||
export class AutoModeServiceCompat {
|
export class AutoModeServiceCompat {
|
||||||
private readonly globalService: GlobalAutoModeService;
|
private readonly globalService: GlobalAutoModeService;
|
||||||
private readonly facadeOptions: FacadeOptions;
|
private readonly facadeOptions: FacadeOptions;
|
||||||
|
private readonly facadeCache = new Map<string, AutoModeServiceFacade>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
events: EventEmitter,
|
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 {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -1362,10 +1362,16 @@ export function BoardView() {
|
|||||||
if (enabled) {
|
if (enabled) {
|
||||||
autoMode.start().catch((error) => {
|
autoMode.start().catch((error) => {
|
||||||
logger.error('[AutoMode] Failed to start:', error);
|
logger.error('[AutoMode] Failed to start:', error);
|
||||||
|
toast.error('Failed to start auto mode', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
autoMode.stop().catch((error) => {
|
autoMode.stop().catch((error) => {
|
||||||
logger.error('[AutoMode] Failed to stop:', error);
|
logger.error('[AutoMode] Failed to stop:', error);
|
||||||
|
toast.error('Failed to stop auto mode', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export function WorktreePanel({
|
|||||||
// Use separate selectors to avoid creating new object references on each render
|
// Use separate selectors to avoid creating new object references on each render
|
||||||
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
|
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
|
||||||
const currentProject = useAppStore((state) => state.currentProject);
|
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)
|
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
|
||||||
const getAutoModeWorktreeKey = useCallback(
|
const getAutoModeWorktreeKey = useCallback(
|
||||||
@@ -137,8 +139,6 @@ export function WorktreePanel({
|
|||||||
async (worktree: WorktreeInfo) => {
|
async (worktree: WorktreeInfo) => {
|
||||||
if (!currentProject) return;
|
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 api = getHttpApiClient();
|
||||||
const branchName = worktree.isMain ? null : worktree.branch;
|
const branchName = worktree.isMain ? null : worktree.branch;
|
||||||
const isRunning = isAutoModeRunningForWorktree(worktree);
|
const isRunning = isAutoModeRunningForWorktree(worktree);
|
||||||
@@ -147,14 +147,17 @@ export function WorktreePanel({
|
|||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
const result = await api.autoMode.stop(projectPath, branchName);
|
const result = await api.autoMode.stop(projectPath, branchName);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
setAutoModeRunning(currentProject.id, branchName, false);
|
||||||
const desc = branchName ? `worktree ${branchName}` : 'main branch';
|
const desc = branchName ? `worktree ${branchName}` : 'main branch';
|
||||||
toast.success(`Auto Mode stopped for ${desc}`);
|
toast.success(`Auto Mode stopped for ${desc}`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to stop Auto Mode');
|
toast.error(result.error || 'Failed to stop Auto Mode');
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
if (result.success) {
|
||||||
|
setAutoModeRunning(currentProject.id, branchName, true, maxConcurrency);
|
||||||
const desc = branchName ? `worktree ${branchName}` : 'main branch';
|
const desc = branchName ? `worktree ${branchName}` : 'main branch';
|
||||||
toast.success(`Auto Mode started for ${desc}`);
|
toast.success(`Auto Mode started for ${desc}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -166,7 +169,13 @@ export function WorktreePanel({
|
|||||||
console.error('Auto mode toggle error:', error);
|
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
|
// Check if init script exists for the project using React Query
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback, useMemo } from 'react';
|
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||||
@@ -11,6 +11,12 @@ import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
|||||||
const logger = createLogger('AutoMode');
|
const logger = createLogger('AutoMode');
|
||||||
|
|
||||||
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
|
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;
|
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
|
// Check if we can start a new task based on concurrency limit
|
||||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||||
|
|
||||||
|
// Ref to prevent refreshStatus from overwriting optimistic state during start/stop
|
||||||
|
const isTransitioningRef = useRef(false);
|
||||||
|
|
||||||
const refreshStatus = useCallback(async () => {
|
const refreshStatus = useCallback(async () => {
|
||||||
if (!currentProject) return;
|
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 {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.autoMode?.status) return;
|
if (!api?.autoMode?.status) return;
|
||||||
@@ -152,18 +165,28 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
const result = await api.autoMode.status(currentProject.path, branchName);
|
const result = await api.autoMode.status(currentProject.path, branchName);
|
||||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||||
const backendIsRunning = result.isAutoLoopRunning;
|
const backendIsRunning = result.isAutoLoopRunning;
|
||||||
|
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';
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||||
logger.info(
|
if (backendIsRunning !== isAutoModeRunning) {
|
||||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
logger.info(
|
||||||
);
|
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
setAutoModeRunning(
|
setAutoModeRunning(
|
||||||
currentProject.id,
|
currentProject.id,
|
||||||
branchName,
|
branchName,
|
||||||
backendIsRunning,
|
backendIsRunning,
|
||||||
result.maxConcurrency,
|
result.maxConcurrency,
|
||||||
result.runningFeatures
|
backendRunningFeatures
|
||||||
);
|
);
|
||||||
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||||
}
|
}
|
||||||
@@ -171,7 +194,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error syncing auto mode state with backend:', 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.
|
// 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.
|
// This handles cases where the backend is still running after a page refresh.
|
||||||
@@ -558,6 +581,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isTransitioningRef.current = true;
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.autoMode?.start) {
|
if (!api?.autoMode?.start) {
|
||||||
@@ -588,14 +612,18 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
|
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
|
||||||
|
// Sync with backend after success (gets runningFeatures if events were delayed)
|
||||||
|
queueMicrotask(() => void refreshStatus());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Revert UI state on error
|
// Revert UI state on error
|
||||||
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||||
setAutoModeRunning(currentProject.id, branchName, false);
|
setAutoModeRunning(currentProject.id, branchName, false);
|
||||||
logger.error('Error starting auto mode:', error);
|
logger.error('Error starting auto mode:', error);
|
||||||
throw 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
|
// Stop auto mode - calls backend to stop the auto loop for this worktree
|
||||||
const stop = useCallback(async () => {
|
const stop = useCallback(async () => {
|
||||||
@@ -604,6 +632,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isTransitioningRef.current = true;
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.autoMode?.stop) {
|
if (!api?.autoMode?.stop) {
|
||||||
@@ -631,12 +660,16 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
// NOTE: Running tasks will continue until natural completion.
|
// NOTE: Running tasks will continue until natural completion.
|
||||||
// The backend stops picking up new features but doesn't abort running ones.
|
// The backend stops picking up new features but doesn't abort running ones.
|
||||||
logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
|
logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
|
||||||
|
// Sync with backend after success
|
||||||
|
queueMicrotask(() => void refreshStatus());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Revert UI state on error
|
// Revert UI state on error
|
||||||
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
|
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
|
||||||
setAutoModeRunning(currentProject.id, branchName, true);
|
setAutoModeRunning(currentProject.id, branchName, true);
|
||||||
logger.error('Error stopping auto mode:', error);
|
logger.error('Error stopping auto mode:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isTransitioningRef.current = false;
|
||||||
}
|
}
|
||||||
}, [currentProject, branchName, setAutoModeRunning]);
|
}, [currentProject, branchName, setAutoModeRunning]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user