using ClaudeDo.Data; using ClaudeDo.Data.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; namespace ClaudeDo.Worker.Prime; public sealed class PrimeClock : IPrimeClock { public DateTimeOffset Now => DateTimeOffset.Now; } public sealed record PrimeSchedulerOptions(TimeSpan CatchUpWindow) { public static PrimeSchedulerOptions Default { get; } = new(TimeSpan.FromMinutes(30)); } public sealed class PrimeScheduler : BackgroundService { private readonly IDbContextFactory _dbFactory; private readonly IPrimeRunner _runner; private readonly IPrimeClock _clock; private readonly IPrimeScheduleSignal _signal; private readonly IPrimeBroadcaster _broadcaster; private readonly PrimeSchedulerOptions _options; private readonly ILogger _logger; public PrimeScheduler( IDbContextFactory dbFactory, IPrimeRunner runner, IPrimeClock clock, IPrimeScheduleSignal signal, IPrimeBroadcaster broadcaster, PrimeSchedulerOptions options, ILogger logger) { _dbFactory = dbFactory; _runner = runner; _clock = clock; _signal = signal; _broadcaster = broadcaster; _options = options; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { await TickAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } catch (Exception ex) { _logger.LogWarning(ex, "PrimeScheduler tick failed; backing off"); await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } } } private async Task TickAsync(CancellationToken stoppingToken) { var schedules = await LoadAsync(stoppingToken); var now = _clock.Now; var due = NextDueCalculator.Compute(schedules, now, _options.CatchUpWindow); var signalToken = _signal.CurrentToken; using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, signalToken); if (due is null) { try { await Task.Delay(TimeSpan.FromHours(1), linked.Token); } catch (OperationCanceledException) { /* signal or shutdown */ } return; } var delay = due.FireImmediately ? TimeSpan.Zero : due.At - now; if (delay > TimeSpan.Zero) { try { await Task.Delay(delay, linked.Token); } catch (OperationCanceledException) { if (signalToken.IsCancellationRequested) return; throw; } } await FireAsync(due.Schedule, stoppingToken); } private async Task> LoadAsync(CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var rows = await new PrimeScheduleRepository(ctx).ListAsync(ct); return rows.Select(ToDto).ToList(); } private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) => new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride); private async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) { var firedAt = _clock.Now; var outcome = await _runner.FireAsync(schedule, ct); await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) await new PrimeScheduleRepository(ctx).UpdateLastRunAsync(schedule.Id, firedAt, ct); await _broadcaster.PrimeFiredAsync(schedule.Id, outcome.Success, outcome.Message, firedAt); if (outcome.Success) _logger.LogInformation("Prime fired {Id} at {When}", schedule.Id, firedAt); else _logger.LogWarning("Prime failed {Id}: {Msg}", schedule.Id, outcome.Message); } }