diff options
Diffstat (limited to 'eviction-limitation.md')
| -rw-r--r-- | eviction-limitation.md | 95 |
1 files changed, 95 insertions, 0 deletions
diff --git a/eviction-limitation.md b/eviction-limitation.md new file mode 100644 index 0000000..a5b948f --- /dev/null +++ b/eviction-limitation.md @@ -0,0 +1,95 @@ +# Known Limitation: Frontend eviction is whole-message, not per-chunk + +Status: **open / deal-with-later.** Documented from the append-only chunk-log +work (see `plan-chunk-log.md`). The backend is not the problem — this is purely +a frontend in-memory concern. + +## TL;DR + +The append-only chunk log made **loading** per-chunk (you fetch the last N +*chunks*, not N whole turns), but the frontend's in-memory **eviction** still +drops whole *messages* (turns). A single pathological turn (e.g. the 150-tool-call +incident — one assistant message holding ~150 chunks) therefore stays resident in +browser memory in full until it scrolls out of the protected window. On a +memory-constrained device, one giant turn can still blow the budget. + +So: the chunk log helps long *histories* (many normal turns), but does **not** +yet help a single oversized *turn*. + +## What "eviction" is (for the record) + +`evictMessages` (`packages/frontend/src/lib/tabs.svelte.ts:345`) trims a tab's +in-memory `messages` array when its total chunk count exceeds +`tab.chunkLimit`, to bound browser RAM. It never deletes anything from the DB — +the `chunks` table is the durable source of truth and evicted content is +re-fetched on scroll-up via `loadMoreMessages` +(`tabs.svelte.ts:402`). The active/streaming turn and the last user+assistant +pair are pinned; eviction is suppressed while the user is scrolled up. + +## Root cause + +Eviction operates at message granularity and explicitly refuses to trim within a +message. From `tabs.svelte.ts` (the `evictMessages` comment, ~`:360-372`): + +> We never trim chunks from WITHIN a message: messages are the +> persistence/pagination unit ... Whole-message eviction from the front is the +> correct granularity. + +That comment is now stale in spirit: persistence/pagination is per-**chunk** +(the flat `chunks` table + `GET /messages` windowing by chunk `seq`), but the +in-memory store still holds **grouped `ChatMessage[]`** as its source of truth, +so the smallest evictable unit is a whole turn. + +``` +DB / wire: per-chunk ✅ (chunks table, chunk-seq pagination) +Frontend load: per-chunk ✅ (GET /messages?limit=N windows the chunk log) +Frontend evict: per-MESSAGE ❌ (tab.messages is grouped; a turn is atomic) +``` + +## Why it wasn't fixed in this pass + +True per-chunk eviction requires the frontend store's **source of truth to be the +flat chunk list**, with `messages` derived for rendering (this was P5 in +`plan-chunk-log.md`). That means: + +- store `tab.chunks: ChunkRow[]` (+ the in-flight live turn) instead of + `tab.messages: ChatMessage[]`; +- rewrite every streaming handler (`applyChunkEvent`, `routeSystemEvent`, the + `done` / `status` / `statuses` / error paths) to mutate the flat list; +- derive `messages` via `groupRowsToMessages` for the render layer. + +That touches ~10 handler sites in `tabs.svelte.ts` and the ~60 frontend tests in +`chat-store.test.ts`, all of which currently assert against the grouped +`tab.messages` model. It was deferred to keep the test suite green and the +diff bounded. The hard part (flat storage + DB-free `explode`/`group` transforms +in `packages/core/src/chunks/transform.ts`) is already done and reusable. + +## Fix options (later) + +1. **Flat-chunk store + derived messages (recommended, the "proper" fix).** + Make `tab.chunks: ChunkRow[]` the source of truth; derive `messages` with + `groupRowsToMessages` (already shared from + `@dispatch/core/src/chunks/transform.js`). Eviction then trims the flat array + at chunk granularity; pin the in-flight turn + the tail. Re-fetch on + scroll-up by chunk `seq` (already supported). Highest effort (handler + + test rewrite), but fully solves it and matches `plan-chunk-log.md` P5. + +2. **Partial trim of an oversized message (incremental, lower effort).** + Keep the grouped model, but when the *oldest* in-memory message alone exceeds + the limit, drop its leading chunks (whole `text`/`thinking`/`tool-batch` + units) and record a per-message `oldestChunkSeq` so `loadMoreMessages` can + re-hydrate it. Caveat: must keep the message renderable and merge correctly on + scroll-up (the `turnId` merge in `loadMoreMessages` already handles the + boundary case). A pragmatic stopgap. + +3. **Render virtualization (separate concern).** + Windowing the *DOM* (only mount visible bubbles) reduces render cost but not + the JS-heap cost of holding the chunks. Complementary to 1/2, not a + substitute. + +## Acceptance check for whichever fix + +Load a tab whose history contains one turn with ≫ `chunkLimit` chunks; confirm +in-memory chunk count stays ≤ `chunkLimit` (± the pinned tail) while scrolled to +the bottom, and that scrolling up re-hydrates the trimmed chunks of that same +turn without duplication. |
