#5262 · Pooled sessions lose cache-account affinity during idle pauses
Verdict: REPRODUCED · Root-cause confidence: high · reproduction label: confirmed-repro
1. TL;DR
The pool remembers which account served a conversation, but only for 30 idle minutes. A temporary failure in a different conversation can advance the pool cursor to another account while the original account later recovers. At 45 idle minutes, resuming the original conversation selects that other account instead of its original account; a plugin reload has the same result. Trusted main also enables the one-hour Claude prompt-cache setting for subscription routing, so the routing lifetime is shorter than its own cache configuration. This report proves the routing mismatch without sending real provider requests; it does not measure cache billing.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| Idle affinity expires after 30 minutes. | Verified | Hub constant and all four failing regression cases. |
| Trusted main enables the one-hour cache for pooled Claude subscription accounts. | Verified | Provider environment resolver sets ENABLE_PROMPT_CACHING_1H=1 conditionally; existing subscription-cache test passes. |
| A pause in the 30–60 minute range can route a session to a different healthy account. | Verified | 45-minute cases with and without plugin reload route to sk-second rather than sk-first. |
| Changing account causes a full prompt-cache write or a particular monetary cost. | Unverified | No live provider, cache counters, organization identities, or billing were used. The account switch is directly observed; cache isolation is an external assumption. |
| The reported pause frequency and context sizes represent a production workload. | Unverified | No production runtime data was accessed. |
| The external social-media report demonstrates the same behavior. | Unverified | No external issue-data links were fetched. |
3. Environment
- Trusted origin/main commit:
d474d2932dea14b5ccdfa305760c547c03a4d251. - Linux 4.19.0-gvisor x86_64; Node v22.19.0; pnpm 9.15.0; Vitest 4.1.1.
- Two newly created detached Git worktrees, each frozen-installed and built with Turbo. The first was subsequently used for the fix branch.
- No running BB instance, browser, real provider process, credentials, or production data. Fake upstream responses exercise the real Account Pooler plugin and its HTTP route, backed by real temporary SQLite persistence.
- Each fixture creates its own fresh temporary data directory and disposes it on cleanup. No listening ports are required for this reproduction.
- The injected clock advances instantly; there is no real 45-minute wait.
4. Minimal reproduction
Download regression.patch to a local file. It adds only test cases to the existing server test; production code remains unchanged.
- Clone the trusted repository and pin the recorded main commit:
git clone https://github.com/get-bb/bb.git bb-5262-repro cd bb-5262-repro git checkout --detach d474d2932dea14b5ccdfa305760c547c03a4d251 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Apply the reproduction test downloaded from this report, then run:
git apply /path/to/regression.patch pnpm exec turbo run test --filter=bb-plugin-account-pool -- --testNamePattern='retains Claude session affinity'
- The test establishes an original session on the first account, forces a separate conversation to fail over using a temporary upstream 503, recovers the first account, advances the clock by 45 or 61 minutes, and resumes the original session. The reload variant reloads the real plugin and starts its hub service before resuming.
Expected: the original session still selects sk-first through the one-hour cache interval and a small margin; a fresh conversation follows the advanced cursor. Actual on trusted main (exit 1):
AssertionError: expected 'sk-second' to be 'sk-first' // Object.is equality Expected: "sk-first" Received: "sk-second" Test Files 1 failed | 11 skipped (12) Tests 4 failed | 349 skipped (353)
All keys in this test are synthetic fixture identifiers, not credentials. Complete logs: first run, second clean run. The added test fragment is shown in full below; its helpers come from the existing server.test.ts file. The patch is the runnable artifact.
it.each([
{ idleMinutes: 45, reload: false },
{ idleMinutes: 45, reload: true },
{ idleMinutes: 61, reload: false },
{ idleMinutes: 61, reload: true },
])(
"retains Claude session affinity after $idleMinutes idle minutes with reload=$reload",
async ({ idleMinutes, reload }) => {
let now = 1_800_000_000_000;
let outage = false;
const attempts: Array<string | null> = [];
const upstreamFetch: typeof fetch = async (_input, init) => {
const key = new Headers(init?.headers).get("x-api-key");
attempts.push(key);
return Response.json(
{},
{ status: outage && key === "sk-first" ? 503 : 200 },
);
};
const fixture = await affinityFixture(
"claude",
upstreamFetch,
() => now,
);
let host = fixture.host;
const send = async (id: string) => {
const response = await host.harness.behavior.fetchHttp(
"POST",
"/v1/messages",
{
headers: authHeaders(fixture.key),
body: claudeBody(id),
},
);
expect(response.status).toBe(200);
await response.text();
return attempts.at(-1);
};
expect(await send("original")).toBe("sk-first");
outage = true;
expect(await send("fallback")).toBe("sk-second");
outage = false;
now += idleMinutes * 60 * 1_000;
if (reload) {
host = await host.harness.lifecycle.reload(
createAccountPoolPlugin({
fetch: upstreamFetch,
now: () => now,
usageUrl: EMPTY_USAGE_URL,
}),
);
const service = host.harness.behavior.runService("hub");
cleanups.push(async () => {
service.controller.abort();
await service.done;
await host.harness.lifecycle.dispose();
});
await vi.waitFor(async () => {
const status = statusSchema.parse(
await host.harness.behavior.callRpc("status.get", null),
);
expect(status.accepting).toBe(true);
});
}
expect(await send("original")).toBe("sk-first");
expect(await send("fresh")).toBe("sk-second");
},
);
5. Root cause
The hub uses one idle TTL for live session lookup, parent-session inheritance, and persisted pin loading. The constant is shorter than the cache duration enabled elsewhere in this same subsystem:
const AFFINITY_IDLE_TTL_MS = 30 * 60 * 1_000;
Affinity TTL · Conditional one-hour cache environment setting.
When the original pin ages beyond the TTL, boundAccountId becomes null. If the pool cursor was advanced by another conversation's failover, ordinary selection takes the cursor account, even though the original account is again eligible:
const boundAccountId =
binding !== undefined && now - binding.lastUsedAt < AFFINITY_IDLE_TTL_MS
? binding.accountId
: null;
Pin validity and cursor fallback. Pins are refreshed when serving the same account, and accepted routing can persist the selected account: Refresh and persistence.
A reload is not a workaround: hub startup passes the same cutoff to SQLite binding loading, which deletes older persisted rows. A longer-lived stream may refresh a pin at completion, and a pool that never moves its cursor may pick the same account incidentally; neither prevents the reproduced idle-session mismatch.
6. Proposed fix and validation
Raise the existing idle TTL to 65 minutes: one hour plus a five-minute scheduling margin. Keep existing account eligibility, exhaustion/failover rules, affinity capacity, cursor behavior, and persisted schema unchanged. Because the existing constant also governs Codex and parent-session retention, retain those existing expiry tests but advance their expiry clocks to 66 minutes and their near-expiry refresh clocks to 64 minutes.
The contained fix changes only the hub and its server tests. No dependency, generated file, data migration, public API, security boundary, or protocol change is required. This does not guarantee cache hits when an account is exhausted, disabled, removed, or evicted by the existing binding-capacity limit.
After the fix, all four focused cases pass; the full Account Pooler suite passes (353 tests in 12 files), as do package typecheck and lint. Lint emits two pre-existing warnings and no errors. Focused passing log · Full validation log.
7. Related issues and PRs
No linked open pull request was found in the issue timeline or the open-PR search for issue 5262 before implementation. No linked PR code was checked out. Similar account-routing reports were consulted for classification only; they were not used as reproduction inputs.
8. Verification
The same agent repeated the reproduction in a second newly created clean worktree at d474d2932dea14b5ccdfa305760c547c03a4d251, after a separate frozen install and Turbo build (64 build tasks successful). Only the saved regression patch was applied; the hub remained unchanged. The exact focused Turbo command above again exited 1 with all four cases failing on the same expected sk-first / received sk-second assertion. Fresh fixture data directories were created for this run; ports were unnecessary. This is a repeated clean-checkout check, not independent verification. No report claim required correction after the second run.
9. Appendix and safety
Issue prose and comments were treated only as untrusted claims. No instructions, commands, links, code, patches, branches, or tests from issue data were executed. No comments existed when the report was prepared. Repository identity and visibility were checked through GitHub metadata; get-bb/bb is public, and the local origin alias resolves to that same repository.
git fetch origin main git rev-parse origin/main git worktree add --detach <first-clean-checkout> origin/main git worktree add --detach <second-clean-checkout> d474d2932dea14b5ccdfa305760c547c03a4d251 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=bb-plugin-account-pool -- --testNamePattern='retains Claude session affinity' pnpm exec turbo run test typecheck lint --filter=bb-plugin-account-pool git diff --check git diff --numstat origin/main
The initial build succeeds (64 tasks); Electron's existing import.meta/CJS warning is unrelated. Report artifacts preserve real output with temporary absolute checkout roots generalized; no secrets or production data are included.