diff --git a/__tests__/agent-claude-usage.test.js b/__tests__/agent-claude-usage.test.js index 4452043..dc8391f 100644 --- a/__tests__/agent-claude-usage.test.js +++ b/__tests__/agent-claude-usage.test.js @@ -431,16 +431,20 @@ describe("at most 12 models per day", () => { // --- Frozen days and the scan threshold ---------------------------------------- describe("frozen days", () => { - const FROZEN = { - "2026-10-01": { - models: { "claude-opus-5-5": { input: 7, output: 7, cacheCreation: 7, cacheRead: 7, requests: 7 } }, - projects: { alpha: { work: 21, cacheRead: 7, requests: 7 } }, - }, - "2026-09-30": { models: {}, projects: {} }, + // What the agent hands over: a contiguous frozen run from the window start + // through 2026-10-01, empty days included. + const FROZEN = {}; + for (const day of windowDays("2026-10-04")) { + if (day > "2026-10-01") break; + FROZEN[day] = { models: {}, projects: {} }; + } + FROZEN["2026-10-01"] = { + models: { "claude-opus-5-5": { input: 7, output: 7, cacheCreation: 7, cacheRead: 7, requests: 7 } }, + projects: { alpha: { work: 21, cacheRead: 7, requests: 7 } }, }; it("opens no file whose mtime day is before the threshold (spy on file opening)", async () => { - // Threshold = day after the most recent frozen day = 2026-10-02. + // Threshold = oldest open day = day after the frozen run = 2026-10-02. const old = writeTranscript( "a/old.jsonl", [line({ ts: "2026-10-02T15:00:00Z", output: 5 })], // would count if opened @@ -544,10 +548,78 @@ describe("frozen days", () => { expect(dayOf(r, "2026-09-30").models).toEqual({}); expect(r.openDays["2026-10-01"]).toBeUndefined(); expect(r.openDays["2026-09-30"]).toBeUndefined(); - expect(Object.keys(r.openDays)).toHaveLength(28); + expect(Object.keys(r.openDays)).toEqual(["2026-10-02", "2026-10-03", "2026-10-04"]); expect(r.projects).toEqual([{ name: "alpha", work: 21 + 1 + 3, cacheRead: 7, requests: 8 }]); }); + it("a frozen day in the future never hides an open day's files", async () => { + const file = writeTranscript( + "a/open.jsonl", + [line({ ts: "2026-10-03T15:00:00Z", output: 3 })], + "2026-10-03T16:00:00Z", + ); + const opened = []; + const r = await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + frozenDays: { "2026-11-15": { models: {}, projects: {} } }, + openFile: (f, o) => { + opened.push(f); + return fs.createReadStream(f, o); + }, + }); + expect(opened).toEqual([file]); + expect(r.openDays["2026-10-03"].models["claude-opus-5-5"].output).toBe(3); + }); + + it("a gap in the frozen run keeps the open day before the gap scanned", async () => { + // 10-03 open, 10-04 (today) frozen: the oldest open day sets the threshold. + const file = writeTranscript( + "a/open.jsonl", + [line({ ts: "2026-10-03T15:00:00Z", output: 3 })], + "2026-10-03T16:00:00Z", + ); + const frozenDays = {}; + for (const day of windowDays("2026-10-04")) { + if (day !== "2026-10-03") frozenDays[day] = { models: {}, projects: {} }; + } + const opened = []; + const r = await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + frozenDays, + openFile: (f, o) => { + opened.push(f); + return fs.createReadStream(f, o); + }, + }); + expect(opened).toEqual([file]); + expect(Object.keys(r.openDays)).toEqual(["2026-10-03"]); + expect(r.openDays["2026-10-03"].models["claude-opus-5-5"].output).toBe(3); + }); + + it("every day frozen: no file is opened", async () => { + writeTranscript("a/x.jsonl", [line({ ts: "2026-10-04T15:00:00Z" })], "2026-10-04T16:00:00Z"); + const frozenDays = {}; + for (const day of windowDays("2026-10-04")) frozenDays[day] = { models: {}, projects: {} }; + const opened = []; + const r = await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + frozenDays, + openFile: (f, o) => { + opened.push(f); + return fs.createReadStream(f, o); + }, + }); + expect(opened).toEqual([]); + expect(Object.keys(r.openDays)).toEqual([]); + expect(r.days).toHaveLength(WINDOW_DAYS); + }); + it("re-reads frozen counters defensively (bad numbers -> 0, junk ignored)", async () => { const r = await aggregate({ root: tmpDir, @@ -634,3 +706,50 @@ describe("robustness", () => { ); }); }); + +// --- Names that collide with Object.prototype ------------------------------------ + +describe("prototype-shaped names", () => { + const PROTO_KEYS = ["work", "cacheRead", "requests", "input", "output", "cacheCreation"]; + + function prototypeIsClean() { + for (const k of PROTO_KEYS) { + expect(Object.prototype.hasOwnProperty.call(Object.prototype, k)).toBe(false); + expect(Object.prototype.hasOwnProperty.call(Object, k)).toBe(false); + } + } + + it("projects named __proto__ and constructor are counted, Object.prototype untouched", async () => { + writeTranscript( + "a/x.jsonl", + [ + line({ ts: "2026-10-03T15:00:00Z", cwd: `${HOME}/claude-code/__proto__`, output: 5 }), + line({ ts: "2026-10-03T16:00:00Z", cwd: `${HOME}/claude-code/constructor`, output: 7 }), + line({ ts: "2026-10-03T17:00:00Z", model: "constructor", output: 2 }), + ], + "2026-10-04T12:00:00Z", + ); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME }); + prototypeIsClean(); + const byName = Object.fromEntries(r.projects.map((p) => [p.name, p])); + expect(Object.keys(byName).sort()).toEqual(["__proto__", "alpha", "constructor"].sort()); + expect(Object.getOwnPropertyDescriptor(byName, "__proto__").value.work).toBe(1 + 5); + expect(byName.constructor.work).toBe(1 + 7); + expect(dayOf(r, "2026-10-03").models.constructor.output).toBe(2); + expect(JSON.parse(JSON.stringify(r.days)).length).toBe(WINDOW_DAYS); + }); + + it("a __proto__ key in a frozen day stays a plain project", async () => { + const frozenDays = JSON.parse( + '{"2026-10-01":{"models":{},"projects":{"__proto__":{"work":4,"cacheRead":0,"requests":1},"constructor":{"work":6,"cacheRead":0,"requests":1}}}}', + ); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays }); + prototypeIsClean(); + expect(r.projects.map((p) => [p.name, p.work]).sort()).toEqual( + [ + ["__proto__", 4], + ["constructor", 6], + ].sort(), + ); + }); +}); diff --git a/agent/claude-usage/aggregate.js b/agent/claude-usage/aggregate.js index c5f358b..d0bf34e 100644 --- a/agent/claude-usage/aggregate.js +++ b/agent/claude-usage/aggregate.js @@ -18,9 +18,13 @@ // are deduplicated on (message.id, requestId) across ALL files, keeping the // line with the largest output_tokens. // * THE SCAN THRESHOLD. Frozen days are never recomputed. A file whose mtime -// (Toronto day) is before the day after the most recent frozen day cannot -// hold a line on an open day, so it is not even opened: this is what keeps -// a run from re-reading ~210 MB of transcripts every time. +// (Toronto day) is before the oldest open day of the window cannot hold a +// line on an open day, so it is not even opened: this is what keeps a run +// from re-reading ~210 MB of transcripts every time. The threshold comes +// from the open days, never from the frozen keys: a stray frozen day (in +// the future, or after a gap) must not hide an open day's files. +// * NO PROTOTYPE. Names read from transcripts or the state file key plain +// maps built by dict(), so `__proto__` or `constructor` stay ordinary keys. const fs = require("node:fs"); const os = require("node:os"); @@ -136,6 +140,13 @@ function count(value) { return Number.isSafeInteger(value) && value >= 0 ? value : 0; } +// Every map keyed by a name read from a transcript or the state file (model, +// project, day) has no prototype: a project named `__proto__` or +// `constructor` is then a plain key, never a write into Object.prototype. +function dict() { + return Object.create(null); +} + function emptyModel() { return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, requests: 0 }; } @@ -179,7 +190,7 @@ function capModels(models) { (a < b ? -1 : a > b ? 1 : 0) ); }); - const out = {}; + const out = dict(); for (const name of names.slice(0, MAX_MODELS_PER_DAY - 1)) out[name] = models[name]; const other = emptyModel(); if (hasOther) addModel(other, models[OTHER_MODEL]); @@ -191,8 +202,8 @@ function capModels(models) { // Frozen days come from the agent's state file: re-read through the same // counters, never trusted verbatim. function normalizeDay(day) { - const models = {}; - const projects = {}; + const models = dict(); + const projects = dict(); const srcModels = day && typeof day.models === "object" && day.models ? day.models : {}; const srcProjects = day && typeof day.projects === "object" && day.projects ? day.projects : {}; @@ -294,7 +305,7 @@ async function aggregate({ } = {}) { if (!isDay(today)) throw new Error("aggregate: today must be a YYYY-MM-DD date"); - const frozen = {}; + const frozen = dict(); if (frozenDays && typeof frozenDays === "object") { for (const [day, value] of Object.entries(frozenDays)) { if (isDay(day)) frozen[day] = value; @@ -305,15 +316,13 @@ async function aggregate({ const windowStart = window[0]; const openDayList = window.filter((d) => !Object.prototype.hasOwnProperty.call(frozen, d)); - // Scan threshold: the day after the most recent frozen day. Floored at the - // window start — a file last written before the window cannot hold a line - // inside it. - const frozenList = Object.keys(frozen).sort(); - let threshold = windowStart; - if (frozenList.length) { - const afterFrozen = shiftDay(frozenList[frozenList.length - 1], 1); - if (afterFrozen > threshold) threshold = afterFrozen; - } + // Scan threshold: the oldest open day of the window. With the agent's + // contiguous frozen prefix this is the day after the most recent frozen day; + // computed from the open days rather than from the frozen keys, so a frozen + // day in the future or a gap in the frozen run can never push the threshold + // past an open day and silently empty it. A file last written before the + // window cannot hold a line inside it. Every day frozen: nothing to read. + const threshold = openDayList.length ? openDayList[0] : shiftDay(today, 1); // (message.id, requestId) -> best line so far. const requests = new Map(); @@ -383,8 +392,8 @@ async function aggregate({ // Open days: every non-frozen day of the window, empty ones included, so // the caller can freeze them all (an unfrozen empty day would hold the // threshold back and every run would re-read the whole window). - const openDays = {}; - for (const day of openDayList) openDays[day] = { models: {}, projects: {} }; + const openDays = dict(); + for (const day of openDayList) openDays[day] = { models: dict(), projects: dict() }; for (const r of requests.values()) { const bucket = openDays[r.day]; if (!bucket.models[r.model]) bucket.models[r.model] = emptyModel(); @@ -399,7 +408,7 @@ async function aggregate({ // Merged 30-day series and project totals over the window. const days = []; - const projectTotals = {}; + const projectTotals = dict(); for (const date of window) { const source = Object.prototype.hasOwnProperty.call(frozen, date) ? normalizeDay(frozen[date])