From 7d87c03cfa66b14bc4246fd0015c3b44fede369d Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 24 Apr 2026 18:15:19 +0200 Subject: [PATCH] feat(worker): add PlanningMergeOrchestrator.ContinueAsync to resume merge after conflict Co-Authored-By: Claude Sonnet 4.6 --- .../Planning/PlanningMergeOrchestrator.cs | 18 +++++ .../PlanningMergeOrchestratorTests.cs | 67 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs index a48d3d6..d76cbf0 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs @@ -64,6 +64,24 @@ public sealed class PlanningMergeOrchestrator await DrainAsync(planningTaskId, ct); } + public async Task ContinueAsync(string planningTaskId, CancellationToken ct) + { + if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null) + throw new InvalidOperationException("no in-progress merge to continue"); + + var current = state.CurrentSubtaskId; + var result = await _merge.ContinueMergeAsync(current, ct); + if (result.Status != TaskMergeService.StatusMerged) + { + await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles); + return; + } + await _broadcaster.PlanningSubtaskMerged(planningTaskId, current); + + state.CurrentSubtaskId = null; + await DrainAsync(planningTaskId, ct); + } + private async Task DrainAsync(string planningTaskId, CancellationToken ct) { if (!_states.TryGetValue(planningTaskId, out var state)) return; diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs index 317086d..561bf08 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs @@ -125,6 +125,73 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable return (parentId, subA, subB); } + [Fact] + public async Task ContinueAsync_AfterConflict_ResumesRemainingMergesAndCompletes() + { + var db = NewDb(); + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var (parentId, subA, subB, subC) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo); + + var (orch, spy) = BuildOrchestrator(db); + await orch.StartAsync(parentId, "main", CancellationToken.None); + + Assert.Contains(spy, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == subA); + Assert.Contains(spy, c => c.Method == "PlanningMergeConflict" && (string)c.Args[1]! == subB); + + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "resolved\n"); + + await orch.ContinueAsync(parentId, CancellationToken.None); + + using var ctx = db.CreateContext(); + Assert.Equal(TaskStatus.Done, ctx.Tasks.Single(t => t.Id == parentId).Status); + Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subB).State); + Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subC).State); + Assert.Contains(spy, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == subB); + Assert.Contains(spy, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == subC); + Assert.Contains(spy, c => c.Method == "PlanningCompleted"); + } + + private async Task<(string parentId, string subA, string subB, string subC)> SeedPlanningThreeChildrenMiddleConflictsAsync( + DbFixture db, GitRepoFixture repo) + { + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change README"); + + using var ctx = db.CreateContext(); + var listId = Guid.NewGuid().ToString(); + ctx.Lists.Add(new ListEntity + { + Id = listId, Name = "test", CreatedAt = DateTime.UtcNow, WorkingDir = repo.RepoDir, + }); + var parentId = Guid.NewGuid().ToString(); + ctx.Tasks.Add(new TaskEntity + { + Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, + Status = TaskStatus.Planned, SortOrder = 0, + }); + var subA = Guid.NewGuid().ToString(); + var subB = Guid.NewGuid().ToString(); + var subC = Guid.NewGuid().ToString(); + ctx.Tasks.AddRange( + new TaskEntity { Id = subA, ListId = listId, Title = "A", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 }, + new TaskEntity { Id = subB, ListId = listId, Title = "B", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2 }, + new TaskEntity { Id = subC, ListId = listId, Title = "C", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 3 } + ); + await ctx.SaveChangesAsync(); + + SeedWorktreeWithFile(ctx, repo, subA, "fileA.txt", "A\n"); + SeedWorktreeWithFile(ctx, repo, subB, "README.md", "branch change\n"); + SeedWorktreeWithFile(ctx, repo, subC, "fileC.txt", "C\n"); + await ctx.SaveChangesAsync(); + + return (parentId, subA, subB, subC); + } + + private void SeedWorktreeWithFile(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content) + => SeedWorktree(ctx, repo, taskId, filename, content); + private void SeedWorktree(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content) { var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");