跳转至

root-lru

One reference-counted bounded root→key cache policy, shared by every layer that keeps immutable per-HEAD work warm — so the same eviction rule is never written twice.

raw source

The same bounded-slot cache guard was written three times: touchRoot in git.ts for the index/drift caches, touchRoot again in spec-eval's scenariofresh.ts for scenario chains — same name, same reference-counted eviction, same recency bump — and a third, simpler per-root scope in freshness.ts. Both duplicate authors documented it as they wrote it (freshness.ts: "the same bounded-slot guard the index caches use"; scenariofresh.ts: "mirroring historyIndex/driftIndex in git.ts"). Nobody was careless. There was nowhere to put a policy that belongs to neither the spec layer nor the eval layer, so each grew its own copy — and the copies drifted where nobody could diff them: three bounds (32 / 64 / 16), two env knobs, and one bare literal that no operator could tune at all.

expanded spec

root-lru owns ONE question: given a root that now wants key, what stays warm and what is evicted. It is a leaf module by construction — it imports nothing from the spec graph, the eval sidecar, or git — so both layers depend on it without either depending on the other. That is the shape the whole spec/eval unification is aiming at: shared derivations live somewhere neither consumer owns.

The policy is reference-counted, not plain LRU, and that distinction is its reason to exist. Entries are keyed by something IMMUTABLE — a HEAD, or a ledger path plus HEAD — so two checkouts on the same commit share one entry instead of building it twice. A root moving A→B therefore drops A only when no other root still names it; evicting eagerly would throw away a sibling worktree's warm work, and never evicting would retain one whole history-shaped index per commit until the bound finally bit. Re-touching an unchanged root is a pure recency move (delete then reinsert), which is what leaves roots insertion-ordered enough for the eviction loop to mean "the oldest root".

Each caller names its own env knob and default, because the families are tuned independently — index/drift work is not scenario-chain work. What no caller may do is invent a different floor or, worse, a bare literal: a cache bound that is a magic number cannot be tuned in the field, which is exactly what scenariofresh.ts's hardcoded 16 was. The floor lives here, once.

Consolidation also has to fix what both copies shared. The bound was read as Math.max(4, Number(env || fallback)), which yields NaN for a mistyped env value — and size > NaN is always false, so a single typo silently turned the bound off and let the cache grow without limit. A bound that fails OPEN is worse than no bound, because nothing reports it. Anything that does not parse to a positive number now falls back to the caller's default. Neither copy was ever going to surface that on its own; it took putting them side by side.

freshness.ts keeps a genuinely different structure — one scope per root, replaced when that root's head moves, with no sharing between roots — and is deliberately NOT forced onto this policy. Unifying unlike things is how a shared layer becomes a second special-case pile; the test for belonging here is that the caller wants the reference-counted immutable-key rule, not merely that it wants a bound.