Skip to content

Commit 8046ab7

Browse files
saphidclaude
andcommitted
fix(shared): keep attributed scans unless they drop records
The newest-scan rule made shared folders change owner on every refresh, so thread attribution flapped between environments. Attribution chooses the owner again; a scan that counted fewer records than the newest one cannot win, which is the case that dropped usage. Co-Authored-By: Claude Fable 5.2 <[email protected]>
1 parent 8135101 commit 8046ab7

3 files changed

Lines changed: 46 additions & 31 deletions

File tree

‎docs/user/usage.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ saved totals first, then updates them when Cursor's API responds. When an enviro
7676
Usage shows its last report for the selected window when this browser saved that exact window,
7777
marked with the time it was read. Otherwise that environment shows no saved usage until it
7878
reconnects. Environments on one machine that read
79-
the same history folder count its newest completed scan once. When scans were read at the same time,
80-
the environment that attributed more of the work to T3 threads is used.
79+
the same history folder count it once, from the environment that attributed more of the work to T3
80+
threads. A scan that counted fewer records than a newer one is never used.
8181

8282
If recent work is missing or a new model shows no cost, refresh to rescan session history and
8383
update model pricing.

‎packages/shared/src/usageMerge.test.ts‎

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -318,13 +318,18 @@ describe("mergeUsage", () => {
318318
],
319319
projects: [],
320320
});
321-
// At the same read time, prefer the scan with stronger T3 attribution.
321+
// Read later, but it only sees the session from outside, or (an older
322+
// server) cannot split by thread at all.
322323
const outside = environment("outside", {
323324
...summary([bucket({ thread: 0 })], [source]),
325+
readAt: "2026-08-07T00:01:00.000Z",
324326
threads: [{ key: "session:claude:s", located: true }],
325327
projects: [],
326328
});
327-
const older = environment("older", summary([bucket()], [source], USAGE_MERGE_COMPATIBLE_SINCE));
329+
const older = environment("older", {
330+
...summary([bucket()], [source], USAGE_MERGE_COMPATIBLE_SINCE),
331+
readAt: "2026-08-07T00:02:00.000Z",
332+
});
328333

