fix(usage-log): propagate non-ENOENT read errors to prevent silent data loss

readJsonArray previously caught all errors and returned [], so a
malformed token-usage.json (e.g. from an interrupted writeFile) caused
the next recordTokenUsage call to overwrite the file with only the new
entry, permanently erasing all prior records.

Fix: only suppress ENOENT (file not yet created). Any other error
(SyntaxError, EACCES, …) is re-thrown so appendRecord aborts and the
existing file is left intact. The write-queue slot still absorbs the
rejection via .catch() so future writes are not stalled; callers that
need to observe the failure (e.g. attempt.ts) can attach their own
.catch() handler.
This commit is contained in:
jiarung 2026-03-13 14:25:13 +00:00
parent 98822509a8
commit 020001d9b2
2 changed files with 29 additions and 2 deletions

View File

@ -120,6 +120,26 @@ describe("recordTokenUsage", () => {
expect(records[0].outputTokens).toBe(50);
});
it("does not overwrite a malformed token-usage.json — preserves corrupted file", async () => {
// Simulate an interrupted write that left partial JSON
await fs.mkdir(path.join(tmpDir, "memory"), { recursive: true });
await fs.writeFile(usageFile, '{"broken":true', "utf-8");
// recordTokenUsage must reject (caller is responsible for handling, e.g.
// attempt.ts uses .catch()) and must NOT overwrite the existing file.
await expect(
recordTokenUsage({
workspaceDir: tmpDir,
label: "llm_output",
usage: { input: 100, output: 50, total: 150 },
}),
).rejects.toThrow(SyntaxError);
// File must still contain the original corrupted content, not a new array.
const content = await fs.readFile(usageFile, "utf-8");
expect(content).toBe('{"broken":true');
});
it("serialises concurrent writes — no record is lost", async () => {
const N = 20;
await Promise.all(

View File

@ -27,8 +27,15 @@ async function readJsonArray(file: string): Promise<TokenUsageRecord[]> {
const raw = await fs.readFile(file, "utf-8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as TokenUsageRecord[]) : [];
} catch {
return [];
} catch (err) {
// File does not exist yet — start with an empty array.
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
// Any other error (malformed JSON, permission denied, partial write, …)
// must propagate so appendRecord aborts and the existing file is not
// silently overwritten with only the new entry.
throw err;
}
}