using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; namespace ClaudeDo.Worker.Runner; public sealed class TaskRunner { private readonly IClaudeProcess _claude; private readonly TaskRepository _taskRepo; private readonly ListRepository _listRepo; private readonly HubBroadcaster _broadcaster; private readonly WorktreeManager _wtManager; private readonly WorkerConfig _cfg; private readonly ILogger _logger; public TaskRunner( IClaudeProcess claude, TaskRepository taskRepo, ListRepository listRepo, HubBroadcaster broadcaster, WorktreeManager wtManager, WorkerConfig cfg, ILogger logger) { _claude = claude; _taskRepo = taskRepo; _listRepo = listRepo; _broadcaster = broadcaster; _wtManager = wtManager; _cfg = cfg; _logger = logger; } public async Task RunAsync(Data.Models.TaskEntity task, string slot, CancellationToken ct) { try { var list = await _listRepo.GetByIdAsync(task.ListId, ct); if (list is null) { await MarkFailed(task.Id, slot, "List not found."); return; } // Determine working directory: worktree or sandbox. WorktreeContext? wtCtx = null; string runDir; if (list.WorkingDir is not null) { try { wtCtx = await _wtManager.CreateAsync(task, list, ct); runDir = wtCtx.WorktreePath; } catch (Exception ex) { _logger.LogError(ex, "Failed to create worktree for task {TaskId}", task.Id); await MarkFailed(task.Id, slot, $"Worktree creation failed: {ex.Message}"); return; } } else { // Non-worktree sandbox path. runDir = Path.Combine(_cfg.SandboxRoot, task.Id); Directory.CreateDirectory(runDir); } var logPath = Path.Combine(_cfg.LogRoot, $"{task.Id}.ndjson"); await _taskRepo.SetLogPathAsync(task.Id, logPath, ct); var now = DateTime.UtcNow; await _taskRepo.MarkRunningAsync(task.Id, now, ct); await _broadcaster.TaskStarted(slot, task.Id, now); // Build prompt. var prompt = string.IsNullOrWhiteSpace(task.Description) ? task.Title : $"{task.Title}\n\n{task.Description.Trim()}"; await using var logWriter = new LogWriter(logPath); var result = await _claude.RunAsync( prompt, runDir, logPath, task.Id, async line => { await logWriter.WriteLineAsync(line, ct); await _broadcaster.TaskMessage(task.Id, line); }, ct); var finishedAt = DateTime.UtcNow; if (result.IsSuccess) { // Auto-commit if worktree mode and run succeeded. if (wtCtx is not null) { var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct); if (committed) await _broadcaster.WorktreeUpdated(task.Id); } await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct); await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt); _logger.LogInformation("Task {TaskId} completed successfully", task.Id); } else { // Failed run: do NOT commit. Worktree row stays active for inspection. await _taskRepo.MarkFailedAsync(task.Id, finishedAt, result.ErrorMarkdown, ct); await _broadcaster.TaskFinished(slot, task.Id, "failed", finishedAt); _logger.LogWarning("Task {TaskId} failed: {Error}", task.Id, result.ErrorMarkdown); } await _broadcaster.TaskUpdated(task.Id); } catch (OperationCanceledException) { _logger.LogInformation("Task {TaskId} was cancelled", task.Id); await MarkFailed(task.Id, slot, "Task cancelled."); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id); await MarkFailed(task.Id, slot, $"Unhandled error: {ex.Message}"); } } private async Task MarkFailed(string taskId, string slot, string error) { try { var now = DateTime.UtcNow; await _taskRepo.MarkFailedAsync(taskId, now, error); await _broadcaster.TaskFinished(slot, taskId, "failed", now); await _broadcaster.TaskUpdated(taskId); } catch (Exception ex) { _logger.LogError(ex, "Failed to mark task {TaskId} as failed", taskId); } } }