Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,205 @@ starts from them rather than rediscovering them.
- `tests/adapters/anthropic/anthropic-account-pool.test.ts` — parity
- `tests/providers/kiro/kiro-pool-rank.test.ts` — the kiro exhaustion special case
in `account-quota-rank.ts:84-108` survives

## wp2b implementation plan (re-verified against `dev` 29d632ff2)

Every anchor below was re-read on the post-merge tree, after #4275/#4277/#4279/#4284 landed.

| Symbol | File | Line |
|---|---|---|
| `isProactivePreferenceEnabled` | `src/oauth/generic-account-failover.ts` | 150 |
| `rotateGenericOAuthAccountOn429` | `src/oauth/generic-account-failover.ts` | 178 |
| `preferredInitialAccount` | `src/oauth/generic-account-failover.ts` | 246 |
| `forgetGenericFailoverRoster` | `src/oauth/generic-account-failover.ts` | 308 |
| `GenericPoolSettingsDto` / `inert: true` | `src/oauth/pool-settings-capability.ts` | 40 / 54, 65 |
| `PUT /api/oauth/accounts/active` | `src/server/management/oauth-account-routes.ts` | 325 |
| generic GET / PUT DTO | `src/server/management/oauth-account-routes.ts` | 360 / 422 |
| `stickyLimit` 400 | `src/server/management/oauth-account-routes.ts` | 396 |
| `genericPoolKey` / `pickRoundRobinAccount` / `peekRoundRobinAccount` / `notePoolRotationSuccess` | `src/oauth/pool-kernel.ts` | 12 / 198 / 210 / 222 |
| `genericFailoverAccountId = resolved.accountId` | `src/server/responses/core.ts` | 4407 |
| per-provider `oauthAccountFailover` | `src/types/provider.ts` | 520 |

### The question 020 left open: where does a round-robin proposal commit?

`peekRoundRobinAccount` exists and does not advance the ring, which is correct for
`preferredInitialAccount` — that answer is discardable, and the resolver drops it when the
account turns out to be removed, reauth-flagged, or missing a Cloud Code Assist project. But
a peek that never commits is a ring that never turns: every request would propose the same
account forever, and "round-robin" would be a label on a constant.

So a commit site is mandatory, and it has to be the admission point, not the proposal. That
point already exists and already has a generic-only branch:

```
src/server/responses/core.ts:4405-4408
if (isGenericFailoverProvider(route.providerName, route.provider)) {
genericFailoverAccountId = resolved.accountId;
}
```

One line joins it: `noteGenericPoolSelection(config, route.providerName, resolved.accountId)`.
The function lives in `generic-account-failover.ts` and does the flag read, the strategy read
and the `notePoolRotationSuccess(genericPoolKey(name), id, stickyLimit)` call itself. No policy
moves into `core.ts`, the import comes from a module `core.ts` already imports from, and the
core-path Lab boundary is untouched — `pool-kernel.ts` pulls only two types.

This is the one file in the unit that sits on every user's request path, so it takes exactly
one statement and no branching of its own.

### Change surface

**`src/types/config.ts`** — add `pool?: { kernel?: boolean }` beside the existing optional flag
objects (`resetCreditAutoRedeem` at :833 is the nearest shape). **`src/config.ts`** — add
`pool: z.object({ kernel: z.boolean().optional() }).optional().catch(undefined)` next to
`resetCreditAutoRedeem` at :1304. `.catch(undefined)` matches the house rule: a malformed hand
edit turns the feature off rather than costing the operator their providers.

**`src/types/provider.ts`** — add `stickyLimit?: number` to the per-provider
`oauthAccountFailover` block at :520, with the same 1..100 range the Anthropic pool documents.

**`src/oauth/generic-account-failover.ts`** — branch BOTH paths on strategy, because branching
one leaves the setting inert in practice:

| Strategy | `preferredInitialAccount` | `rotateGenericOAuthAccountOn429` |
|---|---|---|
| flag off, or absent/`quota` | unchanged: healthy-active return :262, `hasHeadroomEvidence` :267, `rankAccountsByHeadroom` | unchanged: ring after the failed id, then `rankAccountsByHeadroom` |
| `round-robin` | skip BOTH guards, `peekRoundRobinAccount(genericPoolKey(name), eligible, stickyLimit)` | `pickRoundRobinAccount` over the eligible ring |
| `fill-first` | skip the healthy-active return; keep active while its usage is under `autoSwitchThreshold`, else advance to the next eligible account | must NOT keep the failed account: advance to the next eligible one |

