Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions devlog/_plan/260914_cost_guard_stabilization/000_unit.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@ what protects the operator who explicitly opts back out.
## Write scope

Permitted: `src/codex/routing.ts`, `src/types/config.ts`, `src/config.ts`, the
account-pool and session-affinity code, their tests under
`tests/codex-integration/`, `docs-site/` configuration reference and its locales,
account-pool and session-affinity code, `src/routing/` for the identity, quota and
cache-domain layers `090_remaining_stack.md` plans (wpc's classifier, wpe's reservation
ledger, wpf's probe lease), their tests under `tests/codex-integration/` and
`tests/routing/`, `docs-site/` configuration reference and its locales,
`structure/` docs that own the affected invariants, and this unit.

Excluded, owned by concurrent lanes: `src/providers/devin*`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 090 — what is left after 2.55.0, as a seven-layer stack

## Why this doc exists

`070_delivery.md` closed the 2.55.0 release record with a list of things the
release deliberately does not claim: the durable cross-restart reservation
ledger, V2 child first placement, the minimum quota/cache domain contract, the
transient half-open probe lease, combo hops on the shared budget, Cursor's inner
retries, and sends-per-logical-request surfacing. That list is accurate and it is
also unordered, which is the problem. Each item touches a different layer of the
same request path, and three of them change the same two files.

This doc fixes the order and the write scopes so the remaining work can ship as a
stack of independently revertible pull requests rather than one unreviewable diff.

## What is already true

Stating this once, because repeating the original problem statement as if nothing
landed is the failure mode this unit keeps hitting. On `dev@4f788f91`: a request
carries a guarded four-send profile with a three-send base allowance and one shared
final-recovery reserve (#4609); a zero budget no longer floors to one (#4613); the
workflow guard caps physical sends, distinct children and concurrency and reserves
an interactive slot (#4614); a healthy detour is promoted rather than discarded when
a hold expires, and `Retry-After` is honoured on the transient path (#4616).

So the remaining work is not "add a budget". It is: make the budget reach the
paths it still cannot see, make it correct under concurrency, and stop it from
being reset by a restart or side-stepped by a fresh identity.

## The stack

Listed in the order the branches are stacked, each one based on the branch above it.

| # | Layer | Branch | What it closes |
| --- | --- | --- | --- |
| 1 | wpc | `codex/4546-wpc-quota-cache-domains` | Auth identity, quota domain and cache domain as three separate values, plus conversational-state portability as its own check |
| 2 | wpe | `codex/4546-wpe-durable-reservation` | Token-and-output reservation at three scopes, unresolved spend, and a ledger that survives restart |
| 3 | wpf | `codex/4546-wpf-probe-lease-backpressure` | The half-open probe lease, `Retry-After` preserved past the local maximum, and pool-wide retry backpressure |
| 4 | wpd | `codex/4546-wpd-v2-lineage-placement` | V2 root/parent/thread lineage and child first placement onto the parent's current serving account |
| 5 | wpa | `codex/4546-wpa-dispatch-coverage` | The reset-retry counting seam, compact's routed fallback, the generic OAuth and Anthropic hops, the gated-400 ladder's relation to the shared cap, and permit atomicity |
| 6 | wpb | `codex/4546-wpb-combo-adapter-retries` | Combo's real hop and target transition under a per-target policy, and Cursor's and Kiro's inner retries |
| 7 | wpg | `codex/4546-wpg-spend-instrumentation` | Sends per logical request, reserved/settled/unresolved spend, cache provenance, and a no-account failure that explains itself |

Only two of those adjacencies are real dependencies. wpb needs wpa's permit
contract to be atomic before a second dispatcher may be wired to it, and wpg
reports what every earlier layer produces, so it is last by construction rather
than by importance. The rest are contract layers that introduce a module and its
tests without rewiring a call site, which is what makes them stackable in
readiness order and revertible one at a time.

That independence is deliberate and it is also the honest limitation of the first
three layers: wpc's classifier, wpe's ledger and wpf's lease are each landed
tested and, for now, partly unreferenced. Each one names in its own pull request
which later layer is obliged to call it. A module that nobody calls does not
protect anything, so the stack is not finished until the wiring layers land on
top of it.

## The three corrections this stack is built on

**"Passes the holder" and "limits every send" are different completion
conditions.** #4608 gave a combo child the budget object; #4609 gave the request a
policy. Neither makes a second combo target draw the remainder, because the
adapter's initial send still reads its own policy allowance. A layer that receives
the counter and does not consult it as a limit reintroduces the multiplier
silently.

**The permit is not yet atomic.** `reserveDispatch()` evaluates the remainder and
`permit.use()` charges it. Two legs that reserve concurrently against one
remaining send both receive a permit. The fix is to make the reservation the
charge and add an explicit release for an abandoned reservation, which is why wpa
has to land before anything else wires a new caller.

**A memory Map is not a budget.** The ledger lives in process memory, and cleanup
only protects roots with active requests, so an exhausted-but-idle root can be
deleted and recreated fresh under the same id. Until reservations are durable and
cleanup is exhaustion-aware, "this root is out of budget" means "out of budget
until something restarts".

## Verification posture

Unchanged from `070_delivery.md` and restated because it governs every layer here:
the local suite, typecheck, install and build are **not run**, by explicit
instruction. Pushes use `--no-verify`. The only proof is hosted CI at the exact
final head SHA of each branch, and a green run against an earlier commit is not
evidence for the head that merges. Each pull request states that posture in its
Verification section rather than implying a local green.

## What would make this fail

Landing wpe's refusal path with a default limit low enough to refuse an
unconfigured install. The count caps from #4614 are already live and permissive;
token accounting must start observational and only enforce behind explicit
operator configuration, or the first upgrade turns a cost guard into an outage.
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR
| `autoSwitchThreshold?` | `number` | `80` | Usage threshold for placing new/unbound work. `quota` can re-evaluate unbound tasks on their next request once usage crosses this threshold. Bound tasks keep their account past the threshold by default (`pool.cacheAffinity`); they leave only when that account is exhausted or otherwise cannot serve, and then only for an account with genuine quota headroom and strictly lower usage. Set `pool.cacheAffinity: false` to re-evaluate bound tasks at this threshold, still only onto such a destination. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. |
| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request. Bound tasks follow `pool.cacheAffinity` (on by default): they stay until the account is exhausted (known usage at 100%) or otherwise cannot serve, and then may rebind only to an account with genuine quota headroom and strictly lower usage. Set the flag `false` to proactively rebind a bound task at the threshold, still only onto such a destination. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. |
| `pool.cacheAffinity?` | `boolean` | `true` | Cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. On by default; omitting the key or setting `true` keeps a bound task on its account until that account genuinely cannot serve. Only an explicit `false` restores threshold-based rebinding of bound tasks. A live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — and then only to an account with genuine quota headroom and strictly lower usage. Under either setting, an account with unknown usage is never chosen as a destination for a bound task, so when every account sits above the threshold the task stays put. Affinity is a reordering, not a pin. |
| `pool.credentialGroups?` | `Array<{ id: string; credentials: string[]; note?: string }>` | `[]` | Accepted and validated, but not yet consumed by routing: declaring a group changes no routing decision until a consuming layer lands. Operator-declared quota domains: groups of credentials that demonstrably share one upstream usage limit. Members of one group count once toward available capacity, and a quota refusal inside a group is not answered by rotating to another member — the limit is the same, so the move would pay a cold prefix for zero new capacity. Declared groups speak only to quota; sharing a limit says nothing about prompt-cache compatibility, which is classified separately. Each member is written provider-qualified as `"<provider>:<credential-id>"`, because a credential id means something only inside its provider; the provider segment accepts the usual aliases (`chatgpt:` and `codex:` both mean OpenAI). Group ids must be unique, `credentials` must be non-empty, and a credential may appear in at most one group — an ambiguous declaration is rejected on write and dropped with a warning on load rather than resolved by whichever group is listed first, since that would merge two unrelated quota domains. A malformed list costs only the grouping: `pool.kernel` and `pool.cacheAffinity` are preserved. Absent or empty means no declared grouping, so an unconfigured install behaves exactly as before. |
| `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. |
| `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. |
| `upstreamHostCircuitThreshold?` | `number` | `0` | Opt-in circuit threshold for proven pre-connection DNS/TCP failures on native OpenAI forward Responses and compact sends. `0` disables it; `1`–`20` opens a 30-second provider-origin cooldown after that many terminal logical requests. While open, requests receive `503` with `Retry-After` before account selection or upstream send; after cooldown, one half-open request is admitted. Timeouts and HTTP responses never count, and any HTTP response closes the circuit. Applies only to Codex Pool routing with no pinned account; it is inert for `codexAccountMode: "direct"` and account-qualified selectors. |
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,7 @@
"routing-compatibility-boundaries.test.ts": "routing",
"routing-compatibility-model-matching.test.ts": "routing",
"routing-compatibility.test.ts": "routing",
"routing-identity-domains.test.ts": "routing",
"routing-intelligence-ui.test.ts": "gui",
"routing-policy-fallback.test.ts": "routing",
"routing-policy-pool-quota.test.ts": "routing",
Expand Down
Loading
Loading