fix(desktop): survive older backends missing the batched sidebar endpoint (#67986)

Two stacked failures turned desktop/runtime version skew into a full
'Hermes couldn't start' boot brick:

1. listSidebarSessions() had no fallback when the backend predates the
   batched /api/profiles/sessions/sidebar route (added Jul 18) — the
   backend catch-all 404 ('No such API endpoint') rejected the refresh.
2. boot() awaited refreshSessions() unguarded inside Promise.all —
   unlike the reconnect and softSwitch call sites — so a session-LIST
   failure rejected the whole boot and raised failDesktopBoot() even
   though the gateway WS was already open and the app was usable.

Fixes:
- listSidebarSessions() now detects endpoint-missing errors (backend
  catch-all, IPC-wrapped 404, Electron HTML-guard), falls back to the
  three proven per-slice /api/profiles/sessions calls with identical
  scoping (recents on the caller's profile, cron + messaging
  cross-profile), and remembers the verdict so later refreshes skip the
  dead probe. Transient failures (timeout, 5xx, ECONNREFUSED) still
  throw — no silent fast-path degradation from one blip.
- wipeSessionListsForGatewaySwitch() resets the capability flag so a
  soft gateway switch re-probes the next backend instead of leaking the
  old one's verdict (hard re-homes reload the window and reset anyway).
- boot() treats refreshSessions() as non-fatal, matching its sibling
  call sites: worst case is an empty sidebar that the next
  reconnect/turn refresh repopulates, never a bricked boot.

Tests: endpoint-missing fallback slices + scoping, sticky verdict (no
re-probe), re-probe after gateway switch, transient errors NOT
triggering fallback, and a real-hook harness test proving a rejecting
refreshSessions still completes boot with no overlay.
This commit is contained in:
Teknium 2026-07-20 03:40:04 -07:00 committed by GitHub
parent 34a304abb3
commit 4ef92d2e5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 264 additions and 8 deletions

View file

@ -105,13 +105,13 @@ function fakeDesktop() {
}
}
function Harness() {
function Harness({ refreshSessions }: { refreshSessions?: () => Promise<void> } = {}) {
useGatewayBoot({
handleGatewayEvent: () => undefined,
onConnectionReady: () => undefined,
onGatewayReady: () => undefined,
refreshHermesConfig: async () => undefined,
refreshSessions: async () => undefined
refreshSessions: refreshSessions ?? (async () => undefined)
})
return null
@ -267,4 +267,25 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
expect($gatewayState.get()).toBe('open')
expect($desktopBoot.get().error).toBeNull()
})
it('FIX: a failed session-list fetch during boot is non-fatal — the app still boots', async () => {
// The version-skew report: gateway WS connects fine, but refreshSessions()
// rejects (e.g. older backend 404s an endpoint the fallback didn't cover,
// or a transient read error). That must NOT reject boot() into
// failDesktopBoot's "Hermes couldn't start" overlay — the socket is open
// and the app is fully usable with an empty sidebar.
const refreshSessions = vi.fn(async () => {
throw new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}')
})
render(<Harness refreshSessions={refreshSessions} />)
await flushAsync()
expect(refreshSessions).toHaveBeenCalled()
expect($gatewayState.get()).toBe('open')
// Boot completed: no error, overlay dismissed.
expect($desktopBoot.get().error).toBeNull()
expect($desktopBoot.get().visible).toBe(false)
expect($desktopBoot.get().phase).toBe('renderer.ready')
})
})

View file

