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]
@@ -17,13 +17,15 @@ public static class TaskStateServiceBuilder
PlanningChainCoordinator Chain,
CapturingHubContext Hub,
Func<int> WakeCount,
CountingQueueWaker Waker);
CountingQueueWaker Waker,
RunCancellationRegistry RunCancels);
public static Built Build(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
var hub = new CapturingHubContext();
var broadcaster = new HubBroadcaster(hub);
var waker = new CountingQueueWaker();
var runCancels = new RunCancellationRegistry();
TaskStateService? state = null;
var chain = new PlanningChainCoordinator(dbFactory, () => state!);
@@ -32,9 +34,10 @@ public static class TaskStateServiceBuilder
broadcaster,
waker,
chain,
runCancels,
NullLogger<TaskStateService>.Instance);
return new Built(state, chain, hub, () => waker.Count, waker);
return new Built(state, chain, hub, () => waker.Count, waker, runCancels);
}
}
@@ -0,0 +1,51 @@
using ClaudeDo.Worker.Queue;
namespace ClaudeDo.Worker.Tests.Queue;
public sealed class RunCancellationRegistryTests
{
[Fact]
public void TryCancel_RegisteredTask_CancelsAndReturnsTrue()
{
var sut = new RunCancellationRegistry();
using var cts = new CancellationTokenSource();
sut.Register("t1", cts);
Assert.True(sut.TryCancel("t1"));
Assert.True(cts.IsCancellationRequested);
}
[Fact]
public void TryCancel_UnknownTask_ReturnsFalse()
{
var sut = new RunCancellationRegistry();
Assert.False(sut.TryCancel("nope"));
}
[Fact]
public void Unregister_RemovesOnlyTheGivenRegistration()
{
var sut = new RunCancellationRegistry();
using var stale = new CancellationTokenSource();
using var current = new CancellationTokenSource();
sut.Register("t1", stale);
sut.Register("t1", current); // re-run replaced the registration
sut.Unregister("t1", stale); // late cleanup of the old slot must not evict the new one
Assert.True(sut.TryCancel("t1"));
Assert.True(current.IsCancellationRequested);
Assert.False(stale.IsCancellationRequested);
}
[Fact]
public void TryCancel_DisposedCts_ReturnsFalse()
{
var sut = new RunCancellationRegistry();
var cts = new CancellationTokenSource();
sut.Register("t1", cts);
cts.Dispose();
Assert.False(sut.TryCancel("t1"));
}
}
@@ -52,13 +52,14 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var built = TaskStateServiceBuilder.Build(dbFactory);
var state = built.State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
_waker = new QueueWaker();
var picker = new QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, built.RunCancels);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state, built.RunCancels);
return (service, fake);
}
@@ -53,13 +53,14 @@ public sealed class QueueServiceTests : IDisposable
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var built = TaskStateServiceBuilder.Build(dbFactory);
var state = built.State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
_waker = new QueueWaker();
var picker = new QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, built.RunCancels);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state, built.RunCancels);
return (service, fake);
}
@@ -214,6 +214,41 @@ public sealed class WaitingForChildrenLifecycleTests : IDisposable
Assert.Null(queuedChild.BlockedByTaskId);
}
[Fact]
public async Task Cancelling_WaitingForChildren_parent_stops_running_child_process()
{
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent",
Status = TaskStatus.WaitingForChildren, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "c_running", ListId = "l1", Title = "Running child",
Status = TaskStatus.Running, ParentTaskId = "par", CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
using var childCts = new CancellationTokenSource();
_built.RunCancels.Register("c_running", childCts);
var result = await _built.State.CancelAsync("par", DateTime.UtcNow, default);
Assert.True(result.Ok);
Assert.True(childCts.IsCancellationRequested);
}
[Fact]
public async Task Cancelling_running_task_signals_its_registered_run()
{
var id = await SeedRunningStandaloneAsync();
using var cts = new CancellationTokenSource();
_built.RunCancels.Register(id, cts);
var result = await _built.State.CancelAsync(id, DateTime.UtcNow, default);
Assert.True(result.Ok);
Assert.True(cts.IsCancellationRequested);
}
// ─── FinalizePlanningAsync ────────────────────────────────────────────
private async Task<string> SeedActivePlanningParentAsync(string id = "par")