Skip to content

fix(claude): fail cost scans when source roots are unreadable - #4617

Merged
steipete merged 3 commits into
steipete:mainfrom
dhruva77:fix/claude-unreadable-source-roots
Oct 19, 2026
Merged

steipete merged 3 commits into
steipete:mainfrom
dhruva77:fix/claude-unreadable-source-roots

Conversation

@dhruva77

Copy link
Copy Markdown
Contributor

Summary

The Claude cost scanner builds a source inventory before it touches the cache. Three failures in that inventory currently look the same as "no files here":

  • Root check: FileManager.fileExists(atPath:) returns false both for a root that is missing and for a root it cannot stat. One example is a parent directory without search permission.
  • Directory enumeration: FileManager.enumerator(at:...) runs without an errorHandler. A subdirectory that can't be read (EACCES) is skipped silently, and so is every transcript under it.
  • File stamps: CostUsageClaudeFileStamp.read(at:) returns nil for any fstatat failure, so the inventory drops the file.

Because the inventory is incomplete, the scan treats those files as deleted. It removes their rows from the cache, saves the smaller cache, and stores the low report in the report memo. The menu and spend views then show totals that are too low, and nothing reports an error. Once the files are readable again, the next refresh has to reparse them all.

Concrete scenario: ~/.claude/projects/<project>/ contains a subdirectory that isn't readable, for example after a permissions change, a restore or sync tool, or a directory created by another user. That project's spend disappears from the Claude cost report. The report still says "updated just now", and no error is shown.

May be related: #3209. That issue reports the spend chart not updating with no error message, which matches this failure mode. I haven't confirmed the cause there.

Fix

The changes are confined to inventoryClaudeRoots and CostUsageClaudeFileStamp:

  • Root check: stat decides whether a root exists. ENOENT and ENOTDIR still mean "missing, skip". Any other error is thrown.
  • Enumeration: the enumerator gets an errorHandler. Files that vanish mid-walk are ignored as normal churn. Any other enumeration error stops the walk and is thrown. If the enumerator can't be created for a root that still exists, that is thrown too.
  • File stamps: inventory uses a new CostUsageClaudeFileStamp.readIfPresent(at:). It returns nil for a missing file and throws for other fstatat errors. The existing read(at:) is unchanged for its artifact-stamp callers.

Inventory runs before the cache artifact or memo is read or written, so a throw leaves the last valid cache and the completed-report memo untouched. The error reaches the caller like any other scan failure. After the sources become readable, the next refresh hits the existing memo without parsing anything.

Missing roots and files that disappear during a scan behave exactly as before. Callers outside this path, the cache format and the memo format are unchanged. No CHANGELOG entry is included, to avoid conflicts with other open PRs; I'm happy to add one.

Tests

New parameterized test in CostUsageClaudeReportContextTests: unreadable source inventory fails without replacing the cache or report memo. It covers two cases:

  1. a subdirectory under the projects root set to mode 000;
  2. a second configured root whose parent is set to mode 000.

Each case:

  1. builds a warm cache and memo;
  2. makes the source unreadable;
  3. expects the load to throw, with the cache and memo files byte-identical;
  4. restores permissions and expects the original report back with zero scan work, which shows the memo was preserved.

The test is disabled when running as root, because root ignores directory permissions.

The same test against main fails in both cases. With an unreadable subdirectory, main returns a 10-token report instead of 15, raises no error, and rewrites both the cache and the memo. With an unreadable root, it also returns successfully and raises no error.

taskpolicy -b nice -n 20 swift build --build-tests --jobs 2
taskpolicy -b nice -n 20 swift test --skip-build --filter 'CostUsageClaudeReportContextTests|CostUsageScannerClaudeMemoTests|CostUsageClaudeCacheIsolationTests|CostUsageScannerClaudeRegressionTests'
# 45 tests in 4 suites passed

./Scripts/install_lint_tools.sh swiftformat swiftlint
.build/lint-tools/bin/swiftformat --lint <touched files>   # 0/3 files require formatting
.build/lint-tools/bin/swiftlint lint --strict --no-cache    # 0 violations

Notes

The fix is small and isolated: about 50 production lines across two files in the Claude cost scanner, plus one focused test. It doesn't touch the daily-report aggregation hunk changed by #4588, so the two should rebase cleanly in either order.

