fix(coding-agent): timeboxed status usage refresh
Deferred the status-line quota refresh off the render path and raced it against a short startup timeout so slow Anthropic usage lookups cannot pin interactive startup. Added regression coverage for non-synchronous refresh startup and timeout backoff.\n\nFixes #3057
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Deferred status-line usage refreshes off the render path and timeboxed the startup fetch so slow Anthropic quota lookups no longer block interactive startup. ([#3057](https://github.com/can1357/oh-my-pi/issues/3057))
|
||||
|
||||
## [16.1.3] - 2026-06-19
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -154,6 +154,8 @@ interface ContextUsageMemo {
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGES: readonly AgentMessage[] = [];
|
||||
const STATUS_USAGE_START_DELAY_MS = 0;
|
||||
const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000;
|
||||
|
||||
function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
||||
return segments.includes("context_pct") || segments.includes("context_total");
|
||||
@@ -212,6 +214,7 @@ export class StatusLineComponent implements Component {
|
||||
} | null = null;
|
||||
#usageFetchedAt = 0;
|
||||
#usageInFlight = false;
|
||||
#usageStartTimer: Timer | null = null;
|
||||
// Context-usage memo. The status line redraws on every agent event, so the
|
||||
// hot path must not recompute context tokens unless an input changed.
|
||||
// `getContextUsage()` anchors on the last assistant's real prompt-token
|
||||
@@ -344,16 +347,24 @@ export class StatusLineComponent implements Component {
|
||||
dispose(): void {
|
||||
this.#disposed = true;
|
||||
this.#onBranchChange = null;
|
||||
this.#clearUsageStartTimer();
|
||||
if (this.#gitWatcher) {
|
||||
this.#gitWatcher.close();
|
||||
this.#gitWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
#clearUsageStartTimer(): void {
|
||||
if (!this.#usageStartTimer) return;
|
||||
clearTimeout(this.#usageStartTimer);
|
||||
this.#usageStartTimer = null;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.#invalidateGitCaches();
|
||||
}
|
||||
#invalidateSessionCaches(): void {
|
||||
this.#clearUsageStartTimer();
|
||||
this.#cachedUsage = null;
|
||||
this.#usageFetchedAt = 0;
|
||||
this.#usageInFlight = false;
|
||||
@@ -521,36 +532,53 @@ export class StatusLineComponent implements Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Background-refresh the Anthropic OAuth quota report. Guarded by a 5-min
|
||||
* TTL on both success (cache lifetime) and error (backoff). Exposed
|
||||
* (non-private) so unit tests can verify the backoff invariant.
|
||||
* Background-refresh the Anthropic OAuth quota report outside the render call.
|
||||
* Startup redraws only arm a short-delayed task; the fetch itself gets a hard
|
||||
* timeout and render continues with cached/empty quota data.
|
||||
*/
|
||||
refreshUsageInBackground(): void {
|
||||
const now = Date.now();
|
||||
if (this.#usageInFlight) return;
|
||||
if (this.#usageInFlight || this.#usageStartTimer) return;
|
||||
if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
|
||||
const session = this.session;
|
||||
const fetcher = (session as { fetchUsageReports?: () => Promise<unknown> }).fetchUsageReports;
|
||||
const fetcher = (session as { fetchUsageReports?: (signal?: AbortSignal) => Promise<unknown> }).fetchUsageReports;
|
||||
if (typeof fetcher !== "function") return;
|
||||
this.#usageInFlight = true;
|
||||
void fetcher
|
||||
.call(session)
|
||||
.then(reports => {
|
||||
if (this.session !== session) return;
|
||||
this.#cachedUsage = this.#normalizeUsageReports(reports);
|
||||
this.#usageFetchedAt = Date.now();
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.session !== session) return;
|
||||
// Backoff on error: stamp the fetch time so the 5-min TTL guard
|
||||
// also acts as an error budget. Without this, every render
|
||||
// kicks off another fetch (gated only by #usageInFlight),
|
||||
// which hammers the endpoint during a network outage / 5xx.
|
||||
this.#usageFetchedAt = Date.now();
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.session === session) this.#usageInFlight = false;
|
||||
});
|
||||
this.#usageStartTimer = setTimeout(() => {
|
||||
this.#usageStartTimer = null;
|
||||
void this.#runUsageRefresh(session, fetcher);
|
||||
}, STATUS_USAGE_START_DELAY_MS);
|
||||
}
|
||||
|
||||
async #runUsageRefresh(session: AgentSession, fetcher: (signal?: AbortSignal) => Promise<unknown>): Promise<void> {
|
||||
if (this.#disposed || this.session !== session) {
|
||||
this.#usageInFlight = false;
|
||||
return;
|
||||
}
|
||||
const signal = AbortSignal.timeout(STATUS_USAGE_REFRESH_TIMEOUT_MS);
|
||||
try {
|
||||
const reports = await this.#raceUsageRefreshWithSignal(fetcher.call(session, signal), signal);
|
||||
if (this.session !== session) return;
|
||||
this.#cachedUsage = this.#normalizeUsageReports(reports);
|
||||
this.#usageFetchedAt = Date.now();
|
||||
} catch {
|
||||
if (this.session !== session) return;
|
||||
this.#usageFetchedAt = Date.now();
|
||||
} finally {
|
||||
if (this.session === session) this.#usageInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async #raceUsageRefreshWithSignal(promise: Promise<unknown>, signal: AbortSignal): Promise<unknown> {
|
||||
if (signal.aborted) throw signal.reason;
|
||||
const aborted = Promise.withResolvers<never>();
|
||||
const onAbort = () => aborted.reject(signal.reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
return await Promise.race([promise, aborted.promise]);
|
||||
} finally {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
#normalizeUsageReports(reports: unknown): {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { StatusLineComponent } from "@oh-my-pi/pi-coding-agent/modes/components/status-line";
|
||||
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function makeSession(fetchUsageReports: (signal?: AbortSignal) => Promise<unknown>): AgentSession {
|
||||
return { fetchUsageReports } as unknown as AgentSession;
|
||||
}
|
||||
|
||||
describe("StatusLineComponent usage refresh", () => {
|
||||
beforeEach(async () => {
|
||||
resetSettingsForTest();
|
||||
await Settings.init({ inMemory: true });
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetSettingsForTest();
|
||||
});
|
||||
|
||||
it("does not invoke usage fetching synchronously on the render path", async () => {
|
||||
let calls = 0;
|
||||
const component = new StatusLineComponent(
|
||||
makeSession(async () => {
|
||||
calls++;
|
||||
return [];
|
||||
}),
|
||||
);
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
expect(calls).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("passes a startup timeout signal to the background usage fetch", async () => {
|
||||
let signal: AbortSignal | undefined;
|
||||
const component = new StatusLineComponent(
|
||||
makeSession(async nextSignal => {
|
||||
signal = nextSignal;
|
||||
return [];
|
||||
}),
|
||||
);
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
vi.advanceTimersByTime(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("backs off after the startup timeout when usage fetching hangs", async () => {
|
||||
let calls = 0;
|
||||
const component = new StatusLineComponent(
|
||||
makeSession(() => {
|
||||
calls++;
|
||||
return Promise.withResolvers<unknown>().promise;
|
||||
}),
|
||||
);
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
vi.advanceTimersByTime(0);
|
||||
await flushMicrotasks();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
await flushMicrotasks();
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
vi.advanceTimersByTime(0);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user