Merge pull request #1035 from getagentseal/fix/scoped-save-republish
Some checks failed
CI / semgrep (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Windows Menubar CI / check (ubuntu-latest) (push) Has been cancelled
Windows Menubar CI / check (windows-latest) (push) Has been cancelled

cache: stop republishing unchanged month shards on ranged runs
This commit is contained in:
Resham Joshi 2026-08-18 12:48:16 -07:00 committed by GitHub
commit bb1974dab8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 32 additions and 0 deletions

View file

@ -33,6 +33,7 @@
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)
### Fixed
- **A date-ranged run no longer republishes the month shards it never read.** A scoped load leaves an out-of-range month on disk, so the files it holds have no visible cache entry and the reconcile re-parses them — re-deriving the entry the shard already stores. That re-parse marked the unloaded month dirty, and the save merged and republished it under a fresh nonce name on every single run, byte-identical content and all, so a repeated `codeburn status --format json` churned old months (on a real corpus: claude/2026-03, cursor/2026-02 and warp/2026-03 renamed every run) and left the retired shards for the sweeper. A merge into an unloaded month that neither adds, changes nor removes an entry now keeps the published shard, so unchanged months keep their names and their bytes. (#1032)
- **`models` and `audit` no longer show two identical `Grok 4.5` rows.** `grok-4.5-build` — the Grok Build harness's variant id — fell into the `grok-4.5` display entry by prefix, and since rows bucket by model id, not display name, the two came out as visually identical rows with different numbers. The variant now shows as `Grok 4.5 (build)`. Display only: no id is rewritten and no cost moves. (#1029)
- **An upgrade no longer loses history for days whose transcripts have only PARTLY aged out.** The never-lose contract carried a cached (day, provider) slice forward only when the re-derivation found NOTHING for it, but transcripts expire per FILE rather than per day: on a day whose sources are mostly gone, a handful of turns from surviving later files still bucket onto it, so the fresh slice came back non-empty but truncated and REPLACED the full cached one. On a real cache upgrading from the last shipped daily-cache version, 2026-07-16 fell from $1,685.17 / 12,530 calls to $385.44 / 560 calls, and 13 days lost $2,765.75, 19,209 calls and 520 sessions in total. A fresh slice now replaces a settled baseline slice only when it carries at least as many CALLS - the same or more evidence; fewer calls means the source set demonstrably lost data, and the baseline is kept whole. The comparison is on calls alone: cost and tokens are re-priced accounting on the same evidence, which is exactly what a legitimate re-derivation changes (the Grok accounting fix keeps its per-day calls and is unaffected), and session counts drift down by a few on days whose sources are entirely intact. Days inside a 7-day settle window stay authoritative - their session files are still on disk, so a shrink there is a real change rather than expiry. The trade-off is deliberate and matches the direction this cache has always chosen: a future fix that legitimately REDUCES calls on a settled day keeps the older, higher value until that day is re-derived at an equal or greater call count. The timezone-change re-derive gets the exact form of the same rule - what the fresh parse can no longer explain under the old bucketing is added on top of the fresh slice instead of being dropped - and the cross-file adoption union is unchanged, where the newer schema still wins per (day, provider).
- **The session chart legend now leads with a visible session disambiguator and title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now puts the short session id first, prefers the title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997)

View file

@ -1043,6 +1043,15 @@ export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Pr
const files = plan.groups.get(bucket)!
const onDisk = from ? await loadShard(join(dir, from)) : null
if (!onDisk) return writeShard(provider, bucket, files)
// A file whose month this run never loaded has no visible cache entry, so it
// looks uncached and is re-parsed into the same bucket — re-deriving the
// entry the shard already holds. Republishing then churns the shard's nonce
// name on every run for content that never changed (#1032), so a merge that
// neither adds, changes nor removes an entry keeps the published shard.
const adds = Object.entries(files).some(([path, file]) =>
onDisk[path] === undefined || JSON.stringify(onDisk[path]) !== JSON.stringify(file))
const removes = [...plan.moved].some(path => onDisk[path] !== undefined && files[path] === undefined)
if (!adds && !removes) return { name: from!, until: untilMonth(onDisk) }
for (const path of plan.moved) delete onDisk[path]
return writeShard(provider, bucket, { ...onDisk, ...files })
}

View file

@ -499,6 +499,28 @@ describe('scoped load', () => {
.toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/jun2.jsonl', '/live/mar.jsonl'])
})
it('keeps the unloaded month\'s shard name when a re-parse re-derives the same entry', async () => {
await seedThreeMonths()
// The March entry is invisible to a June-scoped run, so the reconcile
// re-parses that file and writes the identical entry straight back. Nothing
// changed, so the March shard must keep its name run after run (#1032).
const nameOf = async (): Promise<string> => (await envelope()).providers['claude']!.shards['2026-03']!.name
const before = await nameOf()
for (let run = 0; run < 2; run++) {
clearLoadCacheMemo()
const scoped = await loadCache(juneScope)
scoped.providers['claude']!.files['/live/mar.jsonl'] = fileSpanning('2026-03-10T10:00:00Z')
markCacheDirty(scoped, 'claude', '/live/mar.jsonl')
await saveCache(scoped)
expect(await nameOf(), `March republished on run ${run + 1}`).toBe(before)
}
clearLoadCacheMemo()
const full = await loadCache()
expect(Object.keys(full.providers['claude']!.files).sort())
.toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/mar.jsonl'])
})
it('merges rather than replaces when a re-parse lands in an unloaded month', async () => {
await seedThreeMonths()
clearLoadCacheMemo()