Skip to content

feat(routing): sticky-balanced placement, session reset, and cachekeep sustain - #82

Closed
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:feat/sticky-balanced-routing
Closed

feat(routing): sticky-balanced placement, session reset, and cachekeep sustain#82
iceteaSA wants to merge 1 commit into
cortexkit:mainfrom
iceteaSA:feat/sticky-balanced-routing

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Stacked on #57. This branch is built on #57's tip, so the diff here currently shows two commits — #57's fix(routing): skip known-exhausted accounts at admission and this feature commit. Only the second is new work. GitHub won't let a fork branch be a PR base, hence the flat view. Once #57 merges this collapses to a single commit; happy to rebase on request. The dependency is real, not just ordering: this builds on #57's isQuotaExhausted / exhaustedQuotaResetAt and its candidate-selection code.

Why

Routing today is mode-driven: every request re-derives the same answer, so a session's account can change between turns. That is cheap when the prompt cache is cold and expensive when it is not — moving a large session to a different account re-uploads its whole context at write price. The sibling anthropic-auth plugin measured that at 450–560K tokens of cache-write per mid-session move, which is the number this design is built around.

What

sticky-balanced routing mode. Decides once, at cold start, then stops deciding.

  • Placement picks the account with the least projected pressure — sustainable spend rate (spendable / hoursUntilReset), not raw remaining percent. An account resetting in twenty minutes outranks one with more headroom but days to go.
  • Capacity is the minimum across present windows: an account is only as usable as its tightest binding window.
  • A stale or missing quota snapshot is excluded, not assumed healthy. If every candidate is excluded, selection fails open to the configured mode order and the wire stays authoritative.
  • Concurrent processes converge with no coordination beyond state they already share: assignments resolve under the existing sidebar lock, and each account carries the pending bytes of sessions already placed on it, so a second process sees the first one's load and disperses. Ordering is fully deterministic — no jitter — which is what makes the in-lock recheck idempotent.
  • A placed session moves only on confirmed exhaustion or permanent auth failure. Never for load. A replacement that cannot be resolved leaves the existing pin intact rather than stranding the session.
  • Session ids are persisted as SHA-256 hashes with a 7-day TTL; the sidebar state file and directory are now 0600/0700.

/openai-routing reset clears the current session's pin and nothing else. Quota, backoff, killswitch and cachekeep state are account-health facts — asking to re-route a session asserts nothing about them. It does not force a different account; the next request may legitimately pick the same one.

/openai-cachekeep sustain on|off keeps main-agent sessions warm past the idle cap. Subagents keep every existing bound (30-min idle, 75-min gpt-5.6, 2-warm cap) — warming a short-lived session forever buys a cache nothing returns to. Sustain bypasses idle reclamation only; ceilings, eviction and any clock window still apply. Defaults off.

Two decisions worth flagging for review

No hold/Retry-After branch. The sibling plugin holds a session for ≤15 min when a short window is about to reset, rather than paying a cold start. That depends on the host honouring Retry-After. Checked here: retry-after appears zero times across every @ai-sdk/provider-utils version on this box (4.0.21→5.0.12), and the retry policy is maxRetries: 2, initialDelayInMs: 2000, backoffFactor: 2 with response headers never consulted. A 15-minute hold would surface as a visible failure after ~6s. So exhaustion migrates immediately. Worth revisiting if Codex reintroduces a short (5h-class) window.

Migration after a response is limited to statuses that did not serve. The request that exhausts an account returns 200 OK with headers showing 100% used. An earlier revision classified that as migrate and re-sent the turn, cancelling the successful response — billing the same turn twice. Caught in review; the quota push from that 200 is what steers the next request elsewhere.

Subagent pins are independent. opencode sets x-session-affinity to each request's own session id, so subagents get their own pins and may land on different accounts than their parent — which is what keeps a parent's warm cache from being depleted by its children. Their serving account is therefore no longer mirrored into the parent's sidebar entry under this mode (it still is under the other two).

Naming

sustain, not always — the sibling plugin already ships /claude-cachekeep always for the clock-window axis. Ours is the idle-cap axis. Both READMEs document the split so one word doesn't mean two things across sibling plugins.

Testing

833 pass / 0 fail (from a 750 baseline — 83 new tests). Build, typecheck and biome clean; full suite run 3× with no transient failures.

