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:
Hermes Agent
2026-09-25 18:53:20 -05:00
committed by brooklyn!
parent 80f9c698b9
commit f0288f2272
2 changed files with 95 additions and 11 deletions

View File

@@ -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()

View File

@@ -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}`,