fix(anthropic-subscription): keep the failure that blocked an account and log every block

Draft
CO
4d ago
+545-11

Summary

When one Anthropic Subscription account fails while every other account in the pool is already blocked, the turn now reports that account's own failure (with the pool refusal attached as context) instead of a bare "All Claude accounts ... are currently blocked". Every account block that is saved is logged as account_blocked, from the failover and from the background usage poll, and the blocked-pool error/guidance lists each account's state and names the account that needs /login.

Root cause

  • packages/coding-agent/src/core/extensions/builtin/anthropic-subscription/failover.ts (runFailover catch block): after saving the block, event.nextAccount = options.selectFn(accounts) ran inside try/finally. With every other account blocked, selectAccount (affinity.ts) throws AllAccountsBlockedError, which escaped and replaced the original failure (a refresh failure or a 401).
  • The block was only emitted as an in-memory event; nothing was logged. The usage poll's markAuthRejected (subscription-limits/usage-accounts.ts) could also add an auth_error block silently.

Fix

  • failover.ts: catch AllAccountsBlockedError from selectFn on the failover-event path and throw the original ClassifiedSdkError with the refusal attached as poolState; any other error still propagates. Log account_blocked {account, reason, until, shared, detail} whenever a block is saved.
  • account-block-log.ts (new): logAccountBlocked, through the extension's existing session.log boundary (logContinuityEvent); detail capped at 200 chars.
  • usage-accounts.ts: markAuthRejected logs account_blocked (detail: usage_poll_token_rejected) when it actually locks the slot.
  • affinity.ts: AllAccountsBlockedError.accounts (name, reason, until); the message keeps its first sentence unchanged and appends each account's state, naming /login anthropic-subscription for auth-blocked accounts. guidance.ts/stream-guidance.ts show the same lines to the user.

RED -> GREEN

RED (test/suite/regressions/4671-pool-block-keeps-cause.test.ts on origin/main + the test only): 3 failed

FAIL ... keeps the original auth failure when no other account can be selected, and logs the block
AssertionError: expected AllAccountsBlockedError: All Anthropic Su… { …(4) } to be an instance of ClassifiedSdkError
FAIL ... lists every account's state and names the account that needs /login
AssertionError: expected undefined to deeply equal [ { name: 'alpha', …(2) }, …(1) ]
FAIL ... logs the block the usage poll saves when the token endpoint rejects a login
AssertionError: expected [] to deeply equal [ { event: 'account_blocked', …(1) } ]

GREEN: the regression plus failover, affinity, guidance, model-scoped-limit, usage-accounts, credential-error-taxonomy and stream tests: Test Files 8 passed (8) / Tests 147 passed (147). Gates (each its own process via the gated push): focused vitest, root tsc --noEmit, check-pr-changelog, lint-ratchet, oxfmt --check, git diff --check.

Fixes #4671
Part of code-yeongyu/oh-my-openagent#11683


Summary by cubic

Fixes #4671: when one Anthropic Subscription account fails while every other account is already blocked, the turn now reports that account's own failure instead of hiding it behind "all accounts blocked". Every block that is saved is logged as account_blocked with the account named by seat digest only, and the pool refusal is attached as context rather than replacing the failure.

Changes

  • runFailover throws the original failure with the AllAccountsBlockedError refusal attached as poolState and as the error's cause; other selection errors still propagate.
  • The turn error keeps the soonest automatic retry reachable, so goal mode's providerRetryAt still parses it.
  • Every saved block — from failover and from the usage poll's markAuthRejected — writes an account_blocked log line with reason, until, shared, and detail (capped at 200 characters).
  • AllAccountsBlockedError carries each candidate's state and appends it to the message; guidance lists the same per-account lines and names the account needing /login.
  • The session.log data-key allowlist admits seat, until, and shared so those fields survive in the real log.