Reviewed per task by cross-family reviewers (gemini-3.1-pro, MiniMax-M3, deepseek-v4-pro), plus a dual review on the behaviour-changing commit and a final adversarial whole-branch pass. Every new test was checked by per-hunk reverse-apply — that found three tests that looked like coverage but gated nothing (one passing via IEEE-754 semantics rather than the guard under test, one exercising pre-existing transport plumbing, one whose idle bound pruned before eviction could run); all three were rewritten to gate.

main-first and fallback-first are unchanged.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Adds sticky-balanced routing to opencode to pin sessions to the least‑pressured account and cut cache churn, plus a per-session pin reset and a main-only cachekeep sustain toggle. Also threads sessionId through RPC/TUI and records the serving account in request dumps.

  • New Features

    • Sticky-balanced placement: picks by sustainable rate and tightest present window; excludes stale (>15m) or missing snapshots; no mid-session rebalance. Pins are per-session (SHA‑256, 7‑day TTL), independent for subagents, and migrate only on confirmed exhaustion, permanent auth failure, or killswitch. Killswitched accounts are excluded from placement and fail-open; if all are killed, return the same 429 as ordered modes.
    • Commands/UI/RPC: /openai-routing reset clears only this session’s pin. /openai-cachekeep sustain on|off bypasses the main agent’s idle warm cap and toggles at runtime. TUI shows a compact pin row when usable. RPC apply carries sessionId so actions target the correct session. Request dumps record the internal serving account id (ChatGPT account id stays redacted).
  • Bug Fixes

    • Admission skips confirmed‑exhausted accounts; never replays a served turn (200 responses push quota to steer the next turn).
    • Quota cache binds snapshots to the ChatGPT identity; policy reads drop snapshots from a different identity after re‑login, and a genuine re‑login clears mid‑stream rate‑limit marks.
    • Sidebar routing prefers the observed serving account, then the session pin, then mode order; pin pruning no longer deletes fallback pins on an unknown roster and still respects an authoritative empty roster, age expiry, and explicit removals.
    • Fixed fail‑open credit priority to use resetCreditsAvailable correctly.

Written for commit 132c0b9. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 25 files

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread packages/opencode/src/sidebar-state.ts Outdated
Comment thread packages/opencode/src/index.ts Outdated
Comment thread packages/opencode/src/core/quota-manager.ts Outdated
Comment thread packages/opencode/src/sidebar-state.ts Outdated
Comment thread packages/opencode/src/tests/integration.test.ts
Comment thread packages/opencode/src/core/sticky-routing.ts Outdated
Comment thread packages/opencode/src/tests/sidebar-state.test.ts Outdated
@iceteaSA
iceteaSA force-pushed the feat/sticky-balanced-routing branch 3 times, most recently from 4f8e0e4 to 9ce70e0 Compare August 10, 2026 13:52
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Pushed 9ce70e0 — live testing found a real bug, and reviewing the fix found a second one. 860 pass / 0 fail (from 833).

The bug: a degraded config read silently deleted routing state.

pruneStickyAssignments drops any pin whose account is absent from the roster, which is correct when the roster is authoritative. But several call sites passed accounts ?? [] from a read that can fail, and [] was ambiguous — it meant both "no fallbacks are configured" (prune fallback pins) and "I could not read the config" (must not prune). On a transient read failure the roster collapsed to {main} and every fallback-pinned session lost its placement.

Fixed at the type level rather than per call site: the roster now expresses unknown (undefined) separately from empty, and unknown skips the account-membership test while age expiry and explicit removal still apply. Guarding each call site would have left the API able to express the dangerous case, so a fourth site added later would reintroduce it. The machine-state writer already did the right thing (if (!store) return); this brings the rest in line.

Found while live-testing, and worth stating plainly: I couldn't tell what had happened. A pin count dropped to zero and nothing in the log explained it. That gap is now closed on both sides — pruning logs what it removed and why, placement and migration log at debug with hashed session keys, and requests record which account served them. Under main-first/fallback-first the serving account was inferable from mode plus quota state; with per-session pins it is not. Retaining a healthy pin stays silent, since that is the common path and a log there would emit a line per request.

The sidebar was displaying routing that contradicted reality. Its resolver never consulted pins, so a session whose display entry was missing fell through to the mode default and reported main while actually pinned elsewhere. It now prefers the observed serving account, then the session's own pin, then the previous mode-driven answer — main-first/fallback-first unchanged — and under sticky-balanced it shows which account a session is pinned to, since that is the question the mode creates. Request dumps carry the account too; the wire identity stays redacted everywhere.

On verification. An independent reviewer ran per-hunk mutation proofs and found that one section — the debug logs above — reverted with the suite still green. Unverified code, and pointedly so: that logging exists because silent routing state cost real debugging time, and it shipped with nothing gating it.

