diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 69fa00f6..ead1a174 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -47,6 +47,10 @@ public sealed record MergeTaskResultDto( bool Merged, string? MergeCommit, IReadOnlyList Conflicts, bool ConflictsInTree = false, string? RepoPath = null); +public sealed record MergeContinuationResultDto( + bool Merged, string TaskStatus, IReadOnlyList 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 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 conflicts = Array.Empty(); + 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(); + 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 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), " + diff --git a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs index cde06f83..50f6d75d 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs @@ -108,6 +108,10 @@ public sealed class PlanningMergeOrchestrator await DrainAsync(parentTaskId, ct); } + /// True when a unit merge for this parent is paused on a conflict (in-memory state). + 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) diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 6127d9cc..96ad8f59 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -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( + () => 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( + () => 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]