diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 94034375f..7a52bcfa1 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed provider usage window stats silently showing no data during SQLite contention by installing a five-second busy timeout on read-only agent database connections ([#7300](https://github.com/can1357/oh-my-pi/issues/7300)). + ## [17.1.2] - 2026-07-24 ### Added diff --git a/packages/stats/src/usage-windows.ts b/packages/stats/src/usage-windows.ts index a1cbccadd..ebeff82e2 100644 --- a/packages/stats/src/usage-windows.ts +++ b/packages/stats/src/usage-windows.ts @@ -59,6 +59,7 @@ export function readUsageSnapshots(sinceMs: number, dbPath = getAgentDbPath()): let db: Database | null = null; try { db = new Database(dbPath, { readonly: true }); + db.run("PRAGMA busy_timeout = 5000"); const rows = db .prepare( `SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status diff --git a/packages/stats/test/provider-stats.test.ts b/packages/stats/test/provider-stats.test.ts index 6810572be..8a4994ff5 100644 --- a/packages/stats/test/provider-stats.test.ts +++ b/packages/stats/test/provider-stats.test.ts @@ -183,6 +183,35 @@ describe("readUsageSnapshots", () => { usedFraction: 0.3, }); }); + + it("waits for a contended database instead of returning no snapshots", async () => { + createAgentDb([snapshot({ recordedAt: T0, usedFraction: 0.3 })]); + const locker = Bun.spawn( + [ + process.execPath, + "-e", + `import { Database } from "bun:sqlite"; +const db = new Database(process.argv[1]); +db.run("BEGIN EXCLUSIVE"); +process.stdout.write("locked\\n"); +// This integration probe needs a real SQLite lock lifetime; fake timers cannot advance a separate process. +await Bun.sleep(100); +db.run("COMMIT"); +db.close();`, + getAgentDbPath(), + ], + { env: { HOME: process.env.HOME ?? "", PATH: process.env.PATH ?? "" }, stdout: "pipe", stderr: "pipe" }, + ); + const output = locker.stdout.getReader(); + const ready = await output.read(); + output.releaseLock(); + expect(new TextDecoder().decode(ready.value)).toContain("locked"); + + const rows = readUsageSnapshots(0); + const [exitCode, stderr] = await Promise.all([locker.exited, new Response(locker.stderr).text()]); + expect(exitCode, stderr).toBe(0); + expect(rows).toHaveLength(1); + }); }); describe("getProviderDashboardStats", () => {