fix(worker): kill cancelled runs' processes and make MCP approve actually merge

- CancelAsync now signals the running Claude process of the cancelled task and
  its cascaded children via the new RunCancellationRegistry (queue + override
  slots register their CTS there) instead of only flipping DB state.
- external MCP review_task 'approve' now mirrors the hub's ApproveReview:
  unit merge for parents, ApproveAndMergeAsync for childless tasks, optional
  targetBranch; ReviewTaskResult carries mergeStatus/conflicts.
This commit is contained in:
mika kuns
2026-07-23 20:24:36 +02:00
parent 451afc80f8
commit fee69998f8
15 changed files with 330 additions and 46 deletions
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -72,14 +73,18 @@ public sealed class AddSubtaskToolTests : IDisposable
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state);
var runCancels = new RunCancellationRegistry();
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels);
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(dbFactory, git, broadcaster, TaskStateServiceBuilder.Build(dbFactory).State, NullLogger<TaskMergeService>.Instance);
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
return new ExternalMcpService(
_tasks, _lists, queue, broadcaster,
state,
git, dbFactory, maintenance, merge);
git, dbFactory, maintenance, merge, planningMerge);
}
[Fact]
+11 -5
View File
@@ -6,6 +6,7 @@ using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -64,11 +65,15 @@ public sealed class BatchMcpToolsTests : IDisposable
var git = new GitService();
var factory = _db.CreateFactory();
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(factory, git, _broadcaster, TaskStateServiceBuilder.Build(factory).State, NullLogger<TaskMergeService>.Instance);
var state = TaskStateServiceBuilder.Build(factory).State;
var merge = new TaskMergeService(factory, git, _broadcaster, state, NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
var svc = new ExternalMcpService(
_tasks, _lists, CreateQueue(), _broadcaster,
TaskStateServiceBuilder.Build(factory).State,
git, factory, maintenance, merge);
state,
git, factory, maintenance, merge, planningMerge);
return new BatchMcpTools(svc);
}
@@ -88,9 +93,10 @@ public sealed class BatchMcpToolsTests : IDisposable
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg,
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var runCancels = new RunCancellationRegistry();
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance,
new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state);
new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state, runCancels);
}
[Fact]
@@ -6,6 +6,7 @@ using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -126,11 +127,15 @@ public sealed class ExternalMcpServiceTests : IDisposable
var git = new GitService();
var factory = _db.CreateFactory();
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(factory, git, _broadcaster, TaskStateServiceBuilder.Build(factory).State, NullLogger<TaskMergeService>.Instance);
var state = TaskStateServiceBuilder.Build(factory).State;
var merge = new TaskMergeService(factory, git, _broadcaster, state, NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
return new ExternalMcpService(
_tasks, _lists, queue, _broadcaster,
TaskStateServiceBuilder.Build(factory).State,
git, factory, maintenance, merge);
state,
git, factory, maintenance, merge, planningMerge);
}
private QueueService CreateQueue()
@@ -154,8 +159,9 @@ public sealed class ExternalMcpServiceTests : IDisposable
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state);
var runCancels = new RunCancellationRegistry();
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels);
}
[Fact]
@@ -210,9 +216,10 @@ public sealed class ExternalMcpServiceTests : IDisposable
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var dto = await sut.ReviewTask(task.Id, "approve", null, CancellationToken.None);
var result = await sut.ReviewTask(task.Id, "approve", null, null, CancellationToken.None);
Assert.Equal("Done", dto.Status);
Assert.Equal("Done", result.Task.Status);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
}
[Fact]
@@ -223,7 +230,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReviewTask(task.Id, "reject_rerun", null, CancellationToken.None));
sut.ReviewTask(task.Id, "reject_rerun", null, null, CancellationToken.None));
}
[Fact]
@@ -233,9 +240,9 @@ public sealed class ExternalMcpServiceTests : IDisposable
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var dto = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", CancellationToken.None);
var result = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", null, CancellationToken.None);
Assert.Equal("Queued", dto.Status);
Assert.Equal("Queued", result.Task.Status);
var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
Assert.Equal("fix it", loaded!.ReviewFeedback);
}
@@ -248,7 +255,79 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReviewTask(task.Id, "bogus", null, CancellationToken.None));
sut.ReviewTask(task.Id, "bogus", null, null, CancellationToken.None));
}
[Fact]
public async Task ReviewTask_Approve_WithActiveWorktree_MergesIntoTargetBranch()
{
if (!GitAvailable) return;
var (task, list, wt) = await SeedWorktreeAsync(status: TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
GitRepoFixture.RunGit(wt.WorktreePath, "add", "feature.txt");
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "add feature");
using (var ctx = _db.CreateContext())
{
var row = ctx.Worktrees.Single(w => w.TaskId == task.Id);
row.HeadCommit = GitRepoFixture.RunGit(wt.WorktreePath, "rev-parse", "HEAD").Trim();
ctx.SaveChanges();
}
var sut = BuildSut(CreateQueue());
var result = await sut.ReviewTask(task.Id, "approve", null, null, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
Assert.Equal("Done", result.Task.Status);
Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "feature.txt")));
using var verify = _db.CreateContext();
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == task.Id).State);
}
[Fact]
public async Task ReviewTask_Approve_ParentWithChildren_RunsUnitMerge()
{
if (!GitAvailable) return;
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var listId = Guid.NewGuid().ToString();
var parentId = Guid.NewGuid().ToString();
var childId = Guid.NewGuid().ToString();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized });
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 });
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_worktreeCleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{childId[..8]}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n");
GitRepoFixture.RunGit(wtPath, "add", "child.txt");
GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt");
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = childId, Path = wtPath, BranchName = branch,
BaseCommit = repo.BaseCommit,
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var result = await sut.ReviewTask(parentId, "approve", null, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
Assert.Equal("Done", result.Task.Status);
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "child.txt")));
using var verify = _db.CreateContext();
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
}
[Fact]