Files
openchamber/packages/ui/src/sync/eviction.ts
T
Bohdan Triapitsyn ea9bb52fe7 fix(sync): stop directory cache thrashing when a project is expanded
Expanding a project with more worktrees and sessions than MAX_DIR_STORES put
the sidebar into an endless request loop (#1472).

Every sidebar row calls ensureChild during render, but the pin that protects
the directory is only taken in an effect after commit. ensureChild marked the
directory and ran eviction synchronously, so directories that were actively
rendering looked unpinned and were disposed. The next render recreated them
with a loading status, which issued another bootstrap request, and the cycle
repeated for as long as the project stayed expanded.

Raising the limit only moves the cliff, so the limit is now a soft target
instead: a directory touched within a grace window is never an overflow
victim. A burst of live directories overflows the cache briefly rather than
thrashing, while idle-time eviction still bounds it. Eviction is also coalesced
into one deferred pass per tick, so a render that mounts many rows no longer
sorts and scans every directory once per row, and a whole commit's pin effects
settle before anything is considered for disposal. Releasing the final consumer
stays synchronous, since that is an explicit lifecycle edge.

The idle profiler gains --expand-projects to reach this state.

Not yet verified end to end: reproducing the loop needs many worktrees under
one project, which this development environment does not have.
2026-08-03 16:28:44 +03:00

61 lines
2.4 KiB
TypeScript

import type { DisposeCheck, EvictPlan, State } from "./types"
/**
* Returns true when the directory's child store holds at least one pending
* blocking request — a question awaiting an answer or a permission awaiting
* approval. Such directories must never be evicted, otherwise the SSE-routed
* request data is lost and the user can never satisfy the agent.
*
* Tracks the same `state.question` / `state.permission` shape that
* {@link bootstrapDirectory} re-hydrates on a fresh `ensureChild` call —
* an empty record key (e.g. after a `question.replied` event clears the
* array) is treated as "no pending requests" so a fully resolved directory
* remains a normal eviction candidate.
*/
export function hasPendingBlockingRequests(state: State | undefined): boolean {
if (!state) return false
for (const list of Object.values(state.question ?? {})) {
if (list && list.length > 0) return true
}
for (const list of Object.values(state.permission ?? {})) {
if (list && list.length > 0) return true
}
return false
}
export function pickDirectoriesToEvict(input: EvictPlan) {
const overflow = Math.max(0, input.stores.length - input.max)
let pendingOverflow = overflow
const graceMs = input.graceMs ?? 0
const sorted = input.stores
.filter((dir) => !input.pins.has(dir))
.filter((dir) => !input.hasPendingBlockingRequests?.(dir))
.slice()
.sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
const output: string[] = []
for (const dir of sorted) {
const last = input.state.get(dir)?.lastAccessAt ?? 0
const age = input.now - last
const idle = age >= input.ttl
if (!idle && pendingOverflow <= 0) continue
// A directory touched moments ago is almost certainly still mounted and
// merely waiting for its pin effect to run. Evicting it starts the
// recreate/bootstrap loop this grace window exists to prevent; going over
// the limit for a while is the cheaper failure.
if (!idle && age < graceMs) continue
output.push(dir)
if (pendingOverflow > 0) pendingOverflow -= 1
}
return output
}
export function canDisposeDirectory(input: DisposeCheck) {
if (!input.directory) return false
if (!input.hasStore) return false
if (input.pinned) return false
if (input.booting) return false
if (input.loadingSessions) return false
if (input.hasPendingBlockingRequests) return false
return true
}