Concurrent sub-agents trigger "Not logged in" errors when ~/.claude/.credentials.json is absent (valid token, macOS Keychain)

Resolved 💬 3 comments Opened Mar 23, 2026 by wangxhere Closed Mar 27, 2026

Description

When multiple Claude Code sub-agents run concurrently on macOS, all agents intermittently receive "Not logged in" errors even though the OAuth token is valid and was issued less than an hour ago. The session recovers on its own within 1-2 minutes without any user action (re-login is not required).

Environment

  • Claude Code version: 2.1.81
  • Node.js: v20.19.0
  • OS: macOS (Keychain-based OAuth storage)

Trigger conditions

  • macOS with Keychain-based OAuth storage
  • 3+ concurrent long-lived sub-agent processes
  • ~/.claude/.credentials.json does not exist (default state after claude login)
  • Token is fully valid (within ~1 hour of login, far from the 8-hour expiry)

Root cause

The fault is a cascade involving two functions in cli.js:

kA9() — called at the top of _P1() on every invocation — checks the mtime of ~/.claude/.credentials.json. When the file does not exist, it unconditionally falls into the catch branch and calls hA.cache?.clear?.():

async function kA9() {
  try {
    let {mtimeMs: A} = await $A9(HA9(d1(), ".credentials.json"));
    if (A !== Gu7) Gu7 = A, Jg6()
  } catch { hA.cache?.clear?.() }   // ← fires on every _P1() call when file absent
}

_P1() then acquires a file lock (Tu7.lock(z)) after hA has already been cleared. With N concurrent agents all reaching this point simultaneously:

  1. All N agents call kA9() → all N clear hA
  2. One agent acquires Tu7.lock(z) — the other N-1 get ELOCKED
  3. ELOCKED retries up to 5× with 1-2s backoff; if all fail, _P1() returns false with hA still null
  4. The losing agents' next API call attempts to read hA() → null → falls back to Keychain read → if the winning agent is currently writing to the Keychain (dI7.BI is momentarily null during security add-generic-password) → returns null → "Not logged in"

The 5-second dI7.BI in-process Keychain cache is the narrow window that makes the final null read probabilistic, explaining why the error is intermittent and self-resolving.

Observed log pattern

Two or more agents fail at the same second (within ~500ms of each other), followed 60-90 seconds later by a 400 cache_control.ttl error on the same agent — the 400 is not an auth error; it means auth has already recovered and the agent is back to normal API operation.

Workaround

Creating a stub credentials file eliminates the catch branch:

echo '{}' > ~/.claude/.credentials.json

With the file present and mtime stable, kA9() becomes a no-op, hA retains its cached valid token, and the lock cascade cannot occur.

Verified this does not affect the 8-hour automatic token refresh: vB(expiresAt) returns true for both "about to expire" and "already expired" states, so _P1() still refreshes correctly when needed.

Suggested fix

Option A: Create ~/.claude/.credentials.json (even as an empty {}) during claude login, so the file is always present after authentication.

Option B: In kA9(), avoid clearing hA in the catch branch when the error is ENOENT specifically — a missing file is not a signal that the token changed:

async function kA9() {
  try {
    let {mtimeMs: A} = await $A9(HA9(d1(), ".credentials.json"));
    if (A !== Gu7) Gu7 = A, Jg6()
  } catch(e) {
    if (e.code !== 'ENOENT') hA.cache?.clear?.()  // only clear on unexpected errors
  }
}

Option B is the minimal targeted fix: it preserves the intent of kA9() (detect credential file changes) while eliminating the unnecessary cache invalidation that triggers the cascade.

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