[Bug]: Usage double-counts a provider transcript directory shared between Windows and a WSL environment (source fingerprint cannot unify one directory across the OS boundary)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 23k
- Forks
- 5.9k
- Avg merge
- 11h 14m
- Merged PRs (30d)
- 357
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
- On a Windows machine, share provider history between Windows and WSL by symlinking the home dir: inside WSL,
ln -s /mnt/c/Users/<user>/.claude ~/.claude. (Nothing here is Claude-specific; a shared~/.codex/sessionsgoes through the same code path.) - Run Claude Code sessions; both sides see the same transcripts.
- In the T3 desktop app on that machine, have the local Windows environment and the managed "WSL (Ubuntu)" environment connected. No provider home overrides configured (default
os.homedir()resolution on both; verifiedsettings.jsonon both sides). - Open the Usage page.
Expected behavior
The shared transcript directory is counted once. The fingerprint dedupe in packages/shared/src/usageMerge.ts (claimSources) exists exactly for this: "several environments on one machine ... resolve the same provider home and would otherwise double count every token."
Actual behavior
Every token and session in the shared directory is counted twice, and only on the machine hosting the WSL environment. The WSL backend is loopback-only (apps/desktop/src/wsl/DesktopWslBackend.ts:19-21), so remote clients can never include that environment, and different PCs viewing the same account show different totals.
Why the dedupe misses: the source fingerprint is hostId + provider + resolvedHomePath + volumeId (UsageService.readSummary), compared as an exact string (usageMerge.ts fingerprintKey), with no fallback matching of any kind. For one physical directory seen from both sides of the WSL boundary:
resolvedHomePathdiffers:C:\Users\<user>\.claude\projectsvs/home/<user>/.claude/projects(resolveTranscriptDirsnever realpaths, and realpath would still yield/mnt/c/..., a different string), andvolumeId(dev:inofromreadDirectoryVolumeId) is a different namespace on drvfs (e.g.67:3659174697951287) than on NTFS.hostIdhappens to match here (WSL2 inherits the Windows computer name by default, see #4820), but that is a default, not a guarantee:/etc/wsl.confcan set a different hostname, so a fix must not rely on hostId equality either.
Two distinct fingerprints, so claimSources claims both and everything merges twice.
Measured on two PCs, same 30-day window, minutes apart during active use:
- MainPC view (hosts the WSL env): Claude 152 sessions / $2,453.55 / 2.78B tokens
- SecondaryPC view: Claude 80 sessions / $1,240.17 / 1.40B tokens
- Codex identical in both views (87 sessions / $290.60 / 523M, split 66 + 21 across the two machines), confirming the cross-environment merge itself works; shared-directory identity is the only defect.
The daily Claude chart shows the identical shape at ~2x scale on MainPC. The 72-session delta is attributable to the duplicated WSL source; the small gap vs the ~80 sessions each source should report is consistent with the scans running minutes apart. Definitive per-source evidence would be each environment's sources[].distinctSessions (both Claude sources reporting ~equal counts) and the WSL source's skippedFiles; happy to capture those if useful. A read check of all 97 in-window transcripts through 9p from WSL had zero failures, so partial walks look unlikely as an explanation for the gap.
Fix directions, offered as options, and the design tension any of them must resolve: volumeId exists specifically so a fleet sharing hostname + home path does not collapse into one source (contracts/src/usage.ts, usageMerge.test.ts), and any cross-boundary unification relaxes exactly that guard.
- Canonical realpath plus a WSL
/mnt/<drive>to Windows path mapping at fingerprint build time. This must also decide whatvolumeIdmeans across the boundary: keeping it as an exact-match requirement leaves cross-boundary fingerprints distinct (fix does nothing), dropping it reintroduces the fleet collision. A workable shape: the server emits an additional normalized identity field, merge prefers it when present on both sides and falls back to today's key otherwise. Requires aUSAGE_CONTRACT_VERSIONbump (v4 today); mixed-version fleets ride the existing stale-environment exclusion. - A trusted provenance alias for desktop-managed WSL environments (stable ids already exist, e.g.
wsl:<distro>). This reduces to the first option plus provenance: identity alone does not unify the path half of the key, and it only covers desktop-provisioned WSL, not servers started manually inside a distro.
Any normalization belongs at fingerprint build time in apps/server; mergeUsage should stay pure. A session-ID-overlap dedupe was considered and rejected: overlap is not source equivalence (forks, copies, resumes overlap legitimately), and shipping session IDs in summaries has privacy and contract downsides.
Impact
Minor bug or occasional failure
(Though it is money-figure reporting off by ~2x for anyone sharing history across WSL; happy to have triage recategorize.)
Version or commit
0.0.34-nightly.20260821.1154; code paths verified against main @ 592c5983c
Environment
Windows 11 Pro, desktop app hosting local + managed WSL (Ubuntu, WSL2) environments, second Windows 11 PC connected via T3 Connect. Claude Code + Codex providers.
Logs or stack traces
No response
Screenshots, recordings, or supporting files
Will attach: usage page from both PCs (same window), and both Remote environments lists.
Workaround
Disconnect the WSL environment, or make ~/.claude/projects a real directory inside WSL instead of shared. Note the second option stops the double count but opens a coverage gap in the other direction: sessions run from WSL then land in a directory only the loopback-only WSL environment can report, which remote clients never see.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with apps/server's UsageService.readSummary and resolveTranscriptDirs, then read packages/shared/src/usageMerge.ts, contracts/src/usage.ts, and usageMerge.test.ts. Reproduce the Windows/WSL case and compare the source fingerprints. Done means shared transcript directories merge once across the boundary without collapsing distinct fleet sources, with coverage for the chosen identity behavior and contract-version implications.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- analytics, backend
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100