fix(agent): take the scan threshold from the open days, key maps without prototype
Some checks are pending
CI / verification (pull_request) Successful in 28s
CI / verification (push) Waiting to run

The threshold came from the most recent frozen key: a frozen day in the
future, or a gap in the frozen run, pushed it past open days whose files
were then never read, and the agent would have frozen them empty for good.
It is now the oldest open day of the window (the same day as before for the
agent's contiguous frozen prefix).

Project, model and day maps are now Object.create(null): a project named
__proto__ or constructor wrote NaN counters onto Object.prototype and
dropped out of the top.

Resolves #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-04 15:25:43 -04:00
parent 5cb9182911
commit c888f7c633
2 changed files with 155 additions and 27 deletions

View file

@ -431,16 +431,20 @@ describe("at most 12 models per day", () => {
// --- Frozen days and the scan threshold ---------------------------------------- // --- Frozen days and the scan threshold ----------------------------------------
describe("frozen days", () => { describe("frozen days", () => {
const FROZEN = { // What the agent hands over: a contiguous frozen run from the window start
"2026-10-01": { // through 2026-10-01, empty days included.
models: { "claude-opus-5-5": { input: 7, output: 7, cacheCreation: 7, cacheRead: 7, requests: 7 } }, const FROZEN = {};
projects: { alpha: { work: 21, cacheRead: 7, requests: 7 } }, for (const day of windowDays("2026-10-04")) {
}, if (day > "2026-10-01") break;
"2026-09-30": { models: {}, projects: {} }, 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 () => { 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( const old = writeTranscript(
"a/old.jsonl", "a/old.jsonl",
[line({ ts: "2026-10-02T15:00:00Z", output: 5 })], // would count if opened [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(dayOf(r, "2026-09-30").models).toEqual({});
expect(r.openDays["2026-10-01"]).toBeUndefined(); expect(r.openDays["2026-10-01"]).toBeUndefined();
expect(r.openDays["2026-09-30"]).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 }]); 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 () => { it("re-reads frozen counters defensively (bad numbers -> 0, junk ignored)", async () => {
const r = await aggregate({ const r = await aggregate({
root: tmpDir, 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(),
);
});
});

View file

@ -18,9 +18,13 @@
// are deduplicated on (message.id, requestId) across ALL files, keeping the // are deduplicated on (message.id, requestId) across ALL files, keeping the
// line with the largest output_tokens. // line with the largest output_tokens.
// * THE SCAN THRESHOLD. Frozen days are never recomputed. A file whose mtime // * 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 // (Toronto day) is before the oldest open day of the window cannot hold a
// hold a line on an open day, so it is not even opened: this is what keeps // line on an open day, so it is not even opened: this is what keeps a run
// a run from re-reading ~210 MB of transcripts every time. // 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 fs = require("node:fs");
const os = require("node:os"); const os = require("node:os");
@ -136,6 +140,13 @@ function count(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : 0; 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() { function emptyModel() {
return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, requests: 0 }; 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) (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]; for (const name of names.slice(0, MAX_MODELS_PER_DAY - 1)) out[name] = models[name];
const other = emptyModel(); const other = emptyModel();
if (hasOther) addModel(other, models[OTHER_MODEL]); 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 // Frozen days come from the agent's state file: re-read through the same
// counters, never trusted verbatim. // counters, never trusted verbatim.
function normalizeDay(day) { function normalizeDay(day) {
const models = {}; const models = dict();
const projects = {}; const projects = dict();
const srcModels = day && typeof day.models === "object" && day.models ? day.models : {}; const srcModels = day && typeof day.models === "object" && day.models ? day.models : {};
const srcProjects = const srcProjects =
day && typeof day.projects === "object" && day.projects ? day.projects : {}; 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"); 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") { if (frozenDays && typeof frozenDays === "object") {
for (const [day, value] of Object.entries(frozenDays)) { for (const [day, value] of Object.entries(frozenDays)) {
if (isDay(day)) frozen[day] = value; if (isDay(day)) frozen[day] = value;
@ -305,15 +316,13 @@ async function aggregate({
const windowStart = window[0]; const windowStart = window[0];
const openDayList = window.filter((d) => !Object.prototype.hasOwnProperty.call(frozen, d)); const openDayList = window.filter((d) => !Object.prototype.hasOwnProperty.call(frozen, d));
// Scan threshold: the day after the most recent frozen day. Floored at the // Scan threshold: the oldest open day of the window. With the agent's
// window start — a file last written before the window cannot hold a line // contiguous frozen prefix this is the day after the most recent frozen day;
// inside it. // computed from the open days rather than from the frozen keys, so a frozen
const frozenList = Object.keys(frozen).sort(); // day in the future or a gap in the frozen run can never push the threshold
let threshold = windowStart; // past an open day and silently empty it. A file last written before the
if (frozenList.length) { // window cannot hold a line inside it. Every day frozen: nothing to read.
const afterFrozen = shiftDay(frozenList[frozenList.length - 1], 1); const threshold = openDayList.length ? openDayList[0] : shiftDay(today, 1);
if (afterFrozen > threshold) threshold = afterFrozen;
}
// (message.id, requestId) -> best line so far. // (message.id, requestId) -> best line so far.
const requests = new Map(); const requests = new Map();
@ -383,8 +392,8 @@ async function aggregate({
// Open days: every non-frozen day of the window, empty ones included, so // 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 // the caller can freeze them all (an unfrozen empty day would hold the
// threshold back and every run would re-read the whole window). // threshold back and every run would re-read the whole window).
const openDays = {}; const openDays = dict();
for (const day of openDayList) openDays[day] = { models: {}, projects: {} }; for (const day of openDayList) openDays[day] = { models: dict(), projects: dict() };
for (const r of requests.values()) { for (const r of requests.values()) {
const bucket = openDays[r.day]; const bucket = openDays[r.day];
if (!bucket.models[r.model]) bucket.models[r.model] = emptyModel(); 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. // Merged 30-day series and project totals over the window.
const days = []; const days = [];
const projectTotals = {}; const projectTotals = dict();
for (const date of window) { for (const date of window) {
const source = Object.prototype.hasOwnProperty.call(frozen, date) const source = Object.prototype.hasOwnProperty.call(frozen, date)
? normalizeDay(frozen[date]) ? normalizeDay(frozen[date])