Proving the retention-silence contract needed a different mechanic than the rest. Removing a log cannot fail a test asserting that no log occurs — it deepens the silence — so the guard was temporarily broadened until a retained pin logged, which reddened the test, then restored. Positive assertions used reverse-removal. Six sections, all now individually proven; the reviewer re-ran every mutation independently rather than accepting the reported evidence.

Live-verified on a real account set before this push: placement picked the predicted account (0.406 sustainable weight vs 0.117, with a 0%-remaining account correctly excluded rather than scored), the pin held across turns with input bytes growing monotonically, and a subagent received its own independent pin.

One known gap, deliberately deferred: StickyAssignment records the account slot but not the ChatGPT identity it was placed under, so re-logging into a different account under the same storage id leaves the pin pointing at a now-cold cache. That is a persisted-schema change and gets its own PR rather than being folded in here.

@ualtinok

Copy link
Copy Markdown
Contributor

Reviewed commit 9ce70e0c only, against main at v0.4.3. The design work here is strong — the in-lock recheck, deterministic ordering, the pin store bounds, the reset scope, and the sustain bounds all hold up under reading, and I agree with both decisions you flagged (no Retry-After hold, and migration limited to statuses that did not serve).

One blocker before merge.

The killswitch is not enforced on the sticky-balanced path.

Under main-first / fallback-first, an account below its configured killswitch floor is hard-blocked before spend (index.ts:2694, killswitchPassesPolicy). Under sticky-balanced the thresholds only ever appear as reservePercent placement weights (index.ts:1989, index.ts:2022), and the send path at index.ts:2544-2680 returns stickyResponse without ever consulting killswitchPassesPolicy. decideStickyBreak migrates on remainingPercent <= 0 (sticky-routing.ts:71), not on the floor.

Two concrete consequences with killswitch on:

  1. A retained pin keeps spending while remaining is above 0% but below the floor — exactly the range the killswitch exists to protect.
  2. When every candidate is under its floor, every weight is 0, the weighted set is empty, and the mode-fallback branch (sticky-routing.ts:210) selects a candidate anyway. The ordered modes would have synthesized a 429 instead.

Note the fallback roster does not cover this either: getUsableFallbackAccounts filters on quotaSnapshotPassesPolicy (the separate quota.minimumRemaining policy), not on killswitch thresholds, and main passes through unfiltered.

Requested change: treat a killswitch-failing account the same as exhaustion on the sticky path — exclude it from weighted placement and from the fail-open branch, migrate a retained pin off it, and when nothing passes, return the same blocked 429 the ordered modes produce (killswitchBlockedResponse). Please add a test with killswitch enabled and every account between 0% and the floor, asserting a 429 rather than a send.

Two smaller notes, non-blocking:

  • The fail-open branch orders by resetCreditsApplicable before configuredOrder, so it is not strictly the configured mode order. Intentional per your tests — worth a line in the README so the documented fallback behavior matches.
  • The routing command blurb still names only main-first and fallback-first (index.ts ~3016).

Please also rebase once #57 lands so the diff collapses to the single commit.

@iceteaSA
iceteaSA force-pushed the feat/sticky-balanced-routing branch from 9ce70e0 to d4c7a4f Compare August 10, 2026 18:17
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Blocker fixed in d4c7a4f — 875 pass / 0 fail (from 833). Your second consequence was the one I would have missed.

Killswitch on the sticky path. A killswitch-failing account is now excluded from weighted placement, a retained pin migrates off one that drops below its floor, and when nothing passes the request gets the shared killswitchBlockedResponse rather than a bespoke error. Inert when the killswitch is off.

The fail-open exclusion is the half worth calling out. Excluding killed accounts from placement is the obvious part; excluding them from the mode-fallback branch is what stops that branch becoming a way to spend on a killed account precisely when every account is killed — the failure only appears in the state where all weights are zero, which is exactly when the escape hatch fires. Tested both directions: all-below-floor returns a 429 without reaching the wire, and a separate test asserts killswitch-disabled placement and retention are unchanged. The second is what proves the default path was not quietly altered; its mutation is an inverse (apply the filter unconditionally), since reverse-removal cannot fail a test asserting nothing changed.

Your README request surfaced a real bug, and I want to flag it rather than let it ride.

You asked me to document that the fail-open branch orders by resetCreditsApplicable before configuredOrder. Writing that sentence made me check it, and the ordering never happened: the extractor at index.ts:1963 read .resetCreditsApplicable from a shape whose field is resetCreditsAvailable (core/accounts.ts:88). It always returned undefined, so the sort silently fell through to pure configuredOrder.