329334
for (const ordered of [
330335
[ran, outside, older],
@@ -336,40 +341,41 @@ describe("mergeUsage", () => {
336341
}
337342
});
338343

339-
it("uses the newest complete scan even when it has less T3 attribution", () => {
344+
it("keeps one complete scan per directory, even when a newer one differs", () => {
345+
// Aliases or a later read can make the same records look like other cells.
340346
const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
341-
const older = environment("older", {
347+
const ran = environment("ran", {
342348
...summary([bucket({ thread: 0 })], [source]),
343349
threads: [{ key: "t3:ship", threadId: ThreadId.make("ship"), located: true }],
344350
projects: [],
345351
});
346-
const newer = environment("newer", {
347-
...summary([bucket({ thread: 0, costUsd: 15, records: 6 })], [source]),
352+
const later = environment("later", {
353+
...summary([bucket({ thread: 0, model: "fable-alias", costUsd: 15 })], [source]),
348354
readAt: "2026-08-07T00:05:00.000Z",
349355
threads: [{ key: "session:claude:s", located: true }],
350356
projects: [],
351357
});
352-
const merged = mergeUsage([newer, older], USAGE_CONTRACT_VERSION);
353-
expect(merged.costUsd).toBe(15);
354-
expect(merged.records).toBe(6);
355-
expect(merged.contributions.map((entry) => entry.environmentId)).toEqual(["newer"]);
358+
const merged = mergeUsage([later, ran], USAGE_CONTRACT_VERSION);
359+
expect(merged.costUsd).toBe(10);
360+
expect(merged.contributions.map((entry) => entry.environmentId)).toEqual(["ran"]);
356361
});
357362

358-
it("does not let a scan kept from a failed refresh hold a directory", () => {
363+
it("does not let a better attributed scan drop records a newer scan counted", () => {
359364
const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
360365
const stale = environment("stale", {
361366
...summary([bucket({ thread: 0 })], [source]),
362367
threads: [{ key: "t3:ship", threadId: ThreadId.make("ship"), located: true }],
363368
projects: [],
364369
});
365370
const fresh = environment("fresh", {
366-
...summary([bucket({ thread: 0, costUsd: 50 })], [source]),
367-
readAt: "2026-08-07T03:00:00.000Z",
371+
...summary([bucket({ thread: 0, costUsd: 50, records: 6 })], [source]),
372+
readAt: "2026-08-07T00:05:00.000Z",
368373
threads: [{ key: "session:claude:s", located: true }],
369374
projects: [],
370375
});
371376
const merged = mergeUsage([stale, fresh], USAGE_CONTRACT_VERSION);
372377
expect(merged.costUsd).toBe(50);
378+
expect(merged.records).toBe(6);
373379
expect(merged.contributions.map((entry) => entry.environmentId)).toEqual(["fresh"]);
374380
});
375381

@@ -390,6 +396,7 @@ describe("mergeUsage", () => {
390396
[bucket({ records: 9 }), bucket({ model: "y", thread: 0, costUsd: 1, records: 1 })],
391397
[source],
392398
),
399+
readAt: "2026-08-07T00:01:00.000Z",
393400
threads,
394401
projects: [],
395402
});

‎packages/shared/src/usageMerge.ts‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -188,23 +188,27 @@ function dayKey(bucket: UsageBucket): string {
188188
}
189189

190190
/**
191-
* How well an environment can attribute a source: -1 when its server cannot
192-
* split usage by thread at all, otherwise the records it placed in its own T3
193-
* threads.
191+
* What an environment's scan of a source holds: every record it counted, and
192+
* how many it placed in its own T3 threads (-1 when its server cannot split
193+
* usage by thread at all).
194194
*/
195-
function recordsInT3Threads(summary: UsageSummary, source: UsageSource): number {
195+
function scanCoverage(
196+
summary: UsageSummary,
197+
source: UsageSource,
198+
): { readonly records: number; readonly inThreads: number } {
196199
const threads = summary.threads;
197-
if (threads === undefined) return -1;
198200
let records = 0;
201+
let inThreads = threads === undefined ? -1 : 0;
199202
for (const bucket of bucketsForSource(summary, source)) {
200-
if (bucket.thread !== undefined && threads[bucket.thread]?.threadId !== undefined) {
201-
records += bucket.records;
203+
records += bucket.records;
204+
if (bucket.thread !== undefined && threads?.[bucket.thread]?.threadId !== undefined) {
205+
inThreads += bucket.records;
202206
}
203207
}
204-
return records;
208+
return { records, inThreads };
205209
}
206210

207-
/** Claim shared physical folders once; prefer complete, fresh scans.
211+
/** Claim shared physical folders once; prefer complete scans, then the best attributed one that drops nothing.
208212
* Claim whole summaries before narrowing buckets so slices retain the full merge's owners.
209213
*/
210214
function claimSources(environments: readonly EnvironmentUsage[]): {
@@ -248,15 +252,19 @@ function claimSources(environments: readonly EnvironmentUsage[]): {
248252
}
249253
}
250254
for (const [key, list] of candidates) {
255+
// `list` is newest first. The scan that attributed most to T3 threads owns
256+
// the folder, unless it counted fewer records than the newest scan: that
257+
// one predates usage the newest saw, and owning the folder would drop it.
258+
const newest = list[0];
259+
const floor =
260+
newest === undefined ? 0 : scanCoverage(newest.environment.summary, newest.source).records;
251261
for (const entry of list) {
252-
entry.inThreads = recordsInT3Threads(entry.environment.summary, entry.source);
262+
const coverage = scanCoverage(entry.environment.summary, entry.source);
263+
entry.inThreads = coverage.records < floor ? -2 : coverage.inThreads;
253264
}
254-
// Parsed timestamps in the same millisecond are equally fresh; attribution breaks only that tie.
255-
const [owner, ...rest] = [...list].sort(
256-
(a, b) =>
257-
(Date.parse(b.environment.summary.readAt) || 0) -
258-
(Date.parse(a.environment.summary.readAt) || 0) || b.inThreads - a.inThreads,
259-
);
265+
// A stable sort keeps the read-time order among equals. A copy, not
266+
// `toSorted`: this also runs on Hermes.
267+
const [owner, ...rest] = [...list].sort((a, b) => b.inThreads - a.inThreads);
260268
if (owner === undefined) continue;
261269
ownerByFingerprint.set(key, owner.environment.environmentId);
262270
ownerScanByFingerprint.set(key, owner);

0 commit comments

Comments
 (0)