feat(worker): add continue_merge and abort_merge MCP tools
This commit is contained in:
+100
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user