Inventory treated an unreadable root, a directory the enumerator could not open, and a failed file stat as if the sources did not exist. The scan then published a low report and replaced the cache and report memo with it. Throw instead, so the previous cache and memo stay in place until the sources are readable again.
@clawsweeper

clawsweeper Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

ClawSweeper review complete

ClawSweeper finished reviewing this revision. The review result is being finalized.

View the workflow run.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-18T19:12:40.821666Z 9908c17 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@clawsweeper clawsweeper Bot added clawsweeper:intake:bug Preliminary submission classification; full source review pending. clawsweeper:intake:needs-environment Preliminary submission classification; full source review pending. P2 Normal priority bug or improvement with limited blast radius. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. and removed clawsweeper:intake:bug Preliminary submission classification; full source review pending. clawsweeper:intake:needs-environment Preliminary submission classification; full source review pending. labels Oct 18, 2026
@clawsweeper

clawsweeper Bot commented Oct 18, 2026

Copy link
Copy Markdown

Codex review: needs real behavior proof before merge.

What this changes

This PR makes Claude source-inventory failures abort cost scans before incomplete results replace cached reports.

Example: Two Claude transcripts contribute 10 and 5 input tokens, then the second transcript's directory becomes unreadable.

  • Before: The scan can publish 10 tokens and overwrite the complete cache and report memo.
  • After: The scan throws before updating either artifact; restoring directory access allows the complete 15-token report to be reused.

Review scores

Measure Result What it means
Overall readiness 🧂 unranked krab (1/6) The core repair is focused and supported by meaningful filesystem tests, but actionable error handling and shipped-behavior evidence remain incomplete.
Proof confidence 🧂 unranked krab (1/6) Real behavior proof is necessary before merge. See Before merge.
Patch quality 🦐 gold shrimp (3/6) 1 actionable review finding remain.

Product

Kind: Bug fix · Worth it: Yes · Fix scope: Complete
User problem: Unreadable local transcript directories can silently make previously recorded Claude spend disappear from refreshed reports.
Reason: An incomplete source walk must not certify and persist an apparently complete lower total; the patch repairs that invariant without adding settings or changing stored formats.

Merge readiness

⛔ Blocked before merge - 3 items remain

Keep open: this PR addresses a source-reproducible cost-cache integrity bug that current main still contains.

Priority: P2
Reviewed head: 9908c17e52b3e4850aee578c03c119a85eb2a08f

Before merge

  • Add real behavior proof - The supplied evidence exercises the real scanner with synthetic filesystem fixtures, but contains no shipped CLI or app output demonstrating unreadable-source failure, unchanged artifacts, and recovery. Please provide redacted terminal output or a terminal recording for that scenario, including a fresh run and reuse of artifacts created by v0.77.0; redact personal paths, IPs, API keys, phone numbers, and non-public endpoints. Serialized formats are unchanged, but the stricter failure behavior has no latest-stable upgrade demonstration. After adding proof, update the PR body, then request a new review with @clawsweeper re-review. The PR author or someone with repository write access can request it.
  • Give unreadable-source errors an actionable recovery description (P2) - When a source root's parent denies search permission, this new throw reaches UsageStore.tokenErrors and CLICostCommand through error.localizedDescription. The NSError supplies only a POSIX code and path, so users receive a generic permission failure without being told that local cost scanning stopped or how to resume it. Translate inventory access failures into a cost-specific localized error that identifies the affected source, explains that the saved cache was preserved, and tells the user to restore access and refresh; retain the underlying error for diagnostics and apply the same treatment to enumeration failures.
  • Add data-model compatibility proof - The review found that existing stored data may not work after upgrade. Show that existing data still loads and works with this change.

Findings

  • [P2] Give unreadable-source errors an actionable recovery description — Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift:588

Tests

  • Missing end-to-end proof: Show the shipped cost command failing on unreadable sources without changing cache artifacts and recovering after access is restored, for both a fresh setup and an upgrade using v0.77.0 artifacts.
Agent review details

How this fits together

The shared Claude and Vertex cost scanner inventories local transcript roots, reconciles cached usage rows, and supplies reports to the menu, spend dashboard, and CLI.

flowchart TD
  A[Claude transcript roots] --> B[Source inventory]
  B --> C[Cache reconciliation]
  C --> D[Report and memo]
  D --> E[Menu dashboard and CLI]
  B --> F[Filesystem failure]
  F --> G[Abort before cache writes]
