Skip to content

Commit c6b1921

Browse files
authored
fix(usage): prune stale usage cache temp files (#93725)
Co-authored-by: markoub <[email protected]>
1 parent 6fa9ea0 commit c6b1921

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

src/infra/session-cost-usage.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,72 @@ describe("session cost usage", () => {
724724
});
725725
});
726726

727+
it("reclaims stale usage cache temp files before refreshing", async () => {
728+
const root = await makeSessionCostRoot("cost-cache-temp-cleanup");
729+
const sessionsDir = path.join(root, "agents", "main", "sessions");
730+
await fs.mkdir(sessionsDir, { recursive: true });
731+
const sessionFile = path.join(sessionsDir, "sess-cache-temp-cleanup.jsonl");
732+
await fs.writeFile(
733+
sessionFile,
734+
transcriptText("sess-cache-temp-cleanup", {
735+
type: "message",
736+
timestamp: "2026-02-05T12:00:00.000Z",
737+
message: {
738+
role: "assistant",
739+
provider: "openai",
740+
model: "gpt-5.4",
741+
usage: {
742+
input: 10,
743+
output: 20,
744+
totalTokens: 30,
745+
cost: { total: 0.03 },
746+
},
747+
},
748+
}),
749+
"utf-8",
750+
);
751+
752+
const staleLegacyTempPath = path.join(sessionsDir, ".usage-cost-cache.json.12345.tmp");
753+
const staleCurrentTempPath = path.join(sessionsDir, ".usage-cost-cache.12345.tmp");
754+
const recentTempPath = path.join(sessionsDir, ".usage-cost-cache.67890.tmp");
755+
const lockTempPath = path.join(sessionsDir, ".usage-cost-cache.json.lock.12345.tmp");
756+
await Promise.all(
757+
[staleLegacyTempPath, staleCurrentTempPath, recentTempPath, lockTempPath].map((tempPath) =>
758+
fs.writeFile(tempPath, "partial\n", "utf-8"),
759+
),
760+
);
761+
const staleTime = new Date(Date.now() - 60_000);
762+
await Promise.all(
763+
[staleLegacyTempPath, staleCurrentTempPath, lockTempPath].map((tempPath) =>
764+
fs.utimes(tempPath, staleTime, staleTime),
765+
),
766+
);
767+
768+
const exists = async (filePath: string): Promise<boolean> =>
769+
await fs.stat(filePath).then(
770+
() => true,
771+
() => false,
772+
);
773+
774+
await withStateDir(root, async () => {
775+
const result = await refreshCostUsageCache();
776+
expect(result).toBe("refreshed");
777+
778+
expect(await exists(staleLegacyTempPath)).toBe(false);
779+
expect(await exists(staleCurrentTempPath)).toBe(false);
780+
expect(await exists(recentTempPath)).toBe(true);
781+
expect(await exists(lockTempPath)).toBe(true);
782+
783+
const summary = await loadCostUsageSummaryFromCache({
784+
startMs: Date.UTC(2026, 1, 5),
785+
endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1,
786+
requestRefresh: false,
787+
});
788+
expect(summary.totals.totalTokens).toBe(30);
789+
expect(summary.cacheStatus?.status).toBe("fresh");
790+
});
791+
});
792+
727793
it("keeps queued durable aggregate refresh state scoped to the cache path", async () => {
728794
const firstRoot = await makeSessionCostRoot("cost-cache-queued-first");
729795
const secondRoot = await makeSessionCostRoot("cost-cache-queued-second");

src/infra/session-cost-usage.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ const emptyTotals = (): CostUsageTotals => ({
9090
const USAGE_COST_CACHE_VERSION = 4;
9191
const USAGE_COST_CACHE_FILE = ".usage-cost-cache.json";
9292
const USAGE_COST_CACHE_LOCK_WRITE_GRACE_MS = 10_000;
93+
const USAGE_COST_CACHE_TEMP_FILE_GRACE_MS = USAGE_COST_CACHE_LOCK_WRITE_GRACE_MS;
9394
const USAGE_COST_TRANSCRIPT_STAT_CONCURRENCY = 32;
9495
// Checkpoint policy for refreshCostUsageCache: bound the cost of full cache
9596
// serialization when scanning thousands of session files. Smaller of the two
@@ -376,6 +377,32 @@ async function writeUsageCostCache(cachePath: string, cache: UsageCostCacheFile)
376377
});
377378
}
378379

380+
function isUsageCostCacheTempFileName(name: string): boolean {
381+
if (!name.endsWith(".tmp") || name.startsWith(`${USAGE_COST_CACHE_FILE}.lock.`)) {
382+
return false;
383+
}
384+
return name.startsWith(".usage-cost-cache.") || name.startsWith(`${USAGE_COST_CACHE_FILE}.`);
385+
}
386+
387+
async function cleanupStaleUsageCostCacheTempFiles(cachePath: string): Promise<void> {
388+
const dir = path.dirname(cachePath);
389+
const cutoffMs = Date.now() - USAGE_COST_CACHE_TEMP_FILE_GRACE_MS;
390+
const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => []);
391+
await Promise.all(
392+
entries.map(async (entry) => {
393+
if (!entry.isFile() || !isUsageCostCacheTempFileName(entry.name)) {
394+
return;
395+
}
396+
const tempPath = path.join(dir, entry.name);
397+
const stats = await fs.promises.stat(tempPath).catch(() => null);
398+
if (!stats || stats.mtimeMs > cutoffMs) {
399+
return;
400+
}
401+
await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
402+
}),
403+
);
404+
}
405+
379406
async function listUsageCountedTranscriptFileStats(
380407
agentId?: string,
381408
params?: { minMtimeMs?: number; sessionsDir?: string },
@@ -1518,6 +1545,7 @@ async function refreshCostUsageCacheForPath(params?: {
15181545
return "busy";
15191546
}
15201547
try {
1548+
await cleanupStaleUsageCostCacheTempFiles(cachePath);
15211549
const pricingFingerprint = resolveUsageCostPricingFingerprint(params?.config);
15221550
const cache = await readUsageCostCache(cachePath);
15231551
const files = await listUsageCountedTranscriptFiles(params?.agentId, {

0 commit comments

Comments
 (0)