Its unit test set resetCreditsApplicable directly on the candidate, bypassing the extractor — green while the production wiring did nothing. Same shape as the :1723 defect you caught on #56: the pure function is tested, the wiring is not.

Fixed the key and added coverage through the real path (sidebar file → roster builder → sort), where configuredOrder and credit priority disagree so only correct wiring produces the right winner.

This is a behaviour change and you should decide whether you want it here. The fail-open pick now genuinely orders by credit priority. Blast radius is narrow — only when every quota is stale or unknown (weighted set empty) and two or more candidates carry non-zero credits. No existing expectation shifted; I checked every suite reference to resetCreditsAvailable, and they all sit on paths that never reach the sticky roster.

My reasoning for keeping it: shipping your README line without the fix would put a false claim in the docs in the PR where you asked for accuracy, and the fix is one key on one extractor with no signature or consumer changes. But it is your call — happy to split it into its own PR (extractor + test + README sentence, nothing else) if you would rather be able to revert one without the other.

Both smaller notes done: routing command blurb now lists sticky-balanced, and the README documents the fail-open ordering — accurately, now.

Will rebase once #57 lands so the diff collapses to a single commit.

@ualtinok

Copy link
Copy Markdown
Contributor

Verified the killswitch fix at d4c7a4fa. It is wired at all three points I asked for: the roster pre-resolves killswitchPasses per candidate using the non-invalidating peek (index.ts:2000, index.ts:2042), selectStickyCandidate excludes failing candidates from both weighted placement and the mode-fallback branch (sticky-routing.ts:199), and decideStickyBreak migrates a retained pin that has since fallen below floor (sticky-routing.ts:62). When nothing passes, choose returns undefined and the request falls through to the shared killswitchBlockedResponse rather than sending. Threading the pre-resolved boolean instead of re-reading policy inside the selector is the right call — it keeps the killswitch-disabled path byte-identical.

875 tests pass locally on the branch.

The remaining item is the stack. This still carries #57's commit as its base, and #57 is now ready to merge on my side. Once #57 lands, please rebase so this collapses to the single commit — I would rather not merge the same commit through two paths.

@iceteaSA

Copy link
Copy Markdown
Contributor Author

Understood on the stack — I would not want the same commit merged through two paths either.

Watching #57. The moment it lands I will rebase this onto the new main, drop its commit from the base, re-run the full gate, and force-push so this collapses to the single commit. I will confirm here with the resulting SHA and test count rather than leaving you to check.

No other outstanding work on my side: the killswitch fix and the reset-credit extractor are both in at d4c7a4f, 875 pass.

Still open for you: whether the reset-credit key fix stays here or splits into its own PR. It rides along because your README request is what made it load-bearing — documenting an ordering that never happened would have been a false claim — but it is a behaviour change in a PR you are reviewing for the killswitch, so the choice should be yours. If you want it split I will pull it out in the same pass as the rebase; otherwise I will leave it.

…p sustain

Routing re-derived the same answer on every request, so a session's
account could change between turns. That is cheap with a cold prompt
cache and expensive with a warm one - moving a large session re-uploads
its whole context at write price, measured on the sibling plugin at
450-560K cache-write tokens per move.

sticky-balanced decides once, at cold start, then stops deciding. It
picks the account with the least projected pressure - sustainable spend
rate, not raw remaining percent, so an account resetting in twenty
minutes outranks one with more headroom but days to go. Capacity is the
minimum across present windows, since an account is only as usable as
its tightest binding one. A stale or missing snapshot is excluded rather
than assumed healthy; if that leaves nothing, selection falls open to
the configured mode order and the wire stays authoritative.

The killswitch binds here as it does everywhere else. An account below
its threshold is excluded from weighted placement AND from the fail-open
branch, a retained pin migrates off one that drops below it, and when no
account passes the request gets the same 429 the ordered modes return
rather than a bespoke error. Without that last exclusion the fail-open
branch would have become a way to spend on a killed account precisely
when every account was killed. The whole path is inert when the
killswitch is off.

Concurrent processes converge without new coordination: assignments
resolve under the existing sidebar lock, and each account carries the
pending bytes of sessions already placed on it, so a second process sees
the first one's load. Ordering is fully deterministic, which is what
makes the in-lock recheck idempotent.

