0024 — A session changes accounts by its file, not by dying

The failure

A session whose account is capped had exactly one remedy: kill the process and re-exec it on a new bearer. tmux respawn-pane -k kills the whole process group, so every subagent died with the parent — subagents are sidechains carrying the parent's sessionId with isSidechain: true, so no resume surface in Claude Code applies to them. They cannot be resumed, only re-dispatched.

We built a great deal of machinery to survive that kill: capture what the respawn is about to destroy, hand the successor a brief plus a transcript pointer, hold the switch while lanes are live. On 2026-09-11 that machinery was measured against the thing it exists for, and the result is the reason this ADR is not a refinement of it:

time (CDT) what the log says
22:05:58 228a617c hold, 2 live"a respawn kills them mid-task"
22:18:43 228a617c hold, 4 live, held 13m
22:06:12 / 22:22:17 ab6d89b8 hold, 1 live ("Fix AppFolio rate-limit overload")
22:52:21 228a617c switch → josegerag, subagents: 0
22:54:57 ab6d89b8 switch → morpheus, subagents: 0

The hold worked and was pointless. It protected those lanes for up to 13 minutes against a wall with ~80 minutes left to run, and every lane was rate-limited out before the respawn fired. The kill found nothing to kill.

Two consequences follow, and both are defects of the capture design rather than bugs in it:

  1. The capture never fires in the real case. It records lanes live at the kill, but the true sequence is wall → lanes die → respawn. Every capture on disk is from an earlier forced respawn, not from a real one.
  2. respawn-losses/<sid>.json is one file per session, overwritten. So it preserved a stale set (21:03) while the set that mattered was never written, and rotation.log truncates its lane list at +1 moreone of 228a617c's four lane names now exists nowhere.

Why we believed the kill was necessary, and what is actually true

PITFALLS.md §1 recorded an "exec-pinning invariant": the credential is fixed at exec time, therefore moving accounts requires a re-exec. §1 is true of the CLAUDE_CODE_OAUTH_TOKEN environment path, which is the path this fleet uses, by three carriers: cmd_exec's env[...] = tok before execvpe, the tmux set-environment -g that herds ~50 sessions onto one bearer, and the session-scoped _tmux_set_session_token. A process holding that variable ignores the credential file entirely.

§1 is false of the file. Measured on the mini, 2026-09-12 03:40–03:46Z, on a throwaway session since cleaned up, launched with env -u CLAUDE_CODE_OAUTH_TOKEN -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN CLAUDE_CONFIG_DIR=… (env token count verified 0 on every run):

A detector that failed its own control, recorded so nobody rebuilds it: per-account 5h utilization from the anthropic-ratelimit-unified-* headers cannot attribute a single turn — the API reports 2 decimals, and a large run provably served by an account left its util_5h at "0.0". Account identity is verified by the identity signature (per-account reset timestamps and weekly totals), never by watching a meter tick.

The decision

1. The credential file is the seam

A swappable session carries no bearer in its environment and reads $CLAUDE_CONFIG_DIR/.credentials.json. All three env carriers are suppressed for it. The launching shell does carry the token, so unsetting is load-bearing; inheriting is the default failure.

2. The write is atomic, and that is a correctness requirement

os.replace or equivalent. Not stylistic. With the file deliberately invalid, a subagent was dead 7.7 seconds later — Please run /login · API Error: 401 (authentication_failed), hard terminate, no retry, despite its prompt instructing it to retry and never stop — while account A's own bearer was still returning 200, which rules out a coincidental outage. The parent survived only because it happened to be parked in a shell command. A subagent has zero tolerance for a momentarily-unusable credential.

3. preflight becomes authoritative

Today it is a cache-only warning. It becomes a live gate with distinct exit codes (0 ok / 1 warn / 2 refuse), and no swap proceeds without it. The sibling branch fede/local-rotation-snapshot-2026-09-11 already has this shape and is the reference.

4. Live subagents raise the bar; they are not a blanket block

A clean swap to a healthy account demonstrably does not harm subagents (23 post-swap requests). A swap to a dead or capped one kills them in under 8 seconds. The distinction that matters is the destination's health, not the presence of lanes — which is the opposite of what the hold rule assumed.

5. Respawn is demoted, not deleted

Every session launched before this change is still env-pinned and still needs respawn, capture, handoff and re-dispatch. tests/respawn-holds-for-live-subagents.py records a real incident (five Workers killed, two of them twice) and keeps passing. Which kind a session is must be derived — read the process's actual environment — never a remembered flag or a maintained list. The standing rule: if a fact has to be remembered to stay true, it will be false.

6. ccswitch auto must stop respawning what it can swap

It runs on UserPromptSubmit. For a swappable session it swaps in place and does not respawn. For a pinned one, today's behaviour exactly.

What this does NOT fix

Changing accounts voids the prompt cache, and no mechanism avoids it. Cache is per-account. One toy swap re-created 68,045 tokens at creation price (parent 46,201 + subagent 21,844) on a five-minute session. The cost scales context × live agents, because every live sidechain pays its own full re-creation on its next request. A 15-lane session at 400K context pays a great deal to move, and a swap made to save a hot account can spend more of the destination than the turn would have. A swap therefore needs a budget check, which this change does not provide. Naming it here so it is not discovered as a surprise.

Also unfixed, and deliberately separate: walled_heal refusing a usable seat (it demands an account clear on all axes while the switch path correctly accepts one whose needed axis is free). That is why both sessions above were freed not by the healer but by coincidence — an unrelated mail wake at 22:52:20 and a post-compact nudge at 22:54:55, each injecting a prompt that fired the hook. Its own change; not this one.

Rejected alternative: the proxy

ANTHROPIC_BASE_URL pointed at a local relay that strips the client's Authorization and injects the chosen bearer. It works — proven the same night: swap at 03:21:54Z verified by account email, 11 requests 100% account A before, 21 requests 100% account B after, bearer fingerprint and reset epochs flipping at the boundary and never mixing; a live subagent's heartbeats crossed it (1–5 A, 6–14 B) and returned; streaming survived unbuffered; and the negative control proved stripping, since two probes carrying a bogus client bearer both returned 200.

Rejected anyway, because it buys nothing the file does not and costs:

The file path needs no new process, no traffic interception, and no new credential location. It is also, concretely, what the sibling snapshot branch already does — the answer to "why can they and we can't" is that they were never env-pinned.

Consequences