- claude process: run stdout/stderr reads without ct; rely on kill-on-cancel closing the pipes to unblock them — previously ReadLineAsync(ct) could hang, stalling task slots and shutdown - task runner: terminal db writes (task_runs, MarkDone, MarkFailed, SetLogPath) now use CancellationToken.None; RunOnceAsync catches OCE and finalizes the run row so ContinueAsync can resume - task repository: GetNextQueuedAgentTaskAsync is now a single UPDATE ... RETURNING statement — closes TOCTOU window where two loop iterations could dispatch the same queued task - queue service: dispose CancellationTokenSource in slot-completion ContinueWith to stop leaking wait handles - git service: register ct.Kill(processTree), drain reads without ct, always reap via WaitForExitAsync(None) — no more git zombies on cancelled worktree ops - worktree manager: branch name uses full task id (dashes stripped) instead of 8-char prefix, eliminating collision risk Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
96 lines
3.7 KiB
C#
96 lines
3.7 KiB
C#
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
public sealed record WorktreeContext(string WorktreePath, string BranchName, string BaseCommit);
|
|
|
|
public sealed class WorktreeManager
|
|
{
|
|
private readonly GitService _git;
|
|
private readonly WorktreeRepository _wtRepo;
|
|
private readonly WorkerConfig _cfg;
|
|
private readonly ILogger<WorktreeManager> _logger;
|
|
|
|
public WorktreeManager(GitService git, WorktreeRepository wtRepo, WorkerConfig cfg, ILogger<WorktreeManager> logger)
|
|
{
|
|
_git = git;
|
|
_wtRepo = wtRepo;
|
|
_cfg = cfg;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<WorktreeContext> CreateAsync(TaskEntity task, ListEntity list, CancellationToken ct)
|
|
{
|
|
var workingDir = list.WorkingDir
|
|
?? throw new InvalidOperationException("list.WorkingDir is null");
|
|
|
|
if (!await _git.IsGitRepoAsync(workingDir, ct))
|
|
throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}");
|
|
|
|
var baseCommit = await _git.RevParseHeadAsync(workingDir, ct);
|
|
// Use the full task id (dashes stripped) in the branch name so
|
|
// two GUIDs sharing an 8-char prefix cannot collide on the same branch.
|
|
var idForBranch = task.Id.Replace("-", "");
|
|
var branchName = $"claudedo/{idForBranch}";
|
|
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
|
|
|
var worktreePath = _cfg.WorktreeRootStrategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
|
? Path.Combine(_cfg.CentralWorktreeRoot, slug, task.Id)
|
|
: Path.Combine(Path.GetDirectoryName(workingDir)!, ".claudedo-worktrees", slug, task.Id);
|
|
|
|
worktreePath = Path.GetFullPath(worktreePath);
|
|
|
|
// Ensure parent directory exists.
|
|
Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
|
|
|
|
// Create the worktree (this also creates the directory).
|
|
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
|
|
|
|
// Insert worktrees row AFTER git succeeds — if git throws, no row is created.
|
|
await _wtRepo.AddAsync(new WorktreeEntity
|
|
{
|
|
TaskId = task.Id,
|
|
Path = worktreePath,
|
|
BranchName = branchName,
|
|
BaseCommit = baseCommit,
|
|
HeadCommit = null,
|
|
DiffStat = null,
|
|
State = WorktreeState.Active,
|
|
CreatedAt = DateTime.UtcNow,
|
|
}, ct);
|
|
|
|
_logger.LogInformation("Created worktree for task {TaskId} at {Path} (branch {Branch}, base {Base})",
|
|
task.Id, worktreePath, branchName, baseCommit);
|
|
|
|
return new WorktreeContext(worktreePath, branchName, baseCommit);
|
|
}
|
|
|
|
/// <returns>true if a commit was made; false if no changes.</returns>
|
|
public async Task<bool> CommitIfChangedAsync(WorktreeContext ctx, TaskEntity task, ListEntity list, CancellationToken ct)
|
|
{
|
|
if (!await _git.HasChangesAsync(ctx.WorktreePath, ct))
|
|
{
|
|
_logger.LogInformation("No changes in worktree for task {TaskId}, skipping commit", task.Id);
|
|
return false;
|
|
}
|
|
|
|
await _git.AddAllAsync(ctx.WorktreePath, ct);
|
|
|
|
var message = CommitMessageBuilder.Build(
|
|
task.CommitType, list.Name, task.Title, task.Description, task.Id);
|
|
|
|
await _git.CommitAsync(ctx.WorktreePath, message, ct);
|
|
|
|
var head = await _git.RevParseHeadAsync(ctx.WorktreePath, ct);
|
|
var diffStat = await _git.DiffStatAsync(ctx.WorktreePath, ctx.BaseCommit, head, ct);
|
|
|
|
await _wtRepo.UpdateHeadAsync(task.Id, head, diffStat, ct);
|
|
|
|
_logger.LogInformation("Committed changes for task {TaskId}: {Head}", task.Id, head);
|
|
return true;
|
|
}
|
|
}
|