fix(desktop): drop ghost sidebar rows absorbed by a reorganized compression chain (#85331)

mergeSessionPage's survivor filter kept any previous row whose id sat in the
keep set and that the incoming page did not return. The tip-rotation
lineage dedup (#43483) only matched through the lineage ROOT key, so
when a compression-chain reorganization minted a FRESH root id (manual
storage-format repair), an old SEGMENT row that was in the keep set (the
working/selected session at refresh time) produced the exact signal of a
legitimately-kept row: absent from the page, unmatched by lineage key.
It survived as a title-less ghost the backend never sent — the sidebar
rendered N+2 rows for a group the endpoints served as N.

Fix: the survivor filter now also drops a row whose id appears anywhere
inside an incoming row's `_lineage_ids` (the full chain the backend
projects, already served on list rows). That is absorption, not
staleness: a genuinely-kept row — pinned aged off the page, an
in-flight first turn — never has its id inside ANOTHER session's
lineage, so the protected survivor behaviors all hold (guards included).
No API change, no extra traffic; the data needed was already on the wire.

Regression tests: the reporter's exact unit repro (old segment in keep
after reorg mints a fresh root) and the pinned-aging-off guard with a
deep lineage on the incoming page.

The official repair-chains maintenance command from the report is a
separate feature (open PR #86743) and stays out of this fix.
This commit is contained in:
Hermes Agent
2026-09-24 18:55:04 -05:00
committed by brooklyn!
parent 3521034ed6
commit 1ec4957d4c
2 changed files with 62 additions and 0 deletions

View File

@@ -606,6 +606,49 @@ describe('mergeSessionPage', () => {
expect(merged.map(s => s.id)).toEqual(['b', 'a-new'])
})
it('drops an old segment kept in the keep set after the chain reorg minted a fresh root (#85331)', () => {
// The reporter's unit repro: a manual compression-chain reorganization
// relinked old segments under a NEW root id. The backend now returns the
// tip (carrying _lineage_ids with every chain segment); the previous
// list still holds an old SEGMENT row that is in the keep set (it was
// the working/selected session at refresh time). Its id is absent from
// the incoming page and unmatched by the root-only lineage key, so it
// used to survive as a title-less ghost row the backend never sent.
const previous = [
session({ id: 'seg2' }), // old segment: no lineage of its own
session({ id: 'tip', _lineage_root_id: 'fresh-root' }),
session({ id: 'other' })
] as SessionInfo[]
const incoming = [
// The reorganized chain served as its tip, with every chain id.
session({ id: 'tip', _lineage_ids: ['seg1', 'seg2', 'fresh-root', 'tip'], _lineage_root_id: 'fresh-root' }),
session({ id: 'other' })
] as SessionInfo[]
// seg2 was the working session at refresh time — in the keep set.
const merged = mergeSessionPage(previous, incoming, ['seg2'])
expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
})
it('keeps a pinned session aged off the page even when another row carries a deep lineage', () => {
// The absorption filter must NOT evict legitimately-kept rows: a pinned
// row's own id never appears inside ANOTHER session's lineage. Guard for
// the #85331 fix — this is the pre-existing pinned-aging-off behavior
// ('keeps a pinned session that has aged off the recent page') plus a
// deep _lineage_ids on the incoming page.
const previous = [session({ id: 'recent' }), session({ id: 'pinned' })]
const incoming = [
session({ id: 'recent', _lineage_ids: ['recent', 'recent-root'], _lineage_root_id: 'recent-root' })
]
const merged = mergeSessionPage(previous, incoming, ['pinned'])
expect(merged.map(s => s.id)).toEqual(['pinned', 'recent'])
})
it('never regresses last_active behind an optimistic user-send bump', () => {
const previous = [session({ id: 'old', last_active: 9_000 })]
const incoming = [session({ id: 'old', last_active: 100, message_count: 4 })]

View File

@@ -683,6 +683,24 @@ export function mergeSessionPage(
// another profile is a DIFFERENT session and must survive the dedupe.
const incomingLineageKeys = new Set(merged.map(lineageIdentity))
// Absorption filter: a survivor whose id appears ANYWHERE inside an
// incoming row's compression lineage is not a separate session anymore —
// the backend now serves that conversation as the chain's projected row.
// `mergeSessionPage`'s own survivors come from the tip-rotation dedup
// (#43483), but that only catches a lineage match through the root key.
// When a reorganized chain mints a FRESH root id (manual compression-chain
// repair, #85331), an old segment row in the keep set (it was the
// working/selected session at refresh time) produced the exact signal of a
// legitimately-kept row: absent from the incoming page, unmatched by
// lineage key. It survived as a title-less ghost. The incoming rows carry
// `_lineage_ids` — every id the chain has answered to — so matching a
// survivor id against that list identifies absorption WITHOUT evicting a
// genuinely-pinned row aged off the page: a pinned row's id never appears
// inside another session's lineage.
const incomingLineageIdMembers = new Set(
merged.flatMap(session => session._lineage_ids ?? [])
)
const survivors = previous.filter(
session =>
// The keep-list answers "live, not listed yet" — a hidden row (canonical
@@ -691,6 +709,7 @@ export function mergeSessionPage(
!session.hidden &&
!incomingIds.has(identity(session)) &&
!incomingLineageKeys.has(lineageIdentity(session)) &&
!incomingLineageIdMembers.has(session.id) &&
(keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id)))
)