diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index facf417cd..875ab3731 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -14,7 +14,7 @@ import { // Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime // RPC method share a single cache instance — opening the desktop panel and the // mobile screen for the same scope must not double-scan hundreds of transcripts. -const AI_VAULT_CACHE_TTL_MS = 15_000 +const AI_VAULT_CACHE_TTL_MS = 60_000 // Why: codex-home + WSL home dirs must be sourced from a serve-mode-reachable // seam (the OrcaRuntimeService deps), NOT the window-only registerCoreHandlers diff --git a/src/main/ipc/parcel-watcher-crash-fuse.test.ts b/src/main/ipc/parcel-watcher-crash-fuse.test.ts new file mode 100644 index 000000000..740dafbb7 --- /dev/null +++ b/src/main/ipc/parcel-watcher-crash-fuse.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse' + +describe('WatcherProcessCrashFuse', () => { + it('opens after three crashes at the watcher restart cadence', () => { + const fuse = new WatcherProcessCrashFuse() + + fuse.recordCrash(0) + fuse.recordCrash(40_000) + fuse.recordCrash(80_000) + + expect(fuse.isOpen(80_000)).toBe(true) + }) + + it('stays open until explicitly reset', () => { + const fuse = new WatcherProcessCrashFuse() + + fuse.recordCrash(0) + fuse.recordCrash(40_000) + fuse.recordCrash(80_000) + + expect(fuse.isOpen(10 * 60_000)).toBe(true) + fuse.reset() + expect(fuse.isOpen(10 * 60_000)).toBe(false) + }) + + it('expires crashes outside the two-minute window', () => { + const fuse = new WatcherProcessCrashFuse() + + fuse.recordCrash(0) + fuse.recordCrash(40_000) + fuse.recordCrash(120_000) + + expect(fuse.isOpen(120_000)).toBe(false) + }) +}) diff --git a/src/main/ipc/parcel-watcher-crash-fuse.ts b/src/main/ipc/parcel-watcher-crash-fuse.ts index 69717311b..428eafe5a 100644 --- a/src/main/ipc/parcel-watcher-crash-fuse.ts +++ b/src/main/ipc/parcel-watcher-crash-fuse.ts @@ -1,21 +1,30 @@ -const CRASH_WINDOW_MS = 30_000 +const CRASH_WINDOW_MS = 2 * 60_000 const MAX_CRASHES_PER_WINDOW = 3 export class WatcherProcessCrashFuse { private crashTimes: number[] = [] + private open = false recordCrash(now = Date.now()): void { + if (this.open) { + return + } this.removeExpired(now) this.crashTimes.push(now) + this.open = this.crashTimes.length >= MAX_CRASHES_PER_WINDOW } isOpen(now = Date.now()): boolean { + if (this.open) { + return true + } this.removeExpired(now) - return this.crashTimes.length >= MAX_CRASHES_PER_WINDOW + return false } reset(): void { this.crashTimes = [] + this.open = false } private removeExpired(now: number): void { diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index 805281bbe..be5000fc4 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -656,6 +656,37 @@ describe('subscribeViaWatcherProcess', () => { ) }) + it('does not respawn for later subscriptions after the crash fuse opens', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(0) + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + ackSubscribe(currentChild()) + await promise + + for (const crashTime of [0, 40_000]) { + vi.setSystemTime(crashTime) + const child = currentChild() + child.connected = false + child.emit('exit', 3221226505, null) + ackSubscribe(currentChild()) + } + vi.setSystemTime(80_000) + const last = currentChild() + last.connected = false + last.emit('exit', 3221226505, null) + expect(forkMock).toHaveBeenCalledTimes(3) + + vi.setSystemTime(10 * 60_000) + await expect(subscribeViaWatcherProcess('/later', vi.fn(), {})).rejects.toMatchObject({ + code: 'process_unavailable' + }) + expect(forkMock).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + it('kills the idle child after the last unsubscribe and respawns on the next subscribe', async () => { const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) const first = currentChild() diff --git a/src/main/ipc/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts index 168b32b48..c77f12104 100644 --- a/src/main/ipc/worktree-base-directory-change-collector.ts +++ b/src/main/ipc/worktree-base-directory-change-collector.ts @@ -16,6 +16,12 @@ export type WorktreeBaseCollectedChanges = { headIdentityRepoIds: string[] } +export function hasCollectedWorktreeBaseChanges(changes: WorktreeBaseCollectedChanges): boolean { + return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some( + (ids) => ids.length > 0 + ) +} + type ChangeBuckets = { structureRepoIds: Set gitStatusRepoIds: Set diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 5a9f8852c..d8f58ff85 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -353,6 +353,44 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') }) + it('throttles repeated structural refreshes from watcher failures', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onWatchError = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onWatchError + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(60_000) + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + warn.mockRestore() + + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2) + }) + + it('lets a real event reset the watcher-failure refresh cooldown', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onWatchError = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onWatchError + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + emit(PROJECT_GIT_COMMON_DIR, [{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'config') }]) + await vi.advanceTimersByTimeAsync(300) + vi.mocked(notifyWorktreesChanged).mockClear() + + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + warn.mockRestore() + + expect(notifyWorktreesChanged).toHaveBeenCalledOnce() + }) + it('keeps linked HEAD and lock metadata structural', async () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 382a21dfc..411faae40 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -9,7 +9,7 @@ import { import { collectLocalWorktreeBaseChanges, collectRemoteWorktreeBaseChanges, - type WorktreeBaseCollectedChanges + hasCollectedWorktreeBaseChanges } from './worktree-base-directory-change-collector' import { clearPendingWorktreeBaseNotifications, @@ -33,6 +33,7 @@ import { updateActiveGitStatusRefBinding, type GitStatusRefBindingRequest } from './worktree-git-status-ref-watch' +import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' type ActiveWatch = WorktreeBaseWatchTarget & { mainWindow: BrowserWindow @@ -43,6 +44,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & { pendingHeadIdentityRepoIds: Set headIdentityRefresh: WorktreeHeadIdentityRefreshState gitStatusRefPaths: Set + watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown disposed: boolean } @@ -57,12 +59,6 @@ export function setWorktreeGitStatusRefWatch( return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef) } -function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean { - return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some( - (ids) => ids.length > 0 - ) -} - function handleLocalWatchEvents( watch: ActiveWatch, error: Error | null, @@ -74,16 +70,19 @@ function handleLocalWatchEvents( if (error) { console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + if (watch.watcherFailureRefresh.consume()) { + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + } return } + watch.watcherFailureRefresh.reset() invalidateGitStatusRefResolutionForPaths( watch, events.map((event) => event.path), () => activeWatches.values() ) const changes = collectLocalWorktreeBaseChanges(watch, events) - if (hasCollectedChanges(changes)) { + if (hasCollectedWorktreeBaseChanges(changes)) { scheduleWorktreeBaseNotification(watch, changes) } } @@ -108,7 +107,7 @@ function handleRemoteWatchEvents( scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) return } - if (hasCollectedChanges(changes)) { + if (hasCollectedWorktreeBaseChanges(changes)) { scheduleWorktreeBaseNotification(watch, changes) } } @@ -129,6 +128,7 @@ function createActiveWatch( pendingHeadIdentityRepoIds: new Set(), headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), gitStatusRefPaths, + watcherFailureRefresh: new WorktreeWatcherFailureRefreshCooldown(), disposed: false } } diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index db9ebc1d5..5baf86a16 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -5,6 +5,7 @@ import { chmodSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { subscribeViaWatcherProcess } from './parcel-watcher-process' +import { WatcherProcessFailure } from './parcel-watcher-process-failure' import type { WatcherProcessCallback, WatcherProcessHooks @@ -273,6 +274,75 @@ describe('worktree git-common narrow watch (darwin)', () => { expect(received).toEqual([]) }) + it('falls back to structural polling after the watcher crash fuse opens', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const visibility = createVisibilityHarness() + const received: WorktreeBasePollEvent[][] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + visibility.source + ) + + childSubscriptions[0].callback( + new WatcherProcessFailure( + 'watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ), + [] + ) + await vi.waitFor(() => { + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledOnce() + expect(visibility.listenerCount()).toBe(3) + }) + + const entryPath = join(worktreesDir, 'fallback-entry') + await mkdir(entryPath) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) + }) + await rm(entryPath, { recursive: true }) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'delete', path: entryPath }) + }) + expect(subscribeMock).toHaveBeenCalledOnce() + await watch.unsubscribe() + expect(visibility.listenerCount()).toBe(0) + }) + + it('starts structural polling when the watcher process is already unavailable', async () => { + subscribeMock.mockRejectedValue( + new WatcherProcessFailure('watcher process unavailable', 'supervisor', 'process_unavailable') + ) + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const visibility = createVisibilityHarness() + const received: WorktreeBasePollEvent[][] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + visibility.source + ) + + const entryPath = join(worktreesDir, 'fallback-entry') + await mkdir(entryPath) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) + }) + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + expect(subscribeMock).toHaveBeenCalledOnce() + + await watch.unsubscribe() + expect(visibility.listenerCount()).toBe(0) + }) + it('reports a structural change after a watcher-child interruption', async () => { installSubscribeMock() const commonDir = await makeCommonDir(true) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index b60278f76..e747b9410 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -1,6 +1,7 @@ import { stat } from 'node:fs/promises' import { join } from 'node:path' import { subscribeViaWatcherProcess } from './parcel-watcher-process' +import { isWatcherProcessFailure } from './parcel-watcher-process-failure' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import type { WorktreeBasePollEvent, @@ -30,12 +31,14 @@ async function startGitCommonNarrowWatch( onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, visibility: WorktreePollerWindowVisibility, + onFullScan?: () => void, onWatchError?: (error: Error) => void ): Promise { const worktreesDir = join(target.path, 'worktrees') let disposed = false let subscription: WorktreeBaseSubscription | null = null let existenceTimer: ReturnType | null = null + let pollingFallbackPromise: Promise | null = null let subscribing = false let parkedWhileHidden = false @@ -46,6 +49,38 @@ async function startGitCommonNarrowWatch( } } + const shouldUsePollingFallback = (error: unknown): boolean => + isWatcherProcessFailure(error) && + (error.code === 'supervisor_crash_fuse' || error.code === 'process_unavailable') + + const ensurePollingFallback = (): Promise => { + if (pollingFallbackPromise) { + return pollingFallbackPromise + } + stopExistencePoll() + const pending = startGitCommonPolling( + target.path, + onEvents, + pollIntervalMs, + visibility, + onFullScan, + false + ).then(async (fallback) => { + if (disposed || subscription) { + await fallback.unsubscribe() + return + } + subscription = fallback + }) + const tracked = pending.finally(() => { + if (pollingFallbackPromise === tracked) { + pollingFallbackPromise = null + } + }) + pollingFallbackPromise = tracked + return pollingFallbackPromise + } + const tryUpgradeToNarrowWatch = async (): Promise => { if (disposed || subscribing || subscription) { return @@ -116,7 +151,7 @@ async function startGitCommonNarrowWatch( // sometimes surfaced as an error, sometimes as a delete event for the // root. Either way: notify, drop the dead stream, and let the existence // poll re-arm when a future worktree add recreates the dir. - const teardownAndRearm = (): void => { + const teardown = (): void => { active = false errored = true const current = subscription @@ -124,6 +159,9 @@ async function startGitCommonNarrowWatch( if (current) { void current.unsubscribe().catch(() => {}) } + } + const teardownAndRearm = (): void => { + teardown() armExistencePoll() } try { @@ -139,7 +177,16 @@ async function startGitCommonNarrowWatch( } else { onEvents([{ type: 'update', path: worktreesDir }]) } - teardownAndRearm() + if (shouldUsePollingFallback(error)) { + teardown() + void ensurePollingFallback().catch(() => { + if (!disposed) { + armExistencePoll() + } + }) + } else { + teardownAndRearm() + } return } if (events.length > 0) { @@ -169,11 +216,16 @@ async function startGitCommonNarrowWatch( ) if (disposed || errored) { void sub.unsubscribe().catch(() => {}) - return !errored + await pollingFallbackPromise?.catch(() => {}) + return !errored || subscription !== null } subscription = { unsubscribe: () => sub.unsubscribe() } return true - } catch { + } catch (error) { + if (shouldUsePollingFallback(error)) { + await ensurePollingFallback() + return subscription !== null + } return false } } @@ -189,6 +241,7 @@ async function startGitCommonNarrowWatch( disposed = true stopExistencePoll() unsubscribeVisibility() + await pollingFallbackPromise?.catch(() => {}) const current = subscription subscription = null if (current) { @@ -210,7 +263,14 @@ export async function startGitCommonWatch( ): Promise { if (platform === 'darwin') { const [narrowWatch, primaryMetadataPoll] = await Promise.all([ - startGitCommonNarrowWatch(target, onEvents, pollIntervalMs, visibility, onWatchError), + startGitCommonNarrowWatch( + target, + onEvents, + pollIntervalMs, + visibility, + onFullScan, + onWatchError + ), startGitCommonPrimaryPolling( target.path, getStatusRefPaths, diff --git a/src/main/ipc/worktree-watcher-failure-refresh-cooldown.ts b/src/main/ipc/worktree-watcher-failure-refresh-cooldown.ts new file mode 100644 index 000000000..76c33a621 --- /dev/null +++ b/src/main/ipc/worktree-watcher-failure-refresh-cooldown.ts @@ -0,0 +1,17 @@ +const WATCHER_FAILURE_REFRESH_COOLDOWN_MS = 60_000 + +export class WorktreeWatcherFailureRefreshCooldown { + private refreshedAt: number | null = null + + consume(now = Date.now()): boolean { + if (this.refreshedAt !== null && now - this.refreshedAt < WATCHER_FAILURE_REFRESH_COOLDOWN_MS) { + return false + } + this.refreshedAt = now + return true + } + + reset(): void { + this.refreshedAt = null + } +} diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 606972f42..7e053daa6 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -186,6 +186,22 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) }) + it('keeps completed scans cached for one minute', async () => { + vi.useFakeTimers({ now: new Date('2026-08-05T00:00:00.000Z') }) + try { + await listAiVaultSessions({ limit: 500 }) + await vi.advanceTimersByTimeAsync(59_999) + await listAiVaultSessions({ limit: 500 }) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + await listAiVaultSessions({ limit: 500 }) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + it('serves lower depths from a larger completed scan', async () => { await listAiVaultSessions({ limit: 1000 }) await listAiVaultSessions({ limit: 250 }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index 3af723343..b1e9b819a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -20,7 +20,7 @@ const EMPTY_RESULT: AiVaultListResult = { scannedAt: '2026-07-01T00:00:00.000Z' } -const THROTTLE_MS = 5_000 +const THROTTLE_MS = 30_000 const listSessionsMock = vi.fn<(args: unknown) => Promise>() const cancelListSessionsMock = vi.fn<() => Promise>() @@ -206,14 +206,14 @@ afterEach(() => { }) describe('useAiVaultSessionRefresh refocus behavior', () => { - it('bypasses the scan cache on mount so panel entry shows new sessions', async () => { + it('uses the shared scan cache on local panel entry', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ executionHostScope: 'local', - force: true + force: false }) }) @@ -351,32 +351,25 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z') }) - it('force re-scans on refocus once the throttle allows it', async () => { + it('refreshes from the shared cache on refocus', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - await advance(THROTTLE_MS + 1) await fireWindowFocused() expect(listSessionsMock).toHaveBeenCalledTimes(2) - expect(lastCallArgs()).toMatchObject({ force: true }) + expect(lastCallArgs()).toMatchObject({ force: false }) }) - it('defers a refocus inside the throttle window to a trailing forced scan', async () => { + it('does not force transcript scans for refocus events', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - // Within the throttle window nothing runs yet — the event must not be - // dropped, so it lands as one trailing scan when the throttle frees up. await fireWindowFocused() - await dispatch(document, 'visibilitychange') - expect(listSessionsMock).toHaveBeenCalledTimes(1) - - await advance(THROTTLE_MS + 1) expect(listSessionsMock).toHaveBeenCalledTimes(2) - expect(lastCallArgs()).toMatchObject({ force: true }) + expect(lastCallArgs()).toMatchObject({ force: false }) }) it('ignores focus/visibility events while the document is hidden', async () => { @@ -393,15 +386,12 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(listSessionsMock).toHaveBeenCalledTimes(1) }) - it('stops listening and cancels trailing scans after unmount', async () => { + it('stops listening after unmount', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - // Queue a trailing forced scan, then unmount before it fires. - await fireWindowFocused() roots.splice(0).forEach((root) => act(() => root.unmount())) - await advance(THROTTLE_MS + 1) await fireWindowFocused() await dispatch(document, 'visibilitychange') @@ -479,7 +469,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(lastCallArgs()).toMatchObject({ force: true }) }) - it('counts a manual force refresh against the rescan throttle', async () => { + it('keeps refocus cache-backed after a manual force refresh', async () => { await renderHook() await flushMicrotasks() @@ -489,9 +479,9 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { }) expect(listSessionsMock).toHaveBeenCalledTimes(2) - // The button just scanned; an immediate refocus defers to trailing. await fireWindowFocused() - expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + expect(lastCallArgs()).toMatchObject({ force: false }) }) }) @@ -514,10 +504,33 @@ describe('useAiVaultSessionRefresh in-app agent session behavior', () => { expect(listSessionsMock).toHaveBeenCalledTimes(1) await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) - expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) await advance(THROTTLE_MS + 1) - expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('re-budgets a trailing agent scan after a manual refresh', async () => { + await renderHook() + await flushMicrotasks() + + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) + await advance(10_000) + await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2') }) + await advance(10_000) + await act(async () => { + await latest?.refresh({ force: true }) + }) + + await advance(10_001) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + + await advance(19_999) + expect(listSessionsMock).toHaveBeenCalledTimes(4) expect(lastCallArgs()).toMatchObject({ force: true }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index e9cafb17d..d991a9510 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -4,7 +4,7 @@ import { type AiVaultListResult, type AiVaultSession } from '../../../../shared/ai-vault-types' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostScope } from '../../../../shared/execution-host' +import type { ExecutionHostScope } from '../../../../shared/execution-host' import { useAppStore } from '@/store' import type { AiVaultSessionLimit } from './ai-vault-session-limit' import { @@ -14,22 +14,11 @@ import { resetAiVaultSessionResultCacheForTest } from './ai-vault-session-result-cache' -// Panel entry and window refocus must show sessions started since the last -// scan, so they bypass the main process's 15s cache — but a full scan parses -// up to ~1000 transcripts, so bound forced scans to one per interval. Module -// scope so the throttle survives panel remounts (the panel unmounts per tab). -const FORCED_RESCAN_MIN_INTERVAL_MS = 5_000 +// In-app session creation bypasses the cache so the new session appears promptly. +// Keep the budget at module scope so tab remounts cannot amplify full scans. +const FORCED_RESCAN_MIN_INTERVAL_MS = 30_000 let lastForcedRescanAt = 0 -function consumeForcedRescanBudget(): boolean { - const now = Date.now() - if (now - lastForcedRescanAt < FORCED_RESCAN_MIN_INTERVAL_MS) { - return false - } - lastForcedRescanAt = now - return true -} - export function resetAiVaultForcedRescanThrottleForTest(): void { lastForcedRescanAt = 0 resetAiVaultSessionResultCacheForTest() @@ -201,7 +190,7 @@ export function useAiVaultSessionRefresh( [currentScanScopeKey] ) - // Forced rescans triggered by events (refocus, agent-session starts) run + // Forced rescans triggered by new agent sessions run // immediately when the throttle allows, otherwise once as soon as it frees // up — dropping the event would leave a just-started session invisible // until some unrelated later trigger. @@ -218,8 +207,7 @@ export function useAiVaultSessionRefresh( } forcedRescanTimerRef.current = setTimeout(() => { forcedRescanTimerRef.current = null - lastForcedRescanAt = Date.now() - void refresh({ background: true, force: true }) + requestForcedRescan() }, waitMs) }, [refresh]) @@ -240,33 +228,23 @@ export function useAiVaultSessionRefresh( } }, []) - // Remote scans can take long enough for normal tab navigation to feel stuck, - // so re-entering a remote panel uses its host/scope cache. Explicit refresh, - // app refocus and new in-app agent sessions still force a fresh scan. + // Panel entry reuses the renderer result first, then the host scan cache. useEffect(() => { if (refreshInFlightRef.current) { void window.api.aiVault.cancelListSessions({ requestToken: requestTokenRef.current }) } - const refreshOnEntry = executionHostScope === LOCAL_EXECUTION_HOST_ID - const force = refreshOnEntry && consumeForcedRescanBudget() - void refresh({ force, reuseLoadedDepth: true }) - if (refreshOnEntry && !force) { - requestForcedRescan() - } - }, [executionHostScope, refresh, requestForcedRescan, scanScopeKey]) + void refresh({ force: false, reuseLoadedDepth: true }) + }, [executionHostScope, refresh, scanScopeKey]) - // Sessions started while the app was backgrounded should appear when the - // user returns, so refocus also bypasses the scan cache (throttled). OS - // refocus arrives via the main process — renderer DOM focus events don't - // fire on macOS app activation; visibilitychange covers minimize-restore. + // Refocus checks the shared host cache without forcing another transcript scan. useEffect(() => { const onRefocus = (): void => { if (document.visibilityState !== 'visible') { return } - requestForcedRescan() + void refresh({ background: true, force: false }) } const unsubscribeWindowFocus = window.api.aiVault.onWindowFocused?.(onRefocus) document.addEventListener('visibilitychange', onRefocus) @@ -274,7 +252,7 @@ export function useAiVaultSessionRefresh( unsubscribeWindowFocus?.() document.removeEventListener('visibilitychange', onRefocus) } - }, [requestForcedRescan]) + }, [refresh]) // Sessions started inside Orca never blur the window, so refocus alone // can't surface them. Agent hooks already report provider sessions; re-scan diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index a1d4690f4..2aab92ac5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -7,18 +7,18 @@ import type { TerminalTab } from '../../../../shared/types' // Why: cold-park hysteresis keeps a hidden pane mounted for 30s so quick tab // flips never pay a re-hydrate; hot-retain keeps a bounded recently-visible -// working set warm for 15 minutes beyond that. The cap (not the clock) is the -// primary evictor — 8 worktrees covers the ordinary working set at ~4-5MB -// renderer floor each, so parking only engages for the many-worktree tail it +// working set warm for 5 minutes beyond that. The cap (not the clock) is the +// primary evictor — 4 worktrees covers the ordinary working set, so parking +// only engages for the many-worktree tail it // was built for. Reveal cost is a flat ~170ms remount regardless of buffer // size, so cutting remount *frequency* beats shaving replay. export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000 -export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 15 * 60_000 -export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 8 +export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4 export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000 -export const TERMINAL_TAB_HOT_RETAIN_MS = 15 * 60_000 -export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12 +export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 6 // Why: tests override these per call (instead of process.env reads inside the // module) to shrink the 30s hysteresis to test-friendly durations. diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 161d33760..856f976cc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -13,8 +13,8 @@ import type { TerminalTab } from '../../../../shared/types' // Why these sizes: a retained hidden pane costs a measured ~2.5MB of V8 heap // at the 5k-row default scrollback and ~19MB at 50k (plus per-pane queues), // not the ~4-5MB per WORKTREE the warm cap assumed — so un-parkable worktrees -// (pty classes parking can't restore) get a retention budget: at most 12 stay -// mounted while hidden and none past 45 minutes, evicted least-recently-hidden +// (pty classes parking can't restore) get a retention budget: at most 4 stay +// mounted while hidden and none past 15 minutes, evicted least-recently-hidden // first via force-park. The TTL is absolute: the last-active exemption bounds // the cap, never the clock. // NOT covered by this bound: eviction-exempt TABS (isEvictionExemptTerminalPty @@ -27,8 +27,8 @@ import type { TerminalTab } from '../../../../shared/types' // spared worktree (last-active, exempt tabs) can hold full 50k-row scrollback // indefinitely. Accepted tradeoff: high-scrollback users rely on unmount // eviction, not demotion. -export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 12 -export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 45 * 60_000 +export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 4 +export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 15 * 60_000 export function hasPendingRetentionSpawnWork( tab: Pick, @@ -127,7 +127,7 @@ export function selectRetentionForceParkedTerminalWorktrees( }) // Why re-applied here: selectIdsBeyondHotRetain spares the last-active id from // its clock too, which is right for the warm cap (instant return after a - // meeting) but makes "none past 45 minutes" false for a lone hidden worktree. + // meeting) but makes "none past 15 minutes" false for a lone hidden worktree. for (const candidate of candidates) { if (args.nowMs - candidate.hiddenSinceMs >= retentionTtlMs) { forceParkedIds.add(candidate.id) diff --git a/src/renderer/src/lib/pane-manager/pane-display-visibility.ts b/src/renderer/src/lib/pane-manager/pane-display-visibility.ts new file mode 100644 index 000000000..7c7fc7f63 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-display-visibility.ts @@ -0,0 +1,15 @@ +import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' + +export function isManagedPaneDisplayNone(pane: ManagedPane): boolean { + const element = (pane as ManagedPaneInternal).xtermContainer ?? pane.container + const view = element?.ownerDocument?.defaultView + if (!element || !view) { + return false + } + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + if (view.getComputedStyle(current).display === 'none') { + return true + } + } + return false +} diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts index 2b2471a2c..e7975eec1 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' -import type { ManagedPane, ScrollState } from './pane-manager-types' +import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' import { safeFit, safeFitAndThen } from './pane-fit' import { paneFitClientSizeChanged } from './pane-reveal-fit' @@ -214,6 +214,54 @@ describe('safeFitAndThen unmeasurable-pane retry', () => { expect(continuation).not.toHaveBeenCalled() await expect(handle.completion).resolves.toBe(false) }) + + it('does not retry a pane explicitly hidden with display none', async () => { + vi.mocked(recordRendererCrashBreadcrumb).mockClear() + const pane = createPane({ rect: { width: 0, height: 0 } }) + const container = (pane as unknown as ManagedPaneInternal).xtermContainer + Object.assign(container, { + ownerDocument: { + defaultView: { getComputedStyle: () => ({ display: 'none' }) } + }, + parentElement: null + }) + const continuation = vi.fn() + + const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + retryIfUnmeasurable: true + }) + + expect(requestAnimationFrame).not.toHaveBeenCalled() + expect(continuation).not.toHaveBeenCalled() + expect(recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + await expect(handle.completion).resolves.toBe(false) + }) + + it('stops retrying when a pane becomes display none', async () => { + vi.mocked(recordRendererCrashBreadcrumb).mockClear() + const pane = createPane({ rect: { width: 0, height: 0 } }) + const container = (pane as unknown as ManagedPaneInternal).xtermContainer + let display = 'block' + Object.assign(container, { + ownerDocument: { + defaultView: { getComputedStyle: () => ({ display }) } + }, + parentElement: null + }) + const continuation = vi.fn() + + const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + retryIfUnmeasurable: true + }) + display = 'none' + flushAnimationFrames() + vi.advanceTimersByTime(16) + + expect(requestAnimationFrame).toHaveBeenCalledOnce() + expect(continuation).not.toHaveBeenCalled() + expect(recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + await expect(handle.completion).resolves.toBe(false) + }) }) describe('paneFitClientSizeChanged (reveal fit gate)', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index e017566e3..5eda63361 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -1,4 +1,5 @@ import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' +import { isManagedPaneDisplayNone } from './pane-display-visibility' import { getFitOverrideForPty } from './mobile-fit-overrides' import { armPaneFitContinuationRetry, @@ -230,7 +231,7 @@ function pruneStaleSafeFitContinuations(pane: ManagedPane): void { return } for (const [operationKey, pending] of operations) { - if (!pending.shouldContinue()) { + if (!pending.shouldContinue() || isManagedPaneDisplayNone(pane)) { settlePendingSafeFitContinuation(pane, operationKey, pending, false) } } @@ -314,7 +315,11 @@ export function safeFitAndThen( () => { if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) { if (!safeFit(pane) && options.retryIfUnmeasurable) { - armSafeFitContinuationRetry(pane) + if (isManagedPaneDisplayNone(pane)) { + cancel() + } else { + armSafeFitContinuationRetry(pane) + } } } } @@ -323,7 +328,11 @@ export function safeFitAndThen( return { completion, cancel } } if (!safeFit(pane) && options.retryIfUnmeasurable) { - armSafeFitContinuationRetry(pane) + if (isManagedPaneDisplayNone(pane)) { + cancel() + } else { + armSafeFitContinuationRetry(pane) + } } return { completion, cancel } }