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 e4649f54055..181ec3ebe27 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 @@ -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. 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 75d1bce6108..df5fa57a98f 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 @@ -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, {