Repository navigation
Proactively refresh auth tokens in the daemon ahead of expiry - #257
Conversation
Auth tokens were refreshed only lazily (on the next call after the access token had already expired), so after an idle period the next hook hit a 401 and forced a `kcap login`. The daemon now runs a low-frequency loop that refreshes the active profile's token *ahead* of expiry, keeping a WorkOS sliding-inactivity session alive for as long as the daemon runs. - TokenStore.RefreshIfExpiringAsync refreshes within a window via the existing cross-process lock (rotation-safe re-read under the lock); no-op for the None provider and when no tokens are stored. Resolves the active profile once and threads it through the read + lock. - TokenRefreshLoop rate-limits attempts to at most one per interval, so a failing refresh (dead/rotated token) or a short-lived token that keeps re-entering the window can't hammer the refresh endpoint every tick. - Wired into AgentOrchestrator alongside the existing heartbeat loops. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR Summary by QodoDaemon: proactively refresh auth tokens ahead of expiry
AI Description
Diagram
High-Level Assessment
Files changed (6)
|
Code Review by Qodo
Context used 1.
|
…ble-refresh after a peer Addresses two review findings on the proactive-refresh change: 1. Profile-switch miswrite (high): RefreshWorkOSAsync/RefreshGitHubAsync persisted via the active-profile-resolving SaveAsync(StoredTokens) overload, so a `kcap profile switch` while a refresh was in flight could write the locked profile's rotated token into a *different* profile's file — without that profile's lock, corrupting its credentials and leaving the original stale. The refresh delegates now return without persisting; RefreshWithCrossProcessLockAsync persists via SaveAsync(profile, refreshed) under the same profile it locked. Fixes both the proactive and the pre-existing reactive (GetValidTokensAsync) path. 2. Double-rotation after a peer refresh (medium): the under-lock re-read only suppressed a refresh when the token was no longer "due". For a short-lived token (or JwtExpiry's now+5min parse fallback), a token a peer had just refreshed was still inside the proactive window, so the proactive caller rotated it again. Now, if the re-read token changed from the one we read and is still valid, we return it without re-refreshing. The reactive path is unaffected (its predicate is IsExpired, already false for a valid token). Exposes RefreshWithCrossProcessLockAsync as internal for unit testing; adds CrossProcessRefreshTests covering persist-under-locked-profile, peer-refresh suppression, and reactive-path preservation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Independent review (Codex) — fix commit
|
…ailure (review round 2) Addresses follow-up review on the proactive-refresh change: - Legacy fallback (Qodo #2): RefreshIfExpiringAsync read only the per-profile token file, so a pre-upgrade install with only tokens.json got no proactive refresh. Extract LoadWithLegacyFallbackAsync (shared with LoadAsync) and use it, so the legacy token is refreshed — and migrated into the per-profile store when the refresh persists under the lock. - Lock contention (Qodo #3): a 15s cross-process-lock acquisition timeout returned null, which the daemon reported as a refresh Failure (misleading "run kcap login" warning + backoff) even though no endpoint call was made. Add an onLockContended callback (proactive path only; reactive GetValidTokensAsync is unaffected — default null) and a ProactiveRefreshOutcome.Contended that the loop logs at Debug with no warning and no backoff (contention is transient). Qodo #1 (profile-switch miswrite) was already resolved in 783b65b. Adds a TokenRefreshLoop test for the Contended path; legacy fallback is covered via the shared LoadWithLegacyFallbackAsync (LoadAsync's existing fallback tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What & why
Auth tokens were refreshed only lazily —
TokenStore.GetValidTokensAsync()refreshes only once the access token is already expired, on the next call that needs it. Nothing kept the refresh credential warm ahead of time, so after an idle period the next hook hit a 401 and the user was forced to re-runkcap login.The daemon now runs a low-frequency loop that refreshes the active profile's token ahead of expiry. For WorkOS's sliding inactivity window, periodic refresh keeps the session alive for as long as the daemon runs, pushing forced re-logins out to the absolute session max instead of the shorter inactivity timeout.
How
TokenStore.RefreshIfExpiringAsync(window)(Core) — refreshes the active profile's token when it's withinwindowof expiry, through the same profile-scoped cross-process lock asGetValidTokensAsync(rotation-safe re-read under the lock, so it can't race a hook/watcher/MCP refresh or double-spend a rotated WorkOS refresh token). No-op for theNoneprovider and when no tokens are stored. The decision is factored into a pure, fully unit-testedDecideProactiveRefresh. Resolves the active profile once and threads it through the read + lock (tighter than the reactive path).TokenRefreshLoop(Daemon) — mirrorsDaemonHeartbeatLoop: a total (never-throwing) tick behind anIProactiveTokenRefreshPort. It rate-limits attempts to at most one per interval, so a failing refresh (dead/rotated token, server down) or a token whose lifetime is shorter than the window can't hammer the refresh endpoint every tick.AgentOrchestratoralongside the existing heartbeat loops (60s tick, 5-min window, 5-min min attempt interval), disposed on shutdown.Acceptance criteria
kcap login.Noneauth provider and when no tokens are stored.Testing
ProactiveTokenRefreshDecisionTests(all decision branches),RefreshIfExpiringTests(offline no-op paths),TokenRefreshLoopTests(outcome logging, totality, and rate-limiting on failed/successful/short-lived refresh).dotnet publish -c Releaseclean — no IL3050/IL2026 AOT warnings on CLI or daemon.No README/CLI-surface change: this is internal daemon behavior (no new command, flag, default, or prerequisite).
Closes #182
Linear: AI-992