mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
Merge pull request #71104 from NousResearch/bb/desktop-boot-readiness
fix(desktop): boot readiness probes a lightweight /api/health
This commit is contained in:
commit
476f009ff6
7 changed files with 270 additions and 33 deletions
136
apps/desktop/electron/backend-health.test.ts
Normal file
136
apps/desktop/electron/backend-health.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { DEFAULT_HEALTH_PROBE_TIMEOUT_MS, isMissingHealthEndpointError, waitForHermesReady } from './backend-health'
|
||||
|
||||
test('uses lightweight /api/health for current backends', async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
await waitForHermesReady('http://127.0.0.1:9000/', {
|
||||
token: 'secret-token',
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
fetchJson: async url => {
|
||||
calls.push(['token', url])
|
||||
throw new Error('status should not be called')
|
||||
},
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [['public', 'http://127.0.0.1:9000/api/health']])
|
||||
})
|
||||
|
||||
test('falls back to /api/status only for old backends without /api/health', async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
await waitForHermesReady('http://127.0.0.1:9000', {
|
||||
token: 'secret-token',
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
|
||||
throw new Error('404: {"detail":"Not Found"}')
|
||||
},
|
||||
fetchJson: async (url, token) => {
|
||||
calls.push(['token', url, token ?? ''])
|
||||
|
||||
return { version: 'old' }
|
||||
},
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['public', 'http://127.0.0.1:9000/api/health'],
|
||||
['token', 'http://127.0.0.1:9000/api/status', 'secret-token']
|
||||
])
|
||||
})
|
||||
|
||||
test('does not fall back to heavyweight /api/status for transient health failures', async () => {
|
||||
const calls: string[][] = []
|
||||
let currentTime = 0
|
||||
|
||||
await assert.rejects(
|
||||
waitForHermesReady('http://127.0.0.1:9000', {
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
throw new Error('Timed out connecting to Hermes backend after 15000ms')
|
||||
},
|
||||
fetchJson: async url => {
|
||||
calls.push(['token', url])
|
||||
},
|
||||
sleep: async () => {},
|
||||
now: () => {
|
||||
currentTime += 20
|
||||
|
||||
return currentTime
|
||||
},
|
||||
timeoutMs: 50,
|
||||
pollMs: 1
|
||||
}),
|
||||
/Timed out connecting/
|
||||
)
|
||||
|
||||
assert.ok(calls.length > 0)
|
||||
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
|
||||
})
|
||||
|
||||
test('probes health on a short timeout but leaves the legacy fallback its own', async () => {
|
||||
const timeouts: (number | undefined)[] = []
|
||||
|
||||
await waitForHermesReady('http://127.0.0.1:9000', {
|
||||
fetchPublicJson: async (_url, options) => {
|
||||
timeouts.push(options?.timeoutMs)
|
||||
|
||||
throw new Error('404: {"detail":"Not Found"}')
|
||||
},
|
||||
fetchJson: async (_url, _token, options) => {
|
||||
timeouts.push(options?.timeoutMs)
|
||||
|
||||
return { version: 'old' }
|
||||
},
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined])
|
||||
})
|
||||
|
||||
test('aborts as superseded when the bootstrap signal fires', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await assert.rejects(
|
||||
waitForHermesReady('http://127.0.0.1:9000', {
|
||||
signal: controller.signal,
|
||||
fetchPublicJson: async () => {
|
||||
throw new Error('should not probe after abort')
|
||||
},
|
||||
fetchJson: async () => {
|
||||
throw new Error('should not probe after abort')
|
||||
},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
}),
|
||||
(error: any) => error.kind === 'superseded'
|
||||
)
|
||||
})
|
||||
|
||||
test('recognizes missing-route shapes only', () => {
|
||||
assert.equal(isMissingHealthEndpointError(new Error('404: {"detail":"Not Found"}')), true)
|
||||
assert.equal(
|
||||
isMissingHealthEndpointError(
|
||||
new Error('Expected JSON from /api/health but got HTML. The endpoint is likely missing on the Hermes backend.')
|
||||
),
|
||||
true
|
||||
)
|
||||
assert.equal(isMissingHealthEndpointError(new Error('Timed out connecting to Hermes backend after 15000ms')), false)
|
||||
assert.equal(isMissingHealthEndpointError(new Error('500: boom')), false)
|
||||
})
|
||||
95
apps/desktop/electron/backend-health.ts
Normal file
95
apps/desktop/electron/backend-health.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
export const DEFAULT_BACKEND_READY_TIMEOUT_MS = 45_000
|
||||
export const DEFAULT_BACKEND_READY_POLL_MS = 500
|
||||
// A cold backend can stall its event loop for tens of seconds while Windows
|
||||
// scans and byte-compiles the gateway import tree. At the default 15s socket
|
||||
// timeout only three probes fit in the budget; a short one keeps retrying
|
||||
// across the stall. Health only — the legacy /api/status fallback is genuinely
|
||||
// slow to answer and keeps the caller's default timeout.
|
||||
export const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 5_000
|
||||
|
||||
type FetchPublicJson = (url: string, options?: { timeoutMs?: number }) => Promise<unknown>
|
||||
type FetchJson = (url: string, token?: string | null, options?: { timeoutMs?: number }) => Promise<unknown>
|
||||
|
||||
export interface HermesReadyOptions {
|
||||
fetchPublicJson: FetchPublicJson
|
||||
fetchJson: FetchJson
|
||||
token?: string | null
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
pollMs?: number
|
||||
healthProbeTimeoutMs?: number
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export function isMissingHealthEndpointError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '')
|
||||
|
||||
return /^404:/.test(message) || message.includes('endpoint is likely missing')
|
||||
}
|
||||
|
||||
function supersededError() {
|
||||
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
error.kind = 'superseded'
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
export async function waitForHermesReady(baseUrl: string, options: HermesReadyOptions): Promise<void> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_BACKEND_READY_TIMEOUT_MS
|
||||
const pollMs = options.pollMs ?? DEFAULT_BACKEND_READY_POLL_MS
|
||||
const healthProbeTimeoutMs = options.healthProbeTimeoutMs ?? DEFAULT_HEALTH_PROBE_TIMEOUT_MS
|
||||
const now = options.now ?? Date.now
|
||||
const signal = options.signal
|
||||
|
||||
const sleep =
|
||||
options.sleep ??
|
||||
(ms =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
reject(supersededError())
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}))
|
||||
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const deadline = now() + timeoutMs
|
||||
let lastError: unknown = null
|
||||
let useStatusFallback = false
|
||||
|
||||
while (now() < deadline) {
|
||||
if (signal?.aborted) {
|
||||
throw supersededError()
|
||||
}
|
||||
|
||||
try {
|
||||
if (useStatusFallback) {
|
||||
await options.fetchJson(`${base}/api/status`, options.token)
|
||||
} else {
|
||||
await options.fetchPublicJson(`${base}/api/health`, { timeoutMs: healthProbeTimeoutMs })
|
||||
}
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
// Only an explicitly missing route means the backend predates
|
||||
// /api/health; timeouts and server errors keep polling health.
|
||||
if (!useStatusFallback && isMissingHealthEndpointError(error)) {
|
||||
useStatusFallback = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
await sleep(pollMs)
|
||||
}
|
||||
}
|
||||
|
||||
const detail = lastError instanceof Error ? lastError.message : 'timeout'
|
||||
throw new Error(`Hermes backend did not become ready: ${detail}`)
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ import { stopBackendChild as stopBackendChildImpl } from './backend-child'
|
|||
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
|
||||
import { waitForHermesReady } from './backend-health'
|
||||
import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
|
||||
import { waitForDashboardPortAnnouncement } from './backend-ready'
|
||||
import { shouldLatchBackendStartFailure } from './backend-start-failure'
|
||||
|
|
@ -4815,39 +4816,12 @@ function closePreviewWatchers() {
|
|||
}
|
||||
|
||||
async function waitForHermes(baseUrl, token, signal?) {
|
||||
const deadline = Date.now() + 45_000
|
||||
let lastError = null
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted) {
|
||||
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
error.kind = 'superseded'
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchJson(`${baseUrl}/api/status`, token)
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 500)
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
aborted.kind = 'superseded'
|
||||
reject(aborted)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Hermes backend did not become ready: ${lastError?.message || 'timeout'}`)
|
||||
return waitForHermesReady(baseUrl, {
|
||||
token,
|
||||
signal,
|
||||
fetchPublicJson,
|
||||
fetchJson
|
||||
})
|
||||
}
|
||||
|
||||
function getWindowButtonPosition() {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ the SPA should bootstrap it after login instead.
|
|||
from __future__ import annotations
|
||||
|
||||
PUBLIC_API_PATHS: frozenset[str] = frozenset({
|
||||
# Minimal process liveness probe for desktop/backend boot handshakes. It
|
||||
# intentionally avoids gateway config, platform discovery, MCP setup, and
|
||||
# host-local detail so readiness checks cannot spend their budget inside
|
||||
# cold plugin imports.
|
||||
"/api/health",
|
||||
# Liveness probe target. Returns version, gateway state, active
|
||||
# session count, and the dashboard auth-gate shape. No bodies, no
|
||||
# session content, no secrets. Documented as the portal's wildcard
|
||||
|
|
|
|||
|
|
@ -3036,6 +3036,16 @@ async def get_ssh_ownership(request: Request):
|
|||
return {"ok": True, "sshOwnerNonce": _SSH_OWNER_NONCE, "protocolVersion": 1}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def get_health():
|
||||
"""Lightweight process liveness for desktop/backend readiness probes."""
|
||||
return {
|
||||
"ok": True,
|
||||
"version": __version__,
|
||||
"auth_required": bool(getattr(app.state, "auth_required", False)),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status(profile: Optional[str] = None):
|
||||
status_scope = None
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ def test_gated_status_is_public(gated_app):
|
|||
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"/api/health",
|
||||
"/api/config/defaults",
|
||||
"/api/config/schema",
|
||||
"/api/model/info",
|
||||
|
|
|
|||
|
|
@ -67,6 +67,22 @@ def test_status_reports_auth_required_in_gated_mode(gated_client):
|
|||
assert body["auth_providers"] == ["stub"]
|
||||
|
||||
|
||||
def test_health_reports_liveness_without_loading_gateway_config(gated_client, monkeypatch):
|
||||
def _boom():
|
||||
raise AssertionError("health must not load gateway config")
|
||||
|
||||
monkeypatch.setattr("gateway.config.load_gateway_config", _boom)
|
||||
|
||||
r = gated_client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body == {
|
||||
"ok": True,
|
||||
"version": web_server.__version__,
|
||||
"auth_required": True,
|
||||
}
|
||||
|
||||
|
||||
def test_status_reports_auth_disabled_in_loopback_mode(loopback_client):
|
||||
r = loopback_client.get("/api/status")
|
||||
assert r.status_code == 200
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue