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:
parent
f71373953b
commit
92f759bb51
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue