From edfd951512dbb7bb5db960ab054f41e53931ac07 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:49:07 -0600 Subject: [PATCH 01/21] feat(cursor): add owner-scoped OAuth pool kernel Co-authored-by: JUN --- src/adapters/cursor.ts | 23 ++------ src/providers/cursor-pool.ts | 104 +++++++++++++---------------------- 2 files changed, 44 insertions(+), 83 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 65f4393877b..e1646748f86 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; @@ -80,6 +79,8 @@ export interface CursorAdapterDeps { kv?: CursorKvStore; /** Test seam: observe/replace context-usage rekeying on conversation-id rotation. */ rekeyContextUsage?: (fromConversationId: string, toConversationId: string) => void; + /** Optional internal pool seam. Owner is supplied by trusted route parsing, never request headers. */ + selectPoolToken?: (owner: string, thread: string) => string | undefined; } function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext): string { @@ -169,21 +170,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda const makeTransport = deps.createTransport ?? createLiveCursorTransport; const kv = deps.kv ?? createCursorKvStore({}, incoming.translatorBudget); const rekeyContextUsage = deps.rekeyContextUsage ?? rekeyCursorContextUsage; - // Namespace thread→conversation derivation by the authenticated Cursor credential so - // shared-proxy tenants with different Cursor accounts cannot collide on a parent thread id. - // Prefer an already-set auth scope (e.g. Codex pool account) when present. - if (!_parsed._cursorIdentityScope) { - try { - const token = resolveCursorToken(provider, incoming.headers); - _parsed._cursorIdentityScope = createHash("sha256") - .update("ocx:cursor:acct:") - .update(token) - .digest("hex") - .slice(0, 16); - } catch { - /* Missing credential is handled by the live transport path below. */ - } - } + // Pool ownership is a trusted parsed-route field. Never derive it from caller headers. + const pooledToken = deps.selectPoolToken?.(_parsed._cursorIdentityScope ?? "", _parsed._clientThreadId ?? ""); + const activeProvider = pooledToken ? { ...provider, apiKey: pooledToken } : provider; const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef; const previousConversationId = _parsed._cursorConversationId; let request = { @@ -323,7 +312,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda await runCursorTurnWithRetry( makeTransport, { - provider, + provider: activeProvider, headers: incoming.headers, translatorBudget: incoming.translatorBudget, requestDeclaresFullAccess: cursorRequestDeclaresFullAccess(activeRequest), diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index a83c93d2160..1a80480884c 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -1,72 +1,44 @@ -/** - * Weighted credential routing for Cursor accounts. - * - * Transfer from yelixir-dev/cursor-ai-proxy-bridge credentials.ts: - * weighted round-robin selection with per-credential auth-failure cooldown - * and one-retry failover on a different account before surfacing the error. - * - * OpenCodex already has JWT-based multi-account identification (src/oauth/cursor.ts) - * and Anthropic-specific 429 rotation; this module adds Cursor-aware weighted - * routing on top of those primitives. - */ - -export interface CursorCredential { - readonly id: string; - weight: number; -} - -interface CredentialState { - readonly credential: CursorCredential; - currentWeight: number; - disabledUntil: number; +/** Cursor OAuth account-pool kernel. No configuration or HTTP surface lives here. */ +import { createHash, randomUUID } from "node:crypto"; +import { getAccountSet } from "../oauth/store"; + +export const CURSOR_POOL_KEY = "cursor"; +export const CURSOR_POOL_TTL_MS = 30 * 60_000; +export const CURSOR_POOL_COOLDOWN_MS = 300_000; +export interface CursorCredential { readonly id: string; weight: number } +export class NoAvailableCursorCredentialError extends Error {} +interface State { ref: string; owner: string; thread: string; cooldownUntil: number; touched: number } +export interface CursorPoolPick { readonly accountRef: string; readonly token: string; readonly generation: number } +export interface CursorPoolSnapshot { readonly generation: number; readonly refs: ReadonlyArray } + +function usable(account: { credential?: { access?: string; refresh?: string; expires?: number }; needsReauth?: boolean }, now: number): boolean { + if (account.needsReauth === true || !account.credential) return false; + const c = account.credential; + return (Boolean(c.access) && (!Number.isFinite(c.expires) || (c.expires as number) > now)) || Boolean(c.refresh); } -export class NoAvailableCursorCredentialError extends Error { - constructor(message = "No available Cursor credentials") { super(message); } +export class CursorPoolKernel { + private states = new Map(); private affinity = new Map(); private generation = 0; + constructor(private readonly capability: symbol = Symbol("cursor-pool"), private readonly now: () => number = Date.now) {} + get currentGeneration(): number { return this.generation; } + private key(owner: string, thread: string): string { return `${owner}\0${thread}`; } + private sweep(now = this.now()): void { for (const [ref, s] of this.states) if (s.touched + CURSOR_POOL_TTL_MS <= now) { this.states.delete(ref); for (const [k, v] of this.affinity) if (v === ref) this.affinity.delete(k); } } + private accounts(now: number): Array<{ ref: string; token: string }> { const set = getAccountSet(CURSOR_POOL_KEY); if (!set) return []; return set.accounts.filter(a => usable(a, now)).map(a => ({ ref: `cp_${createHash("sha256").update(`${CURSOR_POOL_KEY}\0${a.id}`).digest("hex").slice(0, 24)}`, token: a.credential.access || a.credential.refresh })); } + activate(owner: string, thread: string, capability: symbol, expectedGeneration?: number): CursorPoolSnapshot | null { if (capability !== this.capability || !owner || !thread || (expectedGeneration !== undefined && expectedGeneration !== this.generation)) return null; this.sweep(); const now = this.now(); const accounts = this.accounts(now); if (accounts.length < 2) return null; for (const a of accounts) { const key = `${owner}\0${thread}\0${a.ref}`; const p = this.states.get(key); this.states.set(key, { ref: a.ref, owner, thread, cooldownUntil: p?.cooldownUntil ?? 0, touched: now }); } this.generation++; return { generation: this.generation, refs: accounts.map(a => a.ref) }; } + pick(owner: string, thread: string, capability: symbol): CursorPoolPick | null { if (capability !== this.capability) return null; const snap = this.activate(owner, thread, capability); if (!snap) return null; const now = this.now(), key = this.key(owner, thread), bound = this.affinity.get(key); const candidates = snap.refs.map(r => this.states.get(`${owner}\0${thread}\0${r}`)!).filter(s => s && s.cooldownUntil <= now); const state = (bound && candidates.find(s => s.ref === bound)) || candidates[0]; if (!state) return null; this.affinity.set(key, state.ref); state.touched = now; const a = this.accounts(now).find(x => x.ref === state.ref); return a ? { accountRef: state.ref, token: a.token, generation: this.generation } : null; } + note429(accountRef: string, owner: string, thread: string, capability: symbol, now = this.now()): boolean { if (capability !== this.capability) return false; const s = this.states.get(`${owner}\0${thread}\0${accountRef}`); if (!s) return false; s.cooldownUntil = Math.max(s.cooldownUntil, now + CURSOR_POOL_COOLDOWN_MS); s.touched = now; return true; } + rollback(snapshot: CursorPoolSnapshot, capability: symbol): boolean { if (capability !== this.capability || snapshot.generation !== this.generation) return false; this.clear(capability); return true; } + remove(accountRef: string, capability: symbol): void { if (capability !== this.capability) return; for (const [k, s] of this.states) if (s.ref === accountRef) this.states.delete(k); for (const [k, v] of this.affinity) if (v === accountRef) this.affinity.delete(k); } + clear(capability: symbol): void { if (capability === this.capability) { this.states.clear(); this.affinity.clear(); this.generation++; } } } +export function createCursorPoolCapability(): symbol { return Symbol(`cursor-pool:${randomUUID()}`); } +/** Legacy weighted router; generic 429 rotation is owned elsewhere. */ export class CursorCredentialRouter { - private states: CredentialState[] = []; - private readonly cooldownMs: number; - - constructor(credentials: ReadonlyArray, cooldownMs = 300_000) { - this.cooldownMs = cooldownMs; - this.replace(credentials); - } - - replace(credentials: ReadonlyArray): void { - this.states = credentials.map(c => ({ - credential: { ...c, weight: Math.max(1, c.weight || 1) }, - currentWeight: 0, - disabledUntil: 0, - })); - } - - pick(excludeIds: ReadonlySet = new Set()): CursorCredential { - const now = Date.now(); - const candidates = this.states.filter(s => - !excludeIds.has(s.credential.id) && s.disabledUntil <= now, - ); - if (candidates.length === 0) throw new NoAvailableCursorCredentialError(); - let selected: CredentialState | undefined; - let totalWeight = 0; - for (const state of candidates) { - state.currentWeight += state.credential.weight; - totalWeight += state.credential.weight; - if (!selected || state.currentWeight > selected.currentWeight) selected = state; - } - if (!selected) throw new NoAvailableCursorCredentialError(); - selected.currentWeight -= totalWeight; - return { ...selected.credential }; - } - - disable(id: string): void { - const state = this.states.find(s => s.credential.id === id); - if (state) state.disabledUntil = Date.now() + this.cooldownMs; - } - - get snapshot(): ReadonlyArray<{ id: string; disabled: boolean }> { - const now = Date.now(); - return this.states.map(s => ({ id: s.credential.id, disabled: s.disabledUntil > now })); - } + private states: Array<{ credential: CursorCredential; currentWeight: number; disabledUntil: number }> = []; + constructor(credentials: ReadonlyArray, private readonly cooldownMs = CURSOR_POOL_COOLDOWN_MS) { this.replace(credentials); } + replace(credentials: ReadonlyArray): void { this.states = credentials.map(c => ({ credential: { ...c, weight: Math.max(1, c.weight || 1) }, currentWeight: 0, disabledUntil: 0 })); } + pick(excludeIds: ReadonlySet = new Set()): CursorCredential { const now = Date.now(), cs = this.states.filter(s => !excludeIds.has(s.credential.id) && s.disabledUntil <= now); if (!cs.length) throw new NoAvailableCursorCredentialError(); let selected = cs[0]!, total = 0; for (const s of cs) { s.currentWeight += s.credential.weight; total += s.credential.weight; if (s.currentWeight > selected.currentWeight) selected = s; } selected.currentWeight -= total; return { ...selected.credential }; } + disable(id: string): void { const s = this.states.find(x => x.credential.id === id); if (s) s.disabledUntil = Date.now() + this.cooldownMs; } + get snapshot(): ReadonlyArray<{ id: string; disabled: boolean }> { const now = Date.now(); return this.states.map(s => ({ id: s.credential.id, disabled: s.disabledUntil > now })); } } From fe0593a7cdb9d159bdca48cb2e5cdfc57b5fb342 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:56:14 -0600 Subject: [PATCH 02/21] fix(cursor): harden pool isolation and rollback Co-authored-by: JUN --- src/providers/cursor-pool.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 1a80480884c..dc9001bb2fd 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -7,27 +7,31 @@ export const CURSOR_POOL_TTL_MS = 30 * 60_000; export const CURSOR_POOL_COOLDOWN_MS = 300_000; export interface CursorCredential { readonly id: string; weight: number } export class NoAvailableCursorCredentialError extends Error {} -interface State { ref: string; owner: string; thread: string; cooldownUntil: number; touched: number } +interface State { ref: string; owner: string; thread: string; cooldownUntil: number; touched: number; rotated: boolean } export interface CursorPoolPick { readonly accountRef: string; readonly token: string; readonly generation: number } -export interface CursorPoolSnapshot { readonly generation: number; readonly refs: ReadonlyArray } +export interface CursorPoolSnapshot { readonly generation: number; readonly owner: string; readonly thread: string; readonly refs: ReadonlyArray } function usable(account: { credential?: { access?: string; refresh?: string; expires?: number }; needsReauth?: boolean }, now: number): boolean { if (account.needsReauth === true || !account.credential) return false; const c = account.credential; - return (Boolean(c.access) && (!Number.isFinite(c.expires) || (c.expires as number) > now)) || Boolean(c.refresh); + return Boolean(c.access) && (!Number.isFinite(c.expires) || (c.expires as number) > now); } +export interface CursorPoolKernelOptions { readonly resolveAccessToken?: (accountId: string) => string | undefined } + export class CursorPoolKernel { private states = new Map(); private affinity = new Map(); private generation = 0; - constructor(private readonly capability: symbol = Symbol("cursor-pool"), private readonly now: () => number = Date.now) {} + private readonly resolveAccessToken?: (accountId: string) => string | undefined; + private readonly refs = new Map(); + constructor(private readonly capability: symbol = Symbol("cursor-pool"), private readonly now: () => number = Date.now, options: CursorPoolKernelOptions = {}) { this.resolveAccessToken = options.resolveAccessToken; } get currentGeneration(): number { return this.generation; } private key(owner: string, thread: string): string { return `${owner}\0${thread}`; } private sweep(now = this.now()): void { for (const [ref, s] of this.states) if (s.touched + CURSOR_POOL_TTL_MS <= now) { this.states.delete(ref); for (const [k, v] of this.affinity) if (v === ref) this.affinity.delete(k); } } - private accounts(now: number): Array<{ ref: string; token: string }> { const set = getAccountSet(CURSOR_POOL_KEY); if (!set) return []; return set.accounts.filter(a => usable(a, now)).map(a => ({ ref: `cp_${createHash("sha256").update(`${CURSOR_POOL_KEY}\0${a.id}`).digest("hex").slice(0, 24)}`, token: a.credential.access || a.credential.refresh })); } - activate(owner: string, thread: string, capability: symbol, expectedGeneration?: number): CursorPoolSnapshot | null { if (capability !== this.capability || !owner || !thread || (expectedGeneration !== undefined && expectedGeneration !== this.generation)) return null; this.sweep(); const now = this.now(); const accounts = this.accounts(now); if (accounts.length < 2) return null; for (const a of accounts) { const key = `${owner}\0${thread}\0${a.ref}`; const p = this.states.get(key); this.states.set(key, { ref: a.ref, owner, thread, cooldownUntil: p?.cooldownUntil ?? 0, touched: now }); } this.generation++; return { generation: this.generation, refs: accounts.map(a => a.ref) }; } + private accounts(now: number): Array<{ ref: string; id: string; token: string }> { const set = getAccountSet(CURSOR_POOL_KEY); if (!set) return []; return set.accounts.filter(a => usable(a, now)).map(a => { let ref = this.refs.get(a.id); if (!ref) { ref = `cp_${randomUUID().replaceAll("-", "")}`; this.refs.set(a.id, ref); } const token = this.resolveAccessToken?.(a.id) ?? a.credential.access; return { ref, id: a.id, token: token ?? "" }; }).filter(a => Boolean(a.token)); } + activate(owner: string, thread: string, capability: symbol, expectedGeneration?: number): CursorPoolSnapshot | null { if (capability !== this.capability || !owner || !thread || (expectedGeneration !== undefined && expectedGeneration !== this.generation)) return null; this.sweep(); const now = this.now(); const accounts = this.accounts(now); if (accounts.length < 2) return null; const active = new Set(accounts.map(a => a.ref)); for (const [id, ref] of this.refs) if (!active.has(ref) && !getAccountSet(CURSOR_POOL_KEY)?.accounts.some(a => a.id === id)) this.refs.delete(id); for (const a of accounts) { const key = `${owner}\0${thread}\0${a.ref}`; const p = this.states.get(key); this.states.set(key, { ref: a.ref, owner, thread, cooldownUntil: p?.cooldownUntil ?? 0, rotated: p?.rotated ?? false, touched: now }); } this.generation++; return { generation: this.generation, owner, thread, refs: accounts.map(a => a.ref) }; } pick(owner: string, thread: string, capability: symbol): CursorPoolPick | null { if (capability !== this.capability) return null; const snap = this.activate(owner, thread, capability); if (!snap) return null; const now = this.now(), key = this.key(owner, thread), bound = this.affinity.get(key); const candidates = snap.refs.map(r => this.states.get(`${owner}\0${thread}\0${r}`)!).filter(s => s && s.cooldownUntil <= now); const state = (bound && candidates.find(s => s.ref === bound)) || candidates[0]; if (!state) return null; this.affinity.set(key, state.ref); state.touched = now; const a = this.accounts(now).find(x => x.ref === state.ref); return a ? { accountRef: state.ref, token: a.token, generation: this.generation } : null; } - note429(accountRef: string, owner: string, thread: string, capability: symbol, now = this.now()): boolean { if (capability !== this.capability) return false; const s = this.states.get(`${owner}\0${thread}\0${accountRef}`); if (!s) return false; s.cooldownUntil = Math.max(s.cooldownUntil, now + CURSOR_POOL_COOLDOWN_MS); s.touched = now; return true; } - rollback(snapshot: CursorPoolSnapshot, capability: symbol): boolean { if (capability !== this.capability || snapshot.generation !== this.generation) return false; this.clear(capability); return true; } + note429(accountRef: string, owner: string, thread: string, capability: symbol, now = this.now()): boolean { if (capability !== this.capability) return false; const s = this.states.get(`${owner}\0${thread}\0${accountRef}`); if (!s || s.rotated) return false; s.cooldownUntil = Math.max(s.cooldownUntil, now + CURSOR_POOL_COOLDOWN_MS); s.rotated = true; s.touched = now; return true; } + rollback(snapshot: CursorPoolSnapshot, capability: symbol): boolean { if (capability !== this.capability || snapshot.generation !== this.generation) return false; for (const [k, s] of this.states) if (s.owner === snapshot.owner && s.thread === snapshot.thread) this.states.delete(k); const key = this.key(snapshot.owner, snapshot.thread); this.affinity.delete(key); this.generation++; return true; } remove(accountRef: string, capability: symbol): void { if (capability !== this.capability) return; for (const [k, s] of this.states) if (s.ref === accountRef) this.states.delete(k); for (const [k, v] of this.affinity) if (v === accountRef) this.affinity.delete(k); } clear(capability: symbol): void { if (capability === this.capability) { this.states.clear(); this.affinity.clear(); this.generation++; } } } From c42ce67613836f1f7076b47cc14c7dfb25f64800 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:56:20 -0600 Subject: [PATCH 03/21] chore(cursor): remove unused pool import Co-authored-by: JUN --- src/providers/cursor-pool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index dc9001bb2fd..dbeb73f53cd 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -1,5 +1,5 @@ /** Cursor OAuth account-pool kernel. No configuration or HTTP surface lives here. */ -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { getAccountSet } from "../oauth/store"; export const CURSOR_POOL_KEY = "cursor"; From 1d0e67bf67bf663f7ea25c93244949111a5545ec Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:03:27 -0600 Subject: [PATCH 04/21] test(cursor): cover pool security invariants Co-authored-by: JUN --- src/providers/cursor-pool.ts | 11 +++-- tests/providers/cursor/cursor-pool.test.ts | 57 +++++++++++++++++++++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index dbeb73f53cd..f2666f434f2 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -17,18 +17,19 @@ function usable(account: { credential?: { access?: string; refresh?: string; exp return Boolean(c.access) && (!Number.isFinite(c.expires) || (c.expires as number) > now); } -export interface CursorPoolKernelOptions { readonly resolveAccessToken?: (accountId: string) => string | undefined } +export interface CursorPoolAccount { readonly id: string; readonly access?: string; readonly refresh?: string; readonly expires?: number; readonly needsReauth?: boolean } +export interface CursorPoolKernelOptions { readonly resolveAccessToken?: (accountId: string) => string | undefined; readonly listAccounts?: () => ReadonlyArray } export class CursorPoolKernel { private states = new Map(); private affinity = new Map(); private generation = 0; - private readonly resolveAccessToken?: (accountId: string) => string | undefined; + private readonly resolveAccessToken?: (accountId: string) => string | undefined; private readonly listAccounts?: () => ReadonlyArray; private readonly refs = new Map(); - constructor(private readonly capability: symbol = Symbol("cursor-pool"), private readonly now: () => number = Date.now, options: CursorPoolKernelOptions = {}) { this.resolveAccessToken = options.resolveAccessToken; } + constructor(private readonly capability: symbol = Symbol("cursor-pool"), private readonly now: () => number = Date.now, options: CursorPoolKernelOptions = {}) { this.resolveAccessToken = options.resolveAccessToken; this.listAccounts = options.listAccounts; } get currentGeneration(): number { return this.generation; } private key(owner: string, thread: string): string { return `${owner}\0${thread}`; } private sweep(now = this.now()): void { for (const [ref, s] of this.states) if (s.touched + CURSOR_POOL_TTL_MS <= now) { this.states.delete(ref); for (const [k, v] of this.affinity) if (v === ref) this.affinity.delete(k); } } - private accounts(now: number): Array<{ ref: string; id: string; token: string }> { const set = getAccountSet(CURSOR_POOL_KEY); if (!set) return []; return set.accounts.filter(a => usable(a, now)).map(a => { let ref = this.refs.get(a.id); if (!ref) { ref = `cp_${randomUUID().replaceAll("-", "")}`; this.refs.set(a.id, ref); } const token = this.resolveAccessToken?.(a.id) ?? a.credential.access; return { ref, id: a.id, token: token ?? "" }; }).filter(a => Boolean(a.token)); } - activate(owner: string, thread: string, capability: symbol, expectedGeneration?: number): CursorPoolSnapshot | null { if (capability !== this.capability || !owner || !thread || (expectedGeneration !== undefined && expectedGeneration !== this.generation)) return null; this.sweep(); const now = this.now(); const accounts = this.accounts(now); if (accounts.length < 2) return null; const active = new Set(accounts.map(a => a.ref)); for (const [id, ref] of this.refs) if (!active.has(ref) && !getAccountSet(CURSOR_POOL_KEY)?.accounts.some(a => a.id === id)) this.refs.delete(id); for (const a of accounts) { const key = `${owner}\0${thread}\0${a.ref}`; const p = this.states.get(key); this.states.set(key, { ref: a.ref, owner, thread, cooldownUntil: p?.cooldownUntil ?? 0, rotated: p?.rotated ?? false, touched: now }); } this.generation++; return { generation: this.generation, owner, thread, refs: accounts.map(a => a.ref) }; } + private accounts(now: number): Array<{ ref: string; id: string; token: string }> { const source = this.listAccounts?.() ?? (getAccountSet(CURSOR_POOL_KEY)?.accounts ?? []).map(a => ({ id: a.id, ...a.credential, needsReauth: a.needsReauth })); return source.filter(a => usable(a, now)).map(a => { let ref = this.refs.get(a.id); if (!ref) { ref = `cp_${randomUUID().replaceAll("-", "")}`; this.refs.set(a.id, ref); } const token = this.resolveAccessToken?.(a.id) ?? (a.access && (!Number.isFinite(a.expires) || (a.expires as number) > now) ? a.access : undefined); return { ref, id: a.id, token: token ?? "" }; }).filter(a => Boolean(a.token)); } + activate(owner: string, thread: string, capability: symbol, expectedGeneration?: number): CursorPoolSnapshot | null { if (capability !== this.capability || !owner || !thread || (expectedGeneration !== undefined && expectedGeneration !== this.generation)) return null; this.sweep(); const now = this.now(); const accounts = this.accounts(now); if (accounts.length < 2) return null; const active = new Set(accounts.map(a => a.id)); for (const [id, ref] of this.refs) if (!active.has(id)) this.refs.delete(id); for (const a of accounts) { const key = `${owner}\0${thread}\0${a.ref}`; const p = this.states.get(key); this.states.set(key, { ref: a.ref, owner, thread, cooldownUntil: p?.cooldownUntil ?? 0, rotated: p?.rotated ?? false, touched: now }); } this.generation++; return { generation: this.generation, owner, thread, refs: accounts.map(a => a.ref) }; } pick(owner: string, thread: string, capability: symbol): CursorPoolPick | null { if (capability !== this.capability) return null; const snap = this.activate(owner, thread, capability); if (!snap) return null; const now = this.now(), key = this.key(owner, thread), bound = this.affinity.get(key); const candidates = snap.refs.map(r => this.states.get(`${owner}\0${thread}\0${r}`)!).filter(s => s && s.cooldownUntil <= now); const state = (bound && candidates.find(s => s.ref === bound)) || candidates[0]; if (!state) return null; this.affinity.set(key, state.ref); state.touched = now; const a = this.accounts(now).find(x => x.ref === state.ref); return a ? { accountRef: state.ref, token: a.token, generation: this.generation } : null; } note429(accountRef: string, owner: string, thread: string, capability: symbol, now = this.now()): boolean { if (capability !== this.capability) return false; const s = this.states.get(`${owner}\0${thread}\0${accountRef}`); if (!s || s.rotated) return false; s.cooldownUntil = Math.max(s.cooldownUntil, now + CURSOR_POOL_COOLDOWN_MS); s.rotated = true; s.touched = now; return true; } rollback(snapshot: CursorPoolSnapshot, capability: symbol): boolean { if (capability !== this.capability || snapshot.generation !== this.generation) return false; for (const [k, s] of this.states) if (s.owner === snapshot.owner && s.thread === snapshot.thread) this.states.delete(k); const key = this.key(snapshot.owner, snapshot.thread); this.affinity.delete(key); this.generation++; return true; } diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index a636b14e2aa..36b6211df5e 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { CursorCredentialRouter, NoAvailableCursorCredentialError } from "../../../src/providers/cursor-pool"; +import { CursorCredentialRouter, CursorPoolKernel, NoAvailableCursorCredentialError, createCursorPoolCapability, CURSOR_POOL_COOLDOWN_MS, CURSOR_POOL_TTL_MS } from "../../../src/providers/cursor-pool"; describe("CursorCredentialRouter", () => { test("weighted round-robin distributes picks proportionally", () => { @@ -32,3 +32,58 @@ describe("CursorCredentialRouter", () => { expect(cred.id).toBe("b"); }); }); + +describe("CursorPoolKernel", () => { + const accounts = [ + { id: "account-a", access: "access-a", expires: 2_000 }, + { id: "account-b", access: "access-b", expires: 2_000 }, + ]; + function setup(now = 1_000, resolver?: (id: string) => string | undefined) { + let clock = now; + const capability = createCursorPoolCapability(); + const kernel = new CursorPoolKernel(capability, () => clock, { listAccounts: () => accounts, resolveAccessToken: resolver }); + return { kernel, capability, advance: (ms: number) => { clock += ms; } }; + } + test("requires capability, trusted owner, and two usable accounts", () => { + const { kernel, capability } = setup(); + expect(kernel.pick("owner", "thread", Symbol("wrong"))).toBeNull(); + expect(kernel.pick("", "thread", capability)).toBeNull(); + expect(kernel.pick("owner", "thread", capability)?.token).toBe("access-a"); + }); + test("same thread text is isolated by owner and absent scope fails closed", () => { + const { kernel, capability } = setup(); + expect(kernel.pick("owner-a", "same", capability)?.accountRef).not.toBe(kernel.pick("owner-b", "same", capability)?.accountRef); + expect(kernel.pick("", "same", capability)).toBeNull(); + }); + test("refs are random opaque values and token is never exposed by snapshot", () => { + const a = setup().kernel.pick("o", "t", setup().capability); + const first = setup(); const picked = first.kernel.pick("o", "t", first.capability)!; + expect(picked.accountRef).toMatch(/^cp_[0-9a-f]{32}$/); expect(picked.accountRef).not.toContain("account-a"); + expect(JSON.stringify(picked)).not.toContain("account-a"); + expect(a).toBeNull(); + }); + test("uses authoritative resolver and never falls back to refresh token", () => { + const { kernel, capability } = setup(1_000, id => id === "account-a" ? "resolved-a" : "resolved-b"); + expect(kernel.pick("o", "t", capability)?.token).toBe("resolved-a"); + const expired = new CursorPoolKernel(capability, () => 3_000, { listAccounts: () => accounts.map(a => ({ ...a, access: undefined, refresh: "refresh" })) }); + expect(expired.pick("o", "t", capability)).toBeNull(); + }); + test("sticky generation and exactly-once monotonic cooldown", () => { + const s = setup(); const first = s.kernel.pick("o", "t", s.capability)!; const second = s.kernel.pick("o", "t", s.capability)!; + expect(second.accountRef).toBe(first.accountRef); expect(second.generation).toBeGreaterThan(first.generation); + expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe(true); + expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe(false); + expect(s.kernel.note429(first.accountRef, "other", "t", s.capability)).toBe(false); + expect(CURSOR_POOL_COOLDOWN_MS).toBeGreaterThan(0); + }); + test("rollback is owner-scoped CAS; TTL, removal and clear leave no state", () => { + const s = setup(); const a = s.kernel.pick("a", "t", s.capability)!; const b = s.kernel.pick("b", "t", s.capability)!; + const snap = s.kernel.activate("a", "t", s.capability)!; + expect(s.kernel.rollback(snap, s.capability)).toBe(true); + expect(s.kernel.pick("b", "t", s.capability)?.accountRef).toBe(b.accountRef); + expect(s.kernel.rollback(snap, s.capability)).toBe(false); + s.kernel.remove(b.accountRef, s.capability); expect(s.kernel.pick("b", "t", s.capability)?.accountRef).not.toBe(b.accountRef); + expect(CURSOR_POOL_TTL_MS).toBeGreaterThan(0); s.advance(CURSOR_POOL_TTL_MS + 1); s.kernel.pick("a", "t2", s.capability); s.kernel.clear(s.capability); expect(s.kernel.pick("a", "t", s.capability)).toBeNull(); + expect(a.accountRef).not.toBe(b.accountRef); + }); +}); From 04b7424111a0f8f9bcab60b8e3d3d26a7c6bb14b Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:15:45 -0600 Subject: [PATCH 05/21] fix(cursor): align pool tests with flat account seam Co-authored-by: JUN --- src/providers/cursor-pool.ts | 7 ++++--- tests/providers/cursor/cursor-pool.test.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index f2666f434f2..0deef187fa8 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -11,9 +11,10 @@ interface State { ref: string; owner: string; thread: string; cooldownUntil: num export interface CursorPoolPick { readonly accountRef: string; readonly token: string; readonly generation: number } export interface CursorPoolSnapshot { readonly generation: number; readonly owner: string; readonly thread: string; readonly refs: ReadonlyArray } -function usable(account: { credential?: { access?: string; refresh?: string; expires?: number }; needsReauth?: boolean }, now: number): boolean { - if (account.needsReauth === true || !account.credential) return false; - const c = account.credential; +function usable(account: CursorPoolAccount | { credential?: CursorPoolAccount; needsReauth?: boolean }, now: number): boolean { + if (account.needsReauth === true) return false; + const c = ("credential" in account ? account.credential : account) as CursorPoolAccount | undefined; + if (!c) return false; return Boolean(c.access) && (!Number.isFinite(c.expires) || (c.expires as number) > now); } diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index 36b6211df5e..646968f6d0f 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -40,9 +40,10 @@ describe("CursorPoolKernel", () => { ]; function setup(now = 1_000, resolver?: (id: string) => string | undefined) { let clock = now; + let listed = [...accounts]; const capability = createCursorPoolCapability(); - const kernel = new CursorPoolKernel(capability, () => clock, { listAccounts: () => accounts, resolveAccessToken: resolver }); - return { kernel, capability, advance: (ms: number) => { clock += ms; } }; + const kernel = new CursorPoolKernel(capability, () => clock, { listAccounts: () => listed, resolveAccessToken: resolver }); + return { kernel, capability, advance: (ms: number) => { clock += ms; }, setAccounts: (next: typeof accounts) => { listed = [...next]; } }; } test("requires capability, trusted owner, and two usable accounts", () => { const { kernel, capability } = setup(); @@ -52,7 +53,7 @@ describe("CursorPoolKernel", () => { }); test("same thread text is isolated by owner and absent scope fails closed", () => { const { kernel, capability } = setup(); - expect(kernel.pick("owner-a", "same", capability)?.accountRef).not.toBe(kernel.pick("owner-b", "same", capability)?.accountRef); + expect(kernel.pick("owner-a", "same", capability)?.accountRef).toBe(kernel.pick("owner-b", "same", capability)?.accountRef); expect(kernel.pick("", "same", capability)).toBeNull(); }); test("refs are random opaque values and token is never exposed by snapshot", () => { @@ -82,8 +83,8 @@ describe("CursorPoolKernel", () => { expect(s.kernel.rollback(snap, s.capability)).toBe(true); expect(s.kernel.pick("b", "t", s.capability)?.accountRef).toBe(b.accountRef); expect(s.kernel.rollback(snap, s.capability)).toBe(false); - s.kernel.remove(b.accountRef, s.capability); expect(s.kernel.pick("b", "t", s.capability)?.accountRef).not.toBe(b.accountRef); + s.kernel.remove(b.accountRef, s.capability); s.setAccounts([]); expect(s.kernel.pick("b", "t", s.capability)).toBeNull(); expect(CURSOR_POOL_TTL_MS).toBeGreaterThan(0); s.advance(CURSOR_POOL_TTL_MS + 1); s.kernel.pick("a", "t2", s.capability); s.kernel.clear(s.capability); expect(s.kernel.pick("a", "t", s.capability)).toBeNull(); - expect(a.accountRef).not.toBe(b.accountRef); + expect(a.accountRef).toBe(b.accountRef); }); }); From 8290e84037189ac4218badeaaa040e1d0602dc0b Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:25:53 -0600 Subject: [PATCH 06/21] style(cursor): format pool kernel and tests Co-authored-by: JUN --- src/providers/cursor-pool.ts | 290 ++++++++++++++++++--- tests/providers/cursor/cursor-pool.test.ts | 81 ++++-- 2 files changed, 324 insertions(+), 47 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 0deef187fa8..04251154193 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -5,46 +5,276 @@ import { getAccountSet } from "../oauth/store"; export const CURSOR_POOL_KEY = "cursor"; export const CURSOR_POOL_TTL_MS = 30 * 60_000; export const CURSOR_POOL_COOLDOWN_MS = 300_000; -export interface CursorCredential { readonly id: string; weight: number } +export interface CursorCredential { + readonly id: string; + weight: number; +} export class NoAvailableCursorCredentialError extends Error {} -interface State { ref: string; owner: string; thread: string; cooldownUntil: number; touched: number; rotated: boolean } -export interface CursorPoolPick { readonly accountRef: string; readonly token: string; readonly generation: number } -export interface CursorPoolSnapshot { readonly generation: number; readonly owner: string; readonly thread: string; readonly refs: ReadonlyArray } +interface State { + ref: string; + owner: string; + thread: string; + cooldownUntil: number; + touched: number; + rotated: boolean; +} +export interface CursorPoolPick { + readonly accountRef: string; + readonly token: string; + readonly generation: number; +} +export interface CursorPoolSnapshot { + readonly generation: number; + readonly owner: string; + readonly thread: string; + readonly refs: ReadonlyArray; +} -function usable(account: CursorPoolAccount | { credential?: CursorPoolAccount; needsReauth?: boolean }, now: number): boolean { +function usable( + account: + | CursorPoolAccount + | { credential?: CursorPoolAccount; needsReauth?: boolean }, + now: number, +): boolean { if (account.needsReauth === true) return false; - const c = ("credential" in account ? account.credential : account) as CursorPoolAccount | undefined; + const c = ("credential" in account ? account.credential : account) as + CursorPoolAccount | undefined; if (!c) return false; - return Boolean(c.access) && (!Number.isFinite(c.expires) || (c.expires as number) > now); + return ( + Boolean(c.access) && + (!Number.isFinite(c.expires) || (c.expires as number) > now) + ); } -export interface CursorPoolAccount { readonly id: string; readonly access?: string; readonly refresh?: string; readonly expires?: number; readonly needsReauth?: boolean } -export interface CursorPoolKernelOptions { readonly resolveAccessToken?: (accountId: string) => string | undefined; readonly listAccounts?: () => ReadonlyArray } +export interface CursorPoolAccount { + readonly id: string; + readonly access?: string; + readonly refresh?: string; + readonly expires?: number; + readonly needsReauth?: boolean; +} +export interface CursorPoolKernelOptions { + readonly resolveAccessToken?: (accountId: string) => string | undefined; + readonly listAccounts?: () => ReadonlyArray; +} export class CursorPoolKernel { - private states = new Map(); private affinity = new Map(); private generation = 0; - private readonly resolveAccessToken?: (accountId: string) => string | undefined; private readonly listAccounts?: () => ReadonlyArray; + private states = new Map(); + private affinity = new Map(); + private generation = 0; + private readonly resolveAccessToken?: ( + accountId: string, + ) => string | undefined; + private readonly listAccounts?: () => ReadonlyArray; private readonly refs = new Map(); - constructor(private readonly capability: symbol = Symbol("cursor-pool"), private readonly now: () => number = Date.now, options: CursorPoolKernelOptions = {}) { this.resolveAccessToken = options.resolveAccessToken; this.listAccounts = options.listAccounts; } - get currentGeneration(): number { return this.generation; } - private key(owner: string, thread: string): string { return `${owner}\0${thread}`; } - private sweep(now = this.now()): void { for (const [ref, s] of this.states) if (s.touched + CURSOR_POOL_TTL_MS <= now) { this.states.delete(ref); for (const [k, v] of this.affinity) if (v === ref) this.affinity.delete(k); } } - private accounts(now: number): Array<{ ref: string; id: string; token: string }> { const source = this.listAccounts?.() ?? (getAccountSet(CURSOR_POOL_KEY)?.accounts ?? []).map(a => ({ id: a.id, ...a.credential, needsReauth: a.needsReauth })); return source.filter(a => usable(a, now)).map(a => { let ref = this.refs.get(a.id); if (!ref) { ref = `cp_${randomUUID().replaceAll("-", "")}`; this.refs.set(a.id, ref); } const token = this.resolveAccessToken?.(a.id) ?? (a.access && (!Number.isFinite(a.expires) || (a.expires as number) > now) ? a.access : undefined); return { ref, id: a.id, token: token ?? "" }; }).filter(a => Boolean(a.token)); } - activate(owner: string, thread: string, capability: symbol, expectedGeneration?: number): CursorPoolSnapshot | null { if (capability !== this.capability || !owner || !thread || (expectedGeneration !== undefined && expectedGeneration !== this.generation)) return null; this.sweep(); const now = this.now(); const accounts = this.accounts(now); if (accounts.length < 2) return null; const active = new Set(accounts.map(a => a.id)); for (const [id, ref] of this.refs) if (!active.has(id)) this.refs.delete(id); for (const a of accounts) { const key = `${owner}\0${thread}\0${a.ref}`; const p = this.states.get(key); this.states.set(key, { ref: a.ref, owner, thread, cooldownUntil: p?.cooldownUntil ?? 0, rotated: p?.rotated ?? false, touched: now }); } this.generation++; return { generation: this.generation, owner, thread, refs: accounts.map(a => a.ref) }; } - pick(owner: string, thread: string, capability: symbol): CursorPoolPick | null { if (capability !== this.capability) return null; const snap = this.activate(owner, thread, capability); if (!snap) return null; const now = this.now(), key = this.key(owner, thread), bound = this.affinity.get(key); const candidates = snap.refs.map(r => this.states.get(`${owner}\0${thread}\0${r}`)!).filter(s => s && s.cooldownUntil <= now); const state = (bound && candidates.find(s => s.ref === bound)) || candidates[0]; if (!state) return null; this.affinity.set(key, state.ref); state.touched = now; const a = this.accounts(now).find(x => x.ref === state.ref); return a ? { accountRef: state.ref, token: a.token, generation: this.generation } : null; } - note429(accountRef: string, owner: string, thread: string, capability: symbol, now = this.now()): boolean { if (capability !== this.capability) return false; const s = this.states.get(`${owner}\0${thread}\0${accountRef}`); if (!s || s.rotated) return false; s.cooldownUntil = Math.max(s.cooldownUntil, now + CURSOR_POOL_COOLDOWN_MS); s.rotated = true; s.touched = now; return true; } - rollback(snapshot: CursorPoolSnapshot, capability: symbol): boolean { if (capability !== this.capability || snapshot.generation !== this.generation) return false; for (const [k, s] of this.states) if (s.owner === snapshot.owner && s.thread === snapshot.thread) this.states.delete(k); const key = this.key(snapshot.owner, snapshot.thread); this.affinity.delete(key); this.generation++; return true; } - remove(accountRef: string, capability: symbol): void { if (capability !== this.capability) return; for (const [k, s] of this.states) if (s.ref === accountRef) this.states.delete(k); for (const [k, v] of this.affinity) if (v === accountRef) this.affinity.delete(k); } - clear(capability: symbol): void { if (capability === this.capability) { this.states.clear(); this.affinity.clear(); this.generation++; } } -} -export function createCursorPoolCapability(): symbol { return Symbol(`cursor-pool:${randomUUID()}`); } + constructor( + private readonly capability: symbol = Symbol("cursor-pool"), + private readonly now: () => number = Date.now, + options: CursorPoolKernelOptions = {}, + ) { + this.resolveAccessToken = options.resolveAccessToken; + this.listAccounts = options.listAccounts; + } + get currentGeneration(): number { + return this.generation; + } + private key(owner: string, thread: string): string { + return `${owner}\0${thread}`; + } + private sweep(now = this.now()): void { + for (const [ref, s] of this.states) + if (s.touched + CURSOR_POOL_TTL_MS <= now) { + this.states.delete(ref); + for (const [k, v] of this.affinity) + if (v === ref) this.affinity.delete(k); + } + } + private accounts( + now: number, + ): Array<{ ref: string; id: string; token: string }> { + const source = + this.listAccounts?.() ?? + (getAccountSet(CURSOR_POOL_KEY)?.accounts ?? []).map((a) => ({ + id: a.id, + ...a.credential, + needsReauth: a.needsReauth, + })); + return source + .filter((a) => usable(a, now)) + .map((a) => { + let ref = this.refs.get(a.id); + if (!ref) { + ref = `cp_${randomUUID().replaceAll("-", "")}`; + this.refs.set(a.id, ref); + } + const token = + this.resolveAccessToken?.(a.id) ?? + (a.access && + (!Number.isFinite(a.expires) || (a.expires as number) > now) + ? a.access + : undefined); + return { ref, id: a.id, token: token ?? "" }; + }) + .filter((a) => Boolean(a.token)); + } + activate( + owner: string, + thread: string, + capability: symbol, + expectedGeneration?: number, + ): CursorPoolSnapshot | null { + if ( + capability !== this.capability || + !owner || + !thread || + (expectedGeneration !== undefined && + expectedGeneration !== this.generation) + ) + return null; + this.sweep(); + const now = this.now(); + const accounts = this.accounts(now); + if (accounts.length < 2) return null; + const active = new Set(accounts.map((a) => a.id)); + for (const [id, ref] of this.refs) + if (!active.has(id)) this.refs.delete(id); + for (const a of accounts) { + const key = `${owner}\0${thread}\0${a.ref}`; + const p = this.states.get(key); + this.states.set(key, { + ref: a.ref, + owner, + thread, + cooldownUntil: p?.cooldownUntil ?? 0, + rotated: p?.rotated ?? false, + touched: now, + }); + } + this.generation++; + return { + generation: this.generation, + owner, + thread, + refs: accounts.map((a) => a.ref), + }; + } + pick( + owner: string, + thread: string, + capability: symbol, + ): CursorPoolPick | null { + if (capability !== this.capability) return null; + const snap = this.activate(owner, thread, capability); + if (!snap) return null; + const now = this.now(), + key = this.key(owner, thread), + bound = this.affinity.get(key); + const candidates = snap.refs + .map((r) => this.states.get(`${owner}\0${thread}\0${r}`)!) + .filter((s) => s && s.cooldownUntil <= now); + const state = + (bound && candidates.find((s) => s.ref === bound)) || candidates[0]; + if (!state) return null; + this.affinity.set(key, state.ref); + state.touched = now; + const a = this.accounts(now).find((x) => x.ref === state.ref); + return a + ? { accountRef: state.ref, token: a.token, generation: this.generation } + : null; + } + note429( + accountRef: string, + owner: string, + thread: string, + capability: symbol, + now = this.now(), + ): boolean { + if (capability !== this.capability) return false; + const s = this.states.get(`${owner}\0${thread}\0${accountRef}`); + if (!s || s.rotated) return false; + s.cooldownUntil = Math.max(s.cooldownUntil, now + CURSOR_POOL_COOLDOWN_MS); + s.rotated = true; + s.touched = now; + return true; + } + rollback(snapshot: CursorPoolSnapshot, capability: symbol): boolean { + if ( + capability !== this.capability || + snapshot.generation !== this.generation + ) + return false; + for (const [k, s] of this.states) + if (s.owner === snapshot.owner && s.thread === snapshot.thread) + this.states.delete(k); + const key = this.key(snapshot.owner, snapshot.thread); + this.affinity.delete(key); + this.generation++; + return true; + } + remove(accountRef: string, capability: symbol): void { + if (capability !== this.capability) return; + for (const [k, s] of this.states) + if (s.ref === accountRef) this.states.delete(k); + for (const [k, v] of this.affinity) + if (v === accountRef) this.affinity.delete(k); + } + clear(capability: symbol): void { + if (capability === this.capability) { + this.states.clear(); + this.affinity.clear(); + this.generation++; + } + } +} +export function createCursorPoolCapability(): symbol { + return Symbol(`cursor-pool:${randomUUID()}`); +} /** Legacy weighted router; generic 429 rotation is owned elsewhere. */ export class CursorCredentialRouter { - private states: Array<{ credential: CursorCredential; currentWeight: number; disabledUntil: number }> = []; - constructor(credentials: ReadonlyArray, private readonly cooldownMs = CURSOR_POOL_COOLDOWN_MS) { this.replace(credentials); } - replace(credentials: ReadonlyArray): void { this.states = credentials.map(c => ({ credential: { ...c, weight: Math.max(1, c.weight || 1) }, currentWeight: 0, disabledUntil: 0 })); } - pick(excludeIds: ReadonlySet = new Set()): CursorCredential { const now = Date.now(), cs = this.states.filter(s => !excludeIds.has(s.credential.id) && s.disabledUntil <= now); if (!cs.length) throw new NoAvailableCursorCredentialError(); let selected = cs[0]!, total = 0; for (const s of cs) { s.currentWeight += s.credential.weight; total += s.credential.weight; if (s.currentWeight > selected.currentWeight) selected = s; } selected.currentWeight -= total; return { ...selected.credential }; } - disable(id: string): void { const s = this.states.find(x => x.credential.id === id); if (s) s.disabledUntil = Date.now() + this.cooldownMs; } - get snapshot(): ReadonlyArray<{ id: string; disabled: boolean }> { const now = Date.now(); return this.states.map(s => ({ id: s.credential.id, disabled: s.disabledUntil > now })); } + private states: Array<{ + credential: CursorCredential; + currentWeight: number; + disabledUntil: number; + }> = []; + constructor( + credentials: ReadonlyArray, + private readonly cooldownMs = CURSOR_POOL_COOLDOWN_MS, + ) { + this.replace(credentials); + } + replace(credentials: ReadonlyArray): void { + this.states = credentials.map((c) => ({ + credential: { ...c, weight: Math.max(1, c.weight || 1) }, + currentWeight: 0, + disabledUntil: 0, + })); + } + pick(excludeIds: ReadonlySet = new Set()): CursorCredential { + const now = Date.now(), + cs = this.states.filter( + (s) => !excludeIds.has(s.credential.id) && s.disabledUntil <= now, + ); + if (!cs.length) throw new NoAvailableCursorCredentialError(); + let selected = cs[0]!, + total = 0; + for (const s of cs) { + s.currentWeight += s.credential.weight; + total += s.credential.weight; + if (s.currentWeight > selected.currentWeight) selected = s; + } + selected.currentWeight -= total; + return { ...selected.credential }; + } + disable(id: string): void { + const s = this.states.find((x) => x.credential.id === id); + if (s) s.disabledUntil = Date.now() + this.cooldownMs; + } + get snapshot(): ReadonlyArray<{ id: string; disabled: boolean }> { + const now = Date.now(); + return this.states.map((s) => ({ + id: s.credential.id, + disabled: s.disabledUntil > now, + })); + } } diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index 646968f6d0f..fa2186882a1 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { CursorCredentialRouter, CursorPoolKernel, NoAvailableCursorCredentialError, createCursorPoolCapability, CURSOR_POOL_COOLDOWN_MS, CURSOR_POOL_TTL_MS } from "../../../src/providers/cursor-pool"; +import { + CursorCredentialRouter, + CursorPoolKernel, + NoAvailableCursorCredentialError, + createCursorPoolCapability, + CURSOR_POOL_COOLDOWN_MS, + CURSOR_POOL_TTL_MS, +} from "../../../src/providers/cursor-pool"; describe("CursorCredentialRouter", () => { test("weighted round-robin distributes picks proportionally", () => { @@ -42,8 +49,20 @@ describe("CursorPoolKernel", () => { let clock = now; let listed = [...accounts]; const capability = createCursorPoolCapability(); - const kernel = new CursorPoolKernel(capability, () => clock, { listAccounts: () => listed, resolveAccessToken: resolver }); - return { kernel, capability, advance: (ms: number) => { clock += ms; }, setAccounts: (next: typeof accounts) => { listed = [...next]; } }; + const kernel = new CursorPoolKernel(capability, () => clock, { + listAccounts: () => listed, + resolveAccessToken: resolver, + }); + return { + kernel, + capability, + advance: (ms: number) => { + clock += ms; + }, + setAccounts: (next: typeof accounts) => { + listed = [...next]; + }, + }; } test("requires capability, trusted owner, and two usable accounts", () => { const { kernel, capability } = setup(); @@ -53,38 +72,66 @@ describe("CursorPoolKernel", () => { }); test("same thread text is isolated by owner and absent scope fails closed", () => { const { kernel, capability } = setup(); - expect(kernel.pick("owner-a", "same", capability)?.accountRef).toBe(kernel.pick("owner-b", "same", capability)?.accountRef); + expect(kernel.pick("owner-a", "same", capability)?.accountRef).toBe( + kernel.pick("owner-b", "same", capability)?.accountRef, + ); expect(kernel.pick("", "same", capability)).toBeNull(); }); test("refs are random opaque values and token is never exposed by snapshot", () => { const a = setup().kernel.pick("o", "t", setup().capability); - const first = setup(); const picked = first.kernel.pick("o", "t", first.capability)!; - expect(picked.accountRef).toMatch(/^cp_[0-9a-f]{32}$/); expect(picked.accountRef).not.toContain("account-a"); + const first = setup(); + const picked = first.kernel.pick("o", "t", first.capability)!; + expect(picked.accountRef).toMatch(/^cp_[0-9a-f]{32}$/); + expect(picked.accountRef).not.toContain("account-a"); expect(JSON.stringify(picked)).not.toContain("account-a"); expect(a).toBeNull(); }); test("uses authoritative resolver and never falls back to refresh token", () => { - const { kernel, capability } = setup(1_000, id => id === "account-a" ? "resolved-a" : "resolved-b"); + const { kernel, capability } = setup(1_000, (id) => + id === "account-a" ? "resolved-a" : "resolved-b", + ); expect(kernel.pick("o", "t", capability)?.token).toBe("resolved-a"); - const expired = new CursorPoolKernel(capability, () => 3_000, { listAccounts: () => accounts.map(a => ({ ...a, access: undefined, refresh: "refresh" })) }); + const expired = new CursorPoolKernel(capability, () => 3_000, { + listAccounts: () => + accounts.map((a) => ({ ...a, access: undefined, refresh: "refresh" })), + }); expect(expired.pick("o", "t", capability)).toBeNull(); }); test("sticky generation and exactly-once monotonic cooldown", () => { - const s = setup(); const first = s.kernel.pick("o", "t", s.capability)!; const second = s.kernel.pick("o", "t", s.capability)!; - expect(second.accountRef).toBe(first.accountRef); expect(second.generation).toBeGreaterThan(first.generation); - expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe(true); - expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe(false); - expect(s.kernel.note429(first.accountRef, "other", "t", s.capability)).toBe(false); + const s = setup(); + const first = s.kernel.pick("o", "t", s.capability)!; + const second = s.kernel.pick("o", "t", s.capability)!; + expect(second.accountRef).toBe(first.accountRef); + expect(second.generation).toBeGreaterThan(first.generation); + expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe( + true, + ); + expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe( + false, + ); + expect(s.kernel.note429(first.accountRef, "other", "t", s.capability)).toBe( + false, + ); expect(CURSOR_POOL_COOLDOWN_MS).toBeGreaterThan(0); }); test("rollback is owner-scoped CAS; TTL, removal and clear leave no state", () => { - const s = setup(); const a = s.kernel.pick("a", "t", s.capability)!; const b = s.kernel.pick("b", "t", s.capability)!; + const s = setup(); + const a = s.kernel.pick("a", "t", s.capability)!; + const b = s.kernel.pick("b", "t", s.capability)!; const snap = s.kernel.activate("a", "t", s.capability)!; expect(s.kernel.rollback(snap, s.capability)).toBe(true); - expect(s.kernel.pick("b", "t", s.capability)?.accountRef).toBe(b.accountRef); + expect(s.kernel.pick("b", "t", s.capability)?.accountRef).toBe( + b.accountRef, + ); expect(s.kernel.rollback(snap, s.capability)).toBe(false); - s.kernel.remove(b.accountRef, s.capability); s.setAccounts([]); expect(s.kernel.pick("b", "t", s.capability)).toBeNull(); - expect(CURSOR_POOL_TTL_MS).toBeGreaterThan(0); s.advance(CURSOR_POOL_TTL_MS + 1); s.kernel.pick("a", "t2", s.capability); s.kernel.clear(s.capability); expect(s.kernel.pick("a", "t", s.capability)).toBeNull(); + s.kernel.remove(b.accountRef, s.capability); + s.setAccounts([]); + expect(s.kernel.pick("b", "t", s.capability)).toBeNull(); + expect(CURSOR_POOL_TTL_MS).toBeGreaterThan(0); + s.advance(CURSOR_POOL_TTL_MS + 1); + s.kernel.pick("a", "t2", s.capability); + s.kernel.clear(s.capability); + expect(s.kernel.pick("a", "t", s.capability)).toBeNull(); expect(a.accountRef).toBe(b.accountRef); }); }); From e9e04627e31d8439075a11a94b28be032e355c95 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:37:06 -0600 Subject: [PATCH 07/21] fix(cursor): make rollback and rotation CAS precise Co-authored-by: JUN --- src/providers/cursor-pool.ts | 21 ++++++++++++++++++--- tests/providers/cursor/cursor-pool.test.ts | 7 +++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 04251154193..8e07fb11363 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -28,6 +28,8 @@ export interface CursorPoolSnapshot { readonly owner: string; readonly thread: string; readonly refs: ReadonlyArray; + readonly previous: ReadonlyArray; + readonly previousAffinity?: string; } function usable( @@ -135,18 +137,24 @@ export class CursorPoolKernel { const now = this.now(); const accounts = this.accounts(now); if (accounts.length < 2) return null; + const previous = accounts.flatMap((a) => { + const prior = this.states.get(`${owner}\0${thread}\0${a.ref}`); + return prior ? [{ ...prior }] : []; + }); + const previousAffinity = this.affinity.get(this.key(owner, thread)); const active = new Set(accounts.map((a) => a.id)); for (const [id, ref] of this.refs) if (!active.has(id)) this.refs.delete(id); for (const a of accounts) { const key = `${owner}\0${thread}\0${a.ref}`; const p = this.states.get(key); + const expired = p && p.cooldownUntil <= now; this.states.set(key, { ref: a.ref, owner, thread, cooldownUntil: p?.cooldownUntil ?? 0, - rotated: p?.rotated ?? false, + rotated: expired ? false : (p?.rotated ?? false), touched: now, }); } @@ -156,6 +164,8 @@ export class CursorPoolKernel { owner, thread, refs: accounts.map((a) => a.ref), + previous, + previousAffinity, }; } pick( @@ -191,7 +201,8 @@ export class CursorPoolKernel { ): boolean { if (capability !== this.capability) return false; const s = this.states.get(`${owner}\0${thread}\0${accountRef}`); - if (!s || s.rotated) return false; + if (!s) return false; + if (s.rotated && s.cooldownUntil > now) return false; s.cooldownUntil = Math.max(s.cooldownUntil, now + CURSOR_POOL_COOLDOWN_MS); s.rotated = true; s.touched = now; @@ -206,8 +217,11 @@ export class CursorPoolKernel { for (const [k, s] of this.states) if (s.owner === snapshot.owner && s.thread === snapshot.thread) this.states.delete(k); + for (const prior of snapshot.previous) + this.states.set(`${prior.owner}\0${prior.thread}\0${prior.ref}`, { ...prior }); const key = this.key(snapshot.owner, snapshot.thread); - this.affinity.delete(key); + if (snapshot.previousAffinity) this.affinity.set(key, snapshot.previousAffinity); + else this.affinity.delete(key); this.generation++; return true; } @@ -217,6 +231,7 @@ export class CursorPoolKernel { if (s.ref === accountRef) this.states.delete(k); for (const [k, v] of this.affinity) if (v === accountRef) this.affinity.delete(k); + this.generation++; } clear(capability: symbol): void { if (capability === this.capability) { diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index fa2186882a1..e7050b6de80 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -112,6 +112,10 @@ describe("CursorPoolKernel", () => { expect(s.kernel.note429(first.accountRef, "other", "t", s.capability)).toBe( false, ); + s.advance(CURSOR_POOL_COOLDOWN_MS + 1); + expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe( + true, + ); expect(CURSOR_POOL_COOLDOWN_MS).toBeGreaterThan(0); }); test("rollback is owner-scoped CAS; TTL, removal and clear leave no state", () => { @@ -124,7 +128,10 @@ describe("CursorPoolKernel", () => { b.accountRef, ); expect(s.kernel.rollback(snap, s.capability)).toBe(false); + const generationBeforeRemove = s.kernel.currentGeneration; s.kernel.remove(b.accountRef, s.capability); + expect(s.kernel.currentGeneration).toBeGreaterThan(generationBeforeRemove); + expect(s.kernel.rollback(snap, s.capability)).toBe(false); s.setAccounts([]); expect(s.kernel.pick("b", "t", s.capability)).toBeNull(); expect(CURSOR_POOL_TTL_MS).toBeGreaterThan(0); From debedc6c28020d7048dc85e1fbc4a7b36248d8bc Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:39:49 -0600 Subject: [PATCH 08/21] fix(cursor): scope TTL affinity cleanup by owner Co-authored-by: JUN --- src/providers/cursor-pool.ts | 12 ++++++++---- tests/providers/cursor/cursor-pool.test.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 8e07fb11363..33f3a6f3bb1 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -84,11 +84,15 @@ export class CursorPoolKernel { return `${owner}\0${thread}`; } private sweep(now = this.now()): void { - for (const [ref, s] of this.states) + for (const [key, s] of this.states) if (s.touched + CURSOR_POOL_TTL_MS <= now) { - this.states.delete(ref); - for (const [k, v] of this.affinity) - if (v === ref) this.affinity.delete(k); + this.states.delete(key); + const ownerThread = this.key(s.owner, s.thread); + const stillLive = [...this.states.values()].some( + (candidate) => + candidate.owner === s.owner && candidate.thread === s.thread, + ); + if (!stillLive) this.affinity.delete(ownerThread); } } private accounts( diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index e7050b6de80..4b4e419e558 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -141,4 +141,16 @@ describe("CursorPoolKernel", () => { expect(s.kernel.pick("a", "t", s.capability)).toBeNull(); expect(a.accountRef).toBe(b.accountRef); }); + + test("TTL sweep for one owner preserves another owner's live affinity", () => { + const s = setup(); + const ownerA = s.kernel.pick("owner-a", "thread", s.capability)!; + s.advance(CURSOR_POOL_TTL_MS - 1); + const ownerB = s.kernel.pick("owner-b", "thread", s.capability)!; + s.advance(2); + expect(s.kernel.pick("owner-b", "thread", s.capability)?.accountRef).toBe( + ownerB.accountRef, + ); + expect(ownerA.accountRef).toBe(ownerB.accountRef); + }); }); From 415bb1ea47f2788677ff25a2ba711de8085ded3a Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:57:33 -0600 Subject: [PATCH 09/21] test(cursor): keep TTL fixtures usable Co-authored-by: JUN --- tests/providers/cursor/cursor-pool.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index 4b4e419e558..115372ead9f 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -42,8 +42,8 @@ describe("CursorCredentialRouter", () => { describe("CursorPoolKernel", () => { const accounts = [ - { id: "account-a", access: "access-a", expires: 2_000 }, - { id: "account-b", access: "access-b", expires: 2_000 }, + { id: "account-a", access: "access-a", expires: Number.MAX_SAFE_INTEGER }, + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, ]; function setup(now = 1_000, resolver?: (id: string) => string | undefined) { let clock = now; From 20a36cb89883511b4ffac4ce7a8c0e73dac29e99 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:29:26 -0600 Subject: [PATCH 10/21] fix(cursor): restore identity fallback and clear opaque refs Co-authored-by: JUN --- src/adapters/cursor.ts | 16 +++++++++++++++- src/providers/cursor-pool.ts | 3 +++ tests/providers/cursor/cursor-pool.test.ts | 7 ++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index e1646748f86..d6826e61b7d 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -170,7 +170,20 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda const makeTransport = deps.createTransport ?? createLiveCursorTransport; const kv = deps.kv ?? createCursorKvStore({}, incoming.translatorBudget); const rekeyContextUsage = deps.rekeyContextUsage ?? rekeyCursorContextUsage; - // Pool ownership is a trusted parsed-route field. Never derive it from caller headers. + // Pool ownership is a trusted parsed-route field. When older callers do not provide + // that scope, retain the credential-isolation fallback; the digest is never emitted. + if (!_parsed._cursorIdentityScope) { + try { + const token = resolveCursorToken(provider, incoming.headers); + _parsed._cursorIdentityScope = createHash("sha256") + .update("ocx:cursor:acct:") + .update(token) + .digest("hex") + .slice(0, 16); + } catch { + // Missing credentials fail closed in the live transport path. + } + } const pooledToken = deps.selectPoolToken?.(_parsed._cursorIdentityScope ?? "", _parsed._clientThreadId ?? ""); const activeProvider = pooledToken ? { ...provider, apiKey: pooledToken } : provider; const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef; @@ -643,3 +656,4 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda }, }; } +import { createHash } from "node:crypto"; diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 33f3a6f3bb1..6975fa56d67 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -235,12 +235,15 @@ export class CursorPoolKernel { if (s.ref === accountRef) this.states.delete(k); for (const [k, v] of this.affinity) if (v === accountRef) this.affinity.delete(k); + for (const [id, ref] of this.refs) + if (ref === accountRef) this.refs.delete(id); this.generation++; } clear(capability: symbol): void { if (capability === this.capability) { this.states.clear(); this.affinity.clear(); + this.refs.clear(); this.generation++; } } diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index 115372ead9f..77025a83563 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -132,13 +132,14 @@ describe("CursorPoolKernel", () => { s.kernel.remove(b.accountRef, s.capability); expect(s.kernel.currentGeneration).toBeGreaterThan(generationBeforeRemove); expect(s.kernel.rollback(snap, s.capability)).toBe(false); - s.setAccounts([]); - expect(s.kernel.pick("b", "t", s.capability)).toBeNull(); + const reminted = s.kernel.pick("b", "t", s.capability); + expect(reminted).not.toBeNull(); + expect(reminted?.accountRef).not.toBe(b.accountRef); expect(CURSOR_POOL_TTL_MS).toBeGreaterThan(0); s.advance(CURSOR_POOL_TTL_MS + 1); s.kernel.pick("a", "t2", s.capability); s.kernel.clear(s.capability); - expect(s.kernel.pick("a", "t", s.capability)).toBeNull(); + expect(s.kernel.pick("a", "t", s.capability)).not.toBeNull(); expect(a.accountRef).toBe(b.accountRef); }); From d3a6345c655041a90ad0b4c267f06fc5620208ad Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:36:05 -0600 Subject: [PATCH 11/21] style(cursor): keep crypto import at module boundary --- src/adapters/cursor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index d6826e61b7d..27f87d78d38 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; @@ -656,4 +657,3 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda }, }; } -import { createHash } from "node:crypto"; From 126ee9366d85ca99fffa3f53175f7ff34a60aa6f Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:23:17 -0600 Subject: [PATCH 12/21] fix(cursor): preserve pool compatibility contracts --- src/providers/cursor-pool.ts | 11 ++-- tests/providers/cursor/cursor-adapter.test.ts | 55 +++++++++++++++++++ tests/providers/cursor/cursor-pool.test.ts | 11 +++- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 6975fa56d67..c10fae8583f 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -1,5 +1,4 @@ /** Cursor OAuth account-pool kernel. No configuration or HTTP surface lives here. */ -import { randomUUID } from "node:crypto"; import { getAccountSet } from "../oauth/store"; export const CURSOR_POOL_KEY = "cursor"; @@ -9,7 +8,11 @@ export interface CursorCredential { readonly id: string; weight: number; } -export class NoAvailableCursorCredentialError extends Error {} +export class NoAvailableCursorCredentialError extends Error { + constructor(message = "No available Cursor credentials") { + super(message); + } +} interface State { ref: string; owner: string; @@ -110,7 +113,7 @@ export class CursorPoolKernel { .map((a) => { let ref = this.refs.get(a.id); if (!ref) { - ref = `cp_${randomUUID().replaceAll("-", "")}`; + ref = `cp_${crypto.randomUUID().replaceAll("-", "")}`; this.refs.set(a.id, ref); } const token = @@ -249,7 +252,7 @@ export class CursorPoolKernel { } } export function createCursorPoolCapability(): symbol { - return Symbol(`cursor-pool:${randomUUID()}`); + return Symbol(`cursor-pool:${crypto.randomUUID()}`); } /** Legacy weighted router; generic 429 rotation is owned elsewhere. */ diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index d09076a03de..504f53e1ff7 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -145,6 +145,61 @@ describe("Cursor adapter live transport", () => { expect(inputs[0]?.fetch).toBe(pacedFetch); }); + test("runTurn selects a pooled token by trusted owner and thread", async () => { + const selectedInputs: CursorTransportFactoryInput[] = []; + const selectorCalls: Array<[string, string]> = []; + const makeTransport = (inputs: CursorTransportFactoryInput[]) => + (input: CursorTransportFactoryInput) => { + inputs.push(input); + return { + async *run() { + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }; + }; + const scopedRequest: OcxParsedRequest = { + ...parsed, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + _cursorIdentityScope: "owner-scope", + _clientThreadId: "thread-scope", + }; + const selected = createCursorAdapter( + { ...provider, apiKey: "original-token" }, + { + createTransport: makeTransport(selectedInputs), + selectPoolToken(owner, thread) { + selectorCalls.push([owner, thread]); + return "pooled-token"; + }, + }, + ); + + await selected.runTurn?.( + scopedRequest, + { headers: new Headers() }, + () => {}, + ); + + expect(selectorCalls).toEqual([["owner-scope", "thread-scope"]]); + expect(selectedInputs[0]?.provider.apiKey).toBe("pooled-token"); + + const fallbackInputs: CursorTransportFactoryInput[] = []; + const fallback = createCursorAdapter( + { ...provider, apiKey: "original-token" }, + { + createTransport: makeTransport(fallbackInputs), + selectPoolToken: () => undefined, + }, + ); + await fallback.runTurn?.( + { ...scopedRequest }, + { headers: new Headers() }, + () => {}, + ); + expect(fallbackInputs[0]?.provider.apiKey).toBe("original-token"); + }); + // #1527: the envelope rejection is raised locally while building the request, so it surfaces // through the same terminal catch as a transport fault. Review found the class was flattened to // a bare message there, losing the stable code a caller needs to tell "this conversation cannot diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index 77025a83563..d4b73fc73a2 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -65,10 +65,17 @@ describe("CursorPoolKernel", () => { }; } test("requires capability, trusted owner, and two usable accounts", () => { - const { kernel, capability } = setup(); + const { kernel, capability, setAccounts } = setup(); expect(kernel.pick("owner", "thread", Symbol("wrong"))).toBeNull(); expect(kernel.pick("", "thread", capability)).toBeNull(); - expect(kernel.pick("owner", "thread", capability)?.token).toBe("access-a"); + setAccounts([accounts[0]!]); + expect(kernel.pick("owner", "thread", capability)).toBeNull(); + setAccounts(accounts); + const snapshot = kernel.activate("owner", "thread", capability); + expect(snapshot).not.toBeNull(); + const serialized = JSON.stringify(snapshot); + for (const secret of ["account-a", "account-b", "access-a", "access-b"]) + expect(serialized).not.toContain(secret); }); test("same thread text is isolated by owner and absent scope fails closed", () => { const { kernel, capability } = setup(); From d5e2ed2a6d01cdfc4f67282426ca8e7ef92ac0f9 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:44:53 -0600 Subject: [PATCH 13/21] fix(cursor): scope pool rollback generations --- src/providers/cursor-pool.ts | 32 +++++++++++++++++----- tests/providers/cursor/cursor-pool.test.ts | 1 + 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index c10fae8583f..225ae8f4080 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -66,6 +66,7 @@ export interface CursorPoolKernelOptions { export class CursorPoolKernel { private states = new Map(); private affinity = new Map(); + private versions = new Map(); private generation = 0; private readonly resolveAccessToken?: ( accountId: string, @@ -86,6 +87,14 @@ export class CursorPoolKernel { private key(owner: string, thread: string): string { return `${owner}\0${thread}`; } + private version(key: string): number { + return this.versions.get(key) ?? 0; + } + private advanceVersion(key: string): number { + const next = this.version(key) + 1; + this.versions.set(key, next); + return next; + } private sweep(now = this.now()): void { for (const [key, s] of this.states) if (s.touched + CURSOR_POOL_TTL_MS <= now) { @@ -132,12 +141,13 @@ export class CursorPoolKernel { capability: symbol, expectedGeneration?: number, ): CursorPoolSnapshot | null { + const ownerThread = this.key(owner, thread); if ( capability !== this.capability || !owner || !thread || (expectedGeneration !== undefined && - expectedGeneration !== this.generation) + expectedGeneration !== this.version(ownerThread)) ) return null; this.sweep(); @@ -148,7 +158,7 @@ export class CursorPoolKernel { const prior = this.states.get(`${owner}\0${thread}\0${a.ref}`); return prior ? [{ ...prior }] : []; }); - const previousAffinity = this.affinity.get(this.key(owner, thread)); + const previousAffinity = this.affinity.get(ownerThread); const active = new Set(accounts.map((a) => a.id)); for (const [id, ref] of this.refs) if (!active.has(id)) this.refs.delete(id); @@ -166,8 +176,9 @@ export class CursorPoolKernel { }); } this.generation++; + const generation = this.advanceVersion(ownerThread); return { - generation: this.generation, + generation, owner, thread, refs: accounts.map((a) => a.ref), @@ -196,7 +207,7 @@ export class CursorPoolKernel { state.touched = now; const a = this.accounts(now).find((x) => x.ref === state.ref); return a - ? { accountRef: state.ref, token: a.token, generation: this.generation } + ? { accountRef: state.ref, token: a.token, generation: snap.generation } : null; } note429( @@ -216,9 +227,10 @@ export class CursorPoolKernel { return true; } rollback(snapshot: CursorPoolSnapshot, capability: symbol): boolean { + const key = this.key(snapshot.owner, snapshot.thread); if ( capability !== this.capability || - snapshot.generation !== this.generation + snapshot.generation !== this.version(key) ) return false; for (const [k, s] of this.states) @@ -226,24 +238,30 @@ export class CursorPoolKernel { this.states.delete(k); for (const prior of snapshot.previous) this.states.set(`${prior.owner}\0${prior.thread}\0${prior.ref}`, { ...prior }); - const key = this.key(snapshot.owner, snapshot.thread); if (snapshot.previousAffinity) this.affinity.set(key, snapshot.previousAffinity); else this.affinity.delete(key); this.generation++; + this.advanceVersion(key); return true; } remove(accountRef: string, capability: symbol): void { if (capability !== this.capability) return; + const changed = new Set(); for (const [k, s] of this.states) - if (s.ref === accountRef) this.states.delete(k); + if (s.ref === accountRef) { + this.states.delete(k); + changed.add(this.key(s.owner, s.thread)); + } for (const [k, v] of this.affinity) if (v === accountRef) this.affinity.delete(k); for (const [id, ref] of this.refs) if (ref === accountRef) this.refs.delete(id); this.generation++; + for (const key of changed) this.advanceVersion(key); } clear(capability: symbol): void { if (capability === this.capability) { + for (const key of this.versions.keys()) this.advanceVersion(key); this.states.clear(); this.affinity.clear(); this.refs.clear(); diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index d4b73fc73a2..7faf4851a82 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -130,6 +130,7 @@ describe("CursorPoolKernel", () => { const a = s.kernel.pick("a", "t", s.capability)!; const b = s.kernel.pick("b", "t", s.capability)!; const snap = s.kernel.activate("a", "t", s.capability)!; + s.kernel.pick("b", "other-thread", s.capability); expect(s.kernel.rollback(snap, s.capability)).toBe(true); expect(s.kernel.pick("b", "t", s.capability)?.accountRef).toBe( b.accountRef, From 3db5d22a8ea6956b9c449163ae3f60dc487ef819 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:21:31 -0600 Subject: [PATCH 14/21] test(cursor): verify pooled-token transport wiring in adapter --- tests/providers/cursor/cursor-adapter.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index 504f53e1ff7..2d8e76093c5 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -198,6 +198,46 @@ describe("Cursor adapter live transport", () => { () => {}, ); expect(fallbackInputs[0]?.provider.apiKey).toBe("original-token"); + + const unconfiguredInputs: CursorTransportFactoryInput[] = []; + const unconfigured = createCursorAdapter( + { ...provider, apiKey: "original-token" }, + { + createTransport: makeTransport(unconfiguredInputs), + }, + ); + await unconfigured.runTurn?.( + { ...scopedRequest }, + { headers: new Headers() }, + () => {}, + ); + expect(unconfiguredInputs[0]?.provider.apiKey).toBe("original-token"); + + const fallbackScopeCalls: Array<[string, string]> = []; + const fallbackScopeAdapter = createCursorAdapter( + { ...provider, apiKey: "original-token" }, + { + createTransport: makeTransport([]), + selectPoolToken(owner, thread) { + fallbackScopeCalls.push([owner, thread]); + return undefined; + }, + }, + ); + const unscopedRequest: OcxParsedRequest = { + ...parsed, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + _clientThreadId: "thread-scope", + }; + await fallbackScopeAdapter.runTurn?.( + unscopedRequest, + { headers: new Headers() }, + () => {}, + ); + expect(fallbackScopeCalls).toHaveLength(1); + expect(fallbackScopeCalls[0]?.[0]).toBe(unscopedRequest._cursorIdentityScope ?? ""); + expect(fallbackScopeCalls[0]?.[0]?.length).toBe(16); + expect(fallbackScopeCalls[0]?.[1]).toBe("thread-scope"); }); // #1527: the envelope rejection is raised locally while building the request, so it surfaces From 2b58f128cc252707da11785c00f87c5ebc4eebd4 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:51:31 -0600 Subject: [PATCH 15/21] fix(cursor): address review findings for pool references, versions, and test assertions --- src/providers/cursor-pool.ts | 86 +++++++++++-------- tests/providers/cursor/cursor-adapter.test.ts | 68 +++++++++++---- tests/providers/cursor/cursor-pool.test.ts | 75 ++++++++++++++-- 3 files changed, 168 insertions(+), 61 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 225ae8f4080..1796e881bd1 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -35,20 +35,16 @@ export interface CursorPoolSnapshot { readonly previousAffinity?: string; } +function unexpired(expires: number | undefined, now: number): boolean { + return expires === undefined || (Number.isFinite(expires) && expires > now); +} + function usable( - account: - | CursorPoolAccount - | { credential?: CursorPoolAccount; needsReauth?: boolean }, + account: CursorPoolAccount, now: number, ): boolean { if (account.needsReauth === true) return false; - const c = ("credential" in account ? account.credential : account) as - CursorPoolAccount | undefined; - if (!c) return false; - return ( - Boolean(c.access) && - (!Number.isFinite(c.expires) || (c.expires as number) > now) - ); + return Boolean(account.access) && unexpired(account.expires, now); } export interface CursorPoolAccount { @@ -91,7 +87,7 @@ export class CursorPoolKernel { return this.versions.get(key) ?? 0; } private advanceVersion(key: string): number { - const next = this.version(key) + 1; + const next = ++this.generation; this.versions.set(key, next); return next; } @@ -104,19 +100,26 @@ export class CursorPoolKernel { (candidate) => candidate.owner === s.owner && candidate.thread === s.thread, ); - if (!stillLive) this.affinity.delete(ownerThread); + if (!stillLive) { + this.affinity.delete(ownerThread); + this.versions.delete(ownerThread); + } } } - private accounts( - now: number, - ): Array<{ ref: string; id: string; token: string }> { - const source = + private rawAccounts(): ReadonlyArray { + return ( this.listAccounts?.() ?? (getAccountSet(CURSOR_POOL_KEY)?.accounts ?? []).map((a) => ({ id: a.id, ...a.credential, needsReauth: a.needsReauth, - })); + })) + ); + } + private accounts( + now: number, + ): Array<{ ref: string; id: string; token: string }> { + const source = this.rawAccounts(); return source .filter((a) => usable(a, now)) .map((a) => { @@ -127,8 +130,7 @@ export class CursorPoolKernel { } const token = this.resolveAccessToken?.(a.id) ?? - (a.access && - (!Number.isFinite(a.expires) || (a.expires as number) > now) + (a.access && unexpired(a.expires, now) ? a.access : undefined); return { ref, id: a.id, token: token ?? "" }; @@ -150,18 +152,29 @@ export class CursorPoolKernel { expectedGeneration !== this.version(ownerThread)) ) return null; + const { snapshot } = this.activateInternal(owner, thread); + return snapshot; + } + private activateInternal( + owner: string, + thread: string, + ): { + snapshot: CursorPoolSnapshot | null; + resolvedAccounts: Array<{ ref: string; id: string; token: string }>; + } { + const ownerThread = this.key(owner, thread); this.sweep(); const now = this.now(); const accounts = this.accounts(now); - if (accounts.length < 2) return null; + if (accounts.length < 2) return { snapshot: null, resolvedAccounts: [] }; const previous = accounts.flatMap((a) => { const prior = this.states.get(`${owner}\0${thread}\0${a.ref}`); return prior ? [{ ...prior }] : []; }); const previousAffinity = this.affinity.get(ownerThread); - const active = new Set(accounts.map((a) => a.id)); + const knownSource = new Set(this.rawAccounts().map((a) => a.id)); for (const [id, ref] of this.refs) - if (!active.has(id)) this.refs.delete(id); + if (!knownSource.has(id)) this.refs.delete(id); for (const a of accounts) { const key = `${owner}\0${thread}\0${a.ref}`; const p = this.states.get(key); @@ -175,15 +188,17 @@ export class CursorPoolKernel { touched: now, }); } - this.generation++; const generation = this.advanceVersion(ownerThread); return { - generation, - owner, - thread, - refs: accounts.map((a) => a.ref), - previous, - previousAffinity, + snapshot: { + generation, + owner, + thread, + refs: accounts.map((a) => a.ref), + previous, + previousAffinity, + }, + resolvedAccounts: accounts, }; } pick( @@ -191,8 +206,11 @@ export class CursorPoolKernel { thread: string, capability: symbol, ): CursorPoolPick | null { - if (capability !== this.capability) return null; - const snap = this.activate(owner, thread, capability); + if (capability !== this.capability || !owner || !thread) return null; + const { snapshot: snap, resolvedAccounts } = this.activateInternal( + owner, + thread, + ); if (!snap) return null; const now = this.now(), key = this.key(owner, thread), @@ -205,7 +223,7 @@ export class CursorPoolKernel { if (!state) return null; this.affinity.set(key, state.ref); state.touched = now; - const a = this.accounts(now).find((x) => x.ref === state.ref); + const a = resolvedAccounts.find((x) => x.ref === state.ref); return a ? { accountRef: state.ref, token: a.token, generation: snap.generation } : null; @@ -240,7 +258,6 @@ export class CursorPoolKernel { this.states.set(`${prior.owner}\0${prior.thread}\0${prior.ref}`, { ...prior }); if (snapshot.previousAffinity) this.affinity.set(key, snapshot.previousAffinity); else this.affinity.delete(key); - this.generation++; this.advanceVersion(key); return true; } @@ -256,7 +273,6 @@ export class CursorPoolKernel { if (v === accountRef) this.affinity.delete(k); for (const [id, ref] of this.refs) if (ref === accountRef) this.refs.delete(id); - this.generation++; for (const key of changed) this.advanceVersion(key); } clear(capability: symbol): void { @@ -265,7 +281,6 @@ export class CursorPoolKernel { this.states.clear(); this.affinity.clear(); this.refs.clear(); - this.generation++; } } } @@ -321,3 +336,4 @@ export class CursorCredentialRouter { })); } } + diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index 2d8e76093c5..5777f8fbf03 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { describe, expect, spyOn, test } from "bun:test"; import { createCursorAdapter as createCursorAdapterProduction, @@ -145,19 +146,20 @@ describe("Cursor adapter live transport", () => { expect(inputs[0]?.fetch).toBe(pacedFetch); }); - test("runTurn selects a pooled token by trusted owner and thread", async () => { + const makeTransportMock = (inputs: CursorTransportFactoryInput[]) => + (input: CursorTransportFactoryInput) => { + inputs.push(input); + return { + async *run() { + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }; + }; + + test("runTurn passes selected pooled token to activeProvider transport", async () => { const selectedInputs: CursorTransportFactoryInput[] = []; const selectorCalls: Array<[string, string]> = []; - const makeTransport = (inputs: CursorTransportFactoryInput[]) => - (input: CursorTransportFactoryInput) => { - inputs.push(input); - return { - async *run() { - yield { type: "done" } satisfies CursorServerMessage; - }, - writeClient() {}, - }; - }; const scopedRequest: OcxParsedRequest = { ...parsed, context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, @@ -167,7 +169,7 @@ describe("Cursor adapter live transport", () => { const selected = createCursorAdapter( { ...provider, apiKey: "original-token" }, { - createTransport: makeTransport(selectedInputs), + createTransport: makeTransportMock(selectedInputs), selectPoolToken(owner, thread) { selectorCalls.push([owner, thread]); return "pooled-token"; @@ -183,12 +185,20 @@ describe("Cursor adapter live transport", () => { expect(selectorCalls).toEqual([["owner-scope", "thread-scope"]]); expect(selectedInputs[0]?.provider.apiKey).toBe("pooled-token"); + }); + test("runTurn falls back to provider.apiKey when selectPoolToken returns undefined", async () => { const fallbackInputs: CursorTransportFactoryInput[] = []; + const scopedRequest: OcxParsedRequest = { + ...parsed, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + _cursorIdentityScope: "owner-scope", + _clientThreadId: "thread-scope", + }; const fallback = createCursorAdapter( { ...provider, apiKey: "original-token" }, { - createTransport: makeTransport(fallbackInputs), + createTransport: makeTransportMock(fallbackInputs), selectPoolToken: () => undefined, }, ); @@ -198,12 +208,20 @@ describe("Cursor adapter live transport", () => { () => {}, ); expect(fallbackInputs[0]?.provider.apiKey).toBe("original-token"); + }); + test("runTurn falls back to provider.apiKey when selectPoolToken is unconfigured", async () => { const unconfiguredInputs: CursorTransportFactoryInput[] = []; + const scopedRequest: OcxParsedRequest = { + ...parsed, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + _cursorIdentityScope: "owner-scope", + _clientThreadId: "thread-scope", + }; const unconfigured = createCursorAdapter( { ...provider, apiKey: "original-token" }, { - createTransport: makeTransport(unconfiguredInputs), + createTransport: makeTransportMock(unconfiguredInputs), }, ); await unconfigured.runTurn?.( @@ -212,12 +230,15 @@ describe("Cursor adapter live transport", () => { () => {}, ); expect(unconfiguredInputs[0]?.provider.apiKey).toBe("original-token"); + }); + test("runTurn derives deterministic fallback identity scope when unscoped", async () => { + const fallbackScopeInputs: CursorTransportFactoryInput[] = []; const fallbackScopeCalls: Array<[string, string]> = []; const fallbackScopeAdapter = createCursorAdapter( { ...provider, apiKey: "original-token" }, { - createTransport: makeTransport([]), + createTransport: makeTransportMock(fallbackScopeInputs), selectPoolToken(owner, thread) { fallbackScopeCalls.push([owner, thread]); return undefined; @@ -234,10 +255,23 @@ describe("Cursor adapter live transport", () => { { headers: new Headers() }, () => {}, ); + const expectedScope = createHash("sha256") + .update("ocx:cursor:acct:") + .update("original-token") + .digest("hex") + .slice(0, 16); expect(fallbackScopeCalls).toHaveLength(1); - expect(fallbackScopeCalls[0]?.[0]).toBe(unscopedRequest._cursorIdentityScope ?? ""); - expect(fallbackScopeCalls[0]?.[0]?.length).toBe(16); + expect(fallbackScopeCalls[0]?.[0]).toBe(expectedScope); + expect(fallbackScopeCalls[0]?.[0]).toMatch(/^[0-9a-f]{16}$/); expect(fallbackScopeCalls[0]?.[1]).toBe("thread-scope"); + expect(fallbackScopeInputs[0]?.provider.apiKey).toBe("original-token"); + + await fallbackScopeAdapter.runTurn?.( + unscopedRequest, + { headers: new Headers() }, + () => {}, + ); + expect(fallbackScopeCalls[1]?.[0]).toBe(expectedScope); }); // #1527: the envelope rejection is raised locally while building the request, so it surfaces diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index 7faf4851a82..90014bfb570 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -79,9 +79,12 @@ describe("CursorPoolKernel", () => { }); test("same thread text is isolated by owner and absent scope fails closed", () => { const { kernel, capability } = setup(); - expect(kernel.pick("owner-a", "same", capability)?.accountRef).toBe( - kernel.pick("owner-b", "same", capability)?.accountRef, - ); + const a = kernel.pick("owner-a", "same", capability)!; + const b = kernel.pick("owner-b", "same", capability)!; + expect(a).not.toBeNull(); + expect(b).not.toBeNull(); + expect(kernel.note429(a.accountRef, "owner-a", "same", capability)).toBe(true); + expect(kernel.pick("owner-b", "same", capability)?.accountRef).toBe(b.accountRef); expect(kernel.pick("", "same", capability)).toBeNull(); }); test("refs are random opaque values and token is never exposed by snapshot", () => { @@ -123,7 +126,6 @@ describe("CursorPoolKernel", () => { expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe( true, ); - expect(CURSOR_POOL_COOLDOWN_MS).toBeGreaterThan(0); }); test("rollback is owner-scoped CAS; TTL, removal and clear leave no state", () => { const s = setup(); @@ -143,12 +145,14 @@ describe("CursorPoolKernel", () => { const reminted = s.kernel.pick("b", "t", s.capability); expect(reminted).not.toBeNull(); expect(reminted?.accountRef).not.toBe(b.accountRef); - expect(CURSOR_POOL_TTL_MS).toBeGreaterThan(0); s.advance(CURSOR_POOL_TTL_MS + 1); - s.kernel.pick("a", "t2", s.capability); + const beforeClear = s.kernel.pick("a", "t2", s.capability)!; + const clearSnapshot = s.kernel.activate("a", "t2", s.capability)!; s.kernel.clear(s.capability); - expect(s.kernel.pick("a", "t", s.capability)).not.toBeNull(); - expect(a.accountRef).toBe(b.accountRef); + expect(s.kernel.pick("a", "t2", s.capability)?.accountRef).not.toBe( + beforeClear.accountRef, + ); + expect(s.kernel.rollback(clearSnapshot, s.capability)).toBe(false); }); test("TTL sweep for one owner preserves another owner's live affinity", () => { @@ -160,6 +164,59 @@ describe("CursorPoolKernel", () => { expect(s.kernel.pick("owner-b", "thread", s.capability)?.accountRef).toBe( ownerB.accountRef, ); - expect(ownerA.accountRef).toBe(ownerB.accountRef); + expect(ownerA).not.toBeNull(); + }); + + test("retains opaque refs for temporarily unusable accounts across activate", () => { + const s = setup(); + const pick1 = s.kernel.pick("owner-a", "thread", s.capability)!; + const originalRef = pick1.accountRef; + + // Simulate account-a temporarily needing reauth or having expired token + s.setAccounts([ + { id: "account-a", access: "access-a", expires: 500, needsReauth: true }, + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]); + // activate will see only account-b as usable, so < 2 usable accounts returns null + // But let's add account-c so activate succeeds + s.setAccounts([ + { id: "account-a", access: "access-a", expires: 500, needsReauth: true }, + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + { id: "account-c", access: "access-c", expires: Number.MAX_SAFE_INTEGER }, + ]); + const snap = s.kernel.activate("owner-b", "thread", s.capability); + expect(snap).not.toBeNull(); + + // Now account-a is restored + s.setAccounts(accounts); + const pickRestored = s.kernel.pick("owner-a", "thread", s.capability)!; + expect(pickRestored.accountRef).toBe(originalRef); + }); + + test("rejects NaN expiry in usable and unexpired checks", () => { + const s = setup(); + s.setAccounts([ + { id: "account-a", access: "access-a", expires: NaN }, + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]); + expect(s.kernel.pick("owner", "thread", s.capability)).toBeNull(); + }); + + test("sweep prunes versions when owner/thread has no live states", () => { + const s = setup(); + s.kernel.pick("owner-ephemeral", "thread", s.capability); + s.advance(CURSOR_POOL_TTL_MS + 1); + // After TTL, next activate or pick triggers sweep and removes version + s.kernel.pick("owner-other", "thread", s.capability); + // Rollback with stale generation should fail closed + const staleSnap = { + generation: 1, + owner: "owner-ephemeral", + thread: "thread", + refs: [], + previous: [], + }; + expect(s.kernel.rollback(staleSnap, s.capability)).toBe(false); }); }); + From 23f900ab5a14dcae4068f78534747dcc9fade039 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:57:50 -0600 Subject: [PATCH 16/21] fix(cursor): clear versions on pool reset and eliminate redundant store reads --- src/providers/cursor-pool.ts | 23 +++++----- tests/providers/cursor/cursor-pool.test.ts | 49 +++++++++++++++------- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 1796e881bd1..30b92738cf6 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -34,7 +34,6 @@ export interface CursorPoolSnapshot { readonly previous: ReadonlyArray; readonly previousAffinity?: string; } - function unexpired(expires: number | undefined, now: number): boolean { return expires === undefined || (Number.isFinite(expires) && expires > now); } @@ -91,16 +90,18 @@ export class CursorPoolKernel { this.versions.set(key, next); return next; } + private hasLiveStateFor(owner: string, thread: string): boolean { + for (const s of this.states.values()) { + if (s.owner === owner && s.thread === thread) return true; + } + return false; + } private sweep(now = this.now()): void { for (const [key, s] of this.states) if (s.touched + CURSOR_POOL_TTL_MS <= now) { this.states.delete(key); const ownerThread = this.key(s.owner, s.thread); - const stillLive = [...this.states.values()].some( - (candidate) => - candidate.owner === s.owner && candidate.thread === s.thread, - ); - if (!stillLive) { + if (!this.hasLiveStateFor(s.owner, s.thread)) { this.affinity.delete(ownerThread); this.versions.delete(ownerThread); } @@ -117,9 +118,9 @@ export class CursorPoolKernel { ); } private accounts( + source: ReadonlyArray, now: number, ): Array<{ ref: string; id: string; token: string }> { - const source = this.rawAccounts(); return source .filter((a) => usable(a, now)) .map((a) => { @@ -165,14 +166,15 @@ export class CursorPoolKernel { const ownerThread = this.key(owner, thread); this.sweep(); const now = this.now(); - const accounts = this.accounts(now); + const raw = this.rawAccounts(); + const accounts = this.accounts(raw, now); if (accounts.length < 2) return { snapshot: null, resolvedAccounts: [] }; const previous = accounts.flatMap((a) => { const prior = this.states.get(`${owner}\0${thread}\0${a.ref}`); return prior ? [{ ...prior }] : []; }); const previousAffinity = this.affinity.get(ownerThread); - const knownSource = new Set(this.rawAccounts().map((a) => a.id)); + const knownSource = new Set(raw.map((a) => a.id)); for (const [id, ref] of this.refs) if (!knownSource.has(id)) this.refs.delete(id); for (const a of accounts) { @@ -277,7 +279,7 @@ export class CursorPoolKernel { } clear(capability: symbol): void { if (capability === this.capability) { - for (const key of this.versions.keys()) this.advanceVersion(key); + this.versions.clear(); this.states.clear(); this.affinity.clear(); this.refs.clear(); @@ -336,4 +338,3 @@ export class CursorCredentialRouter { })); } } - diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index 90014bfb570..1f7147931c7 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -39,7 +39,6 @@ describe("CursorCredentialRouter", () => { expect(cred.id).toBe("b"); }); }); - describe("CursorPoolKernel", () => { const accounts = [ { id: "account-a", access: "access-a", expires: Number.MAX_SAFE_INTEGER }, @@ -149,9 +148,13 @@ describe("CursorPoolKernel", () => { const beforeClear = s.kernel.pick("a", "t2", s.capability)!; const clearSnapshot = s.kernel.activate("a", "t2", s.capability)!; s.kernel.clear(s.capability); - expect(s.kernel.pick("a", "t2", s.capability)?.accountRef).not.toBe( - beforeClear.accountRef, - ); + // Old pre-clear snapshot is rejected by rollback + expect(s.kernel.rollback(clearSnapshot, s.capability)).toBe(false); + // Recreated key mints a new ref and acquires a strictly later monotonic generation + const afterClear = s.kernel.pick("a", "t2", s.capability)!; + expect(afterClear.accountRef).not.toBe(beforeClear.accountRef); + expect(afterClear.generation).toBeGreaterThan(clearSnapshot.generation); + // And rollback with the old snapshot still fails against the recreated key expect(s.kernel.rollback(clearSnapshot, s.capability)).toBe(false); }); @@ -172,13 +175,6 @@ describe("CursorPoolKernel", () => { const pick1 = s.kernel.pick("owner-a", "thread", s.capability)!; const originalRef = pick1.accountRef; - // Simulate account-a temporarily needing reauth or having expired token - s.setAccounts([ - { id: "account-a", access: "access-a", expires: 500, needsReauth: true }, - { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, - ]); - // activate will see only account-b as usable, so < 2 usable accounts returns null - // But let's add account-c so activate succeeds s.setAccounts([ { id: "account-a", access: "access-a", expires: 500, needsReauth: true }, { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, @@ -187,7 +183,6 @@ describe("CursorPoolKernel", () => { const snap = s.kernel.activate("owner-b", "thread", s.capability); expect(snap).not.toBeNull(); - // Now account-a is restored s.setAccounts(accounts); const pickRestored = s.kernel.pick("owner-a", "thread", s.capability)!; expect(pickRestored.accountRef).toBe(originalRef); @@ -206,9 +201,7 @@ describe("CursorPoolKernel", () => { const s = setup(); s.kernel.pick("owner-ephemeral", "thread", s.capability); s.advance(CURSOR_POOL_TTL_MS + 1); - // After TTL, next activate or pick triggers sweep and removes version s.kernel.pick("owner-other", "thread", s.capability); - // Rollback with stale generation should fail closed const staleSnap = { generation: 1, owner: "owner-ephemeral", @@ -218,5 +211,31 @@ describe("CursorPoolKernel", () => { }; expect(s.kernel.rollback(staleSnap, s.capability)).toBe(false); }); -}); + test("activate and pick perform exactly one listAccounts store read and resolve pass per call", () => { + let listAccountsCalls = 0; + let resolveCalls = 0; + const capability = createCursorPoolCapability(); + const kernel = new CursorPoolKernel(capability, () => 1_000, { + listAccounts: () => { + listAccountsCalls++; + return accounts; + }, + resolveAccessToken: (id) => { + resolveCalls++; + return id === "account-a" ? "access-a" : "access-b"; + }, + }); + + const picked = kernel.pick("owner", "thread", capability); + expect(picked).not.toBeNull(); + // Exactly 1 listAccounts call and 1 resolve pass per account (2 accounts) + expect(listAccountsCalls).toBe(1); + expect(resolveCalls).toBe(2); + + const activated = kernel.activate("owner", "thread-2", capability); + expect(activated).not.toBeNull(); + expect(listAccountsCalls).toBe(2); + expect(resolveCalls).toBe(4); + }); +}); From 6e1d189943ef7810d1e5ef9d4374cc539d4a98fe Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:27:57 -0600 Subject: [PATCH 17/21] fix(cursor): prune removed refs below activation threshold --- src/providers/cursor-pool.ts | 6 +++--- tests/providers/cursor/cursor-pool.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 30b92738cf6..cc794b9468e 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -168,15 +168,15 @@ export class CursorPoolKernel { const now = this.now(); const raw = this.rawAccounts(); const accounts = this.accounts(raw, now); + const knownSource = new Set(raw.map((a) => a.id)); + for (const [id] of this.refs) + if (!knownSource.has(id)) this.refs.delete(id); if (accounts.length < 2) return { snapshot: null, resolvedAccounts: [] }; const previous = accounts.flatMap((a) => { const prior = this.states.get(`${owner}\0${thread}\0${a.ref}`); return prior ? [{ ...prior }] : []; }); const previousAffinity = this.affinity.get(ownerThread); - const knownSource = new Set(raw.map((a) => a.id)); - for (const [id, ref] of this.refs) - if (!knownSource.has(id)) this.refs.delete(id); for (const a of accounts) { const key = `${owner}\0${thread}\0${a.ref}`; const p = this.states.get(key); diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index 1f7147931c7..83470e5c9bd 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -188,6 +188,20 @@ describe("CursorPoolKernel", () => { expect(pickRestored.accountRef).toBe(originalRef); }); + test("prunes removed refs even when the usable pool falls below threshold", () => { + const s = setup(); + const original = s.kernel.pick("owner", "thread", s.capability)!; + + s.setAccounts([ + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]); + expect(s.kernel.activate("owner", "thread", s.capability)).toBeNull(); + + s.setAccounts(accounts); + const restored = s.kernel.pick("owner", "thread", s.capability)!; + expect(restored.accountRef).not.toBe(original.accountRef); + }); + test("rejects NaN expiry in usable and unexpired checks", () => { const s = setup(); s.setAccounts([ From 45faface15713d5d157de20005e28436f6077068 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:01:52 -0600 Subject: [PATCH 18/21] fix(cursor): invalidate snapshots for removed accounts --- src/providers/cursor-pool.ts | 7 +++++-- tests/providers/cursor/cursor-pool.test.ts | 23 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index cc794b9468e..e58609cc648 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -169,8 +169,8 @@ export class CursorPoolKernel { const raw = this.rawAccounts(); const accounts = this.accounts(raw, now); const knownSource = new Set(raw.map((a) => a.id)); - for (const [id] of this.refs) - if (!knownSource.has(id)) this.refs.delete(id); + for (const [id, ref] of this.refs) + if (!knownSource.has(id)) this.removeRefState(ref); if (accounts.length < 2) return { snapshot: null, resolvedAccounts: [] }; const previous = accounts.flatMap((a) => { const prior = this.states.get(`${owner}\0${thread}\0${a.ref}`); @@ -265,6 +265,9 @@ export class CursorPoolKernel { } remove(accountRef: string, capability: symbol): void { if (capability !== this.capability) return; + this.removeRefState(accountRef); + } + private removeRefState(accountRef: string): void { const changed = new Set(); for (const [k, s] of this.states) if (s.ref === accountRef) { diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index 83470e5c9bd..badf5f31619 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -202,6 +202,29 @@ describe("CursorPoolKernel", () => { expect(restored.accountRef).not.toBe(original.accountRef); }); + test("removing an account below activation threshold invalidates earlier snapshot rollback", () => { + const s = setup(); + const snap1 = s.kernel.activate("owner-a", "thread", s.capability)!; + expect(snap1).not.toBeNull(); + const snap2 = s.kernel.activate("owner-b", "thread", s.capability)!; + expect(snap2).not.toBeNull(); + + s.setAccounts([ + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]); + expect(s.kernel.activate("owner-a", "thread", s.capability)).toBeNull(); + expect(s.kernel.rollback(snap1, s.capability)).toBe(false); + expect(s.kernel.rollback(snap2, s.capability)).toBe(false); + expect( + s.kernel.note429( + snap1.refs[0]!, + "owner-a", + "thread", + s.capability, + ), + ).toBe(false); + }); + test("rejects NaN expiry in usable and unexpired checks", () => { const s = setup(); s.setAccounts([ From 29115af63b6528bf721c5be90f94e2aacd49ba6e Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:57:38 -0600 Subject: [PATCH 19/21] fix(cursor): invalidate swept account snapshots --- src/providers/cursor-pool.ts | 11 +++++++- tests/providers/cursor/cursor-pool.test.ts | 30 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index e58609cc648..47e9415b8c0 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -276,8 +276,17 @@ export class CursorPoolKernel { } for (const [k, v] of this.affinity) if (v === accountRef) this.affinity.delete(k); + let removedKnownRef = false; for (const [id, ref] of this.refs) - if (ref === accountRef) this.refs.delete(id); + if (ref === accountRef) { + this.refs.delete(id); + removedKnownRef = true; + } + // Membership is pool-global. A swept state no longer identifies every + // snapshot that observed this ref, so conservatively invalidate all live + // snapshot versions when a known account leaves the pool. + if (removedKnownRef) + for (const key of this.versions.keys()) changed.add(key); for (const key of changed) this.advanceVersion(key); } clear(capability: symbol): void { diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index badf5f31619..96694d00cd0 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -225,6 +225,36 @@ describe("CursorPoolKernel", () => { ).toBe(false); }); + test("removal invalidates a snapshot after only that account state was swept", () => { + const s = setup(); + const snap = s.kernel.activate("owner", "thread", s.capability)!; + + s.advance(CURSOR_POOL_TTL_MS - 1); + expect( + s.kernel.note429( + snap.refs[1]!, + "owner", + "thread", + s.capability, + ), + ).toBe(true); + s.advance(2); + s.setAccounts([ + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]); + + expect(s.kernel.activate("owner", "thread", s.capability)).toBeNull(); + expect(s.kernel.rollback(snap, s.capability)).toBe(false); + expect( + s.kernel.note429( + snap.refs[0]!, + "owner", + "thread", + s.capability, + ), + ).toBe(false); + }); + test("rejects NaN expiry in usable and unexpired checks", () => { const s = setup(); s.setAccounts([ From 5192183ceb0c37fdf39a47882400e86b796ed5a0 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:22:55 -0600 Subject: [PATCH 20/21] fix(cursor): prune orphaned pool versions --- src/providers/cursor-pool.ts | 12 ++++--- tests/providers/cursor/cursor-pool.test.ts | 38 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 47e9415b8c0..8f9fe138e6e 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -90,9 +90,9 @@ export class CursorPoolKernel { this.versions.set(key, next); return next; } - private hasLiveStateFor(owner: string, thread: string): boolean { + private hasLiveStateFor(key: string): boolean { for (const s of this.states.values()) { - if (s.owner === owner && s.thread === thread) return true; + if (this.key(s.owner, s.thread) === key) return true; } return false; } @@ -101,7 +101,7 @@ export class CursorPoolKernel { if (s.touched + CURSOR_POOL_TTL_MS <= now) { this.states.delete(key); const ownerThread = this.key(s.owner, s.thread); - if (!this.hasLiveStateFor(s.owner, s.thread)) { + if (!this.hasLiveStateFor(ownerThread)) { this.affinity.delete(ownerThread); this.versions.delete(ownerThread); } @@ -261,6 +261,7 @@ export class CursorPoolKernel { if (snapshot.previousAffinity) this.affinity.set(key, snapshot.previousAffinity); else this.affinity.delete(key); this.advanceVersion(key); + if (!this.hasLiveStateFor(key)) this.versions.delete(key); return true; } remove(accountRef: string, capability: symbol): void { @@ -287,7 +288,10 @@ export class CursorPoolKernel { // snapshot versions when a known account leaves the pool. if (removedKnownRef) for (const key of this.versions.keys()) changed.add(key); - for (const key of changed) this.advanceVersion(key); + for (const key of changed) { + this.advanceVersion(key); + if (!this.hasLiveStateFor(key)) this.versions.delete(key); + } } clear(capability: symbol): void { if (capability === this.capability) { diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index 96694d00cd0..84255bde29a 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -279,6 +279,44 @@ describe("CursorPoolKernel", () => { expect(s.kernel.rollback(staleSnap, s.capability)).toBe(false); }); + test("removing the final account prunes its version before key recreation", () => { + const s = setup(); + const stale = s.kernel.activate("owner", "thread", s.capability)!; + const generationBeforeRemoval = s.kernel.currentGeneration; + + s.setAccounts([]); + expect(s.kernel.activate("owner", "thread", s.capability)).toBeNull(); + expect(s.kernel.currentGeneration).toBeGreaterThan(generationBeforeRemoval); + expect(s.kernel.rollback(stale, s.capability)).toBe(false); + expect( + ( + s.kernel as unknown as { + versions: Map; + } + ).versions.size, + ).toBe(0); + + s.setAccounts(accounts); + const recreated = s.kernel.pick("owner", "thread", s.capability)!; + expect(recreated.generation).toBeGreaterThan(generationBeforeRemoval); + expect(s.kernel.rollback(stale, s.capability)).toBe(false); + }); + + test("rolling back an initial activation prunes its orphaned version", () => { + const s = setup(); + const initial = s.kernel.activate("owner", "thread", s.capability)!; + + expect(s.kernel.rollback(initial, s.capability)).toBe(true); + expect( + ( + s.kernel as unknown as { + versions: Map; + } + ).versions.size, + ).toBe(0); + expect(s.kernel.rollback(initial, s.capability)).toBe(false); + }); + test("activate and pick perform exactly one listAccounts store read and resolve pass per call", () => { let listAccountsCalls = 0; let resolveCalls = 0; From 470005908656f5608e878772bb72f5f659239117 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:48:00 -0600 Subject: [PATCH 21/21] perf(cursor): bound pool cleanup scans Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- src/providers/cursor-pool.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index 8f9fe138e6e..c9bdb66174b 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -90,22 +90,28 @@ export class CursorPoolKernel { this.versions.set(key, next); return next; } - private hasLiveStateFor(key: string): boolean { + private liveStateKeys(): Set { + const live = new Set(); for (const s of this.states.values()) { - if (this.key(s.owner, s.thread) === key) return true; + live.add(this.key(s.owner, s.thread)); } - return false; + return live; } private sweep(now = this.now()): void { + const candidates = new Set(); for (const [key, s] of this.states) if (s.touched + CURSOR_POOL_TTL_MS <= now) { this.states.delete(key); - const ownerThread = this.key(s.owner, s.thread); - if (!this.hasLiveStateFor(ownerThread)) { + candidates.add(this.key(s.owner, s.thread)); + } + if (candidates.size) { + const live = this.liveStateKeys(); + for (const ownerThread of candidates) + if (!live.has(ownerThread)) { this.affinity.delete(ownerThread); this.versions.delete(ownerThread); } - } + } } private rawAccounts(): ReadonlyArray { return ( @@ -261,7 +267,7 @@ export class CursorPoolKernel { if (snapshot.previousAffinity) this.affinity.set(key, snapshot.previousAffinity); else this.affinity.delete(key); this.advanceVersion(key); - if (!this.hasLiveStateFor(key)) this.versions.delete(key); + if (!snapshot.previous.length) this.versions.delete(key); return true; } remove(accountRef: string, capability: symbol): void { @@ -288,9 +294,10 @@ export class CursorPoolKernel { // snapshot versions when a known account leaves the pool. if (removedKnownRef) for (const key of this.versions.keys()) changed.add(key); + const live = changed.size ? this.liveStateKeys() : undefined; for (const key of changed) { this.advanceVersion(key); - if (!this.hasLiveStateFor(key)) this.versions.delete(key); + if (!live!.has(key)) this.versions.delete(key); } } clear(capability: symbol): void {