Fix cross-process Linear OAuth refresh wiping valid connections - #400
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub. |
3c9fdca to
93a6ff3Comparearul28
commented
May 29, 2026
@copilot review but do not make fixes |
There was a problem hiding this comment.
PR Review
Scope: 7 file(s), +352 / −45
Verdict: Looks good
This PR fixes a real cross-process race: when desktop and ade serve refresh Linear OAuth near expiry, the loser’s invalid_grant was clearing a still-valid shared connection. It adds a file lock under .ade/secrets, re-reads credentials inside the lock, and treats invalid_grant as benign when the store already shows rotation or a fresh expiry (except on forced refresh after auth failure). Tests cover the race, forced refresh, and headless parity.
Notes
- Tests: The concurrent-rotation and forced-refresh cases in
linearAuth.test.tsandheadlessLinearServices.test.tsmatch the failure mode described in the PR and give good regression coverage. - Lock helper:
linearOAuthRefreshLock.tsfollows the samewx+ stale-mtime pattern asapps/ade-cli/src/tuiClient/state.ts; lock contention should be rare (only when two runtimes refresh at once). - Callers:
linearClientalready swallowsensureFreshTokenerrors, so a 15s lock timeout surfaces as a best-effort skip rather than a user-facing crash. - Docs (non-blocking):
docs/features/cto/linear-integration.mdstill saysinvalid_grantalways clears the connection; behavior is now conditional on stale rotation detection.
Sent by Cursor Automation: BUGBOT in Versic
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
arul28
commented
May 29, 2026
@copilot review but do not make fixes |
Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews. |
e6da089 to
392df60Comparearul28
commented
May 29, 2026
@copilot review but do not make fixes |
Uh oh!
There was an error while loading. Please reload this page.
arul28
commented
May 29, 2026
@copilot review but do not make fixes |
When desktop and ade serve both refresh near token expiry, Linear rotates the refresh token on the first exchange. The loser gets invalid_grant and was clearing the shared credential store, forcing a full reconnect. Serialize refresh with a cross-process lock under .ade/secrets and treat invalid_grant as stale when the store already has a rotated refresh token or a fresh access token. Co-authored-by: Arul Sharma <arul28@users.noreply.github.com>
a6a721d to
1ab066cComparearul28
commented
May 30, 2026
@copilot review but do not make fixes |
Uh oh!
There was an error while loading. Please reload this page.


Fixes ADE-63
Bug and impact
When ADE desktop and
ade serve(headless runtime) are both active for the same project, a near-expiry Linear OAuth refresh can race. Linear rotates the refresh token on the first successful exchange; the second runtime retries with the now-invalid refresh token, getsinvalid_grant, and clears the shared credential store — forcing the user to reconnect Linear even though the connection was still valid.This was introduced by the automatic OAuth token refresh added in #395.
Root cause
ensureFreshToken()deduped refreshes in-process only (refreshInFlight).EncryptedFileCredentialStoreunder.ade/secrets.invalid_grant, both services unconditionally cleared all Linear credentials.Fix
linear-oauth-refresh.lock) so only one runtime refreshes at a time.invalid_grant; if the refresh token was rotated or the access token is now fresh, treat the failure as a stale race and do not clear the connection.linearCredentialService) and headless (headlessLinearServices).Validation
npm run test -- --run src/main/services/cto/linearTokenRefresh.test.ts src/main/services/cto/linearAuth.test.ts(45 tests passed)npm --prefix apps/ade-cli run test -- --run src/headlessLinearServices.test.ts(22 tests passed)npm --prefix apps/desktop run typecheckGreptile Summary
This PR fixes a cross-process race condition where a near-expiry Linear OAuth refresh triggered by both the ADE desktop app and
ade servesimultaneously would cause the slower process to receiveinvalid_grant(Linear rotates the refresh token on each exchange) and incorrectly wipe the shared credential store.linear-oauth-refresh.lockin the secrets dir) serializes refresh attempts across runtimes; both desktop and headless services acquire the lock, re-read credentials inside it, and skip the refresh if a peer already completed it.linearInvalidGrantLikelyStaleRotation) re-reads the credential store oninvalid_grantand treats the failure as benign if the refresh token was already rotated by a peer or the access token expiry is freshly extended; this guard is intentionally bypassed on forced refreshes (force: true) to avoid masking genuinely dead tokens.LOCK_STALE_MS(10 s) is now less thanLOCK_TIMEOUT_MS(15 s), making stale-lock recovery actually reachable within the wait window, and the retry loop now uses asyncsetTimeout-based sleep so the event loop is not blocked.Confidence Score: 5/5
Safe to merge. The fix correctly serializes cross-process token refreshes and avoids wiping valid connections on a stale invalid_grant; the previous reviewer concerns about event-loop blocking, stale-constant ordering, and unhandled timeout rejections are all resolved.
The lock implementation, stale-rotation heuristic, and forced-refresh bypass are all well-reasoned and covered by 45+ tests. The one remaining finding (fd leak when writeFileSync fails after openSync) is a minor edge case that self-recovers via stale detection within 10 s and does not affect correctness in normal operation.
linearOAuthRefreshLock.ts — the lock acquisition block could be tightened to close and delete the lock file if writeFileSync fails before the finally block is reached.
Important Files Changed
Sequence Diagram
sequenceDiagram participant D as Desktop (ensureFreshToken) participant L as Lock File (.ade/secrets/linear-oauth-refresh.lock) participant S as ade serve (ensureFreshToken) participant Linear as Linear OAuth API participant Store as EncryptedFileCredentialStore D->>L: openSync("wx") — acquired S->>L: openSync("wx") — EEXIST, wait 25 ms loop D->>Store: re-read latest refreshToken D->>Linear: POST /oauth/token (rt_old) Linear-->>D: "{access_token, refresh_token: rt_new}" D->>Store: write at_new, rt_new, expiresAt D->>L: unlinkSync — released S->>L: openSync("wx") — acquired S->>Store: re-read latest refreshToken → rt_new Note over S: linearTokenNeedsRefresh(expiresAt) = false → skip refresh S->>L: unlinkSync — releasedPrompt To Fix All With AI
Reviews (5): Last reviewed commit: "fix: make Linear OAuth refresh stale loc..." | Re-trigger Greptile