perf: bound repeated watcher, Session History, and terminal work (#12828)

* perf: bound repeated watcher, vault, and terminal work

* fix(terminal): preserve redraw recovery while bounding fit retries

* fix(watcher): retain structural fallback after crash fuse

* fix(ai-vault): preserve forced scan budget
This commit is contained in:
Brennan Benson 2026-08-06 10:37:52 -07:00 committed by GitHub
parent f71373953b
commit 92f759bb51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 437 additions and 91 deletions

View File

@ -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

View File

@ -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)
})
})

View File

@ -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 {

View File

@ -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()

View File

@ -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<string>
gitStatusRepoIds: Set<string>

View File

@ -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)

View File

@ -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<string>
headIdentityRefresh: WorktreeHeadIdentityRefreshState
gitStatusRefPaths: Set<string>
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
}
}

View File

@ -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)

View File

@ -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<WorktreeBaseSubscription> {
const worktreesDir = join(target.path, 'worktrees')
let disposed = false
let subscription: WorktreeBaseSubscription | null = null
let existenceTimer: ReturnType<typeof setInterval> | null = null
let pollingFallbackPromise: Promise<void> | 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<void> => {
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<void> => {
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<WorktreeBaseSubscription> {
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,

View File

@ -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
}
}

View File

@ -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 })

View File

@ -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<AiVaultListResult>>()
const cancelListSessionsMock = vi.fn<() => Promise<void>>()
@ -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 })
})

View File

@ -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

View File

@ -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.

View File

@ -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<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>,
@ -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)

View File

@ -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
}

View File

@ -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)', () => {

View File

@ -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 }
}