Files
ClaudeDo/src/ClaudeDo.Worker/Runner/TaskRunner.cs
Mika Kuns 01235d986f feat(worker,data): add git worktree support and conventional commits
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>
2026-04-13 13:29:26 +02:00

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);
}
}
}