The two guards are skipped deliberately and for different reasons, both measured in 020's audit:
`hasHeadroomEvidence` returns false for any provider with no quota data, so leaving it in front
of round-robin makes round-robin unreachable exactly where it is most useful; and the
healthy-active early return fires before `autoSwitchThreshold` can ever be read, so fill-first
would never reach its own threshold test. Keep the presence quorum, the `EXCLUDED_PROVIDERS`
guard and the per-provider `health` cooldown on every branch.

**`src/oauth/pool-settings-capability.ts`** — `inert` becomes `boolean` computed from the flag
instead of the literal `true`. `genericPoolSettingsDto` takes the flag as a third argument
rather than reading config itself, so the DTO stays a pure projection.

**`src/server/management/oauth-account-routes.ts`** — three edits. The active PUT at :325 gains
`seedPoolRotationAccount(genericPoolKey(provider), accountId)` beside `forgetGenericFailoverRoster`,
or the operator's pick immediately loses to sticky rotation — the same defect wp1b just fixed on
the Codex side, and `forgetGenericFailoverRoster` only drops the presence count, never the
cursor. The 400 at :396 narrows to `quotaWindow` alone. The pool PUT accepts and persists
`stickyLimit` with the 1..100 validation.

**`src/cli/account-extended.ts`** — the generic branch at :395 currently hardcodes
`const enabled = false`. With the kernel on it reports the real state.

### Acceptance

Criterion c-3: a test asserts a configured strategy actually changes the selected account, and
the DTO stops reporting `inert` once the flag is on.

- `tests/oauth/generic-oauth-failover.test.ts` — round-robin rotates across dispatches for a
provider with NO quota data (the case the evidence guard blocks today); fill-first holds the
active account under threshold and advances over it; quota is byte-identical to today; every
one of them is a no-op with `pool.kernel` off.
- `tests/server/account-pool-management-api.test.ts` — `inert` follows the flag, `stickyLimit`
round-trips, `quotaWindow` still 400s. The existing marker test at :435 reads the source for
the literal `inert: true;` and moves with the type.
- `tests/cli/cli-account-pool-verbs.test.ts` — the CLI reports the live threshold when on.
- Red control for each new case, as in wp1b: the assertion must fail with its production branch
removed. A test that passes either way is not coverage.

### Reversibility

`pool.kernel` defaults off, and off means the pre-kernel code path byte for byte: the guards
stay, the DTO still says `inert: true`, and `noteGenericPoolSelection` returns before touching
the ring. No migration writes on upgrade; the kernel reads keys that are already persisted.

### A-phase findings folded into this plan

Verified while auditing the plan above, before any code was written.

