feat(worker): add WorktreeMaintenanceService for idle-worktree cleanup

This commit is contained in:
Mika Kuns
2026-04-21 15:55:35 +02:00
parent 62a1121571
commit cfb9ca1ca4
6 changed files with 441 additions and 4 deletions

View File

@@ -39,8 +39,19 @@ public sealed class WorktreeManager
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)
string strategy;
string? centralRoot;
using (var settingsCtx = _dbFactory.CreateDbContext())
{
var settings = await new AppSettingsRepository(settingsCtx).GetAsync(ct);
strategy = settings.WorktreeStrategy;
centralRoot = !string.IsNullOrWhiteSpace(settings.CentralWorktreeRoot)
? settings.CentralWorktreeRoot
: _cfg.CentralWorktreeRoot;
}
var worktreePath = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug, task.Id)
: Path.Combine(Path.GetDirectoryName(workingDir)!, ".claudedo-worktrees", slug, task.Id);
worktreePath = Path.GetFullPath(worktreePath);
@@ -48,12 +59,43 @@ public sealed class WorktreeManager
// 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);
// Create the worktree. If a stale branch from a previous run remains
// (e.g. after force-remove), delete it and retry once.
try
{
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Branch {Branch} already exists; cleaning phantom worktrees and retrying", branchName);
// Find and forcefully remove any existing worktree registered against this branch.
List<string> stalePaths;
try { stalePaths = await _git.ListWorktreePathsForBranchAsync(workingDir, branchName, ct); }
catch (Exception listEx)
{
_logger.LogWarning(listEx, "git worktree list failed during self-heal");
stalePaths = new();
}
foreach (var stalePath in stalePaths)
{
try { await _git.WorktreeRemoveAsync(workingDir, stalePath, force: true, ct); }
catch (Exception wrEx) { _logger.LogWarning(wrEx, "Failed to remove stale worktree at {Path}", stalePath); }
}
try { await _git.WorktreePruneAsync(workingDir, ct); }
catch (Exception pruneEx) { _logger.LogWarning(pruneEx, "git worktree prune failed during self-heal"); }
try { await _git.BranchDeleteAsync(workingDir, branchName, force: true, ct); }
catch (Exception delEx) { _logger.LogWarning(delEx, "git branch -D failed during self-heal"); }
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
}
// Insert worktrees row AFTER git succeeds — if git throws, no row is created.
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
// Drop any stale row from a prior run (force-remove may have left the DB side behind).
await wtRepo.DeleteAsync(task.Id, ct);
await wtRepo.AddAsync(new WorktreeEntity
{
TaskId = task.Id,