Skip to content

Commit 885fd94

Browse files
committed
feat(quota): background poll keeps idle account quota fresh
1 parent 4352eed commit 885fd94

10 files changed

Lines changed: 1871 additions & 75 deletions

packages/opencode/src/core/accounts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,8 @@ export class FallbackAccountManager {
15141514
checkedAt,
15151515
},
15161516
account.access,
1517+
false,
1518+
account.accountId,
15171519
)
15181520
}
15191521

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import {
2+
type RefreshAllQuotaDeps,
3+
type RefreshAllQuotaResult,
4+
refreshAllQuota,
5+
} from './refresh-all-quota'
6+
import { acquireRefreshFileLock } from './refresh-file-lock'
7+
8+
export const BACKGROUND_QUOTA_REFRESH_INTERVAL_MS = 5 * 60_000
9+
export const BACKGROUND_QUOTA_REFRESH_JITTER_MS = 30_000
10+
export const BACKGROUND_QUOTA_FRESHNESS_MS = 4 * 60_000
11+
export const BACKGROUND_QUOTA_REFRESH_LOCK_NAME = 'bg-quota-refresh'
12+
// The lock must outlive the worst-case serial refresh: each fallback account
13+
// costs a token refresh plus a wham fetch (up to ~15s each), so a fleet of
14+
// accounts can run well past a minute. A TTL that expires mid-run lets a second
15+
// process start a concurrent refresh — 120s covers a multi-account pass with
16+
// margin while still releasing a crashed owner's lock within one poll interval.
17+
export const BACKGROUND_QUOTA_REFRESH_LOCK_TTL_MS = 120_000
18+
19+
type TimerHandle = ReturnType<typeof setInterval>
20+
21+
interface BackgroundQuotaRefreshOptions {
22+
setIntervalFn?: (callback: () => void, intervalMs: number) => TimerHandle
23+
clearIntervalFn?: (timer: TimerHandle) => void
24+
random?: () => number
25+
onError?: (error: unknown) => void
26+
}
27+
28+
type RefreshAllQuotaFn = (
29+
deps: RefreshAllQuotaDeps,
30+
) => Promise<RefreshAllQuotaResult[]>
31+
32+
type BackgroundLockHandle = { release: () => Promise<void> }
33+
export type BackgroundLockAcquirer = () => Promise<BackgroundLockHandle | null>
34+
35+
/**
36+
* Acquire the cross-process lock that serializes the background refresh.
37+
* `renew` re-arms the TTL on an interval until release(), so a serial pass that
38+
* outlasts the TTL (a fleet of accounts each costing a token refresh plus a
39+
* wham fetch) never lets the lock expire mid-run — an expiry would let a second
40+
* process start a concurrent refresh. Overrides exist so tests can drive the
41+
* renewal with a mock clock and a fast interval.
42+
*/
43+
export function acquireBackgroundRefreshLock(
44+
configPath: string,
45+
overrides?: { now?: () => number; renewIntervalMs?: number },
46+
): Promise<BackgroundLockHandle | null> {
47+
return acquireRefreshFileLock({
48+
name: BACKGROUND_QUOTA_REFRESH_LOCK_NAME,
49+
ttlMs: BACKGROUND_QUOTA_REFRESH_LOCK_TTL_MS,
50+
path: configPath,
51+
renew: true,
52+
...overrides,
53+
})
54+
}
55+
56+
export function refreshQuotaInBackground(
57+
deps: RefreshAllQuotaDeps,
58+
refreshFn: RefreshAllQuotaFn = refreshAllQuota,
59+
acquireLock: BackgroundLockAcquirer = () =>
60+
acquireBackgroundRefreshLock(deps.configPath),
61+
): Promise<RefreshAllQuotaResult[]> {
62+
// The lock is advisory and only serializes the fetch across processes. A
63+
// clean null means another live owner is already refreshing, so this tick
64+
// skips — that process will update the shared sidebar file. A lock-mechanism
65+
// failure fails open and refreshes anyway, matching the pre-lock behavior
66+
// rather than stranding quota freshness on a broken lock.
67+
return claimBackgroundRefresh(deps, refreshFn, acquireLock)
68+
}
69+
70+
async function claimBackgroundRefresh(
71+
deps: RefreshAllQuotaDeps,
72+
refreshFn: RefreshAllQuotaFn,
73+
acquireLock: BackgroundLockAcquirer,
74+
): Promise<RefreshAllQuotaResult[]> {
75+
let lock: BackgroundLockHandle | null | undefined
76+
try {
77+
lock = await acquireLock()
78+
} catch {
79+
lock = undefined
80+
}
81+
if (lock === null) return []
82+
try {
83+
return await refreshFn({
84+
...deps,
85+
respectBackoff: true,
86+
skipFresherThanMs: BACKGROUND_QUOTA_FRESHNESS_MS,
87+
})
88+
} finally {
89+
await lock?.release().catch(() => {})
90+
}
91+
}
92+
93+
export class BackgroundQuotaRefresh {
94+
private readonly setIntervalFn: NonNullable<
95+
BackgroundQuotaRefreshOptions['setIntervalFn']
96+
>
97+
private readonly clearIntervalFn: NonNullable<
98+
BackgroundQuotaRefreshOptions['clearIntervalFn']
99+
>
100+
private readonly random: () => number
101+
private onError: ((error: unknown) => void) | undefined
102+
private run: (() => Promise<void>) | undefined
103+
private timer: TimerHandle | undefined
104+
private tickPromise: Promise<void> | undefined
105+
private stopped = false
106+
107+
constructor(options: BackgroundQuotaRefreshOptions = {}) {
108+
this.setIntervalFn = options.setIntervalFn ?? setInterval
109+
this.clearIntervalFn = options.clearIntervalFn ?? clearInterval
110+
this.random = options.random ?? Math.random
111+
this.onError = options.onError
112+
}
113+
114+
start(
115+
run: () => Promise<void>,
116+
onError: ((error: unknown) => void) | undefined = this.onError,
117+
): void {
118+
this.run = run
119+
this.onError = onError
120+
this.stopped = false
121+
if (this.timer) return
122+
123+
const jitter = Math.round(
124+
(this.random() * 2 - 1) * BACKGROUND_QUOTA_REFRESH_JITTER_MS,
125+
)
126+
this.timer = this.setIntervalFn(() => {
127+
const currentRun = this.run
128+
if (this.stopped || !currentRun || this.tickPromise) return
129+
const tickPromise = currentRun()
130+
.catch((error) => {
131+
try {
132+
this.onError?.(error)
133+
} catch {}
134+
})
135+
.finally(() => {
136+
if (this.tickPromise === tickPromise) this.tickPromise = undefined
137+
})
138+
this.tickPromise = tickPromise
139+
}, BACKGROUND_QUOTA_REFRESH_INTERVAL_MS + jitter)
140+
if ('unref' in this.timer) this.timer.unref()
141+
}
142+
143+
// Non-blocking stop: clears the timer and bars further ticks, but lets an
144+
// in-flight run finish naturally (it checks isStopped() before committing
145+
// sidebar state, so a stopping poller never writes a stale snapshot).
146+
stop(): void {
147+
this.stopped = true
148+
this.run = undefined
149+
if (!this.timer) return
150+
this.clearIntervalFn(this.timer)
151+
this.timer = undefined
152+
}
153+
154+
isStopped(): boolean {
155+
return this.stopped
156+
}
157+
}

packages/opencode/src/core/quota-manager.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@ export class QuotaManager {
191191
// a re-login (credential change) for the same account id invalidates the
192192
// stale entry instead of being treated as fresh.
193193
private fallbackTokenFps = new Map<string, string>()
194+
// Stable ChatGPT account identity that produced each fallback cache entry,
195+
// keyed by the internal storage id. Mirrors mainQuotaAccountId for the main
196+
// account: lets the policy peek survive a token REFRESH (same identity) while
197+
// still dropping on a genuine account SWITCH — a re-login under the same
198+
// storage id must not let the old account's quota judge the new one.
199+
private fallbackAccountIds = new Map<string, string>()
194200

195201
// --- Inflight deduplication ---
196202
private inflightMain: Promise<OAuthQuotaSnapshot> | null = null
@@ -345,8 +351,26 @@ export class QuotaManager {
345351
* Non-invalidating read of a cached fallback quota for POLICY decisions
346352
* (killswitch). Keyed by the stable internal account id, so a token refresh
347353
* for the same account does not drop it (getFallback(id, token) would).
354+
*
355+
* Pass the live ChatGPT accountId to still drop on a genuine account SWITCH:
356+
* if the cached entry's identity is known and differs, return null so the
357+
* killswitch treats it as unknown rather than judging the account now logged
358+
* in under this id by the previous account's quota — mirroring
359+
* peekMainForPolicy. When identity is unknown on either side, the cached
360+
* snapshot is returned (best-effort).
348361
*/
349-
peekFallbackForPolicy(accountId: string): QuotaEntry | null {
362+
peekFallbackForPolicy(
363+
accountId: string,
364+
chatgptAccountId?: string,
365+
): QuotaEntry | null {
366+
const storedIdentity = this.fallbackAccountIds.get(accountId)
367+
if (
368+
chatgptAccountId &&
369+
storedIdentity &&
370+
storedIdentity !== chatgptAccountId
371+
) {
372+
return null
373+
}
350374
return this.fallbacks.get(accountId) ?? null
351375
}
352376

@@ -355,6 +379,7 @@ export class QuotaManager {
355379
entry: QuotaEntry,
356380
accessToken?: string,
357381
completeSnapshot = false,
382+
chatgptAccountId?: string,
358383
): void {
359384
// Empty snapshots replace cache only when their transport guarantees a
360385
// complete frame; otherwise a non-quota response could erase good data.
@@ -365,6 +390,11 @@ export class QuotaManager {
365390
} else {
366391
this.fallbackTokenFps.delete(accountId)
367392
}
393+
// Record the stable identity only when known; a later write that omits it
394+
// (an internal refresh lacks the ChatGPT id) keeps the prior value,
395+
// mirroring setMain's set-only-when-present rule.
396+
if (chatgptAccountId)
397+
this.fallbackAccountIds.set(accountId, chatgptAccountId)
368398
if (quotaLooksHealthy(entry.quota)) this.clearRateLimited(accountId)
369399
}
370400

@@ -623,6 +653,8 @@ export class QuotaManager {
623653
checkedAt,
624654
},
625655
account.access,
656+
false,
657+
account.accountId,
626658
)
627659
}
628660
}

0 commit comments

Comments
 (0)