Skip to content

fix: clear a permanent main refresh latch when the refresh token rotates - #195

Merged
ualtinok merged 1 commit into
cortexkit:mainfrom
iceteaSA:fix/relogin-clears-permanent-latch
Sep 2, 2026
Merged

fix: clear a permanent main refresh latch when the refresh token rotates#195
ualtinok merged 1 commit into
cortexkit:mainfrom
iceteaSA:fix/relogin-clears-permanent-latch

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Incident (2026-09-02, live)

The main account's background refresh got 400 invalid_grant "Refresh token expired" at 15:35Z. The plugin recorded it as permanent: true with a 24h nextRetryAt, keyed to the main account's rotation-invariant slot id (v1.21.0). The operator re-logged in at 16:17Z: new refresh + access token in auth.json, same slot. The latch did not clear. Every tick logged skipped backoff, the next due refresh would have been skipped, and the fresh 8h token would have died at expiry with a valid family on disk.

Before v1.21.0 the error was keyed to the refresh-token hash, so a new token cleared it. Re-keying to the stable identity (#164) lost that edge: clearStaleMainRefreshError only clears on identity mismatch, and re-login into the same slot leaves the identity unchanged by design.

Fix

  • Record refreshTokenFingerprint (non-reversible tokenFingerprint() of the refresh token that was actually sent) on every refresh error, main and fallback.
  • refreshBackoffActive(error, identity, now, currentFingerprint): an error whose recorded fingerprint differs from the current refresh token's is stale evidence — the family rotated or re-logged under the same slot — and no longer holds the backoff. Threaded at all six main call sites and all six fallback call sites.
  • clearStaleMainRefreshError also clears and persists on fingerprint change.
  • /claude-account reset-backoff (OpenCode + Pi): explicit operator escape hatch that clears mainLastRefreshError and the quota-API error under the current identity through the locked save path, bumping the generation / clearedAt fences so a stale cross-process writer cannot resurrect them.

Errors recorded before this change carry no fingerprint. Those hold until identity change or one reset-backoff — stated in the commit body and pinned by a test. Every error recorded from now on clears on re-login.

Proof

  • Background tick with a persisted permanent error (fingerprint F1) and a rotated refresh token (F2) → refresh attempted, error cleared, "cleared stale backoff after token rotation" logged. Mutation (drop the fingerprint compare): 0 attempts, red.
  • Same fingerprint → backoff holds.
  • Fingerprint-less legacy error → holds; reset-backoff clears; next tick attempts. Mutation (Pi persistence removed): ENOENT, red.
  • Fallback: permanent error F1, account.refresh now F2 → refresh attempted; mutation red.
  • Root 1371/0 twice, typecheck, format, biome clean. Cross-family review (M3-thinking) REVISE→fixed: the first cut's legacy rule read a state field nothing writes; removed.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes a permanent refresh backoff latch that survived re-login into the same account slot, which could let a fresh access token expire despite a valid refresh token on disk.

Bug Fixes

  • Records a fingerprint of the refresh token that produced each refresh error, main and fallback.
  • Backoff is ignored when the current refresh token's fingerprint differs from the recorded error's.

Migration

  • Errors recorded before this change carry no fingerprint and hold until identity change or one /claude-account reset-backoff.
  • Adds /claude-account reset-backoff for OpenCode and Pi to explicitly clear main refresh and quota backoff.

Written for commit f095ad5. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/pi/src/commands.ts
Comment thread packages/pi/src/commands.ts Outdated
Comment thread packages/core/src/accounts.ts
Comment thread packages/core/src/accounts.ts Outdated
Comment thread packages/opencode/src/index.ts
Comment thread packages/opencode/src/index.ts
Comment thread packages/pi/src/tests/commands.test.ts
@iceteaSA
iceteaSA force-pushed the fix/relogin-clears-permanent-latch branch from 148cd75 to a81806e Compare September 2, 2026 17:55
A permanent invalid_grant on the main OAuth account can outlive a re-login when the stable slot identity does not change.

Persist the fingerprint of the refresh token that produced a refresh error and ignore active backoff when the current credential's fingerprint differs. The same lineage check now applies to fallback refreshes, and reset-backoff clears the main refresh and quota latches explicitly.

Existing fingerprint-less records need one /claude-account reset-backoff; every error recorded by this build carries the fingerprint.
@iceteaSA
iceteaSA force-pushed the fix/relogin-clears-permanent-latch branch from a81806e to f095ad5 Compare September 2, 2026 18:33
@ualtinok
ualtinok merged commit 37478af into cortexkit:main Sep 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants