Skip to content

[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) #7858

Open

Description

@That1Drifter

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

  1. 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/sessions goes through the same code path.)
  2. Run Claude Code sessions; both sides see the same transcripts.
  3. 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; verified settings.json on both sides).
  4. 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:

  • resolvedHomePath differs: C:\Users\<user>\.claude\projects vs /home/<user>/.claude/projects (resolveTranscriptDirs never realpaths, and realpath would still yield /mnt/c/..., a different string), and
  • volumeId (dev:ino from readDirectoryVolumeId) is a different namespace on drvfs (e.g. 67:3659174697951287) than on NTFS.
  • hostId happens to match here (WSL2 inherits the Windows computer name by default, see [Feature]: Mark WSL environments in their label, since WSL inherits the Windows host name #4820), but that is a default, not a guarantee: /etc/wsl.conf can 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 what volumeId means 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 a USAGE_CONTRACT_VERSION bump (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 @ 592c598

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.

Activity

  1. That1Drifter commented on Aug 22, 2026

    @That1Drifter
    ContributorAuthor

    SecondaryPC (Correct) Image

    MainPC (Doubled) Image

  2. kvnloo commented on Sep 27, 2026

    @kvnloo
    Contributor

    I prototyped a narrower source-identity fix on kvnloo/t3code:fix/usage-wsl-source-identity:

    https://github.com/kvnloo/t3code/tree/fix/usage-wsl-source-identity

    The invariant is: cross-runtime dedupe needs both a shared physical-host scope and a normalized physical path. Path normalization alone can collapse two different PCs with the same C:\\Users\\... layout.

    The branch does this:

    • UsageSourceFingerprint gets an optional physicalSourceId. usageMerge prefers it when present; otherwise the existing hostId + provider + resolvedHomePath + volumeId key is unchanged.
    • Desktop generates a separate non-secret 128-bit T3CODE_USAGE_HOST_ID and gives the same value to its Windows and managed WSL backends. It is deliberately not the bootstrap bearer token.
    • The server only emits physicalSourceId for Windows drive paths and WSL /mnt/<drive> aliases. For example, C:\\Users\\Theo\\.claude\\projects and /mnt/c/Users/Theo/.claude/projects normalize to the same drive-backed path.
    • Native paths such as /home/theo/.claude/projects do not receive the alias identity, so two WSL distros with matching Linux paths cannot collapse accidentally.
    • Different desktop hosts get different host scopes, so identical Windows paths on two PCs stay distinct.
    • The new fingerprint field is optional/additive; mixed-version environments simply fall back to today's conservative behavior rather than requiring a contract-version cutover.

    I kept this as a fork branch rather than opening a PR because this crosses contracts/shared/server/desktop and the repo currently asks for discussion before non-trivial changes.

    I have focused regressions for Windows/WSL normalization, different-host separation, the legacy fallback, and the desktop sharing the same host scope with the WSL backend. I have not claimed a test run from this GitHub-only session.

  3. That1Drifter commented on Sep 27, 2026

    @That1Drifter
    ContributorAuthor

    Thanks for prototyping this. I pulled fd0883c2b into a worktree and ran it on the machine that hits the bug (Windows 11, desktop-managed WSL Ubuntu, WSL ~/.claude symlinked to /mnt/c/Users/Drifter/.claude).

    It fixes my setup. Probing the same realPath step UsageService uses, both sides normalize to windows:c:/Users/Drifter/.claude/projects, and WSLENV carries the host id into the WSL backend. Typecheck is clean for contracts, shared, server, and desktop. usageMerge.test.ts 28/28 and UsageService.test.ts 18/18 pass.

    Things I found, most important first:

    1. The physical key replaces the legacy key instead of supplementing it, which regresses same-machine dedupe that works today. fingerprintKey switches namespaces whenever physicalSourceId is present. A desktop-managed Windows backend and a worktree dev server or standalone npx t3 on the same PC scan the same directory with identical hostId/provider/resolvedHomePath/volumeId, but only the desktop backend has T3CODE_USAGE_HOST_ID. They now land under different keys and count twice. Treating two sources as the same when either the legacy tuple or the physical id matches (register both keys for the owner in claimSources) keeps the old behavior and adds the new one.
    2. The host scope is regenerated per desktop launch. Remote web/mobile clients retain the previous summary while a backend reconnects (client-runtime keeps the last successful value), so after a desktop restart a client can merge Windows under the new scope with WSL under the old one and double count until both refresh. Persisting the id (desktop settings or userdata) instead of generating it per process avoids that.
    3. /mnt/<letter> is trusted by spelling. With a custom automount.root in wsl.conf the WSL path gets no identity and stays doubled; with automount off and something else mounted at /mnt/c the two unrelated stores would be merged and one silently dropped. Reading the drive from /proc/mounts (drvfs/9p entries carry the Windows source, e.g. C:\) instead of the path prefix covers both.
    4. Case. The normalized tail keeps its case, and Node's realpath preserves the spelling of ordinary components, so a symlink created as /mnt/c/users/drifter/.claude would not match C:\Users\Drifter\.claude. Mine matches, so this is an edge case, and blanket lowercasing is not safe given WSL per-directory case sensitivity. Probably worth a note or a test either way.
    5. One existing test breaks: DesktopBackendConfiguration.test.ts › "resolveWsl preserves existing WSLENV entries when forwarding backend secrets" still expects the old WSLENV string; the resolver now inserts T3CODE_USAGE_HOST_ID after the inherited entries (32/33 in that file). vp fmt --check also flags UsageService.test.ts and usageMerge.test.ts.
    6. Minor: the merge tests use a hand-written lowercase physicalSourceId rather than one produced by usagePhysicalSourceId, so nothing covers normalization through to merge, and there is no case where a physical-id source meets a legacy-only source (which is how item 1 slipped through).

    Happy to test a revision on this machine.

    Claude Opus 5.5 responding on behalf of That1Drifter; second opinion from GPT-6 Astra in Codex

  4. juliusmarminge commented on Oct 22, 2026

    @juliusmarminge
    Member

    Checked current main ed9b7ca9c5 on a Linux VM. The reported Windows + managed WSL setup could not be run: neither wsl.exe nor powershell.exe is available, and there is no Windows drive mount. No runtime reproduction is claimed.

    Main now resolves transcript-directory symlinks, but source deduplication still compares host, provider, resolved path, and volume identity exactly. The container-host identity work in #15626 addresses a different case; no matching open fix was found.

    Please provide the offered per-environment sources[] entries for Windows and WSL on a current build, including fingerprints, distinctSessions, scan status, and skippedFiles, plus the exact build and selected date range. Redact usernames consistently across paths. This would establish whether both sources still scan the shared directory on current main. Windows/WSL runtime verification remains required; leaving this open.

  5. That1Drifter commented on Oct 22, 2026

    @That1Drifter
    ContributorAuthor

    Thanks for checking. Here is the data from the affected machine (Windows 11 Pro, WSL2 Ubuntu, WSL ~/.claude symlinked to /mnt/c/Users/<user>/.claude). Usernames are redacted as <user> everywhere.

    Build: main @ 6f6f68ad2d, which is newer than ed9b7ca9c5. I keep the desktop-managed WSL backend off on this machine as a workaround, so I didn't go through the desktop app. Instead I ran UsageService.make from that commit directly in each environment:

    • a clean checkout on Windows (Node 24.14.0) and a native checkout inside WSL (Node 24.22.1);
    • ServerSettings.layerTest({}) defaults and the real HOME (the real Windows settings.json has an empty Claude homePath, and the WSL side has no settings.json);
    • readSummary, the same method server.getUsageSummary calls;
    • the scan cache in a fresh temp dir;
    • the rates fetch stubbed, so there are no cost figures.

    Neither environment overrides the Claude home, and os.hostname() returns MainPC on both.

    Window: sinceDay 2026-09-22, untilDay 2026-10-21, timeZone America/Chicago, daily resolution. The two scans ran 13 s apart while a Claude session was writing, which likely explains the one-record difference below.

    Claude sources[] entries:

    Windows:

    {
      "fingerprint": {
        "hostId": "MainPC",
        "provider": "claude",
        "resolvedHomePath": "C:\\Users\\<user>\\.claude\\projects",
        "volumeId": "1280555009:3659174697951285"
      },
      "status": "ok",
      "scannedFiles": 356,
      "skippedFiles": 1,
      "malformedRecords": 0,
      "distinctSessions": 142,
      "message": null
    }

    WSL:

    {
      "fingerprint": {
        "hostId": "MainPC",
        "provider": "claude",
        "resolvedHomePath": "/mnt/c/Users/<user>/.claude/projects",
        "volumeId": "112:3659174697951287"
      },
      "status": "ok",
      "scannedFiles": 356,
      "skippedFiles": 1,
      "malformedRecords": 0,
      "distinctSessions": 142,
      "message": null
    }

    Both scan the same directory, with the same scannedFiles, skippedFiles and distinctSessions. The realpath change worked: WSL now reports /mnt/c/... instead of /home/<user>/.... But that is still a different spelling of the same Windows path, and volumeId differs in both halves. In August the WSL side reported 67:...; today a cold scan reports 112:..., so the WSL device number isn't even stable over time. Either way, the WSL inode differs from the Windows file ID (...285 vs ...287), so volumeId can't link the two sides.

    Merge: I fed both summaries through mergeUsage from the same commit:

    Environments merged Claude sessions Records Tokens duplicateSources
    Windows only 142 22,433 4,928,353,027 none
    WSL only 142 22,432 4,928,228,147 none
    Windows + WSL 284 44,865 9,856,581,174 none
    Windows + identical copy of Windows (control) 142 22,433 4,928,353,027 6 sources dropped

    On current main, both sources still scan the shared directory and the merge counts it twice. The control shows the dedupe works when fingerprints match exactly; the two sides just never produce matching fingerprints.

    Other providers don't overlap here: WSL has no Codex, Grok, Pi, OpenCode or Antigravity data (missing), and Windows' Codex source is its own C:\Users\<user>\.codex\sessions.

    I haven't run this through the desktop app's WSL backend itself. If you want that rather than a direct UsageService run, I can turn the backend on and capture it.

    Claude Opus 5.5 responding on behalf of That1Drifter

  6. That1Drifter commented on Oct 22, 2026

    @That1Drifter
    ContributorAuthor

    Verified through the desktop app as well (nightly 0.1.2-nightly.20261021.3499, desktop-managed WSL backend, "Run both backends"). The WSL backend's own sources[] entry matched the direct run above (/mnt/c/..., 112:...287, 142 sessions). On the Usage page (30 days), Claude Code went from 193 sessions / $2,549.17 with WSL off to 335 / $4,360.74 with it on; the +142 is the WSL source re-counting the shared directory. Feeding the captured responses through mergeUsage reproduces both totals exactly with no duplicateSources.

    Claude Opus 5.5 responding on behalf of That1Drifter

  7. juliusmarminge commented on Oct 22, 2026

    @juliusmarminge
    Member

    Replayed the reported source fingerprints through current main 3b95e7831b, in Node and in the web dev app's browser runtime. Windows + WSL produces 284 sessions, 44,865 records and 9,856,581,174 tokens; the identical-Windows control produces 142 sessions. This confirms the merge failure with the supplied data. Your desktop verification also establishes that the managed WSL backend reaches this path.

    The Linux VM cannot repeat the native Windows/WSL scan. The fix needs to retain same-source dedupe with standalone servers and keep independent hosts and non-Windows mounts distinct.

  8. added
    bugSomething is broken or behaving incorrectly.
    on Oct 22, 2026
  9. Zeus-Deus commented on Oct 22, 2026

    @Zeus-Deus
    Contributor

    Same root cause, different trigger: this also happens on a single Linux machine with no WSL or Windows involved, because the saved volumeId goes stale.

    Setup: Linux (btrfs), nightly 0.1.2-nightly.20261021.3457. Two backends run on the same host and read the same ~/.claude/projects and ~/.codex/sessions:

    • the service-managed t3 serve (data in ~/.t3/userdata)
    • the desktop app's local environment (data in ~/.t3-desktop/userdata, loopback only)

    What happens: hostname and resolvedHomePath are the same for both, but the volumeId differs:

    Backend Cached volumeId for ~/.claude/projects
    service (~/.t3/userdata, cache written before a reboot) 53:…287
    desktop local (~/.t3-desktop/userdata, created after) 88:…230

    stat on the directory now returns the second value (88:…). The first one is kept on purpose by resolveTranscriptDirs: when previous.dir === dir and there is retained history, it reuses previous.volumeId instead of currentVolumeId (behavior added in #12304). The newer backend has no saved entry, so it stores the current value. That gives two fingerprints, claimSources claims both, and the merge counts everything twice.

    Measured: in the desktop app, Usage → All environments → 30 days showed about 2x this host's real totals. Claude Code, Codex, input, cache write and output were each roughly doubled, and session counts were about 2x. Account-level sources (Cursor, OpenCode) were not doubled. A second machine reaching only the service through remote access showed the correct ~1x totals. That is the same "different totals on different PCs" symptom as the original report.

    Implication for the fix: matching on volumeId can't be the only check for same-host dedupe, even when hostname and path are identical. The ID a backend has saved can legitimately drift from the live dev:ino (after a reboot or remount on btrfs, or when the directory is moved or recreated), and two backends on one host can disagree. One option: when two sources share hostId and resolvedHomePath, also compare the live dev:ino, or the saved ID history, before treating them as distinct.

    🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions