framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
118 lines • 4.55 kB
JavaScript
import { isTransientQuotaReason } from './driver/index.js';
/**
* How often to read the quota when everything is healthy. A read spawns the
* whole agent CLI (~5s), and the agent's own usage fetch is refused upstream if
* asked too often, so this is deliberately slow: the boundary moves over days,
* which a 5-minute sample rate resolves comfortably.
*/
export const DEFAULT_POLL_MS = 5 * 60 * 1000;
/** Longest gap between reads once backoff has stretched it. */
export const MAX_POLL_MS = 30 * 60 * 1000;
/**
* Keeps a recent quota reading on hand (#525).
*
* Polling is slow by design and backs *off* on failure rather than retrying
* into it: the agent's usage fetch is refused upstream when asked too often,
* and the penalty window is minutes long, so an eager retry loop would keep the
* number permanently unavailable — the opposite of the goal.
*/
export class QuotaPoller {
opts;
envelope = { latest: undefined, lastGood: undefined, lastGoodAt: undefined, lastFailureAt: undefined };
timer;
running = false;
currentIntervalMs;
stopped = false;
now;
constructor(opts) {
this.opts = opts;
this.currentIntervalMs = opts.intervalMs ?? DEFAULT_POLL_MS;
this.now = opts.now ?? (() => Date.now());
}
/** What we currently believe. */
current() {
return { ...this.envelope };
}
/** The gap before the next read, which grows while the fetch keeps being refused. */
get intervalMs() {
return this.currentIntervalMs;
}
/** Whether the poller has given up (an authoritative failure, or {@link stop}). */
get isStopped() {
return this.stopped;
}
/**
* Read once, now, and fold the result in. Safe to call on demand (e.g. right
* after a turn settles) alongside the timer.
*/
async poll() {
let quota;
try {
quota = await this.opts.read();
}
catch {
// A driver that throws is the same story as one that reports a failed
// fetch: this attempt told us nothing, and it may work next time.
quota = { available: false, reason: 'fetch-failed' };
}
this.envelope = { ...this.envelope, latest: quota };
if (quota.available) {
this.onGood(quota);
}
else {
this.onBad(quota.reason);
}
return quota;
}
/**
* Begin polling, starting with a read right now rather than one interval from
* now: a poller whose first reading lands five minutes in is no use to an agent
* that just started, and the session's own measurement needs a baseline. Not
* awaited — the read takes ~5s and nothing should wait on it. Idempotent.
*/
start() {
if (this.running || this.stopped)
return;
this.running = true;
void this.poll().finally(() => this.schedule());
}
/** Stop polling. Idempotent. */
stop() {
this.stopped = true;
this.running = false;
if (this.timer)
clearTimeout(this.timer);
this.timer = undefined;
}
onGood(quota) {
this.envelope = { ...this.envelope, lastGood: quota, lastGoodAt: this.now() };
// Reset the backoff: the fetch is working again.
this.currentIntervalMs = this.opts.intervalMs ?? DEFAULT_POLL_MS;
}
onBad(reason) {
this.envelope = { ...this.envelope, lastFailureAt: this.now() };
if (!isTransientQuotaReason(reason)) {
// Authoritative: no subscription, or no agent. Asking again changes
// nothing, and the retained reading would misrepresent the account.
this.envelope = { ...this.envelope, lastGood: undefined, lastGoodAt: undefined };
this.stop();
return;
}
// Transient, so keep lastGood and ask again later — but later than last time.
this.currentIntervalMs = Math.min(this.currentIntervalMs * 2, this.opts.maxIntervalMs ?? MAX_POLL_MS);
}
schedule() {
if (this.stopped)
return;
this.timer = setTimeout(() => {
void this.poll().finally(() => {
this.timer = undefined;
this.schedule();
});
}, this.currentIntervalMs);
// Don't hold the process open just to read a quota; the daemon's own work
// decides its lifetime. Unlike the read's timeout, nothing awaits this.
this.timer.unref?.();
}
}
//# sourceMappingURL=quota-poller.js.map