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:
@@ -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
@@ -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]
|
||||
|
||||
+90
-11
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user