fix(desktop): qualify lineage absorption members by profile (#92454)

`_lineage_ids` members were matched bare, so a kept row in another
profile whose stored id merely coincided with an incoming lineage was
evicted. Qualify members with the owning row's profile, matching the
identity and lineage keys in the same predicate.
This commit is contained in:
Hermes Agent
2026-09-25 17:49:50 -05:00
committed by brooklyn!
parent 7c80df7120
commit 8507948c04
2 changed files with 50 additions and 3 deletions

View File

@@ -649,6 +649,50 @@ describe('mergeSessionPage', () => {
expect(merged.map(s => s.id)).toEqual(['pinned', 'recent'])
})
it('keeps a pinned twin in another profile when an incoming lineage carries the same stored id (#92454)', () => {
// Bare-id lineage matching evicted a kept row whose id merely coincided
// with another profile's `_lineage_ids`: in that other profile the id
// really was absorbed into the projected tip, but the pinned row is a
// different session. Lineage members must be profile-qualified like
// every other key in the survivor predicate.
const previous = [
session({ id: 'sess-42', profile: 'quietbot', title: 'Pinned quiet work' })
] as SessionInfo[]
const incoming = [
session({
id: 'tip',
profile: 'testbot',
_lineage_ids: ['sess-42', 'fresh-root', 'tip'],
_lineage_root_id: 'fresh-root'
})
] as SessionInfo[]
const merged = mergeSessionPage(previous, incoming, ['sess-42'])
expect(merged.map(s => `${s.profile}:${s.id}`).sort()).toEqual(['quietbot:sess-42', 'testbot:tip'])
})
it('drops a same-profile segment in the keep set when the incoming lineage absorbed it', () => {
// Counterpart to the twin guard: qualification must not weaken the actual
// absorption. seg2 was absorbed into testbot's projected tip and must be
// evicted even though it sits in the keep set.
const previous = [session({ id: 'seg2', profile: 'testbot' })] as SessionInfo[]
const incoming = [
session({
id: 'tip',
profile: 'testbot',
_lineage_ids: ['seg1', 'seg2', 'fresh-root', 'tip'],
_lineage_root_id: 'fresh-root'
})
] as SessionInfo[]
const merged = mergeSessionPage(previous, incoming, ['seg2'])
expect(merged.map(s => `${s.profile}:${s.id}`)).toEqual(['testbot:tip'])
})
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

@@ -696,9 +696,12 @@ export function mergeSessionPage(
// `_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.
// inside another session's lineage. Like the identity and lineage keys
// above, members are qualified by the owning row's profile — stored ids
// are only unique per-profile (#92454), so a bare-id match would evict a
// kept twin in another profile whose id merely coincides with a lineage.
const incomingLineageIdMembers = new Set(
merged.flatMap(session => session._lineage_ids ?? [])
merged.flatMap(session => (session._lineage_ids ?? []).map(id => `${profileKeyOf(session)}::${id}`))
)
const survivors = previous.filter(
@@ -709,7 +712,7 @@ export function mergeSessionPage(
!session.hidden &&
!incomingIds.has(identity(session)) &&
!incomingLineageKeys.has(lineageIdentity(session)) &&
!incomingLineageIdMembers.has(session.id) &&
!incomingLineageIdMembers.has(identity(session)) &&
(keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id)))
)