v2.9.2
Auth refresh-race + relay 401 split-brain fix (re-ship of v2.9.1)
v2.9.2 contains the same code fix as v2.9.1 (which failed to publish — its publish.yml was missing the optional provider SDKs needed by the test gate).
What's fixed
Two compounding bugs were logging users out repeatedly — on CLI restart and mid-session — even when the refresh_token was still valid:
- Refresh race in
AuthService.refresh_token. Concurrent 401-retries presented the same refresh_token to/api/oauth/refresh. First rotated it; second hit a revoked token and got400 invalid_grant. Blunt non-200 handler killed the session. Fix: per-processasyncio.Lock+ disk re-read inside the lock to dedup concurrent refreshes, plus_classify_refresh_failureso only400 invalid_grant/401/403marks the session dead (429/5xx/ network errors stay transient). - Local 1h clock killed the UI.
AuthToken.is_authenticatedgated ontime.time() >= expires_at→ CLI flagged "not connected" after 1h without ever firing the call that would have triggered refresh. Relaxed: server is the source of truth via the 401-retry path. - Relay split-brain.
RelayListenerowns its ownhttpx.AsyncClientand bypassedAPIClient's refresh-on-401, so the heartbeat firedsession_expiredinstantly on a stale access_token while the login screen correctly reported "logged in". New_authed_requesthelper threads through heartbeat / mercure-token / command-result and refreshes-and-retries once before declaring the session expired.
Workflow fix (the reason v2.9.2 exists)
publish.yml now installs [test,hetzner,ovh,mcp] to match ci.yml. Without this, the optional-SDK-using tests block every release-triggered publish.
Coordination
Backend agreed to add server-side row locking on the refresh-token row + a short idempotency window in TokenService::refreshToken as belt-and-braces (covers cross-process and shared-auth.json-over-Dropbox cases).
Tests
7 new auth tests + 2 new relay tests. Full suite 2913 passing.