fix: stop a run on an auth failure instead of retrying it (#86) - #87
Merged
Conversation
"Failed to authenticate: OAuth session expired and could not be refreshed"
stopped the work and said nothing about why. The visible symptom hid a
worse defect: an auth failure is not subtype:'success', so all three agent
loops AUTO-CONTINUED it. Each continue fails instantly with the identical
error, the whole MAX_AUTO_CONTINUES budget empties in seconds, and the run
ends as a generic 'agent_incomplete' that names nothing. A chain task then
retried twice more on top of that.
auth-errors.js is the third class of "the turn stopped and it was not the
agent's fault", alongside the two in rate-limit-utils.js — and the only one
that NEVER passes on its own. It refreshes nothing and cannot: the OAuth
tokens belong to the `claude` CLI's own credentials store, and the refresh
exchange needs client credentials this server has never held and must not
hold. Detecting it precisely and refusing to retry IS the fix.
- Detection is anchored to CLI/API-internal wording, disjoint from the
rate-limit anchors, and a CLEAN SUCCESS is never classified as an auth
failure — this repo's own issue, module and test contain the phrase, so
an agent asked to read them reproduces it in a turn that succeeded.
- A bare "/login" or "unauthorized" is deliberately not matched: the studio
serves its own /login route and answers {error:'unauthorized'}.
- The detector runs BEFORE the success break and before the auto-continue in
all three loops (chat, SSH, taskWorker); the chain retry excludes it.
- The chat gets a real status line (--- fence, or the SPA stamps "Done" over
the warning), the log gets a root-cause kind, the Kanban card gets a
parseable auth_error:<kind> reason and a lock badge in 5 languages.
test/auth-errors.test.js: 66 assertions, including structural pins on the
ordering in server.js — a reorder there silently restores the bug.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #86.
What was reported
The work stops and nothing says why. The ask was: display the root cause, and refresh
the session automatically.
What was actually broken
The visible symptom hid a worse defect. An auth failure is not
subtype:'success', soall three agent loops auto-continued it. Every continue fails instantly with the
identical error, the whole
MAX_AUTO_CONTINUESbudget empties in seconds, and the runends as a generic
agent_incompletethat names nothing. A chain task then retried twicemore on top of that. The burnt budget is the part nobody could see.
What this PR does — and does not do
auth-errors.jsis the third class of "the turn stopped and it was not the agent'sfault", alongside the two in
rate-limit-utils.js— and the only one that neverpasses on its own. An overload clears after a backoff; a quota clears at
resetsAt;an expired OAuth session clears when a human re-authenticates the CLI, and not before.
It refreshes nothing, and cannot. The OAuth tokens belong to the
claudeCLI's owncredentials store; the refresh exchange needs a refresh token and client credentials
this server has never held and must not hold. "Refresh automatically" is the CLI's job —
when the CLI reports that the refresh failed, the only correct move here is to stop at
once and name the action. Detecting it precisely and refusing to retry is the fix.
anchors in both directions (reading a quota banner as an auth stop would strand work
that would have resumed by itself).
test all contain the phrase "OAuth session expired", so an agent asked to read any of
them reproduces it in a turn that succeeded. Same guard
shouldRetryOverload()has./loginorunauthorizedis deliberately not matched — this project servesits own
/loginroute and answers{error:'unauthorized'}from its own middleware.three loops (CLI chat, SSH chat,
taskWorker), and the chain auto-retry excludes it.That ordering is the fix, so the test pins it by index — verified by mutation.
---fence, or the SPA stamps its own"✅ Done" badge over the warning), the log gets a root-cause
kind, and the Kanban cardgets a parseable
auth_error:<kind> <line>reason plus a 🔐 badge in all 5 languages.Note that
claudeCliStatus().authenticatedcannot cover this by construction: it teststhat
~/.claude/.credentials.jsonexists, and an expired token's file exists too.Telling a live token from a dead one needs a network round trip, so the stop is caught
reactively, mid-turn.
Verification
npm test— 77 files, 1617 passed, 0 failed, exit 0. The newtest/auth-errors.test.jscontributes 66 assertions across 8 sections: detection,false-positive guards, disjointness from
rate-limit-utils, the clean-success guard,root-cause buckets, notice format, structural pins on the ordering in
server.js, andkbAuthStoplifted out ofkanban.htmland executed.Two mutations were run to prove the pins are real: removing
!taskAuthStopfrom thechain-retry condition, and moving the detector below
taskContinueCount++. Both werecaught.