using ClaudeDo.Data; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Runner; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Refine; public sealed class RefineRunner : IRefineRunner { private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5); private const int MaxTurns = 25; private readonly IClaudeProcess _claude; private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; private readonly IRefineBroadcaster _broadcaster; private readonly object _lock = new(); private readonly HashSet _inFlight = new(); public RefineRunner( IClaudeProcess claude, IDbContextFactory dbFactory, ILogger logger, IRefineBroadcaster broadcaster) { _claude = claude; _dbFactory = dbFactory; _logger = logger; _broadcaster = broadcaster; } public async Task RefineAsync(string taskId, CancellationToken ct) { lock (_lock) { if (!_inFlight.Add(taskId)) return new RefineRunOutcome(false, "Already refining this task"); } var success = false; string? error = null; try { ClaudeDo.Data.Models.TaskEntity task; List subs; string? workingDir; await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct)) { var tasks = new TaskRepository(dbCtx); task = await tasks.GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status != TaskStatus.Idle) return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status})."); subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct); var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct); workingDir = list?.WorkingDir; } var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir); var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot(); Directory.CreateDirectory(cwd); var logPath = RefinePrompt.LogPath(taskId); try { if (File.Exists(logPath)) File.Delete(logPath); } catch { } await using var logWriter = new LogWriter(logPath); await _broadcaster.RefineStartedAsync(taskId); var prompt = RefinePrompt.BuildPrompt(task, subs); var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(RunTimeout); var result = await _claude.RunAsync( arguments: args, prompt: prompt, workingDirectory: cwd, onStdoutLine: async line => await logWriter.WriteLineAsync(line), ct: timeoutCts.Token); success = result.IsSuccess; if (!success) error = $"exit code {result.ExitCode}"; return success ? new RefineRunOutcome(true, "Refine complete") : new RefineRunOutcome(false, error!); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { error = $"timed out after {RunTimeout.TotalMinutes:0} min"; return new RefineRunOutcome(false, error); } catch (Exception ex) { _logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId); error = ex.Message; return new RefineRunOutcome(false, ex.Message); } finally { await _broadcaster.RefineFinishedAsync(taskId, success, error); lock (_lock) { _inFlight.Remove(taskId); } } } }