A placed session moves only on confirmed exhaustion or permanent auth
failure, never for load. There is no hold-for-reset branch: that would
depend on the host honouring Retry-After, and @ai-sdk/provider-utils
ignores it entirely - a hold would surface as a visible failure after
about six seconds. Migration after a response is limited to statuses
that did not serve, because the request that exhausts an account returns
200 with headers showing 100% used; treating that as a migration
cancelled a successful response and billed the turn twice.

The fail-open branch's credit priority also needed repair to be worth
documenting: it read resetCreditsApplicable from a shape that carries
resetCreditsAvailable, so the sort silently never fired. Its unit test
set the field directly on the candidate and passed while the wiring did
nothing, so the behaviour is now covered through the real path.

The roster a pin is pruned against can be unknown. A degraded config
read produced an empty account list, indistinguishable from a roster
that genuinely has no fallbacks, so a transient failure silently
deleted every fallback-pinned placement. Unknown now skips the
account-membership test; an authoritatively empty roster still prunes,
since main-only is a real state. Age expiry and explicit removal are
unaffected either way.

Routing state also changed silently. Pruning, placement and migration
now log at debug with hashed session keys - retaining a healthy pin
stays silent, since that is the common path - and requests record which
account served them. Under the older modes that was inferable from mode
plus quota; with per-session pins it is not.

The sidebar showed routing that contradicted reality: its resolver never
consulted pins, so a session whose display entry was missing reported
main while pinned elsewhere. It now prefers the observed serving
account, then the session's own pin, then the previous mode-driven
answer, and shows which account a session is pinned to.

/openai-routing reset clears the calling session's pin and nothing else.
Quota, backoff and killswitch state are account-health facts; asking to
re-route a session asserts nothing about them.

/openai-cachekeep sustain keeps main-agent sessions warm past the idle
cap. Subagents keep every existing bound, since warming a short-lived
session forever buys a cache nothing returns to. It is named sustain
rather than always because the sibling plugin already ships
/claude-cachekeep always for the clock-window axis.

Session ids are stored as SHA-256 hashes with a seven-day TTL, capped
with least-recently-seen eviction, and the state file and directory are
private. main-first and fallback-first are unchanged.
@iceteaSA
iceteaSA force-pushed the feat/sticky-balanced-routing branch from d4c7a4f to 132c0b9 Compare August 10, 2026 21:42
ualtinok added a commit that referenced this pull request Aug 10, 2026
feat(routing): sticky-balanced placement, session reset, and cachekeep sustain

Rebased onto the revised #57 so the admission identity rule and the
replayability gate survive; freshestQuotaSnapshot keeps both windows'
timestamps as the freshness key.
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 132c0b9 — single commit, CI clean, 878 pass / 0 fail (up from 875, since #57's suite is now in the base).

The rebase was not clean, and the conflict was worth the care: this branch was cut before your #57 fixes, and it had refactored the same code into a freshestQuotaSnapshot helper carrying the pre-fix semantics. Taking its side would have silently reverted both blockers you just merged, while looking like a tidy restructure.

Resolved as sticky structure with main's semantics:

  • Identity gate is fileAccountId === currentAccountId, not the undefined || form. In the diff it appears as one + and one - — the line moves into the helper, it is not reintroduced.
  • Both-window freshness kept. This is the part that would have gone missing quietly: the shared snapshotCheckedAt in core/sticky-routing.ts read primary and snapshot only, so keeping the helper structure drops secondary without any conflict marker mentioning it. Extended snapshotCheckedAt itself rather than adding a second both-window helper in index.ts, so there is one freshness key rather than two definitions that can drift. Every other caller checked first — decideStickyBreak, candidateWeight, selectStickyCandidate all use it for staleness or recency, where a fresh secondary is strictly a better signal than none. Added a test for the secondary-only path.

Also confirmed present after the rebase rather than assuming: isReplayableRequest gating quotaBlocksMain, mainAccountId stamping in the sidebar machine state, and accountId on fallback quota entries.

Nothing else outstanding from me. The reset-credit key fix is still in this commit — say the word if you would rather have it split and I will pull it into its own PR.

@ualtinok

Copy link
Copy Markdown
Contributor

Integrated into main as af400b2 (merge b628540). Rebased onto the revised #57 locally so the admission identity rule and the replayability gate survive: the stacked base carried the pre-revision versions of both, and freshestQuotaSnapshot now keeps both windows' timestamps as its freshness key. All 875 branch tests plus the integrated suite pass. Thanks — the pre-resolved killswitchPasses threading was a better shape than what I asked for.

@ualtinok ualtinok closed this Aug 10, 2026
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.

2 participants