**The `inert` contract is published, in seven languages.** Turning `inert` into a computed
field makes live documentation false, and AGENTS.md requires docs-site to stay in sync and
translated locales not to contradict the English source. The statements that change:
`docs-site/src/content/docs/reference/configuration/providers.md` :568 ("the generic selector
does not act on it yet, so omitted and set behave the same today"), :569 ("inert until the
selector consumes it") and :590 ("`inert: true` for those two fields only"); and
`reference/cli/providers-accounts.md` :351 ("Generic pool thresholds are currently inert") and
:355, whose signature literally reads `inert: true | null`. The same page exists under
`ko`, `ja`, `fr`, `ru`, `tr`, `zh-cn` and `zh-tw`. All of it moves in this PR: a flag-gated
feature still has to describe both states, not the old one.

**The DTO marker test fails OPEN, which is worse than failing.**
`tests/server/account-pool-management-api.test.ts:435` locates its slice with
`source.indexOf("inert: true;", start)`. Once the type reads `inert: boolean;` that returns
`-1`, and `source.slice(start, -1)` happily returns almost the whole file — which still
contains "strategy", "autoSwitchThreshold" and "enabled", so all three assertions pass while
the test has stopped checking anything. It must be rewritten against the new literal, not
merely allowed to keep passing. This is the same failure mode wp1b was built on, so it gets
named rather than discovered later.

**`src/server/management/provider-routes.ts`:1023-1024 is a reader the plan did not name.**
It carries `oauthAccountFailover` forward when a provider is overwritten, to stop an edit
silently enabling rotation. It copies the whole object, so a new `stickyLimit` rides along
with no change — verified, listed here so the next reader does not have to re-derive it.

**The core-path import edge is already there.** `src/server/responses/core.ts` imports from
`../../oauth/generic-account-failover` at :150, so adding `noteGenericPoolSelection` to that
existing import creates no new module edge at all, and `pool-kernel.ts` imports only two
types. `bun test tests/lab/core-lab-boundary.test.ts` is green at 17 pass / 0 fail on this
branch and is re-run at Check.

**Fill-first's stable order is `eligibleFailoverAccounts`:164**, which preserves
`set.accounts` order from the store and filters out reauth-flagged and cooled accounts. That
is the order the 429 ring already walks, so fill-first advances through the same sequence
rather than inventing a second one.

### Plan audit round 2 — FAIL, three blockers folded

A dispatched reviewer returned FAIL on the plan above. All three blockers are real and two of
them contradict what this document said one revision earlier. Recorded rather than quietly
edited, because the corrections are the useful part.

**Blocker 1 — fill-first must walk the SORTED FULL roster, not the eligible subset.**
The "A-phase findings" note above claimed `eligibleFailoverAccounts`:164 is the order
fill-first advances through. That is wrong, and it is the exact bug 020's own earlier audit
already rejected when it added the `stableAll` argument to `pickFillFirst`. Both shipped
copies walk a stable roster sorted with `localeCompare` — `src/codex/routing.ts`:1443 and
`src/oauth/anthropic-routing.ts`:427 — and dropping to the eligible subset changes the wrap
order whenever an ineligible id sits between two eligible ones. The generic roster is worse
than unsorted-by-accident: `getAccountSet().accounts` is in LOGIN order, so two operators who
added the same accounts in a different sequence would get different rotation. The generic
fill-first sorts the full roster the same way, then skips ineligible ids while walking it.
Supersedes the paragraph above.

**Blocker 2 — the commit site fires on every generic dispatch, so it must gate on
round-robin specifically.** `core.ts`:4407 is reached on every generic first dispatch,
including the preferred-null quota path and the fallback after a preferred account is dropped
at :4368-4388. The plan said `noteGenericPoolSelection` "does the flag read and the strategy
read" without saying what it does with them, which is not precise enough to implement: an
ungated call would advance round-robin sticky state for quota and fill-first pools too.
It returns immediately unless `pool.kernel` is on AND the resolved strategy is
`round-robin`. Anthropic already draws exactly this line — `anthropic-routing.ts`:791 notes
rotation only on its round-robin branch — so this is matching an existing contract, not
inventing one.

**Blocker 3 — the CLI has three states, not two.** `src/cli/account-extended.ts`:402-409
prints "unavailable" and "threshold support is unknown" whenever `inert !== true`, so a
kernel-on `inert: false` would render the live feature as an unknown capability — the
opposite of the truth. `tests/cli/cli-account-pool-verbs.test.ts`:393-403 also feeds
`inert: false` through a malformed-capability loop that expects `enabled: false`. The CLI
needs `true` (stored, not applied), `false` (applied) and `null`/absent (unknown) as three
distinct renderings, and that test's fixture must stop conflating the middle one with
malformed input.

**Major folded — an exact-equality DTO assertion.**
`tests/server/account-pool-management-api.test.ts`:477 asserts the generic GET body with
`toEqual`, so adding `stickyLimit` breaks it. :484 uses `toMatchObject` and is safe. The PUT
round-trip at :486 breaks only once PUT actually persists the field. All three move with the
change.

**Major folded — `src/cli/capabilities.ts`:331** also publishes the inert contract, alongside
the docs-site pages already listed.

**Correction — anchor.** This document cited `hasHeadroomEvidence` at :267; that is where its
comment begins. The call is at :272. The anchor table itself was verified correct.

**Confirmed, no action —** the reviewer independently reached the same conclusion on the Lab
boundary: `core.ts` already imports `generic-account-failover`, and `pool-kernel.ts` is
`import type` only, which the boundary walker skips. No new edge.
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,11 @@ renvoient 1 ; une sonde de quota en amont qui échoue ou expire produit plutôt

### `ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]`

Contrôle le seuil du pool Codex `openai`, ou enregistre celui d’un pool OAuth générique. `on` enregistre 80 %, `off` 0 % et `threshold <n>` accepte 0–100. Les seuils génériques sont actuellement inactifs : leur sauvegarde ne change ni le basculement par seuil, ni l’activation du fournisseur, ni la rotation réactive après une erreur 429. Pour les pools génériques, les sorties utilisent la réponse confirmée du serveur. Pour un pool générique, `poolEnabled` est le réglage enregistré (`null` signifie non spécifié), pas l’état effectif hérité. `inert: true` indique que le seuil ne s’applique pas ; une capacité inconnue ne produit jamais `enabled: true`. Les fournisseurs à clé API, Anthropic et les valeurs invalides sont refusés.
Contrôle le seuil du pool Codex `openai`, ou enregistre celui d’un pool OAuth générique. `on` enregistre 80 %, `off` 0 % et `threshold <n>` accepte 0–100. Un seuil générique n’oriente la sélection que si `pool.kernel` est activé avec `strategy: "fill-first"` ; le drapeau désactivé, sa sauvegarde n’active pas le basculement par seuil. Dans les deux cas, elle ne change ni l’activation du fournisseur, ni la rotation réactive après une erreur 429. Pour les pools génériques, les sorties utilisent la réponse confirmée du serveur. Pour un pool générique, `poolEnabled` est le réglage enregistré (`null` signifie non spécifié), pas l’état effectif hérité. `inert: true` indique un seuil enregistré mais non appliqué, `inert: false` un seuil que le pool applique réellement. L’absence d’`inert` signale une capacité inconnue, qui ne produit jamais `enabled: true`. Les fournisseurs à clé API, Anthropic et les valeurs invalides sont refusés.

```text
openai: { provider, autoSwitchThreshold: number, enabled: boolean }
generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null }
generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null }
```

### `ocx account priority <provider> <account-id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,11 @@ OAuth プロバイダーと API キー プロバイダーの場合、これに

### `ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]`

`openai` Codex プールのしきい値を制御するか、汎用 OAuth プールのしきい値を保存します。`on` は 80%、`off` は 0%、`threshold <n>` は 0–100 を保存します。汎用プールのしきい値は現在適用されません。保存しても、しきい値による切り替え、プロバイダーの有効化設定、429 エラー時のローテーションは変更されません。汎用プールの照会と変更の結果はサーバーの確認値を使用します。汎用プールの `poolEnabled` は保存された設定で、`null` は未指定です。継承後の実効状態ではありません。`inert: true` は未適用を示し、機能が不明な場合も `enabled: true` とは表示しません。API キープロバイダー、Anthropic、不正な値は拒否されます。
`openai` Codex プールのしきい値を制御するか、汎用 OAuth プールのしきい値を保存します。`on` は 80%、`off` は 0%、`threshold <n>` は 0–100 を保存します。汎用プールのしきい値は `pool.kernel` が有効で `strategy: "fill-first"` の場合にのみ選択へ反映されます。フラグが無効なら、保存してもしきい値による切り替えは有効になりません。いずれの場合もプロバイダーの有効化設定と 429 エラー時のローテーションは変更されません。汎用プールの照会と変更の結果はサーバーの確認値を使用します。汎用プールの `poolEnabled` は保存された設定で、`null` は未指定です。継承後の実効状態ではありません。`inert: true` は保存済みで未適用、`inert: false` はプールが適用中であることを示します。`inert` が無い場合は機能が不明であり、その場合も `enabled: true` とは表示しません。API キープロバイダー、Anthropic、不正な値は拒否されます。

```text
openai: { provider, autoSwitchThreshold: number, enabled: boolean }
generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null }
generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null }
```

### `ocx account priority <provider> <account-id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]`
Expand Down
Loading
Loading