From a7a7923d09d6621cedba8cde3da3214cfacb5e3a Mon Sep 17 00:00:00 2001 From: jiarung Date: Fri, 13 Mar 2026 23:35:12 +0000 Subject: [PATCH] fix(usage-log): reject non-array token logs instead of resetting history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readJsonArray treated any valid JSON that is not an array as [], causing appendRecord to overwrite the file with only the new entry — silently deleting all prior data. This is the same data-loss mode the malformed-JSON fix was trying to prevent. Fix: throw ERR_UNEXPECTED_TOKEN_LOG_SHAPE when parsed JSON is not an array so appendRecord aborts and the existing file is preserved. --- src/agents/usage-log.test.ts | 18 ++++++++++++++++++ src/agents/usage-log.ts | 13 ++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/agents/usage-log.test.ts b/src/agents/usage-log.test.ts index e61c48af23d..37ea1a92e54 100644 --- a/src/agents/usage-log.test.ts +++ b/src/agents/usage-log.test.ts @@ -120,6 +120,24 @@ describe("recordTokenUsage", () => { expect(records[0].outputTokens).toBe(50); }); + it("does not overwrite a valid-but-non-array token-usage.json — rejects unexpected shape", async () => { + // Simulate a manual edit or migration that left a valid JSON object + await fs.mkdir(path.join(tmpDir, "memory"), { recursive: true }); + await fs.writeFile(usageFile, '{"legacy": true, "records": []}', "utf-8"); + + await expect( + recordTokenUsage({ + workspaceDir: tmpDir, + label: "llm_output", + usage: { input: 100, output: 50, total: 150 }, + }), + ).rejects.toThrow("not an array"); + + // File must be unchanged — the legacy data is preserved. + const content = await fs.readFile(usageFile, "utf-8"); + expect(content).toBe('{"legacy": true, "records": []}'); + }); + 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 }); diff --git a/src/agents/usage-log.ts b/src/agents/usage-log.ts index 22966a1cee8..2370ee9b24e 100644 --- a/src/agents/usage-log.ts +++ b/src/agents/usage-log.ts @@ -27,7 +27,18 @@ async function readJsonArray(file: string): Promise { try { const raw = await fs.readFile(file, "utf-8"); const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as TokenUsageRecord[]) : []; + if (!Array.isArray(parsed)) { + // Valid JSON but unexpected shape (object, number, string, …). + // Returning [] here would cause appendRecord to overwrite the file + // with only the new entry, silently deleting prior data. + throw Object.assign( + new Error( + `token-usage.json contains valid JSON but is not an array (got ${typeof parsed})`, + ), + { code: "ERR_UNEXPECTED_TOKEN_LOG_SHAPE" }, + ); + } + return parsed as TokenUsageRecord[]; } catch (err) { // File does not exist yet — start with an empty array. if ((err as NodeJS.ErrnoException).code === "ENOENT") {