fix(native-chat): a retraction forgets what is current, not the tombstones

The delete lost a fence the cursor cannot replace: the version cursor only
compares within a lineage, so a delayed frame from an already-superseded epoch
had nothing left to stop it putting a chat row back for a worktree the host no
longer publishes. Keeping the record intact had the opposite fault — the
renderer's own epoch is the history's current, so the next frame under any
other epoch retired it.

Clearing only current does neither: noteRetiredValue retires nothing when there
is nothing current, and the tombstones stay. Each of the four shapes now fails
a different test.
This commit is contained in:
Merge Sim
2026-09-05 20:01:05 -07:00
parent 22c19e924a
commit f24dc45035
2 changed files with 28 additions and 5 deletions
@@ -168,6 +168,20 @@ describe('a revealed chat survives the frames the reveal provokes', () => {
expect(chatTabIds(state)).toEqual([SESSION_TAB])
})
it('still fences a delayed frame from an epoch that was already superseded', () => {
// The retraction keeps its tombstones. The version cursor only fences within a lineage, so
// without them a straggler under a long-dead epoch would put a chat row back on screen for a
// worktree the host no longer publishes.
let state = apply(baseState(), chatFrame('structured:old', 5))
state = apply(state, chatFrame(RENDERER_EPOCH, 120)) // retires structured:old
state = apply(state, emptyFrame(RENDERER_EPOCH, 121))
state = apply(state, { ...emptyFrame('removed:abc', 0), removed: true } as never)
state = apply(state, chatFrame('structured:old', 6)) // straggler from the dead epoch
expect(chatTabIds(state)).toEqual([])
})
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.
@@ -114,11 +114,20 @@ export function applyLocalStructuredSessionTabSnapshots<
// 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)
// The history keeps its tombstones but forgets what is current. Unlike the mainstream path —
// where the epochs belong to a remote publisher — here the consumer IS the publisher, and
// `current` is the renderer's own lifetime epoch: leave it set and the next frame under any
// other epoch retires it for good, which is this bug again one cycle later. Clearing the
// whole record would instead let a delayed frame from an already-superseded epoch back in,
// because the version cursor only fences within a lineage. Dropping `current` alone does
// neither: `noteRetiredValue` retires nothing when there is nothing current.
const history = localStructuredSessionEpochHistoryByWorktree.get(snapshot.worktree)
if (history) {
localStructuredSessionEpochHistoryByWorktree.set(snapshot.worktree, {
current: null,
retired: history.retired
})
}
continue
}
localStructuredSessionVersionByWorktree.set(snapshot.worktree, {