mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
34a304abb3
commit
4ef92d2e5d
5 changed files with 264 additions and 8 deletions
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: [] })
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? [] },
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue