v2.9.1
Fix: OAuth refresh-race and relay 401 split-brain
Two compounding bugs were logging users out repeatedly — on CLI restart and mid-session — even when the refresh_token was still valid for 29 more days.
What was broken
- Refresh race. Concurrent 401-retries (chat stream + status poll, parallel memory-sync batches, etc.) both presented the same refresh_token to
/api/oauth/refresh. First call rotated and revoked it; second call hit a revoked token and got400 invalid_grant. The blunt non-200 handler treated that as a dead session and kicked the user out. - Local 1h clock killed the UI.
AuthToken.is_authenticatedgated ontime.time() >= expires_at, so once the access_token's 1h TTL elapsed the CLI showed "not connected" without ever firing the call that would have triggered refresh. - Relay split-brain on restart.
RelayListenerowns its ownhttpx.AsyncClient(the SSE subscription has to hold it open), so it bypassedAPIClient's refresh-on-401. On a fresh CLI start with a locally-stale access_token, the heartbeat hit 401 and firedsession_expiredimmediately — while the login screen, reading the relaxedis_authenticated, correctly reported "logged in". Hence the mismatched indicators.
What changed
AuthService.refresh_token: per-processasyncio.Lock+ disk re-read inside the lock to dedup concurrent refreshes (adopts a newer disk token without hitting the network). New_classify_refresh_failuresplits responses three ways:200→ success, persist, clear sticky revoked flag.401/403/400 invalid_grant→ set_refresh_grant_revoked, UI asks user to re-authenticate.429/5xx/400with other codes / network error → transient, keep credentials, next 401-retry will try again.
AuthToken.is_authenticatedno longer gates on local expiry — server is the source of truth via the 401-retry path. Stale access_tokens get healed transparently.RelayListenergot arefresh_callbackparameter and_authed_requesthelper. Heartbeat / mercure-token / command-result POSTs now refresh-and-retry on 401 before declaringsession_expired.validate_tokenonly deletesauth.jsonwhen the revoked flag is set — transient failures no longer nuke the file.
Tests
- 7 new auth tests: lock/dedup path, concurrent refresh serialisation,
invalid_grantflagging,429/5xx/network-error transient handling, success-clears-flag. - 2 new relay tests: heartbeat 401 + successful refresh → no
session_expired; heartbeat 401 + failed refresh →session_expiredstill fires. - Full suite: 2913 passing.
Coordination
Server-side fix coordinated with servonaut-web-backend — they'll follow up with SELECT ... FOR UPDATE row locking on the refresh-token row + a short idempotency window in TokenService::refreshToken as belt-and-braces. Not load-bearing now that the client serialises, but covers the cross-process and shared-auth.json-over-Dropbox cases.