using ClaudeDo.Data; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Runner; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Prime; public sealed class PrimeRunner : IPrimeRunner { private static readonly TimeSpan FireTimeout = TimeSpan.FromMinutes(5); private const int MaxTurns = 30; private readonly IClaudeProcess _claude; private readonly IDbContextFactory _dbFactory; private readonly IPrimeClock _clock; private readonly ILogger _logger; private readonly IPrimeBroadcaster _broadcaster; private readonly SemaphoreSlim _gate = new(1, 1); public PrimeRunner( IClaudeProcess claude, IDbContextFactory dbFactory, IPrimeClock clock, ILogger logger, IPrimeBroadcaster broadcaster) { _claude = claude; _dbFactory = dbFactory; _clock = clock; _logger = logger; _broadcaster = broadcaster; } public async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) { if (!await _gate.WaitAsync(0, ct)) return new PrimeRunOutcome(false, "Daily prep already running"); var success = false; try { var logPath = DailyPrepPrompt.LogPath(); try { if (File.Exists(logPath)) File.Delete(logPath); } catch { /* best effort */ } await using var logWriter = new LogWriter(logPath); await _broadcaster.PrepStartedAsync(); var cwd = Paths.AppDataRoot(); Directory.CreateDirectory(cwd); int maxTasks; await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct)) { var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct); maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks; } var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime); var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today); var args = DailyPrepPrompt.BuildArgs(MaxTurns); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(FireTimeout); var result = await _claude.RunAsync( arguments: args, prompt: prompt, workingDirectory: cwd, onStdoutLine: async line => { await logWriter.WriteLineAsync(line); await _broadcaster.PrepLineAsync(line); }, ct: timeoutCts.Token); success = result.IsSuccess; return success ? new PrimeRunOutcome(true, "Daily prep complete") : new PrimeRunOutcome(false, $"exit code {result.ExitCode}"); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min"); } catch (Exception ex) { _logger.LogWarning(ex, "Daily prep run failed"); return new PrimeRunOutcome(false, ex.Message); } finally { await _broadcaster.PrepFinishedAsync(success); _gate.Release(); } } }