GitService (in ClaudeDo.Data so the UI can reuse it) wraps the git CLI:
IsGitRepo, RevParseHead, WorktreeAdd/Remove, HasChanges, AddAll, Commit
(multi-line via -F -), DiffStat, BranchDelete, MergeFfOnly. Throws with
stderr on failure.
WorktreeManager owns the per-task lifecycle: validate working_dir is a
git repo (throws if not, no DB row written), create the worktree at
<repo>/../.claudedo-worktrees/<slug>/<id>/ (or central root per config),
insert the worktrees row. CommitIfChangedAsync skips when there are no
changes, otherwise commits and updates head_commit + diff_stat.
CommitMessageBuilder produces "{type}({list-slug}): {title<=60}" with a
blank-line-separated description (truncated to 400) and a permanent
"ClaudeDo-Task: <id>" trailer. Slug normalises whitespace + strips
non-alphanumerics. Newlines hard-coded to \n so git on Windows doesn't
choke on \r\n.
TaskRunner branches on list.WorkingDir: worktree path runs Claude in the
worktree, commits on success, broadcasts WorktreeUpdated; failure leaves
the worktree row active for inspection. Sandbox path unchanged.
Tests: 38 pass (12 new). GitRepoFixture spins up a real temp repo with a
seed commit; tests skip gracefully if `git` isn't on PATH.
CommitMessageBuilder fully unit-tested. WorktreeManager covers create,
no-change skip, real-commit, and non-repo failure.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
150 lines
5.1 KiB
C#
150 lines
5.1 KiB
C#
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<TaskRunner> _logger;
|
|
|
|
public TaskRunner(
|
|
IClaudeProcess claude,
|
|
TaskRepository taskRepo,
|
|
ListRepository listRepo,
|
|
HubBroadcaster broadcaster,
|
|
WorktreeManager wtManager,
|
|
WorkerConfig cfg,
|
|
ILogger<TaskRunner> 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);
|
|
}
|
|
}
|
|
}
|