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:
roboomp
2026-06-19 17:06:47 +00:00
parent 259bb004e1
commit 31122d2409
3 changed files with 142 additions and 23 deletions
+4
View File
@@ -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);
});
});