fix(native-chat): drop the retraction's epoch history, keep its version cursor

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.
This commit is contained in:
Merge Sim
2026-09-05 19:57:40 -07:00
parent 74b06c9c71
commit 22c19e924a
2 changed files with 24 additions and 4 deletions
@@ -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.
@@ -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, {