From d362c5642aa5b35795392b758bb5400bd05eb741 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Wed, 5 Aug 2026 23:16:23 +0800 Subject: [PATCH] fix: keep watch mode's tokens-by-model fresh when stats-cache.json is stale ClaudeUsageProvider.GetStatsCache only compared stats-cache.json's LastComputedDate (day granularity) to decide freshness, so it trusted Claude Code's own cache file for an entire day even if session transcripts had newer activity. This made the watch models view (and anything else sourced from StatsCache.ModelUsage) appear frozen while hours/today, which read transcripts directly, kept updating. Now freshness also compares actual write timestamps, falling back to rebuilding from session transcripts when they're newer than stats-cache.json. To avoid re-scanning the projects directory on every check, a new SessionActivityTracker maintains the latest session write time incrementally from DataService's existing FileSystemWatcher events instead of repeated directory scans. --- .../Providers/Claude/ClaudeUsageProvider.cs | 6 +- .../Claude/SessionActivityTracker.cs | 53 +++++++++ .../Services/DataService.cs | 13 +++ .../Services/ServiceCollectionExtensions.cs | 1 + .../Claude/ClaudeUsageProviderTests.cs | 103 ++++++++++++++++++ 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 src/AIUsageMonitor.Core/Providers/Claude/SessionActivityTracker.cs create mode 100644 tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs diff --git a/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs b/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs index 8247ce2..c6dbd17 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs @@ -12,6 +12,7 @@ namespace AIUsageMonitor.Core.Providers.Claude; /// Builds a from session transcripts when the cache is missing or stale. /// Builds a summary of recent activity from session transcripts. /// Builds hourly activity data from session transcripts. +/// Tracks the latest session file write time without re-scanning the projects directory on every call. /// Logger used to report cache fallback diagnostics. public sealed class ClaudeUsageProvider( ClaudeDataLocator locator, @@ -19,6 +20,7 @@ public sealed class ClaudeUsageProvider( StatsCacheBuilder statsCacheBuilder, RecentActivityBuilder recentActivityBuilder, HourlyActivityBuilder hourlyActivityBuilder, + SessionActivityTracker sessionActivityTracker, ILogger logger) : IUsageProvider { /// Gets the display name of this usage provider. @@ -65,7 +67,9 @@ public StatsCache GetStatsCache(IProgress? progress = null) { var cache = statsCacheParser.Parse(locator.StatsCachePath); var today = DateOnly.FromDateTime(DateTimeOffset.Now.LocalDateTime); - if (cache.LastComputedDate >= today) + var cacheWriteTime = File.GetLastWriteTimeUtc(locator.StatsCachePath); + + if (cache.LastComputedDate >= today && cacheWriteTime >= sessionActivityTracker.LatestWriteUtc) { progress?.Report(100); return cache; diff --git a/src/AIUsageMonitor.Core/Providers/Claude/SessionActivityTracker.cs b/src/AIUsageMonitor.Core/Providers/Claude/SessionActivityTracker.cs new file mode 100644 index 0000000..c35670b --- /dev/null +++ b/src/AIUsageMonitor.Core/Providers/Claude/SessionActivityTracker.cs @@ -0,0 +1,53 @@ +namespace AIUsageMonitor.Core.Providers.Claude; + +/// +/// Tracks the most recent write time across all Claude session transcript files (*.jsonl) +/// without repeatedly re-scanning the projects directory. The first access to +/// takes a one-time baseline scan; after that, callers who already observe file-system change events +/// (e.g. 's session-file watcher) feed updates via +/// in O(1), so no further scans are needed. +/// +/// Locates Claude session transcript files on disk, used only for the initial baseline scan. +public sealed class SessionActivityTracker(ClaudeDataLocator locator) +{ + private readonly Lock _lock = new(); + private DateTime? _latestWriteUtc; + + /// + /// Gets the latest known write time (UTC) across all session transcript files. Triggers a one-time + /// directory scan on first access if no write has been observed yet via . + /// + public DateTime LatestWriteUtc + { + get + { + lock (_lock) + { + _latestWriteUtc ??= ScanForLatestWriteUtc(); + return _latestWriteUtc.Value; + } + } + } + + /// + /// Records a newly observed write time, advancing if it is more recent + /// than what's currently known. + /// + /// The write time (UTC) observed for a session transcript file. + public void Observe(DateTime writeTimeUtc) + { + lock (_lock) + { + if (_latestWriteUtc is null || writeTimeUtc > _latestWriteUtc) + { + _latestWriteUtc = writeTimeUtc; + } + } + } + + private DateTime ScanForLatestWriteUtc() + { + var files = locator.GetSessionFiles(); + return files.Count == 0 ? DateTime.MinValue : files.Max(File.GetLastWriteTimeUtc); + } +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Services/DataService.cs b/src/AIUsageMonitor.Core/Services/DataService.cs index 686b03f..783b45b 100644 --- a/src/AIUsageMonitor.Core/Services/DataService.cs +++ b/src/AIUsageMonitor.Core/Services/DataService.cs @@ -43,6 +43,13 @@ public sealed class DataService : IDisposable /// private readonly IUsageProvider _provider; + /// + /// Tracks the latest session file write time, fed incrementally from + /// events so can check staleness without + /// re-scanning the projects directory. + /// + private readonly SessionActivityTracker _sessionActivityTracker; + /// /// The session file cache used to cache parsed session transcript rows. This field is initialized in the constructor and is used to store the results of parsing session transcript files, allowing for faster access to the data without needing to repeatedly read and parse the files from disk. /// @@ -64,16 +71,19 @@ public sealed class DataService : IDisposable /// The usage provider used to retrieve AI usage data. /// The usage analyzer used to analyze AI usage data. /// The session file cache used to cache parsed session transcript rows. + /// Tracks the latest session file write time from file-system watcher events. /// The logger instance used for logging warnings and errors. public DataService( IUsageProvider provider, UsageAnalyzer analyzer, SessionFileCache sessionFileCache, + SessionActivityTracker sessionActivityTracker, ILogger logger) { _provider = provider; _analyzer = analyzer; _sessionFileCache = sessionFileCache; + _sessionActivityTracker = sessionActivityTracker; _logger = logger; if (provider is ClaudeUsageProvider claudeProvider) @@ -206,11 +216,14 @@ private void SessionsWatcher_Changed(object sender, FileSystemEventArgs e) { _logger.LogTrace("Session file change detected: {ChangeType} - {FullPath}", e.ChangeType, e.FullPath); + _cache.Remove(StatsCacheKey); + switch (e.ChangeType) { case WatcherChangeTypes.Changed: case WatcherChangeTypes.Created: _sessionFileCache.Set(e.FullPath); + _sessionActivityTracker.Observe(File.GetLastWriteTimeUtc(e.FullPath)); break; case WatcherChangeTypes.Deleted: diff --git a/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs b/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs index a8aa2b9..acd253a 100644 --- a/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs +++ b/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ public static IServiceCollection AddClaudeUsageCore(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs new file mode 100644 index 0000000..23edec2 --- /dev/null +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs @@ -0,0 +1,103 @@ +using AIUsageMonitor.Core.Analytics; +using AIUsageMonitor.Core.Providers.Claude; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AIUsageMonitor.Core.Tests.Providers.Claude; + +public class ClaudeUsageProviderTests : IDisposable +{ + private readonly string _claudeDir = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid()}"); + private readonly ClaudeUsageProvider _sut; + + public ClaudeUsageProviderTests() + { + Directory.CreateDirectory(Path.Combine(_claudeDir, "projects")); + + var locator = new ClaudeDataLocator(_claudeDir); + var sessionParser = new SessionParser(NullLogger.Instance); + var sessionFileCache = new SessionFileCache(sessionParser); + var costCalculator = new CostCalculator(); + + _sut = new ClaudeUsageProvider( + locator, + new StatsCacheParser(), + new StatsCacheBuilder(sessionFileCache), + new RecentActivityBuilder(sessionFileCache, costCalculator), + new HourlyActivityBuilder(sessionFileCache), + new SessionActivityTracker(locator), + NullLogger.Instance); + } + + public void Dispose() + { + if (Directory.Exists(_claudeDir)) + { + Directory.Delete(_claudeDir, recursive: true); + } + } + + [Fact] + public void GetStatsCache_SessionFileNewerThanStatsCache_RebuildsFromTranscripts() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var statsCachePath = Path.Combine(_claudeDir, "stats-cache.json"); + File.WriteAllText(statsCachePath, $$""" + { + "version": 1, + "lastComputedDate": "{{today:yyyy-MM-dd}}", + "totalSessions": 1, + "totalMessages": 1, + "modelUsage": { + "stale-model": { "inputTokens": 1, "outputTokens": 1 } + } + } + """); + File.SetLastWriteTimeUtc(statsCachePath, DateTime.UtcNow.AddMinutes(-30)); + + var sessionFile = Path.Combine(_claudeDir, "projects", "session.jsonl"); + var timestamp = DateTimeOffset.UtcNow.ToString("O"); + File.WriteAllLines(sessionFile, + [ + "{\"type\":\"user\",\"timestamp\":\"" + timestamp + "\",\"sessionId\":\"s1\"}", + "{\"type\":\"assistant\",\"timestamp\":\"" + timestamp + "\",\"sessionId\":\"s1\",\"message\":{\"role\":\"assistant\",\"model\":\"fresh-model\",\"usage\":{\"input_tokens\":100,\"output_tokens\":50}}}", + ]); + File.SetLastWriteTimeUtc(sessionFile, DateTime.UtcNow); + + var cache = _sut.GetStatsCache(); + + Assert.True(cache.ModelUsage.ContainsKey("fresh-model")); + Assert.False(cache.ModelUsage.ContainsKey("stale-model")); + } + + [Fact] + public void GetStatsCache_StatsCacheNewerThanSessions_UsesStatsCacheAsIs() + { + var sessionFile = Path.Combine(_claudeDir, "projects", "session.jsonl"); + var timestamp = DateTimeOffset.UtcNow.ToString("O"); + File.WriteAllLines(sessionFile, + [ + "{\"type\":\"user\",\"timestamp\":\"" + timestamp + "\",\"sessionId\":\"s1\"}", + ]); + File.SetLastWriteTimeUtc(sessionFile, DateTime.UtcNow.AddMinutes(-30)); + + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var statsCachePath = Path.Combine(_claudeDir, "stats-cache.json"); + File.WriteAllText(statsCachePath, $$""" + { + "version": 1, + "lastComputedDate": "{{today:yyyy-MM-dd}}", + "totalSessions": 1, + "totalMessages": 1, + "modelUsage": { + "cached-model": { "inputTokens": 1, "outputTokens": 1 } + } + } + """); + File.SetLastWriteTimeUtc(statsCachePath, DateTime.UtcNow); + + var cache = _sut.GetStatsCache(); + + Assert.True(cache.ModelUsage.ContainsKey("cached-model")); + } +}