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:
+59
-14
@@ -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).")]
|
||||
|
||||
Reference in New Issue
Block a user