fix(anthropic-subscription): report a same-turn retry on another seat as a seat switch, with the serving seat in every continuity line

Merged
CO
2d ago
+283-20

Part of code-yeongyu/oh-my-openagent#10304. Related: #2081 (persisted slot blocks; the other half of the same cascade).

Problem

Prompt caches never cross Anthropic organizations (prompt caching docs: "Different organizations never share caches, even if they use identical prompts"). Each subscription seat is its own organization, so the first request on a new seat writes the whole prefix.

decideFromBinding lets account drift on a shared-root lane fall through to the retry checkpoint (#1432), and that branch labelled the result fork / timeout_retry, the same label as a same-seat retry that reads its cache. In the reported incident (5.1.33, four seats) every timeout_retry record read only the 29,319-token system and tool prefix and wrote 0.8-0.9M tokens. The logs could not tell a seat switch from a same-seat retry, or say which seat served a request.

Change

  • session-continuity.ts: retryCheckpointDecision takes the label. A retry on another seat is account_changed; a same-seat retry stays timeout_retry. The fork boundary and offset are unchanged, so the intent of #1973 and #1974 (fork at a mapped boundary inside the proven prefix) is kept and their tests stay green.
  • session-observability.ts, session-stream.ts: every continuity observation and claude_sdk_oauth_session_continuity log line carries seat (first 8 hex digits of SHA-256 of the slot name, never the name) and seatChanged (the attempt runs on another seat than the lineage's last one).

This PR changes no request bytes: only labels and log fields. It does not change failover policy (when to rotate seats, a per-turn cold-write budget); that is a separate decision tracked on #10304.

Evidence

  • RED at f755b7d815 (main), using the new assertions: 3 failures.
    • anthropic-subscription-continuity-retry-checkpoint: a failover fork was labelled timeout_retry.
    • 1432-anthropic-subscription-failover-reattach: no account_changed fork was observed.
    • 723-anthropic-subscription-timeout-abort-retry-continuity: the observation carried no seat.
  • GREEN at this head: the whole anthropic-subscription test cluster passes: 112 files / 764 tests passed, 4 skipped. compaction/lane-policy and session-log (which also read these observations) pass 25/25. Two more tests that pinned the old output exactly were updated: anthropic-subscription-diagnostic-render and 1432-anthropic-subscription-observation-emit-once, tsc --noEmit passes, and biome check reports nothing.
  • The same-seat retry reading its cache is the behaviour #723 shipped and measured live (changes.md: "five 429s, then fork / timeout_retry with a full cache read"); regression 723 still pins the delta-only resend and now also seatChanged: false.
  • No slot name reaches an observation (asserted in 1432).

Thanks to 욜로개발조아 for the measured incident report.


Summary by cubic

Fixes the Anthropic Subscription continuity logs so a same-turn retry that fails over to another seat is labeled as a seat switch, not a same-seat timeout retry.

  • Retry checkpoint decisions keep the same fork boundary and offset but now carry account_changed for a seat switch and timeout_retry for a same-seat retry, since a new seat writes the whole prompt cache.
  • Continuity observations and claude_sdk_oauth_session_continuity log lines now include a seat digest (first 8 hex digits of an HMAC-SHA-256 of the slot name keyed by a per-install salt, never the name) and a seatChanged flag; only decision observations carry a seat, not the terminal failed or non-resident disabled ones.
  • The per-install salt (32 bytes, mode 0600) is created atomically via a staged write plus hard-link, so concurrent first uses converge on one salt and digests stay unguessable without it.
  • The tracker entry notes that the old timeout_retry labels in the #1432 and #723 entries are superseded, leaving dated entries append-only.
  • Regression tests pin that no slot name, access token, or refresh token reaches an observation or log line.
  • No request bytes or failover policy change; only labels and log fields.