From 22c19e924a753aac031edb24da0a8a27432e6d10 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 19:57:40 -0700 Subject: [PATCH] fix(native-chat): drop the retraction's epoch history, keep its version cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third and final shape for this branch, and the only one of the three that holds. Keeping both maps re-poisons the epoch one cycle later: the consumer here is also the publisher, so the history's current is the renderer's own lifetime epoch, and recording the reveal's fresh epoch retires it. The next chat the renderer publishes is then dropped — this bug again, one close later. Deleting both loses the guard that stops a frame issued before the close landing after it and stranding a row nothing republishes. So: clear the history, keep the cursor. The mainstream path keeps its history as a tombstone because there the epochs belong to a remote publisher; that reasoning does not carry to a path that publishes under its own. Each of the three variants now fails a different test. --- ...l-structured-session-reveal-visibility.test.ts | 15 +++++++++++++++ .../snapshot-apply.ts | 13 +++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts b/src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts index d819cc7e5a6..e4649f54055 100644 --- a/src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts +++ b/src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts @@ -153,6 +153,21 @@ describe('a revealed chat survives the frames the reveal provokes', () => { expect(chatTabIds(state)).toEqual([]) }) + it('does not retire the renderer\u2019s own epoch when a reveal republishes under a new one', () => { + // The consumer is also the publisher here. If the retraction leaves its epoch history behind, + // recording the reveal's fresh epoch retires the renderer's own, and the NEXT chat it + // publishes under that epoch is dropped — the same symptom, one cycle later. + let state = apply(baseState(), chatFrame(RENDERER_EPOCH, 120)) + state = apply(state, emptyFrame(RENDERER_EPOCH, 121)) + state = apply(state, { ...emptyFrame('removed:abc', 0), removed: true } as never) + state = apply(state, chatFrame('structured:mf3k1', 1)) // reveal, fresh lineage + + state = apply(state, emptyFrame('structured:mf3k1', 2)) // closed again + state = apply(state, chatFrame(RENDERER_EPOCH, 130)) // renderer publishes a new chat + + expect(chatTabIds(state)).toEqual([SESSION_TAB]) + }) + it('still prunes the mirrored rows when the host retracts the worktree', () => { // The retraction must not merely stop fencing later frames — it has to take the rows with it, // or a worktree the host no longer publishes keeps a chat on screen that nothing backs. diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index b5563e5a71b..75d1bce6108 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -110,10 +110,15 @@ export function applyLocalStructuredSessionTabSnapshots< // is one string for the whole process lifetime, and every republication afterwards would be // dropped until a reload minted a new one. That is what left a revealed chat invisible. // - // Only the recording is skipped: the cursor and the epoch history stay. The host mints a - // fresh epoch when it rebuilds a pruned entry, so a republication is never gated by the - // retained cursor — while dropping it would leave an in-flight frame from before the close - // free to re-apply and strand a row for a worktree the host no longer publishes. + // The cursor stays: the host mints a fresh epoch when it rebuilds a pruned entry, so a + // republication is never gated by it, while dropping it would leave a frame issued before + // the close free to land afterwards and strand a row nothing republishes. + // + // The epoch history goes. Unlike the mainstream path — where the epochs belong to a remote + // publisher and the history is a useful tombstone — here the consumer IS the publisher, and + // `current` is the renderer's own lifetime epoch. Keep it, and the next frame under any + // other epoch retires that one for good, which is this bug again one cycle later. + localStructuredSessionEpochHistoryByWorktree.delete(snapshot.worktree) continue } localStructuredSessionVersionByWorktree.set(snapshot.worktree, {