@ -480,7 +480,15 @@ export function useGatewayBoot({
await Promise.all([
seedDefaultCwd(),
callbacksRef.current.refreshHermesConfig(),
callbacksRef.current.refreshSessions()
// Session-list population is never boot-fatal. The gateway WS is
// already open by this point — a failed sidebar fetch (transient
// blip, or an endpoint the fallback couldn't cover) must leave the
// app usable with an empty sidebar (the reconnect/turn refreshes
// retry it), not brick boot behind the "Hermes couldn't start"
// overlay. Matches the reconnect + softSwitch call sites.
callbacksRef.current.refreshSessions().catch(() => {
setSessionsLoading(false)
})
])
if (cancelled) {

View file

@ -11,7 +11,8 @@ import {
getStatus,
listAllProfileSessions,
listSessions,
listSidebarSessions
listSidebarSessions,
resetSidebarBatchCapability
} from './hermes'
import { refreshActiveProfile } from './store/profile'
@ -26,6 +27,7 @@ describe('Hermes REST session helpers', () => {
let api: ReturnType<typeof vi.fn>
beforeEach(() => {
resetSidebarBatchCapability()
api = vi.fn().mockResolvedValue(emptySessionsResponse)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
@ -99,6 +101,149 @@ describe('Hermes REST session helpers', () => {
expect(result.messaging.sessions).toEqual([])
})
it('falls back to the per-slice endpoint when the batched route 404s on an older backend', async () => {
const row = (id: string) => ({ id, title: id, profile: 'default' })
api.mockImplementation(({ path }: { path: string }) => {
if (path.startsWith('/api/profiles/sessions/sidebar')) {
// The exact skew failure: Electron surfaces the backend catch-all.
return Promise.reject(
new Error('Error invoking remote method \'hermes:api\': Error: 404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}')
)
}
if (path.includes('source=cron')) {
return Promise.resolve({ ...emptySessionsResponse, sessions: [row('cron-1')], total: 1 })
}
if (path.includes('exclude_sources=cron%2Cdesktop')) {
return Promise.resolve({ ...emptySessionsResponse, sessions: [row('msg-1')], total: 1 })
}
return Promise.resolve({
...emptySessionsResponse,
sessions: [row('recent-1')],
total: 7,
profile_totals: { default: 7 }
})
})
const result = await listSidebarSessions({
recentsProfile: 'work',
recentsLimit: 30,
recentsExclude: ['cron', 'tool'],
cronLimit: 50,
messagingLimit: 100,
messagingExclude: ['cron', 'desktop']
})
// Slices reassembled from the legacy per-slice route with the same
// scoping: recents on the caller's profile, cron + messaging cross-profile.
expect(result.recents.sessions.map(s => s.id)).toEqual(['recent-1'])
expect(result.recents.total).toBe(7)
expect(result.recents.profile_totals).toEqual({ default: 7 })
expect(result.cron.sessions.map(s => s.id)).toEqual(['cron-1'])
expect(result.messaging.sessions.map(s => s.id)).toEqual(['msg-1'])
const paths = api.mock.calls.map(call => (call[0] as { path: string }).path)
expect(paths.filter(p => p.startsWith('/api/profiles/sessions/sidebar'))).toHaveLength(1)
expect(paths.filter(p => p.startsWith('/api/profiles/sessions?'))).toHaveLength(3)
expect(paths).toContainEqual(expect.stringContaining('profile=work'))
expect(paths).toContainEqual(expect.stringContaining('source=cron'))
expect(paths).toContainEqual(expect.stringContaining('exclude_sources=cron%2Ctool'))
})
it('remembers endpoint-missing and skips re-probing the batched route on later refreshes', async () => {
api.mockImplementation(({ path }: { path: string }) =>
path.startsWith('/api/profiles/sessions/sidebar')
? Promise.reject(new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}'))
: Promise.resolve(emptySessionsResponse)
)
const req = {
recentsProfile: 'all' as const,
recentsLimit: 20,
recentsExclude: [],
cronLimit: 50,
messagingLimit: 100,
messagingExclude: []
}
await listSidebarSessions(req)
await listSidebarSessions(req)
const batchedProbes = api.mock.calls.filter(call =>
(call[0] as { path: string }).path.startsWith('/api/profiles/sessions/sidebar')
)
// First refresh probes once and learns; the second goes straight to the
// per-slice route (3 calls each refresh, no repeated dead probe).
expect(batchedProbes).toHaveLength(1)
expect(api.mock.calls.length).toBe(1 + 3 + 3)
})
it('re-probes the batched route after a gateway switch resets the capability flag', async () => {
api.mockImplementation(({ path }: { path: string }) =>
path.startsWith('/api/profiles/sessions/sidebar')
? Promise.reject(new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}'))
: Promise.resolve(emptySessionsResponse)
)
const req = {
recentsProfile: 'all' as const,
recentsLimit: 20,
recentsExclude: [],
cronLimit: 50,
messagingLimit: 100,
messagingExclude: []
}
await listSidebarSessions(req)
// Soft gateway switch: the next backend may support the batched route.
resetSidebarBatchCapability()
api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } })
const result = await listSidebarSessions(req)
const batchedProbes = api.mock.calls.filter(call =>
(call[0] as { path: string }).path.startsWith('/api/profiles/sessions/sidebar')
)
expect(batchedProbes).toHaveLength(2)
expect(result.recents.sessions).toEqual([])
})
it('does NOT fall back on transient failures — only endpoint-missing shapes trigger legacy mode', async () => {
api.mockRejectedValue(new Error('Request timed out after 60000ms'))
await expect(
listSidebarSessions({
recentsProfile: 'all',
recentsLimit: 20,
recentsExclude: [],
cronLimit: 50,
messagingLimit: 100,
messagingExclude: []
})
).rejects.toThrow('timed out')
// One call: the batched probe. No legacy fan-out, no sticky degradation.
expect(api).toHaveBeenCalledTimes(1)
// And the next refresh still uses the batched route.
api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } })
await listSidebarSessions({
recentsProfile: 'all',
recentsLimit: 20,
recentsExclude: [],
cronLimit: 50,
messagingLimit: 100,
messagingExclude: []
})
expect((api.mock.calls[1][0] as { path: string }).path).toMatch(/^\/api\/profiles\/sessions\/sidebar\?/)
})
it('uses a longer timeout for profile listing during desktop startup', async () => {
api.mockResolvedValue({ profiles: [] })

View file

@ -394,7 +394,72 @@ export interface SidebarSessionsRequest {
messagingExclude: string[]
}
// The batched /sidebar endpoint shipped later than the per-slice route, so a
// newer desktop can meet an older backend that 404s it ("No such API
// endpoint"). Endpoint-missing is a capability signal, not a transient
// failure: remember it (per renderer lifetime — a runtime home change reloads
// the window and re-probes) and serve every subsequent refresh straight from
// the three proven per-slice calls instead of re-probing a known-dead route
// once per turn/broadcast.
let sidebarBatchEndpointMissing = false
// Capability flags are per-backend facts. A hard re-home reloads the window
// (module state resets naturally), but a soft gateway switch re-dials in
// place — the next backend may well have the batched route, so the switch
// paths call this to re-probe rather than leak the old backend's capability.
export function resetSidebarBatchCapability() {
sidebarBatchEndpointMissing = false
}
// True only for "the route does not exist on this backend" shapes: the
// backend catch-all ('404: {"detail":"No such API endpoint: ...}'), FastAPI's
// bare 404 on headless serve (surfaces as '404: ...' directly or as
// "Error invoking remote method 'hermes:api': Error: 404: ..." through the
// IPC bridge), and the Electron JSON-guard ("endpoint is likely missing").
// This GET has no path params, so a 404 status can only mean route-missing —
// but transient failures (timeouts, 5xx, connection refused) must NOT match,
// or one blip would silently degrade the fast path for the whole session.
function isEndpointMissingError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return (
/no such api endpoint/i.test(message) ||
/endpoint is likely missing/i.test(message) ||
/(?:^\s*|error:\s*)404\b/i.test(message)
)
}
// Compatibility fallback: reassemble the three sidebar slices from the
// per-slice endpoint, mirroring the batched route's semantics (min_messages=1,
// archived excluded, recency order; recents scoped to the caller's profile,
// cron + messaging cross-profile). Rides the same Electron remote-splice
// interception as the pre-batching desktop, so remote profiles stay correct.
async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
const [recents, cron, messaging] = await Promise.all([
listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, {
excludeSources: req.recentsExclude
}),
listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', 'all', { source: 'cron' }),
listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', 'all', {
excludeSources: req.messagingExclude
})
])
const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])]
return {
recents: { profile_totals: recents.profile_totals, sessions: recents.sessions, total: recents.total },
cron: { sessions: cron.sessions },
messaging: { sessions: messaging.sessions },
...(errors.length ? { errors } : {})
}
}
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
if (sidebarBatchEndpointMissing) {
return listSidebarSessionsLegacy(req)
}
const params = new URLSearchParams({
recents_profile: req.recentsProfile,
recents_limit: String(Math.max(1, req.recentsLimit)),
@ -410,10 +475,23 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<
params.set('messaging_exclude', req.messagingExclude.join(','))
}
const result = await window.hermesDesktop.api<SidebarSessionsResponse>({
path: `/api/profiles/sessions/sidebar?${params.toString()}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
let result: SidebarSessionsResponse
try {
result = await window.hermesDesktop.api<SidebarSessionsResponse>({
path: `/api/profiles/sessions/sidebar?${params.toString()}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
} catch (err) {
if (!isEndpointMissingError(err)) {
throw err
}
// Older backend without the batched route (desktop/runtime version skew).
sidebarBatchEndpointMissing = true
return listSidebarSessionsLegacy(req)
}
return {
recents: { ...result.recents, sessions: result.recents?.sessions ?? [] },

View file

@ -1,5 +1,6 @@
import { atom } from 'nanostores'
import { resetSidebarBatchCapability } from '@/hermes'
import { invalidateProfileScopedQueries } from '@/lib/query-client'
import { resetSessionsLimit } from '@/store/layout'
import {
@ -37,6 +38,9 @@ export const $gatewaySwitching = atom(false)
* alone so the user stays where they were (e.g. mid-Gateway settings).
*/
export function wipeSessionListsForGatewaySwitch(): void {
// The next backend is a different runtime — don't carry the old one's
// "batched sidebar endpoint missing" capability verdict across the switch.
resetSidebarBatchCapability()
setSessions([])
setSessionsTotal(0)
setSessionProfileTotals({})