feat(worker): let review_task/merge_task leave conflicts in tree via MCP

This commit is contained in:
mika kuns
2026-07-24 14:21:26 +02:00
parent 2a3ab5504a
commit f4f7c81059
3 changed files with 163 additions and 25 deletions
+46 -13
View File
@@ -19,7 +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 ReviewTaskResult(TaskDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null);
public sealed record StatusValueDto(string Status, string Meaning);
public sealed record TaskDto(
@@ -44,7 +44,8 @@ public sealed record TaskDiffDto(
string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);
public sealed record MergeTaskResultDto(
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts);
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
bool ConflictsInTree = false, string? RepoPath = null);
public sealed record WorktreeListItemDto(
string? TaskId, string Path, string Branch,
@@ -300,7 +301,10 @@ public sealed class ExternalMcpService
"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. " +
"mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — by default the merge is cleanly " +
"aborted and you resolve in the ClaudeDo UI; pass leaveConflictsInTree=true to instead leave the conflict " +
"markers in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
"or abort_merge to cancel. " +
"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. " +
@@ -310,14 +314,16 @@ public sealed class ExternalMcpService
string decision,
string? feedback = null,
string? targetBranch = null,
bool leaveConflictsInTree = false,
CancellationToken cancellationToken = default)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
string? mergeStatus = null;
IReadOnlyList<string> mergeConflicts = Array.Empty<string>();
string? mergeMessage = null;
string? repoPath = null;
if (decision.Trim().ToLowerInvariant() == "approve")
{
@@ -333,18 +339,38 @@ public sealed class ExternalMcpService
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";
{
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
repoPath = list?.WorkingDir;
mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " +
"resolve them then call continue_merge with the parent task id, or abort_merge to cancel";
}
}
else
{
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", cancellationToken);
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, 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;
if (r.Status == TaskMergeService.StatusConflict)
{
if (leaveConflictsInTree)
{
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
repoPath = list?.WorkingDir;
mergeMessage = "merge conflict — markers left in the working tree; " +
"resolve them then call continue_merge, or abort_merge to cancel";
}
else
{
mergeMessage = "merge conflict — the task stays WaitingForReview; resolve it in the ClaudeDo UI";
}
}
else
{
mergeMessage = r.ErrorMessage;
}
}
}
else
@@ -364,7 +390,7 @@ public sealed class ExternalMcpService
return new ReviewTaskResult(
ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!),
mergeStatus, mergeConflicts, mergeMessage);
mergeStatus, mergeConflicts, mergeMessage, repoPath);
}
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
@@ -484,13 +510,17 @@ public sealed class ExternalMcpService
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
"allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
"On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")]
"On conflict: by default the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files. " +
"leaveConflictsInTree=true: on conflict the merge is NOT aborted — conflict markers are left in the working " +
"tree at repoPath (conflictsInTree=true in the result) so you can resolve them there and call continue_merge, " +
"or abort_merge to cancel.")]
public async Task<MergeTaskResultDto> MergeTask(
string taskId,
string targetBranch = "main",
bool noFf = true,
bool dryRun = false,
bool allowWaitingForReview = false,
bool leaveConflictsInTree = false,
CancellationToken cancellationToken = default)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
@@ -517,7 +547,7 @@ public sealed class ExternalMcpService
var commitMessage = $"Merge task branch for: {task.Title}";
var result = await _merge.MergeAsync(
taskId, targetBranch, removeWorktree: false, commitMessage, cancellationToken);
taskId, targetBranch, removeWorktree: false, commitMessage, leaveConflictsInTree, cancellationToken);
if (result.Status == TaskMergeService.StatusMerged)
{
@@ -532,7 +562,10 @@ public sealed class ExternalMcpService
}
if (result.Status == TaskMergeService.StatusConflict)
return new MergeTaskResultDto(false, null, result.ConflictFiles);
return leaveConflictsInTree
? new MergeTaskResultDto(false, null, result.ConflictFiles,
ConflictsInTree: true, RepoPath: list?.WorkingDir)
: new MergeTaskResultDto(false, null, result.ConflictFiles);
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
}