fix(desktop): require proof of absence before dropping a drained queue entry (#122083 review)
The drain-exhaustion drop treated 'hint lookup returned undefined' as 'the session is gone', but getSessionOwnerHint also returns undefined for TWO OR MORE routes (cloud gateway plus local backend) — a positive liveness signal misread as absence. Use the plural getSessionOwnerHints (≥1 route keeps the entry), and refuse to drop while the loaded list page cannot prove absence either ($sessionsLoadError, or any profile in $sessionProfilesTruncated — the sidebar list is one page, so a session below the fold is unknown, not deleted). 'Maybe' never deletes; the queued prompt stays for a manual send.
This commit is contained in:
@@ -12,7 +12,14 @@ import {
|
||||
parkQueuedPrompts
|
||||
} from '@/store/composer-queue'
|
||||
import { $notifications, clearNotifications } from '@/store/notifications'
|
||||
import { $sessions, setSessions, setSessionsLoading } from '@/store/session'
|
||||
import {
|
||||
$sessions,
|
||||
_resetSessionOwnerHintsForTests,
|
||||
setSessionOwnerHint,
|
||||
setSessionProfilesTruncated,
|
||||
setSessions,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
@@ -64,6 +71,7 @@ describe('useBackgroundQueueDrain', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
_resetSessionOwnerHintsForTests()
|
||||
// The queue store merges over live localStorage on save (cross-window sync,
|
||||
// #46732) — stale persisted entries from an earlier test would be adopted
|
||||
// into the atom and drained here as if they were fresh queue state.
|
||||
@@ -346,6 +354,69 @@ describe('useBackgroundQueueDrain', () => {
|
||||
expect(stuck?.kind).toBe('info')
|
||||
})
|
||||
|
||||
it('keeps the queued prompt when the session is reachable but its owner hint is ambiguous (#122083 review)', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
// Two routes for the same id (cloud gateway + local backend, or a profile
|
||||
// switch that re-stamped the route) make getSessionOwnerHint return
|
||||
// undefined — a POSITIVE liveness signal, not absence. Reading the
|
||||
// singular accessor as an existence test dropped the user's queued
|
||||
// prompt at drain exhaustion.
|
||||
setSessions([])
|
||||
setSessionOwnerHint('stored-session-a', { connectionId: 'conn-cloud', profile: 'default' })
|
||||
setSessionOwnerHint('stored-session-a', { connectionId: 'conn-local', profile: 'work' })
|
||||
const runtimeMap = { current: new Map<string, string>() }
|
||||
const submitText = vi.fn(async () => false)
|
||||
|
||||
enqueueQueuedPrompt('stored-session-a', { text: 'still alive', attachments: [] })
|
||||
|
||||
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
for (let attempt = 1; attempt < MAX_AUTO_DRAIN_ATTEMPTS; attempt++) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(750)
|
||||
})
|
||||
}
|
||||
|
||||
expect(submitText).toHaveBeenCalledTimes(MAX_AUTO_DRAIN_ATTEMPTS)
|
||||
expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the queued prompt when the loaded list page cannot prove the session gone (#122083 review)', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
// $sessions is one PAGE of the sidebar list: a queued session that simply
|
||||
// fell off the loaded window ($sessionProfilesTruncated) is unknown by
|
||||
// row and by hint — but "not on this page" is not "deleted". Dropping the
|
||||
// entry there destroyed real data.
|
||||
setSessionProfilesTruncated({ default: true })
|
||||
setSessions([])
|
||||
const runtimeMap = { current: new Map<string, string>() }
|
||||
const submitText = vi.fn(async () => false)
|
||||
|
||||
enqueueQueuedPrompt('stored-session-a', { text: 'below the fold', attachments: [] })
|
||||
|
||||
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
for (let attempt = 1; attempt < MAX_AUTO_DRAIN_ATTEMPTS; attempt++) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(750)
|
||||
})
|
||||
}
|
||||
|
||||
expect(submitText).toHaveBeenCalledTimes(MAX_AUTO_DRAIN_ATTEMPTS)
|
||||
expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)
|
||||
setSessionProfilesTruncated({})
|
||||
})
|
||||
|
||||
it('does not replay the retry ladder for a queue restored after prior exhaustion (#98015)', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
} from '@/store/composer-queue'
|
||||
import { notify } from '@/store/notifications'
|
||||
import {
|
||||
$sessionProfilesTruncated,
|
||||
$sessions,
|
||||
$sessionsLoadError,
|
||||
$sessionsLoading,
|
||||
getSessionOwnerHint,
|
||||
getSessionOwnerHints,
|
||||
idsShareLineage,
|
||||
sessionMatchesStoredId
|
||||
} from '@/store/session'
|
||||
@@ -109,18 +111,29 @@ export function useBackgroundQueueDrain({
|
||||
if (failures >= MAX_AUTO_DRAIN_ATTEMPTS) {
|
||||
// The session rejected every drain attempt. Discovery has settled
|
||||
// (the effect gates on it), so the loaded list plus owner hints are
|
||||
// authoritative: a session no row or hint answers to — by id or
|
||||
// lineage — is gone from this backend (deleted from another
|
||||
// surface, or its stored resume refuses permanently). Owner hints
|
||||
// count: a hidden bot chat never occupies the recents list, yet
|
||||
// its queue is exactly the one worth preserving. Its queued prompt
|
||||
// can never send; keep it and every future boot replays this
|
||||
// cycle for nothing. Drop it and say so quietly.
|
||||
// authoritative — but only when they can actually PROVE absence.
|
||||
// "Maybe" must never mean delete:
|
||||
// - getSessionOwnerHint is undefined both for "no route" AND for
|
||||
// "two or more routes" (a cloud gateway plus a local backend);
|
||||
// the plural accessor keeps those apart, and ≥1 route is alive.
|
||||
// - $sessions is one PAGE of the sidebar list. A session that fell
|
||||
// off the loaded window ($sessionProfilesTruncated) is unknown
|
||||
// by row and hint, not gone.
|
||||
// Only a session no row, no hint AND a complete, untruncated list
|
||||
// answer to — by id or lineage — is gone from this backend (deleted
|
||||
// from another surface, or its stored resume refuses permanently).
|
||||
// Owner hints count: a hidden bot chat never occupies the recents
|
||||
// list, yet its queue is exactly the one worth preserving. A truly
|
||||
// gone session's queued prompt can never send; drop it and say so
|
||||
// quietly. An unprovable one keeps its entry for a manual send.
|
||||
const sessionKnown =
|
||||
$sessions.get().some(session => sessionMatchesStoredId(session, sessionKey)) ||
|
||||
getSessionOwnerHint(sessionKey) !== undefined
|
||||
getSessionOwnerHints(sessionKey).length > 0
|
||||
|
||||
if (!sessionKnown) {
|
||||
const listIncomplete =
|
||||
$sessionsLoadError.get() || Object.values($sessionProfilesTruncated.get()).some(Boolean)
|
||||
|
||||
if (!sessionKnown && !listIncomplete) {
|
||||
removeQueuedPrompt(sessionKey, entry.id)
|
||||
notify({
|
||||
id: `composer-background-queue-stuck-${sessionKey}`,
|
||||
|
||||
Reference in New Issue
Block a user