diff --git a/apps/ui/src/components/ui/app-error-boundary.tsx b/apps/ui/src/components/ui/app-error-boundary.tsx index 523aeb630..d1886c826 100644 --- a/apps/ui/src/components/ui/app-error-boundary.tsx +++ b/apps/ui/src/components/ui/app-error-boundary.tsx @@ -10,8 +10,16 @@ interface Props { interface State { hasError: boolean; error: Error | null; + isCrashLoop: boolean; } +/** Key used to track recent crash timestamps for crash loop detection */ +const CRASH_TIMESTAMPS_KEY = 'automaker-crash-timestamps'; +/** Number of crashes within the time window that constitutes a crash loop */ +const CRASH_LOOP_THRESHOLD = 3; +/** Time window in ms for crash loop detection (30 seconds) */ +const CRASH_LOOP_WINDOW_MS = 30_000; + /** * Root-level error boundary for the entire application. * @@ -21,14 +29,18 @@ interface State { * Provides a user-friendly error screen with a reload button to recover. * This is especially important for transient errors during initial app load * (e.g., race conditions during auth/hydration on fresh browser sessions). + * + * Includes crash loop detection: if the app crashes 3+ times within 30 seconds, + * the UI cache is automatically cleared to break loops caused by stale cached + * worktree paths or other corrupt persisted state. */ export class AppErrorBoundary extends Component { constructor(props: Props) { super(props); - this.state = { hasError: false, error: null }; + this.state = { hasError: false, error: null, isCrashLoop: false }; } - static getDerivedStateFromError(error: Error): State { + static getDerivedStateFromError(error: Error): Partial { return { hasError: true, error }; } @@ -38,12 +50,48 @@ export class AppErrorBoundary extends Component { stack: error.stack, componentStack: errorInfo.componentStack, }); + + // Track crash timestamps to detect crash loops. + // If the app crashes multiple times in quick succession, it's likely due to + // stale cached data (e.g., worktree paths that no longer exist on disk). + try { + const now = Date.now(); + const raw = sessionStorage.getItem(CRASH_TIMESTAMPS_KEY); + const timestamps: number[] = raw ? JSON.parse(raw) : []; + timestamps.push(now); + // Keep only timestamps within the detection window + const recent = timestamps.filter((t) => now - t < CRASH_LOOP_WINDOW_MS); + sessionStorage.setItem(CRASH_TIMESTAMPS_KEY, JSON.stringify(recent)); + + if (recent.length >= CRASH_LOOP_THRESHOLD) { + logger.error( + `Crash loop detected (${recent.length} crashes in ${CRASH_LOOP_WINDOW_MS}ms) — clearing UI cache` + ); + // Auto-clear the UI cache to break the loop + localStorage.removeItem('automaker-ui-cache'); + sessionStorage.removeItem(CRASH_TIMESTAMPS_KEY); + this.setState({ isCrashLoop: true }); + } + } catch { + // Storage may be unavailable — ignore + } } handleReload = () => { window.location.reload(); }; + handleClearCacheAndReload = () => { + // Clear the UI cache store that persists worktree selections and other UI state. + // This breaks crash loops caused by stale worktree paths that no longer exist on disk. + try { + localStorage.removeItem('automaker-ui-cache'); + } catch { + // localStorage may be unavailable in some contexts + } + window.location.reload(); + }; + render() { if (this.state.hasError) { return ( @@ -82,34 +130,60 @@ export class AppErrorBoundary extends Component {

Something went wrong

- The application encountered an unexpected error. This is usually temporary and can be - resolved by reloading the page. + {this.state.isCrashLoop + ? 'The application crashed repeatedly, likely due to stale cached data. The cache has been cleared automatically. Reload to continue.' + : 'The application encountered an unexpected error. This is usually temporary and can be resolved by reloading the page.'}

- + + Clear Cache & Reload + + {/* Collapsible technical details for debugging */} {this.state.error && ( diff --git a/apps/ui/src/store/ui-cache-store.ts b/apps/ui/src/store/ui-cache-store.ts index 44eb9ed39..513d79319 100644 --- a/apps/ui/src/store/ui-cache-store.ts +++ b/apps/ui/src/store/ui-cache-store.ts @@ -160,11 +160,31 @@ export function restoreFromUICache( // Restore last selected worktree per project so the board doesn't // reset to main branch after PWA memory eviction or tab discard. + // + // IMPORTANT: Only restore entries where path is null (main branch selection). + // Non-null paths point to worktree directories on disk that may have been + // deleted while the PWA was evicted. Restoring a stale worktree path causes + // the board to render with an invalid selection, and if the server can't + // validate it fast enough, the app enters an unrecoverable crash loop + // (the error boundary reloads, which restores the same bad cache). + // Main branch (path=null) is always valid and safe to restore. if ( cache.cachedCurrentWorktreeByProject && Object.keys(cache.cachedCurrentWorktreeByProject).length > 0 ) { - stateUpdate.currentWorktreeByProject = cache.cachedCurrentWorktreeByProject; + const sanitized: Record = {}; + for (const [projectPath, worktree] of Object.entries(cache.cachedCurrentWorktreeByProject)) { + if (worktree.path === null) { + // Main branch selection — always safe to restore + sanitized[projectPath] = worktree; + } + // Non-null paths are dropped; the app will re-discover actual worktrees + // from the server and the validation effect in use-worktrees will handle + // resetting to main if the cached worktree no longer exists. + } + if (Object.keys(sanitized).length > 0) { + stateUpdate.currentWorktreeByProject = sanitized; + } } // Restore the project context when the project object is available.