diff --git a/src/agents/usage-log.test.ts b/src/agents/usage-log.test.ts index e250bd63a3b..0922a328989 100644 --- a/src/agents/usage-log.test.ts +++ b/src/agents/usage-log.test.ts @@ -215,6 +215,36 @@ describe("recordTokenUsage", () => { expect(lockExists).toBe(false); }); + it("different path spellings for the same workspace share one queue — no record is lost", async () => { + // Symlink tmpDir → another name so the same physical directory has two + // spellings. Without queue-key canonicalisation both spellings create + // independent writeQueues entries; when one chain holds the file lock + // (HELD_LOCKS set) the other re-entrantly joins it and both execute the + // read-modify-write cycle concurrently, silently dropping entries. + const symlinkDir = `${tmpDir}-symlink`; + await fs.symlink(tmpDir, symlinkDir); + try { + // Mix canonical and symlink paths across concurrent writes. + const N = 6; + await Promise.all( + Array.from({ length: N }, (_, i) => + recordTokenUsage({ + workspaceDir: i % 2 === 0 ? tmpDir : symlinkDir, + label: "llm_output", + usage: { input: i + 1, output: 1, total: i + 2 }, + }), + ), + ); + + // All N records must survive — none may be lost to a concurrent + // read-modify-write collision. + const records = JSON.parse(await fs.readFile(usageFile, "utf-8")); + expect(records).toHaveLength(N); + } finally { + await fs.unlink(symlinkDir).catch(() => {}); + } + }); + it("serialises concurrent writes — no record is lost", async () => { const N = 20; await Promise.all( diff --git a/src/agents/usage-log.ts b/src/agents/usage-log.ts index 4f04ddd0d01..dc640f7410e 100644 --- a/src/agents/usage-log.ts +++ b/src/agents/usage-log.ts @@ -129,8 +129,16 @@ export async function recordTokenUsage(params: { } const memoryDir = path.join(params.workspaceDir, "memory"); - const file = path.join(memoryDir, "token-usage.json"); await fs.mkdir(memoryDir, { recursive: true }); + // Canonicalize before keying writeQueues so that different path spellings + // for the same physical directory (e.g. a symlink vs its target) share a + // single in-process queue. Without this, two spellings produce separate + // queue entries and both call appendRecord concurrently; when + // withFileLock's HELD_LOCKS map then resolves both to the same normalised + // path the second caller re-entrantly joins the first — allowing concurrent + // read-modify-write cycles that silently drop entries. + const realMemoryDir = await fs.realpath(memoryDir).catch(() => memoryDir); + const file = path.join(realMemoryDir, "token-usage.json"); const entry: TokenUsageRecord = { id: makeId(),