refactor(worker): extract OverrideSlotService and reorganize Worker/Services into domain folders

Slice 5 of the worker state consolidation refactor.

OverrideSlotService (new in Worker/Queue/) owns RunNow, ContinueTask,
and the override-slot piece of CancelTask. QueueService keeps the
queue-slot guard for "task is already running" rejection and delegates
to OverrideSlotService for execution; CancelTask tries the override
slot first, then the queue slot. QueueSlotState is extracted to its own
file.

Folder reorg (via git mv to preserve history):
- Worker/Queue/      QueueService, OverrideSlotService, QueueSlotState
                     (alongside existing waker/picker)
- Worker/Lifecycle/  StaleTaskRecovery, TaskResetService, TaskMergeService
- Worker/Worktrees/  WorktreeMaintenanceService
- Worker/Agents/     AgentFileService, DefaultAgentSeeder

Worker/Services/ folder removed. All consumers updated to the new
namespaces (Program.cs, WorkerHub, ExternalMcpService,
PlanningMergeOrchestrator, all Worker tests).

OverrideSlotService is registered as a DI singleton in both the main
worker app and the external MCP app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-27 14:42:13 +02:00
parent 4ab906ff0b
commit ff7c239959
25 changed files with 200 additions and 109 deletions

View File

@@ -0,0 +1,128 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Worktrees;
public sealed class WorktreeMaintenanceService
{
public sealed record CleanupResult(int Removed);
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly ILogger<WorktreeMaintenanceService> _logger;
public WorktreeMaintenanceService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
ILogger<WorktreeMaintenanceService> logger)
{
_dbFactory = dbFactory;
_git = git;
_logger = logger;
}
public async Task<CleanupResult> CleanupFinishedAsync(CancellationToken ct = default)
{
using var context = _dbFactory.CreateDbContext();
var rows = await (from w in context.Worktrees
join t in context.Tasks on w.TaskId equals t.Id
join l in context.Lists on t.ListId equals l.Id
where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
select new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir))
.AsNoTracking()
.ToListAsync(ct);
int removed = 0;
foreach (var row in rows)
{
if (await TryRemoveAsync(row, force: false, ct))
removed++;
}
return new CleanupResult(removed);
}
public async Task<ResetResult> ResetAllAsync(CancellationToken ct = default)
{
using var context = _dbFactory.CreateDbContext();
var running = await context.Tasks.AsNoTracking()
.CountAsync(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running, ct);
if (running > 0)
return new ResetResult(0, 0, Blocked: true, RunningTasks: running);
var rows = await (from w in context.Worktrees
join t in context.Tasks on w.TaskId equals t.Id
join l in context.Lists on t.ListId equals l.Id
select new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir))
.AsNoTracking()
.ToListAsync(ct);
int removed = 0;
foreach (var row in rows)
{
if (await TryRemoveAsync(row, force: true, ct))
removed++;
}
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0);
}
private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
{
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
if (repoDirExists)
{
try
{
await _git.WorktreeRemoveAsync(row.WorkingDir!, row.Path, force, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"git worktree remove failed for {Path}; falling back to directory delete", row.Path);
try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); }
catch (Exception delEx)
{
_logger.LogError(delEx, "Directory.Delete fallback also failed for {Path}", row.Path);
}
}
}
else
{
try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); }
catch (Exception ex)
{
_logger.LogError(ex, "Directory.Delete failed for {Path}", row.Path);
}
}
// Branch cleanup: otherwise rerunning the task hits "branch already exists".
// Prune first so git no longer thinks the branch is checked out by a phantom worktree.
if (repoDirExists)
{
try { await _git.WorktreePruneAsync(row.WorkingDir!, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "git worktree prune failed for {Repo}", row.WorkingDir); }
if (!string.IsNullOrWhiteSpace(row.BranchName))
{
try
{
await _git.BranchDeleteAsync(row.WorkingDir!, row.BranchName, force: true, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete branch {Branch} for worktree {Path}",
row.BranchName, row.Path);
}
}
}
using var context = _dbFactory.CreateDbContext();
await context.Worktrees.Where(w => w.TaskId == row.TaskId).ExecuteDeleteAsync(ct);
return true;
}
private sealed record WorktreeRow(string TaskId, string Path, string BranchName, string? WorkingDir);
}