mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(desktop): retry readiness on the tick while no verdict exists yet
A gateway flap clears the inference status on every gatewayState change, and a readiness round that answers with no boolean is dropped on purpose because a transport fallback is not proof that inference lost its credentials. Both leave the status null, and the only callers of the readiness legs are three seams the window may never cross again, so the chip can sit on "Checking inference" while the backend is healthy and nothing ever retries it. Let the periodic tick carry the readiness legs while the status is still null. Once a round publishes an authoritative verdict the tick is status-only again, so a healthy gateway pays no extra RPCs.
This commit is contained in:
1 parent
c0fad55154
commit
9dcab1e440
2 files changed
+94
-4
No files matched your search
@@ -310,6 +310,80 @@ describe('useStatusSnapshot', () => {
|
||||
expect(requestGatewayMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('retries readiness on the 60s tick while no authoritative verdict exists yet', async () => {
|
||||
// A gateway flap answers with no boolean at all, so the round falls back to
|
||||
// the transport verdict and the status stays null — the chip reads
|
||||
// "checking" until some later seam fires, if one ever does.
|
||||
let answering = false
|
||||
|
||||
const requestGatewayMock = vi.fn(async (method: string) => {
|
||||
if (!answering) {
|
||||
return {} as never
|
||||
}
|
||||
|
||||
return (method === 'setup.runtime_check' ? { ok: true } : { provider_configured: true }) as never
|
||||
})
|
||||
|
||||
const requestGateway = requestGatewayMock as unknown as GatewayRequester
|
||||
|
||||
const { result } = renderHook(() => useStatusSnapshot('open', requestGateway))
|
||||
await flushAsync()
|
||||
|
||||
expect(result.current.inferenceStatus).toBeNull()
|
||||
|
||||
answering = true
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
|
||||
const methods = requestGatewayMock.mock.calls.map(([method]) => method)
|
||||
expect(methods.filter(method => method === 'setup.runtime_check')).toHaveLength(2)
|
||||
expect(result.current.inferenceStatus).toMatchObject({ ready: true, source: 'runtime_check' })
|
||||
})
|
||||
|
||||
it('retries a fully failed readiness round on the tick, then stops once a verdict lands', async () => {
|
||||
// Both readiness legs fail here, which is the only shape that leaves no
|
||||
// authoritative answer at all: one surviving leg is enough to publish.
|
||||
let answering = false
|
||||
|
||||
const requestGatewayMock = vi.fn(async (method: string) => {
|
||||
if (!answering) {
|
||||
throw new Error(`${method} connection closed`)
|
||||
}
|
||||
|
||||
return (method === 'setup.runtime_check' ? { ok: true } : { provider_configured: true }) as never
|
||||
})
|
||||
|
||||
const requestGateway = requestGatewayMock as unknown as GatewayRequester
|
||||
|
||||
const { result } = renderHook(() => useStatusSnapshot('open', requestGateway))
|
||||
await flushAsync()
|
||||
|
||||
expect(result.current.inferenceStatus).toBeNull()
|
||||
|
||||
answering = true
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
|
||||
expect(result.current.inferenceStatus).toMatchObject({ ready: true, source: 'runtime_check' })
|
||||
|
||||
// Recovery ends the retries: an answered chip needs no further readiness
|
||||
// traffic, so the ambient 60s poll goes back to being status-only.
|
||||
requestGatewayMock.mockClear()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
|
||||
const methods = requestGatewayMock.mock.calls.map(([method]) => method)
|
||||
expect(methods.filter(method => method === 'setup.runtime_check')).toHaveLength(0)
|
||||
expect(getStatus).toHaveBeenCalledTimes(3)
|
||||
expect(result.current.inferenceStatus).toMatchObject({ ready: true, source: 'runtime_check' })
|
||||
})
|
||||
|
||||
it('re-reads readiness and the free-tier verdict once per setup.ready, off the status tick', async () => {
|
||||
const requestGatewayMock = vi.fn(
|
||||
async (method: string) =>
|
||||
|
||||
@@ -31,23 +31,39 @@ export function useStatusSnapshot(
|
||||
let timer: number | undefined
|
||||
let sharedProfileWarning: boolean = false
|
||||
let sharedProfileNoticeId: string | undefined
|
||||
// Whether this run has published an authoritative readiness verdict. The
|
||||
// tick's callback cannot read the state it belongs to, and the flag's
|
||||
// lifetime is exactly this run's — the same run that clears the status on
|
||||
// entry — so the two can never disagree.
|
||||
let readinessAnswered = false
|
||||
|
||||
const publishInferenceStatus = (next: RuntimeReadinessResult | null): void => {
|
||||
readinessAnswered = next !== null
|
||||
setInferenceStatus(next)
|
||||
}
|
||||
|
||||
// Status and inference readiness belong to one backend. A source switch
|
||||
// can keep gatewayState="open" throughout, so clear the previous source's
|
||||
// snapshot and start a fresh scoped request explicitly.
|
||||
setStatusSnapshot(null)
|
||||
setInferenceStatus(null)
|
||||
publishInferenceStatus(null)
|
||||
|
||||
// A closed/connecting gateway cannot have an authoritative live-runtime
|
||||
// result. Clear readiness before starting the REST status leg so a hung
|
||||
// getStatus() cannot leave a stale "ready" state visible after disconnect.
|
||||
if (gatewayState !== 'open') {
|
||||
setInferenceStatus(null)
|
||||
publishInferenceStatus(null)
|
||||
}
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
if (!cancelled) {
|
||||
timer = window.setTimeout(() => void refresh({ readiness: false }), REFRESH_MS)
|
||||
// Readiness rides the tick only while there is still no authoritative
|
||||
// answer to show. A round that came back a transport fallback leaves a
|
||||
// null status, which reads as "checking" — and the only other triggers
|
||||
// are seams this window may never cross again, so a gateway flap
|
||||
// outliving one seam would pin the chip for good. Once a verdict
|
||||
// exists the tick stays status-only, keeping the ambient poll cheap.
|
||||
timer = window.setTimeout(() => void refresh({ readiness: !readinessAnswered }), REFRESH_MS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +102,7 @@ export function useStatusSnapshot(
|
||||
// is a transient/unknown transport state, not proof that inference
|
||||
// became unconfigured. Keep the last authoritative result instead
|
||||
// of flashing "Inference not ready" during a gateway flap.
|
||||
setInferenceStatus(inference)
|
||||
publishInferenceStatus(inference)
|
||||
setFreeTierRoute(inference.freeTier)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user