Skip to content

fix(anthropic-subscription): retry on the account that served the session, not the last one tried - #4318

Open
code-yeongyu wants to merge 3 commits into
mainfrom
fix/4304-cache-holder-affinity
Open

code-yeongyu wants to merge 3 commits into
mainfrom
fix/4304-cache-holder-affinity

Conversation

@code-yeongyu

Copy link
Copy Markdown
Owner

Fixes #4304
Part of code-yeongyu/oh-my-openagent#10304

Problem

A prompt cache lives on the account that served the session. When every attempt of a turn failed (A rate-limited, then B rate-limited, stalled or failed), the failed attempt on B published B's retry checkpoint as the session's binding, although B had never served the session. The binding is what the next request prefers (preferredAccount) and what the seat-change flag compares against (lineageSeat). So:

Change

  • session-turn-attempt.ts: createSessionTurnAttempt takes servedLineage, the session's binding from before the attempt. Once the attempt's process is gone (a failure, a discard, or an abort that closed it), an unanswered attempt on an account other than the one that binding names re-publishes that binding instead of its own retry checkpoint. If Claude Code rejected a fork boundary during the attempt, that boundary is dropped from the binding first, as [Bug]: #1472 still causes repeated full-history replay and stale UUID resume fallback #1958 already does for the attempt's own lineage.
  • Unchanged: an attempt on the serving account records its own checkpoint. A process kept after a clean interrupt also keeps its own checkpoint, because it holds the turn and its live entry keeps serving; publishing another lineage there would make a later reattach resume a lineage that is not the live one.
  • session-stream.ts: createResidentAttempt reads the binding after restored-binding admission and passes it on.

Account selection does not change. It already prefers the binding's account, and with the binding now kept on the serving account, that account is preferred as soon as it can serve, before any cold account. A real move is still labelled account_changed with seatChanged: true, and the new account keeps the session after the old one recovers.

Test authoring gate:

  1. Contract: after a turn whose attempts all went unanswered, the next request goes to the account that last served the session as soon as it is healthy. It sends that account exactly the prompt it was already sent, so it reads that account's cache, and it is logged as a same-seat timeout_retry. A move while that account stays down is still logged as a seat change, and the new account then keeps the session.
  2. Regressions that fail it: writing a non-serving account's checkpoint over the serving lineage; preferring or comparing against the last account attempted instead of the last one that served; restoring a lineage with a boundary Claude Code rejected; restoring the served lineage while a kept process still holds the turn.
  3. Why existing coverage misses it: the senpi#1432 tests cover a failover whose second account succeeds. No test failed on every account and then checked where the next request went or how it was labelled.
  4. Seams: the real Claude Code binary runs against a loopback Messages endpoint that tells accounts apart by their OAuth token and records every request. The managed lanes strip ANTHROPIC_BASE_URL, so the test adds it at the SDK boundary. A fake clock (overrideAuthLaneBoundary({ now })) expires the rate-limit blocks. Claude Code is spawned through spawnClaudeCodeProcess, so cleanup waits for each process to exit before removing its directory. The kept-process case drives the real session pump with a scripted query, because the shipped Claude Code closes the process when a stalled attempt is interrupted (see Evidence).

Evidence

… an unanswered attempt elsewhere

A failed, discarded or uncertainly aborted attempt on account B published
B's retry checkpoint as the session's binding, even though B never served
the session. The binding names the account the next request prefers and
the account the seat-change flag compares against. So once A, the account
that served the session and holds its prompt cache, recovered, the retry
went to B and wrote the whole conversation to an account that had never
cached it. When B was still blocked, the retry went back to A and read A's
cache, but it was logged as account_changed with seatChanged true.

createSessionTurnAttempt now takes the binding from before the attempt.
When an unanswered attempt on another account has no process left, it
re-publishes that binding, minus a fork boundary Claude Code rejected,
instead of its own checkpoint. The next request prefers the serving
account as soon as it can serve, forks that account's own lineage, and is
logged as a same-seat timeout_retry. A process kept after an abort keeps
its own checkpoint, as before.

Fixes #4304
Part of code-yeongyu/oh-my-openagent#10304

This branch has not been deployed

No deployments
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.

Retry log marks a seat change when returning to the cache-holding account

1 participant