Loading

Technical review

Best possible solution:

Keep the inventory-before-write boundary and surface an actionable source-access error through the existing cost-report callers.

Do we have a high-confidence way to reproduce the issue?

Current-main source shows how an unreadable directory produces an incomplete inventory that removes cached rows; the author also reports failing base runs for both permission scenarios, but this read-only review did not execute them.

Is this the best way to solve the issue?

Aborting incomplete inventory at its existing shared owner is a narrow root-cause repair that preserves missing-path churn, report-window isolation, and best-effort artifact stamps.

Full review comments:

  • [P2] Give unreadable-source errors an actionable recovery description — Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift:588
    When a source root's parent denies search permission, this new throw reaches UsageStore.tokenErrors and CLICostCommand through error.localizedDescription. The NSError supplies only a POSIX code and path, so users receive a generic permission failure without being told that local cost scanning stopped or how to resume it. Translate inventory access failures into a cost-specific localized error that identifies the affected source, explains that the saved cache was preserved, and tells the user to restore access and refresh; retain the underlying error for diagnostics and apply the same treatment to enumeration failures.
    Confidence: 0.94

Overall correctness: patch is incorrect
Overall confidence: 0.92

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning medium; reviewed against 6480977b47f6.

Provenance checked

Testing

Proof path: in-process harness.

Security

None.

Evidence

Acceptance criteria:

  • swift test --filter 'CostUsageClaudeReportContextTests|CostUsageScannerClaudeMemoTests|CostUsageClaudeCacheIsolationTests|CostUsageScannerClaudeRegressionTests'
  • make check
  • git diff --check

What I checked:

Likely related people:

  • Peter Steinberger: Raw commit d7934a5 adds Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift:13 relative to its recorded parents. This identifies author metadata, not feature responsibility or a PR merger. (role: source-line author; confidence: high; commits: d7934a5f6121; files: Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift)

Review metrics

Metric Value Why it matters
Patch scope Production +51/-5; tests +33/-1 The focused error-handling changes and filesystem regression coverage are proportionate to the cache-integrity problem.

Labels

Label changes:

  • add P2: Directory-access failures can silently undercount local Claude costs and replace complete generated reports, with a limited provider-specific blast radius.
  • add rating: 🧂 unranked krab: Overall readiness is 🧂 unranked krab; proof is 🧂 unranked krab and patch quality is 🦐 gold shrimp.
  • add status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Label justifications:

  • P2: Directory-access failures can silently undercount local Claude costs and replace complete generated reports, with a limited provider-specific blast radius.
  • rating: 🧂 unranked krab: Overall readiness is 🧂 unranked krab; proof is 🧂 unranked krab and patch quality is 🦐 gold shrimp.
  • status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Provide redacted shipped-CLI permission-failure and recovery output using fresh state and v0.77.0 cache artifacts.

Rating scale

6/6 🦀 challenger crab · 5/6 🦞 diamond lobster · 4/6 🐚 platinum hermit · 3/6 🦐 gold shrimp · 2/6 🦪 silver shellfish · 1/6 🧂 unranked krab. Overall follows the weaker of proof and patch quality; ✨ marks media proof (a screenshot, video, or linked artifact) that directly shows the changed behavior.

Workflow

ClawSweeper edits this one comment on every review. Comment @clawsweeper re-review for a fresh review only. Repair and merge execution lanes are retired.

Reviewed October 18, 2026, 3:21 PM ET / 19:21 UTC.

Refs steipete#4617.

Co-authored-by: Dhruva Sahrawat <21278423+dhruva77@users.noreply.github.com>
steipete added a commit that referenced this pull request Oct 19, 2026
@steipete
steipete merged commit 25ecff1 into steipete:main Oct 19, 2026
1 check passed
@steipete

Copy link
Copy Markdown
Owner

Merged in 25ecff1, thanks @dhruva77! Claude cost scans now keep existing history when a source folder can't be read, instead of treating it as empty. We kept your fail-closed handling, folded a duplicate map into one stamp dictionary, and applied the same check to the new Cowork file roots. It will ship in the next release.

@dhruva77
dhruva77 deleted the fix/claude-unreadable-source-roots branch October 20, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal priority bug or improvement with limited blast radius. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants