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
+59 -14
View File
@@ -6,6 +6,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees;
@@ -18,6 +19,7 @@ namespace ClaudeDo.Worker.External;
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record DeleteTaskResult(bool Deleted, string Id);
public sealed record CancelTaskResult(bool Cancelled, string Id);
public sealed record ReviewTaskResult(TaskDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage);
public sealed record StatusValueDto(string Status, string Meaning);
public sealed record TaskDto(
@@ -72,6 +74,7 @@ public sealed class ExternalMcpService
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _maintenance;
private readonly TaskMergeService _merge;
private readonly PlanningMergeOrchestrator _planningMerge;
public ExternalMcpService(
TaskRepository tasks,
@@ -82,7 +85,8 @@ public sealed class ExternalMcpService
GitService git,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService maintenance,
TaskMergeService merge)
TaskMergeService merge,
PlanningMergeOrchestrator planningMerge)
{
_tasks = tasks;
_lists = lists;
@@ -93,6 +97,7 @@ public sealed class ExternalMcpService
_dbFactory = dbFactory;
_maintenance = maintenance;
_merge = merge;
_planningMerge = planningMerge;
}
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
@@ -292,34 +297,74 @@ public sealed class ExternalMcpService
[McpServerTool, Description(
"Review a task that is WaitingForReview. " +
"decision='approve' → Done. " +
"decision='approve' → review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
"targetBranch (default: the repo's current branch) then goes Done; a task with children drives the unit merge " +
"(parent worktree if active + each Done child in order); a task without an active worktree approves straight to Done. " +
"mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — resolve them in the ClaudeDo UI. " +
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
"decision='reject_park' → Idle for manual editing (feedback ignored). " +
"decision='cancel' → Cancelled. " +
"Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued).")]
public async Task<TaskDto> ReviewTask(
public async Task<ReviewTaskResult> ReviewTask(
string taskId,
string decision,
string? feedback = null,
string? targetBranch = null,
CancellationToken cancellationToken = default)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
TransitionResult result = decision.Trim().ToLowerInvariant() switch
string? mergeStatus = null;
IReadOnlyList<string> mergeConflicts = Array.Empty<string>();
string? mergeMessage = null;
if (decision.Trim().ToLowerInvariant() == "approve")
{
"approve" => await _state.ApproveReviewAsync(taskId, cancellationToken),
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
_ => throw new InvalidOperationException(
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
};
// Approve is the single review+merge action — mirror the hub's ApproveReview
// routing instead of only flipping the status (which left branches unmerged).
bool hasChildren;
await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, cancellationToken);
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
if (hasChildren)
{
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken);
var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done;
mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict;
if (!parentDone)
mergeMessage = "unit merge paused on a conflict — resolve and continue it in the ClaudeDo UI";
}
else
{
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", cancellationToken);
if (r.Status == TaskMergeService.StatusBlocked)
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
mergeStatus = r.Status;
mergeConflicts = r.ConflictFiles;
mergeMessage = r.Status == TaskMergeService.StatusConflict
? "merge conflict — the task stays WaitingForReview; resolve it in the ClaudeDo UI"
: r.ErrorMessage;
}
}
else
{
TransitionResult result = decision.Trim().ToLowerInvariant() switch
{
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
_ => throw new InvalidOperationException(
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
};
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
}
return new ReviewTaskResult(
ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!),
mergeStatus, mergeConflicts, mergeMessage);
}
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]