fix(desktop): sweep zombie optimistic rows on the reconnect seam + keep them out of the render cache - #361
Conversation
…ep them out of the render cache A backend restart / WS reconnect can sever the message.complete stamping path (#352): the turn's committed rows land in state.db but the completion frame never reaches the client, so its optimistic rows (user-<ts> / assistant- stream-<ts>) keep their client-minted ids. The id-only dedupe in appendFetchedMessages then paints the polled committed rows as DUPLICATES beside the zombies — and the render-cache write-through persisted the zombies to disk, re-infecting every subsequent boot (observed live 2026-07-15: assistant-stream-1784172446416 cached next to its committed twin 695173; DB clean, pure render corruption; a relaunch did NOT clear it because the cache re-seeded the zombie). Three narrow changes: 1. appendFetchedMessages runs dropZombieOptimisticRows before appending: an optimistic, non-pending row whose (role, exact text) matches an incoming committed row is dropped in favor of the committed twin. Guardrails: committed rows untouchable, pending (streaming) rows exempt, empty-text rows exempt, each incoming row consumes at most one zombie. 2. pushTranscriptToRenderCache persists committed rows only (prefix-allowlist isCommittedTranscriptRow; unknown string ids fail open). 3. normalizeCachedTranscriptRows drops legacy optimistic rows already on disk before painting (all-optimistic file = cache miss), preserving array identity on the pass-through path. Tests: 6 reconnect-seam cases in use-session-changes.test.ts (zombie drop for user+assistant, pending exempt, no over-collapse, one-zombie-per-row budget, role mismatch) + 4 cache-hygiene cases in render-cache-hydration.test.ts. RED-proven: reverting the merge-loop line fails 2, reverting the cache filters fails 4. Full desktop suite 1320/1320, tsc clean.
|
| Filename | Overview |
|---|---|
| apps/desktop/src/app/chat/hooks/use-session-changes.ts | Adds isOptimisticRowId and dropZombieOptimisticRows; wires the sweep into appendFetchedMessages. All guards correctly applied with MEDIA-tag normalization on both sides of the join key. |
| apps/desktop/src/app/render-cache-hydration.ts | Adds isCommittedTranscriptRow with prefix allowlist; applied in pushTranscriptToRenderCache and normalizeCachedTranscriptRows. Array identity preserved when no filtering needed. |
| apps/desktop/src/app/chat/hooks/use-session-changes.test.ts | Six new reconnect-seam test cases driven against real functions, covering all guards and the MEDIA-tag normalization edge case. |
| apps/desktop/src/app/render-cache-hydration.test.ts | Four new cache-hygiene test cases covering write-through filtering, skip-when-all-optimistic, legacy zombie drop, and all-optimistic cache-miss. |
Reviews (3): Last reviewed commit: "merge fork/main" | Re-trigger Greptile
…ey (Greptile #361 P2) Committed assistant rows arrive media-rendered (assistantTextPart -> renderMediaTags) while a streamed zombie may hold the raw MEDIA: line; normalize both sides through the idempotent renderMediaTags so the (role, text) join key is representation-stable. +1 test.
…pts a committed twin (#362) The #361 zombie sweep drops an un-stamped optimistic row in favor of its polled committed twin — but the runtime footer travels ONLY on the message.complete frame and lives on that optimistic row (DB rows never carry it). So the sweep silently traded a footer-bearing zombie for a footer-less committed row: footer visible live, gone one poll later (regression of the #357 behavior, observed live 2026-07-16). dropZombieOptimisticRows now harvests the swept zombie's footer (keyed by the same media-normalized content key) and adoptZombieFooters grafts it onto the adopting committed row. Guardrails: assistant rows only, never overwrites an existing footer, one graft per harvested key. Tests: transplant case (RED without the graft: footer undefined after adopt) + no-overwrite case. Suite 1323/1323, tsc clean. Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
… ids onto stale zombies (re-land of #364) Re-lands #364 (author: Kyzcreig; branch was 705 commits behind) onto current main. CDP-attach caught the mechanism live 2026-07-15: a stale optimistic assistant row (completion frame severed by a backend restart) absorbed the fresh turn user_id via the old top-down role-blind walk — the zombie wore a committed id (invisible to the #361 sweep) and painted as a permanent duplicate. stampOptimisticTranscriptRows now assigns role-aware and tail-first. Both LIVE Greptile #364 findings adjudicated: (1) silent user-id drop when no user-role stampable row exists is DELIBERATE and now documented + test-pinned — cross-role fallback would re-create the exact mis-stamp this fixes; the poll reconciles the committed row safely. (2) 3+-id frames (array + scalar fields both populated) now documented + test-pinned: assistant-preferring tail-first walk, extras left for the poll, no crash. RED-proven: swapping back the fork/main top-down impl fails 5/36 (the 3 zombie vectors + both edge-frame tests). Hook suite 36 green; full desktop suite delta vs clean fork/main baseline = zero new failures (17 pre-existing reds on both, 1 local-env flake passes 3/3 in isolation); tsc errors identical to baseline (7, all pre-existing). Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
* fix(desktop): role-aware tail-first stamping — stop stamping the turn ids onto stale zombies (re-land of #364) Re-lands #364 (author: Kyzcreig; branch was 705 commits behind) onto current main. CDP-attach caught the mechanism live 2026-07-15: a stale optimistic assistant row (completion frame severed by a backend restart) absorbed the fresh turn user_id via the old top-down role-blind walk — the zombie wore a committed id (invisible to the #361 sweep) and painted as a permanent duplicate. stampOptimisticTranscriptRows now assigns role-aware and tail-first. Both LIVE Greptile #364 findings adjudicated: (1) silent user-id drop when no user-role stampable row exists is DELIBERATE and now documented + test-pinned — cross-role fallback would re-create the exact mis-stamp this fixes; the poll reconciles the committed row safely. (2) 3+-id frames (array + scalar fields both populated) now documented + test-pinned: assistant-preferring tail-first walk, extras left for the poll, no crash. RED-proven: swapping back the fork/main top-down impl fails 5/36 (the 3 zombie vectors + both edge-frame tests). Hook suite 36 green; full desktop suite delta vs clean fork/main baseline = zero new failures (17 pre-existing reds on both, 1 local-env flake passes 3/3 in isolation); tsc errors identical to baseline (7, all pre-existing). Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com> * fix(desktop): 3+-id frames keep the positional [user, assistant] pair role-aware (Greptile #398) --------- Co-authored-by: Apollo <apollo@kyzcreig.local> Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Symptom
Messages painted TWICE in the desktop app after a backend restart — persisting across app relaunches. DB clean (total==distinct), pure render corruption. Live incident 2026-07-15.
Root cause (two compounding halves)
message.completeframe carryingmessage_ids(fix(desktop): stop live-sync duplicating every message on send #352's stamping path). The client's optimistic rows (user-<ts>,assistant-stream-<ts>) keep their client-minted ids; the session-sync poll re-fetches the committed rows and the id-only dedupe appends them as duplicates beside the zombies.transcript-<sid>.json), so a relaunch re-painted them and the next poll duplicated again — relaunching did NOT fix it. Observed:assistant-stream-1784172446416cached beside its committed twin (row 695173).Fix (3 narrow changes)
appendFetchedMessagessweeps zombies via newdropZombieOptimisticRows: optimistic, non-pending row with exact (role, text) match to an incoming committed row is dropped in favor of the committed twin. Guardrails: committed rows untouchable; pending/streaming rows exempt; empty-text rows exempt; one zombie per incoming row (genuine repeats survive).pushTranscriptToRenderCachepersists committed rows only (prefix-allowlistisCommittedTranscriptRow; unknown string ids fail open).normalizeCachedTranscriptRowsdrops legacy optimistic rows already on disk before painting; all-optimistic file = cache miss; pass-through path preserves array identity (existing switch-cache-paint contract).Tests
6 reconnect-seam cases + 4 cache-hygiene cases, driven against the REAL functions (no mocks of the unit under test). RED-proven both halves: reverting the merge-loop line fails 2; reverting the cache filters fails 4. Full desktop suite 1320/1320, tsc clean.