using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Usage.Interfaces; namespace ClaudeDo.Worker.Usage; /// /// Polls on and keeps /// current. Polls once immediately at startup. A failure is logged as a /// warning at most once per distinct error message, to avoid log spam on a persistent outage. /// Broadcasts after every poll cycle, success or failure, /// so the UI can reflect a stale/blocked state as soon as it happens. /// public sealed class UsageMonitorService : BackgroundService { private readonly IUsageClient _client; private readonly UsageState _state; private readonly WorkerConfig _config; private readonly ILogger _logger; private readonly UsageSnapshotBuilder _snapshotBuilder; private readonly HubBroadcaster _broadcaster; private string? _lastLoggedError; public UsageMonitorService( IUsageClient client, UsageState state, WorkerConfig config, ILogger logger, UsageSnapshotBuilder snapshotBuilder, HubBroadcaster broadcaster) { _client = client; _state = state; _config = config; _logger = logger; _snapshotBuilder = snapshotBuilder; _broadcaster = broadcaster; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { await TickAsync(stoppingToken); try { await Task.Delay(TimeSpan.FromSeconds(_config.UsagePollIntervalSeconds), stoppingToken); } catch (OperationCanceledException) { return; } } } internal async Task TickAsync(CancellationToken ct) { try { var snapshot = await _client.GetUsageAsync(ct); _state.ReportSuccess(snapshot); _lastLoggedError = null; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { _state.ReportFailure(ex.Message, DateTime.UtcNow); if (_lastLoggedError != ex.Message) { _logger.LogWarning(ex, "UsageMonitorService: failed to fetch usage: {Message}", ex.Message); _lastLoggedError = ex.Message; } } var dto = await _snapshotBuilder.BuildAsync(ct); await _broadcaster.UsageUpdated(dto); } }