Fix concurrency limits and remote branch fetching issues (#788)

* Changes from fix/bug-fixes

* feat: Refactor worktree iteration and improve error logging across services

* feat: Extract URL/port patterns to module level and fix abort condition

* fix: Improve IPv6 loopback handling, select component layout, and terminal UI

* feat: Add thinking level defaults and adjust list row padding

* Update apps/ui/src/store/app-store.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit

* feat: Add tracked remote detection to pull dialog flow

* feat: Add merge state tracking to git operations

* feat: Improve merge detection and add post-merge action preferences

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Pass merge detection info to stash reapplication and handle merge state consistently

* fix: Call onPulled callback in merge handlers and add validation checks

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-20 13:48:22 -08:00
committed by GitHub
parent 7df2182818
commit 0a5540c9a2
70 changed files with 4525 additions and 857 deletions

View File

@@ -32,6 +32,7 @@ export function useGitDiffs(projectPath: string | undefined, enabled = true) {
return {
files: result.files ?? [],
diff: result.diff ?? '',
...(result.mergeState ? { mergeState: result.mergeState } : {}),
};
},
enabled: !!projectPath && enabled,

View File

@@ -160,6 +160,7 @@ export function useWorktreeDiffs(projectPath: string | undefined, featureId: str
return {
files: result.files ?? [],
diff: result.diff ?? '',
...(result.mergeState ? { mergeState: result.mergeState } : {}),
};
},
enabled: !!projectPath && !!featureId,

View File

@@ -157,8 +157,40 @@ 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
// Ref to prevent refreshStatus and WebSocket handlers from overwriting optimistic state
// during start/stop transitions.
const isTransitioningRef = useRef(false);
// Tracks specifically a restart-for-concurrency transition. When true, the
// auto_mode_started WebSocket handler will clear isTransitioningRef, ensuring
// delayed auto_mode_stopped events that arrive after the HTTP calls complete
// (but before the WebSocket events) are still suppressed.
const isRestartTransitionRef = useRef(false);
// Safety timeout ID to clear the transition flag if the auto_mode_started event never arrives
const restartSafetyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Use refs for mutable state in refreshStatus to avoid unstable callback identity.
// This prevents the useEffect that calls refreshStatus on mount from re-firing
// every time isAutoModeRunning or runningAutoTasks changes, which was a source of
// flickering as refreshStatus would race with WebSocket events and optimistic updates.
const isAutoModeRunningRef = useRef(isAutoModeRunning);
const runningAutoTasksRef = useRef(runningAutoTasks);
useEffect(() => {
isAutoModeRunningRef.current = isAutoModeRunning;
}, [isAutoModeRunning]);
useEffect(() => {
runningAutoTasksRef.current = runningAutoTasks;
}, [runningAutoTasks]);
// Clean up safety timeout on unmount to prevent timer leaks and misleading log warnings
useEffect(() => {
return () => {
if (restartSafetyTimeoutRef.current) {
clearTimeout(restartSafetyTimeoutRef.current);
restartSafetyTimeoutRef.current = null;
}
isRestartTransitionRef.current = false;
};
}, []);
const refreshStatus = useCallback(async () => {
if (!currentProject) return;
@@ -175,20 +207,25 @@ export function useAutoMode(worktree?: WorktreeInfo) {
if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning;
const backendRunningFeatures = result.runningFeatures ?? [];
// Read latest state from refs to avoid stale closure values
const currentIsRunning = isAutoModeRunningRef.current;
const currentRunningTasks = runningAutoTasksRef.current;
const needsSync =
backendIsRunning !== isAutoModeRunning ||
backendIsRunning !== currentIsRunning ||
// Also sync when backend has runningFeatures we're missing (handles missed WebSocket events)
(backendIsRunning &&
Array.isArray(backendRunningFeatures) &&
backendRunningFeatures.length > 0 &&
!arraysEqual(backendRunningFeatures, runningAutoTasks)) ||
!arraysEqual(backendRunningFeatures, currentRunningTasks)) ||
// Also sync when UI has stale running tasks but backend has none
// (handles server restart where features were reconciled to backlog/ready)
(!backendIsRunning && runningAutoTasks.length > 0 && backendRunningFeatures.length === 0);
(!backendIsRunning &&
currentRunningTasks.length > 0 &&
backendRunningFeatures.length === 0);
if (needsSync) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
if (backendIsRunning !== isAutoModeRunning) {
if (backendIsRunning !== currentIsRunning) {
logger.info(
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
);
@@ -206,7 +243,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} catch (error) {
logger.error('Error syncing auto mode state with backend:', error);
}
}, [branchName, currentProject, isAutoModeRunning, runningAutoTasks, setAutoModeRunning]);
}, [branchName, currentProject, 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.
@@ -281,8 +318,23 @@ export function useAutoMode(worktree?: WorktreeInfo) {
'maxConcurrency' in event && typeof event.maxConcurrency === 'number'
? event.maxConcurrency
: getMaxConcurrencyForWorktree(eventProjectId, eventBranchName);
// Always apply start events even during transitions - this confirms the optimistic state
setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency);
}
// If we were in a restart transition (concurrency change), the arrival of
// auto_mode_started confirms the restart is complete. Clear the transition
// flags so future auto_mode_stopped events are processed normally.
// Only clear transition refs when the event is for this hook's worktree,
// to avoid events for worktree B incorrectly affecting worktree A's state.
if (isRestartTransitionRef.current && eventBranchName === branchName) {
logger.debug(`[AutoMode] Restart transition complete for ${worktreeDesc}`);
isTransitioningRef.current = false;
isRestartTransitionRef.current = false;
if (restartSafetyTimeoutRef.current) {
clearTimeout(restartSafetyTimeoutRef.current);
restartSafetyTimeoutRef.current = null;
}
}
}
break;
@@ -307,12 +359,23 @@ export function useAutoMode(worktree?: WorktreeInfo) {
break;
case 'auto_mode_stopped':
// Backend stopped auto loop - update UI state
// Backend stopped auto loop - update UI state.
// Skip during transitions (e.g., restartWithConcurrency) to avoid flickering the toggle
// off between stop and start. The transition handler will set the correct final state.
// Only suppress (and only apply transition guard) when the event is for this hook's
// worktree, to avoid worktree B's stop events being incorrectly suppressed by
// worktree A's transition state.
{
const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
if (eventProjectId) {
setAutoModeRunning(eventProjectId, eventBranchName, false);
if (eventBranchName === branchName && isTransitioningRef.current) {
logger.info(
`[AutoMode] Backend stopped auto loop for ${worktreeDesc} (ignored during transition)`
);
} else {
logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
if (eventProjectId) {
setAutoModeRunning(eventProjectId, eventBranchName, false);
}
}
}
break;
@@ -574,6 +637,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
return unsubscribe;
}, [
projectId,
branchName,
addRunningTask,
removeRunningTask,
addAutoModeActivity,
@@ -582,7 +646,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
setAutoModeRunning,
currentProject?.path,
getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
]);
@@ -624,8 +687,10 @@ 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());
// Sync with backend after a short delay to get runningFeatures if events were delayed.
// The delay ensures the backend has fully processed the start before we poll status,
// avoiding a race where status returns stale data and briefly flickers the toggle.
setTimeout(() => void refreshStatus(), 500);
} catch (error) {
// Revert UI state on error
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
@@ -635,7 +700,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} finally {
isTransitioningRef.current = false;
}
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]);
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree, refreshStatus]);
// Stop auto mode - calls backend to stop the auto loop for this worktree
const stop = useCallback(async () => {
@@ -672,8 +737,8 @@ 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());
// Sync with backend after a short delay to confirm stopped state
setTimeout(() => void refreshStatus(), 500);
} catch (error) {
// Revert UI state on error
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
@@ -683,7 +748,95 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} finally {
isTransitioningRef.current = false;
}
}, [currentProject, branchName, setAutoModeRunning]);
}, [currentProject, branchName, setAutoModeRunning, refreshStatus]);
// Restart auto mode with new concurrency without flickering the toggle.
// Unlike stop() + start(), this keeps isRunning=true throughout the transition
// so the toggle switch never visually turns off.
//
// IMPORTANT: isTransitioningRef is NOT cleared in the finally block here.
// Instead, it stays true until the auto_mode_started WebSocket event arrives,
// which confirms the backend restart is complete. This prevents a race condition
// where a delayed auto_mode_stopped WebSocket event (sent by the backend during
// stop()) arrives after the HTTP calls complete but before the WebSocket events,
// which would briefly set isRunning=false and cause a visible toggle flicker.
// A safety timeout ensures the flag is cleared even if the event never arrives.
const restartWithConcurrency = useCallback(async () => {
if (!currentProject) {
logger.error('No project selected');
return;
}
// Clear any previous safety timeout
if (restartSafetyTimeoutRef.current) {
clearTimeout(restartSafetyTimeoutRef.current);
restartSafetyTimeoutRef.current = null;
}
isTransitioningRef.current = true;
isRestartTransitionRef.current = true;
try {
const api = getElectronAPI();
if (!api?.autoMode?.stop || !api?.autoMode?.start) {
throw new Error('Auto mode API not available');
}
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(
`[AutoMode] Restarting with new concurrency for ${worktreeDesc} in ${currentProject.path}`
);
// Stop backend without updating UI state (keep isRunning=true)
const stopResult = await api.autoMode.stop(currentProject.path, branchName);
if (!stopResult.success) {
logger.error('Failed to stop auto mode during restart:', stopResult.error);
// Don't throw - try to start anyway since the goal is to update concurrency
}
// Start backend with the new concurrency (UI state stays isRunning=true)
const currentMaxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName);
const startResult = await api.autoMode.start(
currentProject.path,
branchName,
currentMaxConcurrency
);
if (!startResult.success) {
// If start fails, we need to revert UI state since we're actually stopped now
isTransitioningRef.current = false;
isRestartTransitionRef.current = false;
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, branchName, false);
logger.error('Failed to restart auto mode with new concurrency:', startResult.error);
throw new Error(startResult.error || 'Failed to restart auto mode');
}
logger.debug(`[AutoMode] Restarted successfully for ${worktreeDesc}`);
// Don't clear isTransitioningRef here - let the auto_mode_started WebSocket
// event handler clear it. Set a safety timeout in case the event never arrives.
restartSafetyTimeoutRef.current = setTimeout(() => {
if (isRestartTransitionRef.current) {
logger.warn('[AutoMode] Restart transition safety timeout - clearing transition flag');
isTransitioningRef.current = false;
isRestartTransitionRef.current = false;
restartSafetyTimeoutRef.current = null;
}
}, 5000);
} catch (error) {
// On error, clear the transition flags immediately
isTransitioningRef.current = false;
isRestartTransitionRef.current = false;
// Revert UI state since the backend may be stopped after a partial restart
if (currentProject) {
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, branchName, false);
}
logger.error('Error restarting auto mode:', error);
throw error;
}
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]);
// Stop a specific feature
const stopFeature = useCallback(
@@ -731,6 +884,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
start,
stop,
stopFeature,
restartWithConcurrency,
refreshStatus,
};
}

View File

@@ -166,6 +166,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
defaultSkipTests: state.defaultSkipTests as boolean,
enableDependencyBlocking: state.enableDependencyBlocking as boolean,
skipVerificationInAutoMode: state.skipVerificationInAutoMode as boolean,
mergePostAction: (state.mergePostAction as 'commit' | 'manual' | null) ?? null,
useWorktrees: state.useWorktrees as boolean,
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
@@ -704,6 +705,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
defaultSkipTests: settings.defaultSkipTests ?? true,
enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
mergePostAction: settings.mergePostAction ?? null,
useWorktrees: settings.useWorktrees ?? true,
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
@@ -718,6 +720,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
validationModel: settings.validationModel ?? 'claude-opus',
phaseModels: settings.phaseModels ?? current.phaseModels,
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'none',
defaultReasoningEffort: settings.defaultReasoningEffort ?? 'none',
enabledCursorModels: allCursorModels, // Always use ALL cursor models
cursorDefaultModel: sanitizedCursorDefaultModel,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
@@ -749,6 +753,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
projectHistory: settings.projectHistory ?? [],
projectHistoryIndex: settings.projectHistoryIndex ?? -1,
lastSelectedSessionByProject: settings.lastSelectedSessionByProject ?? {},
currentWorktreeByProject: settings.currentWorktreeByProject ?? {},
// UI State
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
lastProjectDir: settings.lastProjectDir ?? '',
@@ -802,6 +807,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
mergePostAction: state.mergePostAction,
useWorktrees: state.useWorktrees,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
@@ -812,6 +818,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
phaseModels: state.phaseModels,
defaultThinkingLevel: state.defaultThinkingLevel,
defaultReasoningEffort: state.defaultReasoningEffort,
enabledDynamicModelIds: state.enabledDynamicModelIds,
disabledProviders: state.disabledProviders,
autoLoadClaudeMd: state.autoLoadClaudeMd,
@@ -836,6 +844,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
projectHistory: state.projectHistory,
projectHistoryIndex: state.projectHistoryIndex,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
currentWorktreeByProject: state.currentWorktreeByProject,
worktreePanelCollapsed: state.worktreePanelCollapsed,
lastProjectDir: state.lastProjectDir,
recentFolders: state.recentFolders,

View File

@@ -58,6 +58,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'defaultSkipTests',
'enableDependencyBlocking',
'skipVerificationInAutoMode',
'mergePostAction',
'useWorktrees',
'defaultPlanningMode',
'defaultRequirePlanApproval',
@@ -717,6 +718,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
defaultSkipTests: serverSettings.defaultSkipTests,
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
mergePostAction: serverSettings.mergePostAction ?? null,
useWorktrees: serverSettings.useWorktrees,
defaultPlanningMode: serverSettings.defaultPlanningMode,
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,