feat(worker): add continue_merge and abort_merge MCP tools

This commit is contained in:
mika kuns
2026-07-24 14:21:26 +02:00
parent f4f7c81059
commit 7517f2a9b3
3 changed files with 254 additions and 0 deletions
+100
View File
@@ -47,6 +47,10 @@ public sealed record MergeTaskResultDto(
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
bool ConflictsInTree = false, string? RepoPath = null);
public sealed record MergeContinuationResultDto(
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
string? RepoPath, string? Message);
public sealed record WorktreeListItemDto(
string? TaskId, string Path, string Branch,
string HeadCommit, bool IsDirty, bool MergedIntoMain);
@@ -570,6 +574,102 @@ public sealed class ExternalMcpService
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
}
[McpServerTool, Description(
"Finish an in-progress conflicted merge after the conflict markers in the working tree (repoPath from " +
"merge_task/review_task) have been resolved. Handles both a single task's merge and a parent/children unit " +
"merge — pass the PARENT task id to continue a unit merge. On success merged=true and the task reaches its " +
"post-merge status (Done when approving). If conflict markers are still present, merged=false and conflicts " +
"lists the affected files — resolve them and call continue_merge again. " +
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
var workingDir = list?.WorkingDir;
bool merged;
IReadOnlyList<string> conflicts = Array.Empty<string>();
string? repoPath = null;
string? message = null;
if (_planningMerge.HasActiveMerge(taskId))
{
await _planningMerge.ContinueAsync(taskId, cancellationToken);
var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
if (parent.Status == TaskStatus.Done)
{
merged = true;
}
else
{
var remaining = !string.IsNullOrWhiteSpace(workingDir)
? await _git.ListConflictedFilesAsync(workingDir, cancellationToken)
: new List<string>();
merged = false;
if (remaining.Count > 0)
{
conflicts = remaining;
repoPath = workingDir;
message = "conflicts remain — resolve and call continue_merge again";
}
else
{
message = "unit merge did not complete — the orchestrator aborted or was blocked; " +
"check the parent task and approve again to restart the merge";
}
}
}
else
{
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken);
if (r.Status == TaskMergeService.StatusMerged)
{
merged = true;
}
else if (r.Status == TaskMergeService.StatusConflict)
{
merged = false;
conflicts = r.ConflictFiles;
repoPath = workingDir;
message = r.ErrorMessage;
}
else
{
throw new InvalidOperationException(r.ErrorMessage ?? "continue failed");
}
}
var reloaded = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
}
[McpServerTool, Description(
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
"Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " +
"unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " +
"Throws if there is no in-progress merge for the task.")]
public async Task<TaskDto> AbortMerge(string taskId, CancellationToken cancellationToken)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (_planningMerge.HasActiveMerge(taskId))
{
await _planningMerge.AbortAsync(taskId, cancellationToken);
}
else
{
var r = await _merge.AbortMergeAsync(taskId, cancellationToken);
if (r.Status == TaskMergeService.StatusBlocked)
throw new InvalidOperationException(r.ErrorMessage ?? "abort failed");
}
await _broadcaster.TaskUpdated(taskId);
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
}
[McpServerTool, Description(
"List all ClaudeDo-tracked worktrees. " +
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
@@ -108,6 +108,10 @@ public sealed class PlanningMergeOrchestrator
await DrainAsync(parentTaskId, ct);
}
/// <summary>True when a unit merge for this parent is paused on a conflict (in-memory state).</summary>
public bool HasActiveMerge(string parentTaskId) =>
_states.TryGetValue(parentTaskId, out var s) && s.CurrentSubtaskId is not null;
public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
{
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
@@ -925,6 +925,156 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal("WaitingForReview", result.Task.Status);
}
// ── continue_merge / abort_merge ──────────────────────────────────────────
[Fact]
public async Task ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, CancellationToken.None);
// Resolve the conflict on disk and stage it.
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
Assert.True(result.Merged);
Assert.Equal("Done", result.TaskStatus);
Assert.Empty(result.Conflicts);
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
}
[Fact]
public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, CancellationToken.None);
// Markers never resolved — continue must refuse and list the files.
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
Assert.False(result.Merged);
Assert.Contains("README.md", result.Conflicts);
Assert.Equal(list.WorkingDir, result.RepoPath);
Assert.Equal("WaitingForReview", result.TaskStatus);
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
}
[Fact]
public async Task AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, CancellationToken.None);
var dto = await sut.AbortMerge(task.Id, CancellationToken.None);
Assert.Equal("WaitingForReview", dto.Status);
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
}
[Fact]
public async Task ContinueMerge_NoMergeInProgress_Throws()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.ContinueMerge(task.Id, CancellationToken.None));
Assert.Contains("mid-merge", ex.Message);
}
[Fact]
public async Task AbortMerge_NoMergeInProgress_Throws()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.AbortMerge(task.Id, CancellationToken.None));
Assert.Contains("mid-merge", ex.Message);
}
[Fact]
public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
// main edits README.md so the child's edit conflicts.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
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, "README.md"), "# child change\n");
GitRepoFixture.RunGit(wtPath, "add", "README.md");
GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit");
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 review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus);
Assert.Equal(repo.RepoDir, review.RepoPath);
Assert.Contains("continue_merge", review.MergeMessage);
// Resolve the conflict on disk.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
var result = await sut.ContinueMerge(parentId, CancellationToken.None);
Assert.True(result.Merged);
Assert.Equal("Done", result.TaskStatus);
Assert.Empty(result.Conflicts);
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
using var verify = _db.CreateContext();
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
}
// ── AddTask model override ────────────────────────────────────────────────
[Fact]