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:
kokhlo authored and brooklyn! committed 2026-10-06 13:54:07 -05:00
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)
}
}