# Planning Merge-All & Subtask Visibility — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fix three Planning-feature problems: keep subtasks visible under their Planning parent until it is Done, roll up children's status onto the Planning parent in the Queue List, and provide an aggregated diff viewer plus a single "Merge all subtasks" action with VS-Code-assisted conflict resolution. **Architecture:** Add two Worker services (`PlanningAggregator`, `PlanningMergeOrchestrator`) that compose the existing `TaskMergeService` (which gains conflict-resume support). Expose via `WorkerHub`. On the UI side, change `TasksIslandViewModel.Regroup` to treat Planning parents as roll-ups, and add two new Avalonia views (aggregated diff viewer + conflict resolution dialog). **Tech Stack:** .NET 8, ASP.NET Core, SignalR, Avalonia 12, EF Core Sqlite, CommunityToolkit.Mvvm, xUnit with real git + SQLite fixtures. **Spec:** `docs/superpowers/specs/2026-04-24-planning-merge-all-design.md` --- ## File Structure **New files:** - `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` — integration branch build + per-subtask diff aggregation. - `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` — singleton, owns in-memory Merge-all state, coordinates sequential merges. - `src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs` — record types for SignalR payloads. - `src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs` - `src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs` - `src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml` + `.axaml.cs` - `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` + `.axaml.cs` - `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs` - `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs` **Modified files:** - `src/ClaudeDo.Worker/Services/TaskMergeService.cs` — new `leaveConflictsInTree` param, `ContinueMergeAsync`, `AbortMergeAsync`. - `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — new hub methods. - `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — new broadcast events for the orchestrator. - `src/ClaudeDo.Worker/Program.cs` — DI registration. - `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — `Regroup()` changes + virtual-list filter change. - `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — extend with conflict-leave tests. **Sequence:** backend first (Tasks 1–11), then UI visibility fix (Task 12), then UI views (Tasks 13–15). Each task is independently committable and testable. --- ## Phase 1 — `TaskMergeService` extensions ### Task 1: Add `leaveConflictsInTree` parameter to `MergeAsync` **Goal:** When the caller sets `leaveConflictsInTree: true`, a conflict leaves the repo mid-merge (no `git merge --abort`) and the conflicted files are returned on the result. Default remains `false` so all existing callers are unchanged. **Files:** - Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs` - Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` - [ ] **Step 1: Write failing test for `leaveConflictsInTree: true`** Add to `TaskMergeServiceTests.cs` (follow existing patterns in the file; use `NewRepo()`, `NewDb()`, `SeedListAndTask`, `BuildService` helpers already present). Create a real conflict by adding a worktree branch and target branch that both modify the same file. ```csharp [Fact] public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles() { var db = NewDb(); var repo = NewRepo(); // On main: modify README File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change"); // Worktree branch: conflicting change to README var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _wtCleanups.Add((repo.RepoDir, wtPath)); GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t1", wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n"); GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change"); var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); await SeedWorktree(db, task.Id, wtPath, "claudedo/t1", repo.BaseCommit); var (svc, _) = BuildService(db); var result = await svc.MergeAsync( task.Id, "main", removeWorktree: false, "msg", leaveConflictsInTree: true, CancellationToken.None); Assert.Equal(TaskMergeService.StatusConflict, result.Status); Assert.Contains("README.md", result.ConflictFiles); // Repo should STILL be mid-merge (we asked it not to abort). var midMerge = File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")); Assert.True(midMerge, "repo should be left in mid-merge state"); // Cleanup: abort so subsequent tests can run. GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort"); } ``` (If `SeedWorktree` helper does not yet exist, add it — look at how existing tests seed worktrees. The pattern follows `SeedListAndTask`.) - [ ] **Step 2: Run test — expect fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~MergeAsync_LeaveConflicts" -v minimal` Expected: FAIL — compile error: "MergeAsync does not take 6 arguments". - [ ] **Step 3: Add parameter and skip the abort when requested** Modify `src/ClaudeDo.Worker/Services/TaskMergeService.cs`: Change the `MergeAsync` signature to add `leaveConflictsInTree`: ```csharp public async Task MergeAsync( string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool leaveConflictsInTree, CancellationToken ct) ``` Inside the conflict branch (currently lines 86–108), change the abort logic: ```csharp var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct); if (exitCode != 0) { List files; try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); } catch { files = new(); } if (leaveConflictsInTree && files.Count > 0) { // Caller (Merge-all flow) will drive ContinueMergeAsync or AbortMergeAsync. return new MergeResult(StatusConflict, files, null); } try { await _git.MergeAbortAsync(list.WorkingDir, ct); } catch (Exception ex) { _logger.LogError(ex, "git merge --abort failed after conflict — repo is mid-merge"); return Blocked($"merge conflict and abort failed: {ex.Message} — repo is mid-merge, resolve manually"); } if (files.Count == 0) { return new MergeResult(StatusBlocked, Array.Empty(), $"merge failed: {stderr}"); } return new MergeResult(StatusConflict, files, null); } ``` Also add a backward-compat overload so existing callers compile without change: ```csharp public Task MergeAsync( string taskId, string targetBranch, bool removeWorktree, string commitMessage, CancellationToken ct) => MergeAsync(taskId, targetBranch, removeWorktree, commitMessage, leaveConflictsInTree: false, ct); ``` - [ ] **Step 4: Run test — expect pass** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~MergeAsync_LeaveConflicts" -v minimal` Expected: PASS. Also run the full merge-service test class to ensure no regression: Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskMergeServiceTests" -v minimal` Expected: all pass. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs git commit -m "feat(worker): add leaveConflictsInTree option to TaskMergeService.MergeAsync" ``` --- ### Task 2: Add `ContinueMergeAsync` **Goal:** When the repo is mid-merge (after a conflicted `MergeAsync` with `leaveConflictsInTree: true`), this method stages all conflicted files and commits the merge, then flips the worktree to `Merged`. **Files:** - Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs` - Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` - [ ] **Step 1: Write failing test** ```csharp [Fact] public async Task ContinueMergeAsync_AfterUserResolves_CompletesMergeAndSetsWorktreeMerged() { var db = NewDb(); var repo = NewRepo(); File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change"); var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _wtCleanups.Add((repo.RepoDir, wtPath)); GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t2", wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n"); GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change"); var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); await SeedWorktree(db, task.Id, wtPath, "claudedo/t2", repo.BaseCommit); var (svc, _) = BuildService(db); var first = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None); Assert.Equal(TaskMergeService.StatusConflict, first.Status); // Simulate the user resolving the conflict in VS Code. File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n"); var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None); Assert.Equal(TaskMergeService.StatusMerged, result.Status); Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD"))); using var ctx = db.CreateContext(); var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id); Assert.Equal(WorktreeState.Merged, wt.State); } ``` - [ ] **Step 2: Run test — expect fail** (ContinueMergeAsync does not exist). Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ContinueMergeAsync_After" -v minimal` Expected: FAIL (compile error). - [ ] **Step 3: Implement `ContinueMergeAsync`** Add to `TaskMergeService.cs`: ```csharp public async Task ContinueMergeAsync(string taskId, CancellationToken ct) { TaskEntity task; ListEntity list; WorktreeEntity? wt; using (var ctx = _dbFactory.CreateDbContext()) { task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException("List not found."); wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); } if (wt is null) return Blocked("task has no worktree"); if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); if (!await _git.IsMidMergeAsync(list.WorkingDir, ct)) return Blocked("repo is not mid-merge"); var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); if (remaining.Count > 0) return new MergeResult(StatusConflict, remaining, "conflicts not fully resolved"); await _git.AddAllAsync(list.WorkingDir, ct); await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); using (var ctx = _dbFactory.CreateDbContext()) { await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct); } await _broadcaster.WorktreeUpdated(taskId); _logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName); return new MergeResult(StatusMerged, Array.Empty(), null); } ``` (`AddAllAsync` and `CommitAsync` already exist on `GitService` per its public API — see spec §7.) - [ ] **Step 4: Run test — expect pass.** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ContinueMergeAsync" -v minimal` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs git commit -m "feat(worker): add ContinueMergeAsync to resume a conflicted merge" ``` --- ### Task 3: Add `AbortMergeAsync` **Goal:** Cancel an in-progress conflicted merge (`git merge --abort`), restoring the repo to its pre-merge state. Worktree state is unchanged (still `Active`). **Files:** - Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs` - Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` - [ ] **Step 1: Write failing test** ```csharp [Fact] public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive() { var db = NewDb(); var repo = NewRepo(); File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change"); var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _wtCleanups.Add((repo.RepoDir, wtPath)); GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t3", wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n"); GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change"); var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); await SeedWorktree(db, task.Id, wtPath, "claudedo/t3", repo.BaseCommit); var (svc, _) = BuildService(db); await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None); var result = await svc.AbortMergeAsync(task.Id, CancellationToken.None); Assert.Equal("aborted", result.Status); Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD"))); using var ctx = db.CreateContext(); var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id); Assert.Equal(WorktreeState.Active, wt.State); } ``` - [ ] **Step 2: Run test — expect fail.** - [ ] **Step 3: Implement `AbortMergeAsync` + add `StatusAborted` constant** Add to `TaskMergeService.cs`: ```csharp public const string StatusAborted = "aborted"; public async Task AbortMergeAsync(string taskId, CancellationToken ct) { ListEntity list; WorktreeEntity? wt; using (var ctx = _dbFactory.CreateDbContext()) { var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException("List not found."); wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); } if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); if (!await _git.IsMidMergeAsync(list.WorkingDir, ct)) return Blocked("repo is not mid-merge"); await _git.MergeAbortAsync(list.WorkingDir, ct); _logger.LogInformation("Aborted merge of task {TaskId}", taskId); return new MergeResult(StatusAborted, Array.Empty(), null); } ``` - [ ] **Step 4: Run test — expect pass.** - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs git commit -m "feat(worker): add AbortMergeAsync to cancel a conflicted merge" ``` --- ## Phase 2 — `PlanningAggregator` ### Task 4: `PlanningAggregator.GetAggregatedDiffAsync` **Goal:** Return per-subtask diff entries (title, branch, base commit, head commit, file stats, raw unified diff) for all children of a Planning task. **Files:** - Create: `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` - Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs` - [ ] **Step 1: Define the result records** Create `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` with: ```csharp using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Planning; public sealed record SubtaskDiff( string SubtaskId, string Title, string BranchName, string BaseCommit, string HeadCommit, string? DiffStat, string UnifiedDiff); public sealed record CombinedDiffSuccess(string IntegrationBranch, string UnifiedDiff); public sealed record CombinedDiffFailure(string FirstConflictSubtaskId, IReadOnlyList ConflictedFiles); public abstract record CombinedDiffResult { public sealed record Ok(CombinedDiffSuccess Value) : CombinedDiffResult; public sealed record Failed(CombinedDiffFailure Value) : CombinedDiffResult; } public sealed class PlanningAggregator { private readonly IDbContextFactory _dbFactory; private readonly GitService _git; private readonly ILogger _logger; public PlanningAggregator( IDbContextFactory dbFactory, GitService git, ILogger logger) { _dbFactory = dbFactory; _git = git; _logger = logger; } public async Task> GetAggregatedDiffAsync( string planningTaskId, CancellationToken ct) { using var ctx = _dbFactory.CreateDbContext(); var children = await ctx.Tasks .Include(t => t.Worktree) .Where(t => t.ParentTaskId == planningTaskId) .OrderBy(t => t.SortOrder) .ToListAsync(ct); var result = new List(); foreach (var child in children) { if (child.Worktree is null) continue; var wt = child.Worktree; var head = wt.HeadCommit ?? await _git.RevParseHeadAsync(wt.Path, ct); string unified; try { unified = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, ct); } catch (Exception ex) { _logger.LogWarning(ex, "diff failed for subtask {Id}", child.Id); unified = ""; } result.Add(new SubtaskDiff( child.Id, child.Title, wt.BranchName, wt.BaseCommit, head, wt.DiffStat, unified)); } return result; } // BuildIntegrationBranchAsync & CleanupIntegrationBranchAsync added in Tasks 5 & 6. } ``` - [ ] **Step 2: Write failing test** Create `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs`: ```csharp using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Planning; public class PlanningAggregatorTests : IDisposable { private readonly List _dbs = new(); private readonly List _repos = new(); private readonly List<(string repoDir, string wtPath)> _wtCleanups = new(); private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; } private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; } public void Dispose() { foreach (var (repo, wt) in _wtCleanups) try { GitRepoFixture.RunGit(repo, "worktree", "remove", "--force", wt); } catch { } foreach (var d in _dbs) try { d.Dispose(); } catch { } foreach (var r in _repos) try { r.Dispose(); } catch { } } [Fact] public async Task GetAggregatedDiffAsync_ReturnsOneEntryPerSubtaskInSortOrder() { var db = NewDb(); var repo = NewRepo(); // Seed a planning parent and two child subtasks, each with a worktree + branch. var (parentId, subA, subB) = await SeedPlanningWithChildren(db, repo); var svc = new PlanningAggregator(db.CreateFactory(), new GitService(NullLogger.Instance), NullLogger.Instance); var result = await svc.GetAggregatedDiffAsync(parentId, CancellationToken.None); Assert.Equal(2, result.Count); Assert.Equal(subA, result[0].SubtaskId); Assert.Equal(subB, result[1].SubtaskId); Assert.Contains("diff --git", result[0].UnifiedDiff); } // Helper: creates a planning parent + 2 children with real worktrees. private async Task<(string parent, string childA, string childB)> SeedPlanningWithChildren( DbFixture db, GitRepoFixture repo) { // ... (follow SeedListAndTask pattern from TaskMergeServiceTests; // for each child: git worktree add -b claudedo/ ; // write a file in the worktree; commit; record HeadCommit on WorktreeEntity) throw new NotImplementedException("implement during task"); } } ``` (Fill in `SeedPlanningWithChildren` with actual EF Core inserts + `git worktree add` calls using `GitRepoFixture.RunGit` and `File.WriteAllText`. The two children must each have at least one commit on their branch so the diff has content.) - [ ] **Step 3: Run test — expect fail** (helper throws). Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PlanningAggregatorTests" -v minimal` Expected: FAIL. - [ ] **Step 4: Implement the seeder helper in the test (not the service — the service is already written in step 1). Re-run test — expect PASS.** - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Planning/PlanningAggregator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs git commit -m "feat(worker): add PlanningAggregator.GetAggregatedDiffAsync" ``` --- ### Task 5: `PlanningAggregator.BuildIntegrationBranchAsync` **Goal:** Create/reset `planning/-integration` off the target branch, `git merge --no-ff` each child's branch sequentially. On conflict: abort, reset the integration branch, return `Failed`. On success: return the combined diff. **Files:** - Modify: `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` - Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs` - [ ] **Step 1: Write failing happy-path test** Add to `PlanningAggregatorTests.cs`: ```csharp [Fact] public async Task BuildIntegrationBranchAsync_NonConflictingChildren_ReturnsOkWithCombinedDiff() { var db = NewDb(); var repo = NewRepo(); // Two children that edit DIFFERENT files, so no conflict. var (parentId, _, _) = await SeedPlanningWithChildrenTouchingDifferentFiles(db, repo); var git = new GitService(NullLogger.Instance); var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger.Instance); var result = await svc.BuildIntegrationBranchAsync( parentId, targetBranch: "main", CancellationToken.None); var ok = Assert.IsType(result); Assert.EndsWith("-integration", ok.Value.IntegrationBranch); Assert.Contains("diff --git", ok.Value.UnifiedDiff); // Branch exists and contains both children's commits. var branches = await git.ListLocalBranchesAsync(repo.RepoDir, CancellationToken.None); Assert.Contains(branches, b => b == ok.Value.IntegrationBranch); } [Fact] public async Task BuildIntegrationBranchAsync_ConflictingChildren_ReturnsFailedAndResetsBranch() { var db = NewDb(); var repo = NewRepo(); // Two children that edit the SAME file → conflict on second merge. var (parentId, subA, subB) = await SeedPlanningWithChildrenConflicting(db, repo); var git = new GitService(NullLogger.Instance); var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger.Instance); var result = await svc.BuildIntegrationBranchAsync( parentId, targetBranch: "main", CancellationToken.None); var failed = Assert.IsType(result); Assert.Equal(subB, failed.Value.FirstConflictSubtaskId); Assert.NotEmpty(failed.Value.ConflictedFiles); // Repo must not be left mid-merge. Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None)); } ``` - [ ] **Step 2: Run — expect fail.** - [ ] **Step 3: Implement** Add to `PlanningAggregator.cs`: ```csharp public async Task BuildIntegrationBranchAsync( string planningTaskId, string targetBranch, CancellationToken ct) { var (planning, repoDir, childSubtasks) = await LoadPlanningContextAsync(planningTaskId, ct); var integrationBranch = BuildIntegrationBranchName(planning); // Reset: delete if exists, then recreate off target. try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); } catch { /* didn't exist — ignore */ } await _git.CheckoutBranchAsync(repoDir, targetBranch, ct); // Create new branch pointing at target's HEAD. GitRaw(repoDir, "checkout", "-b", integrationBranch); foreach (var child in childSubtasks) { if (child.Worktree is null) continue; var (code, stderr) = await _git.MergeNoFfAsync( repoDir, child.Worktree.BranchName, $"Integrate subtask: {child.Title}", ct); if (code != 0) { List files; try { files = await _git.ListConflictedFilesAsync(repoDir, ct); } catch { files = new(); } try { await _git.MergeAbortAsync(repoDir, ct); } catch { } try { await _git.CheckoutBranchAsync(repoDir, targetBranch, ct); } catch { } try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); } catch { } return new CombinedDiffResult.Failed( new CombinedDiffFailure(child.Id, files)); } } var unifiedDiff = GitRaw(repoDir, "diff", $"{targetBranch}..{integrationBranch}"); return new CombinedDiffResult.Ok(new CombinedDiffSuccess(integrationBranch, unifiedDiff)); } private async Task<(TaskEntity planning, string repoDir, IReadOnlyList children)> LoadPlanningContextAsync(string planningTaskId, CancellationToken ct) { using var ctx = _dbFactory.CreateDbContext(); var planning = await ctx.Tasks .Include(t => t.List) .Include(t => t.Children).ThenInclude(c => c.Worktree) .SingleOrDefaultAsync(t => t.Id == planningTaskId, ct) ?? throw new KeyNotFoundException($"Planning task '{planningTaskId}' not found."); var repoDir = planning.List.WorkingDir ?? throw new InvalidOperationException("List has no working directory."); var children = planning.Children.OrderBy(c => c.SortOrder).ToList(); return (planning, repoDir, children); } private static string BuildIntegrationBranchName(TaskEntity planning) { var slug = new string(planning.Title .ToLowerInvariant() .Select(c => char.IsLetterOrDigit(c) ? c : '-') .ToArray()) .Trim('-'); if (string.IsNullOrEmpty(slug)) slug = planning.Id[..8]; if (slug.Length > 40) slug = slug[..40].TrimEnd('-'); return $"planning/{slug}-integration"; } private static string GitRaw(string cwd, params string[] args) { var psi = new System.Diagnostics.ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var a in args) psi.ArgumentList.Add(a); using var p = System.Diagnostics.Process.Start(psi)!; var stdout = p.StandardOutput.ReadToEnd(); var stderr = p.StandardError.ReadToEnd(); p.WaitForExit(); if (p.ExitCode != 0) throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {stderr}"); return stdout; } ``` (Note: `GitRaw` is a private fallback for commands `GitService` doesn't currently expose. If you prefer, add `CheckoutNewBranchAsync` and `DiffRangeAsync` to `GitService` instead and replace the calls. Either way is acceptable — keep this scoped to this class if you want the commit minimal.) - [ ] **Step 4: Run — expect both tests pass.** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~BuildIntegrationBranch" -v minimal` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Planning/PlanningAggregator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs git commit -m "feat(worker): add PlanningAggregator.BuildIntegrationBranchAsync" ``` --- ### Task 6: `PlanningAggregator.CleanupIntegrationBranchAsync` **Goal:** Delete the integration branch if it exists. **Files:** - Modify: `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` - Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs` - [ ] **Step 1: Write failing test** ```csharp [Fact] public async Task CleanupIntegrationBranchAsync_RemovesBranchIfPresent() { var db = NewDb(); var repo = NewRepo(); var (parentId, _, _) = await SeedPlanningWithChildrenTouchingDifferentFiles(db, repo); var git = new GitService(NullLogger.Instance); var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger.Instance); var built = await svc.BuildIntegrationBranchAsync(parentId, "main", CancellationToken.None); var ok = Assert.IsType(built); await svc.CleanupIntegrationBranchAsync(parentId, CancellationToken.None); var branches = await git.ListLocalBranchesAsync(repo.RepoDir, CancellationToken.None); Assert.DoesNotContain(branches, b => b == ok.Value.IntegrationBranch); } ``` - [ ] **Step 2: Run — expect fail.** - [ ] **Step 3: Implement** ```csharp public async Task CleanupIntegrationBranchAsync(string planningTaskId, CancellationToken ct) { var (planning, repoDir, _) = await LoadPlanningContextAsync(planningTaskId, ct); var branch = BuildIntegrationBranchName(planning); // Ensure we're not on the integration branch when deleting it. var current = await _git.GetCurrentBranchAsync(repoDir, ct); if (string.Equals(current, branch, StringComparison.Ordinal)) { // Checkout any other branch before deleting current. var branches = await _git.ListLocalBranchesAsync(repoDir, ct); var target = branches.FirstOrDefault(b => b != branch) ?? "main"; await _git.CheckoutBranchAsync(repoDir, target, ct); } try { await _git.BranchDeleteAsync(repoDir, branch, force: true, ct); } catch { } } ``` - [ ] **Step 4: Run — expect pass.** - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Planning/PlanningAggregator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs git commit -m "feat(worker): add PlanningAggregator.CleanupIntegrationBranchAsync" ``` --- ## Phase 3 — `PlanningMergeOrchestrator` ### Task 7: Orchestrator happy path (`StartAsync` with no conflicts) **Goal:** Sequentially merge all child subtasks via `TaskMergeService.MergeAsync` with `leaveConflictsInTree: true`. On all-success, set Planning to `Done`, cleanup integration branch, emit `PlanningCompleted`. **Files:** - Create: `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` - Create: `src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs` - Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` - Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs` - [ ] **Step 1: Define event records** Create `src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs`: ```csharp namespace ClaudeDo.Worker.Planning; public sealed record PlanningMergeStarted(string PlanningTaskId, string TargetBranch); public sealed record PlanningSubtaskMerged(string PlanningTaskId, string SubtaskId); public sealed record PlanningMergeConflict( string PlanningTaskId, string SubtaskId, IReadOnlyList ConflictedFiles); public sealed record PlanningMergeAborted(string PlanningTaskId); public sealed record PlanningCompleted(string PlanningTaskId); ``` - [ ] **Step 2: Add broadcaster methods** Modify `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — add one method per event, following the pattern of existing methods (e.g., `WorktreeUpdated`). Example: ```csharp public Task PlanningMergeStarted(string planningTaskId, string targetBranch) => _hub.Clients.All.SendAsync("PlanningMergeStarted", planningTaskId, targetBranch); public Task PlanningSubtaskMerged(string planningTaskId, string subtaskId) => _hub.Clients.All.SendAsync("PlanningSubtaskMerged", planningTaskId, subtaskId); public Task PlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList files) => _hub.Clients.All.SendAsync("PlanningMergeConflict", planningTaskId, subtaskId, files); public Task PlanningMergeAborted(string planningTaskId) => _hub.Clients.All.SendAsync("PlanningMergeAborted", planningTaskId); public Task PlanningCompleted(string planningTaskId) => _hub.Clients.All.SendAsync("PlanningCompleted", planningTaskId); ``` - [ ] **Step 3: Write failing happy-path test** Create `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs`: ```csharp using ClaudeDo.Data.Models; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Services; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; // ... usings for fixtures and NullLogger public class PlanningMergeOrchestratorTests : IDisposable { // ... same fixture management as PlanningAggregatorTests ... [Fact] public async Task StartAsync_AllChildrenMergeCleanly_MarksPlanningDoneAndEmitsCompleted() { var db = NewDb(); var repo = NewRepo(); var (parentId, subA, subB) = await SeedPlanningWithChildrenReadyToMerge(db, repo); var (orch, broadcasterSpy) = BuildOrchestrator(db, repo); await orch.StartAsync(parentId, targetBranch: "main", CancellationToken.None); using var ctx = db.CreateContext(); var planning = ctx.Tasks.Single(t => t.Id == parentId); Assert.Equal(TaskStatus.Done, planning.Status); var wtA = ctx.Worktrees.Single(w => w.TaskId == subA); var wtB = ctx.Worktrees.Single(w => w.TaskId == subB); Assert.Equal(WorktreeState.Merged, wtA.State); Assert.Equal(WorktreeState.Merged, wtB.State); Assert.Contains(broadcasterSpy.Events, e => e is PlanningCompleted pc && pc.PlanningTaskId == parentId); } // BuildOrchestrator: wires real TaskMergeService + PlanningAggregator + a // fake broadcaster that records events into a list. } ``` (Implement `BuildOrchestrator` and `SeedPlanningWithChildrenReadyToMerge` — the children must have non-conflicting commits on their branches and their `TaskEntity.Status = Done`, worktrees in `Active` state.) - [ ] **Step 4: Run — expect fail.** - [ ] **Step 5: Implement orchestrator happy path** Create `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`: ```csharp using System.Collections.Concurrent; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Services; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Planning; public sealed class PlanningMergeOrchestrator { private readonly IDbContextFactory _dbFactory; private readonly TaskMergeService _merge; private readonly PlanningAggregator _aggregator; private readonly HubBroadcaster _broadcaster; private readonly ILogger _logger; private sealed class State { public required string TargetBranch { get; init; } public required Queue RemainingSubtaskIds { get; init; } public string? CurrentSubtaskId { get; set; } } private readonly ConcurrentDictionary _states = new(); public PlanningMergeOrchestrator( IDbContextFactory dbFactory, TaskMergeService merge, PlanningAggregator aggregator, HubBroadcaster broadcaster, ILogger logger) { _dbFactory = dbFactory; _merge = merge; _aggregator = aggregator; _broadcaster = broadcaster; _logger = logger; } public async Task StartAsync(string planningTaskId, string targetBranch, CancellationToken ct) { // Pre-flight (Task 10 adds the full checks — leave minimal here for now): using (var ctx = _dbFactory.CreateDbContext()) { var children = await ctx.Tasks .Include(t => t.Worktree) .Where(t => t.ParentTaskId == planningTaskId) .OrderBy(t => t.SortOrder) .ToListAsync(ct); var queue = new Queue( children .Where(c => c.Worktree is not null && c.Worktree.State != WorktreeState.Merged) .Select(c => c.Id)); _states[planningTaskId] = new State { TargetBranch = targetBranch, RemainingSubtaskIds = queue, }; } await _broadcaster.PlanningMergeStarted(planningTaskId, targetBranch); await DrainAsync(planningTaskId, ct); } private async Task DrainAsync(string planningTaskId, CancellationToken ct) { if (!_states.TryGetValue(planningTaskId, out var state)) return; while (state.RemainingSubtaskIds.TryDequeue(out var subtaskId)) { state.CurrentSubtaskId = subtaskId; var result = await _merge.MergeAsync( subtaskId, state.TargetBranch, removeWorktree: true, commitMessage: "Merge subtask", leaveConflictsInTree: true, ct); if (result.Status == TaskMergeService.StatusConflict) { await _broadcaster.PlanningMergeConflict(planningTaskId, subtaskId, result.ConflictFiles); return; // Halt — user calls ContinueAsync / AbortAsync. } if (result.Status != TaskMergeService.StatusMerged) { // Non-conflict failure (blocked etc.). Halt and surface. await _broadcaster.PlanningMergeConflict( planningTaskId, subtaskId, new[] { result.ErrorMessage ?? "merge blocked" }); return; } await _broadcaster.PlanningSubtaskMerged(planningTaskId, subtaskId); } state.CurrentSubtaskId = null; await FinalizePlanningDoneAsync(planningTaskId, ct); _states.TryRemove(planningTaskId, out _); await _broadcaster.PlanningCompleted(planningTaskId); } private async Task FinalizePlanningDoneAsync(string planningTaskId, CancellationToken ct) { using var ctx = _dbFactory.CreateDbContext(); var planning = await ctx.Tasks.SingleOrDefaultAsync(t => t.Id == planningTaskId, ct); if (planning is null) return; planning.Status = TaskStatus.Done; planning.FinishedAt = DateTime.UtcNow; await ctx.SaveChangesAsync(ct); try { await _aggregator.CleanupIntegrationBranchAsync(planningTaskId, ct); } catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); } } // ContinueAsync / AbortAsync added in Tasks 8 & 9. // Pre-flight added in Task 10. } ``` - [ ] **Step 6: Run test — expect pass.** - [ ] **Step 7: Commit** ```bash git add src/ClaudeDo.Worker/Planning/ src/ClaudeDo.Worker/Hub/HubBroadcaster.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs git commit -m "feat(worker): add PlanningMergeOrchestrator happy path" ``` --- ### Task 8: Orchestrator `ContinueAsync` **Goal:** Resume a halted Merge-all after the user resolves conflicts. **Files:** - Modify: `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` - Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs` - [ ] **Step 1: Write failing test** ```csharp [Fact] public async Task ContinueAsync_AfterConflict_ResumesRemainingMergesAndCompletes() { var db = NewDb(); var repo = NewRepo(); // Seed: subA non-conflicting, subB conflicting, subC non-conflicting var (parentId, subA, subB, subC) = await SeedPlanningThreeChildrenMiddleConflicts(db, repo); var (orch, spy) = BuildOrchestrator(db, repo); await orch.StartAsync(parentId, "main", CancellationToken.None); // subA merged, subB halted in conflict Assert.Contains(spy.Events, e => e is PlanningSubtaskMerged m && m.SubtaskId == subA); Assert.Contains(spy.Events, e => e is PlanningMergeConflict c && c.SubtaskId == subB); // Simulate user resolving in VS Code var readme = Path.Combine(repo.RepoDir, "README.md"); File.WriteAllText(readme, "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); } ``` - [ ] **Step 2: Run — expect fail.** - [ ] **Step 3: Implement `ContinueAsync`** ```csharp 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); } ``` - [ ] **Step 4: Run — expect pass.** - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs git commit -m "feat(worker): add PlanningMergeOrchestrator.ContinueAsync" ``` --- ### Task 9: Orchestrator `AbortAsync` **Goal:** Cancel an in-progress Merge-all after a conflict. Earlier merged subtasks stay merged. Planning stays `Planned`. - [ ] **Step 1: Write failing test** ```csharp [Fact] public async Task AbortAsync_AfterConflict_RestoresCleanRepoAndClearsState() { var db = NewDb(); var repo = NewRepo(); var (parentId, subA, subB, _) = await SeedPlanningThreeChildrenMiddleConflicts(db, repo); var (orch, spy) = BuildOrchestrator(db, repo); await orch.StartAsync(parentId, "main", CancellationToken.None); await orch.AbortAsync(parentId, CancellationToken.None); using var ctx = db.CreateContext(); Assert.Equal(TaskStatus.Planned, ctx.Tasks.Single(t => t.Id == parentId).Status); Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subA).State); Assert.Equal(WorktreeState.Active, ctx.Worktrees.Single(w => w.TaskId == subB).State); Assert.Contains(spy.Events, e => e is PlanningMergeAborted pma && pma.PlanningTaskId == parentId); var git = new GitService(NullLogger.Instance); Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None)); } ``` - [ ] **Step 2: Run — expect fail.** - [ ] **Step 3: Implement `AbortAsync`** ```csharp public async Task AbortAsync(string planningTaskId, CancellationToken ct) { if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null) throw new InvalidOperationException("no in-progress merge to abort"); await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct); _states.TryRemove(planningTaskId, out _); await _broadcaster.PlanningMergeAborted(planningTaskId); } ``` - [ ] **Step 4: Run — expect pass.** - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs git commit -m "feat(worker): add PlanningMergeOrchestrator.AbortAsync" ``` --- ### Task 10: Pre-flight checks + idempotent restart **Goal:** Verify every subtask is `Done`, every worktree is `Active` or `Merged`, repo is clean, no mid-merge in progress. Already-`Merged` worktrees are skipped (idempotent). **Files:** - Modify: `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` - Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs` - [ ] **Step 1: Write failing tests for each pre-flight failure** ```csharp [Fact] public async Task StartAsync_SubtaskStillRunning_ThrowsWithoutSideEffects() { var db = NewDb(); var repo = NewRepo(); var (parentId, runningSub) = await SeedPlanningWithOneRunningChild(db, repo); var (orch, _) = BuildOrchestrator(db, repo); var ex = await Assert.ThrowsAsync( () => orch.StartAsync(parentId, "main", CancellationToken.None)); Assert.Contains(runningSub, ex.Message); using var ctx = db.CreateContext(); Assert.Equal(TaskStatus.Planned, ctx.Tasks.Single(t => t.Id == parentId).Status); } [Fact] public async Task StartAsync_DirtyRepo_ThrowsWithoutSideEffects() { /* ... */ } [Fact] public async Task StartAsync_IdempotentRestart_SkipsAlreadyMergedWorktrees() { var db = NewDb(); var repo = NewRepo(); var (parentId, alreadyMerged, stillToMerge) = await SeedPlanningWithOneAlreadyMergedChild(db, repo); var (orch, spy) = BuildOrchestrator(db, repo); await orch.StartAsync(parentId, "main", CancellationToken.None); // alreadyMerged should NOT have a PlanningSubtaskMerged event (it was skipped). Assert.DoesNotContain(spy.Events, e => e is PlanningSubtaskMerged m && m.SubtaskId == alreadyMerged); Assert.Contains(spy.Events, e => e is PlanningSubtaskMerged m && m.SubtaskId == stillToMerge); Assert.Contains(spy.Events, e => e is PlanningCompleted); } ``` - [ ] **Step 2: Run — expect fail.** - [ ] **Step 3: Add pre-flight to `StartAsync`** Replace the beginning of `StartAsync`: ```csharp public async Task StartAsync(string planningTaskId, string targetBranch, CancellationToken ct) { string workingDir; List children; using (var ctx = _dbFactory.CreateDbContext()) { var planning = await ctx.Tasks .Include(t => t.List) .Include(t => t.Children).ThenInclude(c => c.Worktree) .SingleOrDefaultAsync(t => t.Id == planningTaskId, ct) ?? throw new KeyNotFoundException($"Planning task '{planningTaskId}' not found."); workingDir = planning.List.WorkingDir ?? throw new InvalidOperationException("List has no working directory."); children = planning.Children.OrderBy(c => c.SortOrder).ToList(); } // Pre-flight foreach (var c in children) { if (c.Status != TaskStatus.Done) throw new InvalidOperationException($"subtask {c.Id} is not Done (status {c.Status})"); if (c.Worktree is null) throw new InvalidOperationException($"subtask {c.Id} has no worktree"); if (c.Worktree.State != WorktreeState.Active && c.Worktree.State != WorktreeState.Merged) throw new InvalidOperationException( $"subtask {c.Id} worktree state is {c.Worktree.State}"); } // Fail fast on mid-merge / dirty repo (MergeAsync also checks, but we want a clear // pre-flight error instead of a per-subtask "blocked" result). if (await _git.IsMidMergeAsync(workingDir, ct)) throw new InvalidOperationException("repo is mid-merge"); if (await _git.HasChangesAsync(workingDir, ct)) throw new InvalidOperationException("working tree has uncommitted changes"); var queue = new Queue( children .Where(c => c.Worktree!.State == WorktreeState.Active) .Select(c => c.Id)); _states[planningTaskId] = new State { TargetBranch = targetBranch, RemainingSubtaskIds = queue, }; await _broadcaster.PlanningMergeStarted(planningTaskId, targetBranch); await DrainAsync(planningTaskId, ct); } ``` Add `GitService` to the `PlanningMergeOrchestrator` constructor and store it as `_git`: ```csharp private readonly ClaudeDo.Data.Git.GitService _git; public PlanningMergeOrchestrator( IDbContextFactory dbFactory, TaskMergeService merge, PlanningAggregator aggregator, ClaudeDo.Data.Git.GitService git, HubBroadcaster broadcaster, ILogger logger) { _dbFactory = dbFactory; _merge = merge; _aggregator = aggregator; _git = git; _broadcaster = broadcaster; _logger = logger; } ``` Update any orchestrator construction in tests (`BuildOrchestrator` helper) to pass a `GitService` instance. - [ ] **Step 4: Run — expect pass.** - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs git commit -m "feat(worker): add pre-flight checks and idempotent restart to PlanningMergeOrchestrator" ``` --- ## Phase 4 — Hub wiring + DI ### Task 11: Register services + add hub methods **Files:** - Modify: `src/ClaudeDo.Worker/Program.cs` - Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` - [ ] **Step 1: Register services in DI** Add to `src/ClaudeDo.Worker/Program.cs` (near the existing `AddSingleton` line): ```csharp builder.Services.AddSingleton(); builder.Services.AddSingleton(); ``` - [ ] **Step 2: Add hub methods** Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs`: Add to constructor parameter list and store as fields: ```csharp PlanningAggregator planningAggregator, PlanningMergeOrchestrator planningMergeOrchestrator, ``` Add these methods (follow the existing `MergeTask` pattern with `try/catch` for `KeyNotFoundException` and `InvalidOperationException`): ```csharp public async Task> GetPlanningAggregate(string planningTaskId) { try { return await _planningAggregator.GetAggregatedDiffAsync(planningTaskId, CancellationToken.None); } catch (KeyNotFoundException) { throw new HubException("planning task not found"); } } public async Task BuildPlanningIntegrationBranch(string planningTaskId, string targetBranch) { try { return await _planningAggregator.BuildIntegrationBranchAsync(planningTaskId, targetBranch, CancellationToken.None); } catch (KeyNotFoundException) { throw new HubException("planning task not found"); } catch (InvalidOperationException ex) { throw new HubException(ex.Message); } } public async Task MergeAllPlanning(string planningTaskId, string targetBranch) { try { await _planningMergeOrchestrator.StartAsync(planningTaskId, targetBranch, CancellationToken.None); } catch (KeyNotFoundException) { throw new HubException("planning task not found"); } catch (InvalidOperationException ex) { throw new HubException(ex.Message); } } public async Task ContinuePlanningMerge(string planningTaskId) { try { await _planningMergeOrchestrator.ContinueAsync(planningTaskId, CancellationToken.None); } catch (InvalidOperationException ex) { throw new HubException(ex.Message); } } public async Task AbortPlanningMerge(string planningTaskId) { try { await _planningMergeOrchestrator.AbortAsync(planningTaskId, CancellationToken.None); } catch (InvalidOperationException ex) { throw new HubException(ex.Message); } } ``` - [ ] **Step 3: Run the full worker test suite — expect pass.** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -v minimal` Expected: all existing tests still pass. - [ ] **Step 4: Build to verify hub compiles and DI resolves** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` Expected: succeeds. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Program.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs git commit -m "feat(worker): register planning services and add Merge-all hub methods" ``` --- ## Phase 5 — UI Visibility (`TasksIslandViewModel.Regroup`) ### Task 12: Planning parents roll up their children's statuses **Goal:** Subtasks (`ParentTaskId != null`) never appear as standalone rows in virtual lists. A Planning/Planned parent is included in `virtual:queued` if any child is `Queued` and in `virtual:running` if any child is `Running`. Children stay attached under the parent in the task tree. When Planning transitions to `Done`, parent + children move to Completed together. **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` - Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs` (create if absent — mirror the structure of existing ViewModel tests) - [ ] **Step 1: Write failing test — queued child with Planning parent is not in virtual:queued standalone** (Inspect how the existing test project resolves `TasksIslandViewModel` — it takes DB input. Follow that pattern. Seed a planning task with status `Planning` and a child with status `Queued`.) ```csharp [Fact] public async Task VirtualQueued_QueuedChildOfPlanningParent_IsNotStandaloneRow() { var db = NewDb(); await SeedPlanningWithQueuedChild(db, parentId: "p1", childId: "c1"); var vm = BuildViewModel(db); await vm.SelectListAsync("virtual:queued"); // Child is NOT present as a top-level row. Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild); // Planning parent IS present (roll-up). Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild); } [Fact] public async Task VirtualQueued_PlanningParentWithAnyQueuedChild_IsIncluded() { var db = NewDb(); await SeedPlanningParent(db, "p1", status: TaskStatus.Planned); await SeedChild(db, parentId: "p1", childId: "c1", status: TaskStatus.Queued); var vm = BuildViewModel(db); await vm.SelectListAsync("virtual:queued"); Assert.Contains(vm.Items, r => r.Id == "p1"); } [Fact] public async Task Regroup_DoneChild_StaysNestedUnderPlanningParent() { var db = NewDb(); await SeedPlanningParent(db, "p1", status: TaskStatus.Planned); await SeedChild(db, parentId: "p1", childId: "c1", status: TaskStatus.Done); var vm = BuildViewModel(db); await vm.SelectListAsync("virtual:queued"); // parent is queued-rollup vm.Items.Single(r => r.Id == "p1").IsExpanded = true; vm.Regroup(); // Done child should NOT be in CompletedItems while parent is Planned. Assert.DoesNotContain(vm.CompletedItems, r => r.Id == "c1"); // Should be nested under parent. Assert.Contains(vm.OpenItems, r => r.Id == "c1" && r.IsChild); } ``` - [ ] **Step 2: Run — expect fail.** - [ ] **Step 3: Update virtual-list filter and `Regroup`** In `TasksIslandViewModel.cs`, change the filtered switch around line 160: ```csharp IEnumerable filtered = list.Kind switch { ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay), ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred), ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null), ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => t.ParentTaskId == null && (t.Status == TaskStatus.Queued || ((t.Status == TaskStatus.Planning || t.Status == TaskStatus.Planned) && t.Children.Any(c => c.Status == TaskStatus.Queued)))), ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.ParentTaskId == null && (t.Status == TaskStatus.Running || ((t.Status == TaskStatus.Planning || t.Status == TaskStatus.Planned) && t.Children.Any(c => c.Status == TaskStatus.Running)))), ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.ParentTaskId == null && t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active), ListKind.User => all.Where(t => t.ParentTaskId == null && $"user:{t.ListId}" == list.Id), _ => Enumerable.Empty(), }; // After filtering top-level, also pull in children so Regroup can nest them. var topIds = filtered.Select(t => t.Id).ToHashSet(); var childRows = all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId)); filtered = filtered.Concat(childRows); ``` In `Regroup()`, replace the completion classification so Planning children don't get pulled into `CompletedItems` unless their parent is already `Done`: ```csharp var today = DateTime.Today; foreach (var r in flat) { // A child of a still-open Planning parent is grouped with its parent, not moved to Completed. var underOpenPlanningParent = r.IsChild && flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done); if (r.Done && !underOpenPlanningParent) CompletedItems.Add(r); else if (r.ScheduledFor is { } d && d.Date < today) OverdueItems.Add(r); else OpenItems.Add(r); } ``` (If `TaskRowViewModel.IsPlanningParent` currently requires status `Planning`, extend it to also return `true` for `Planned` — inspect the existing getter and adjust.) - [ ] **Step 4: Run — expect pass. Run the full UI test suite.** Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -v minimal` Expected: all pass. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ git commit -m "fix(ui): planning parents roll up child status; children stay nested until parent Done" ``` --- ## Phase 6 — UI Planning Detail Panel extensions ### Task 13: Add Merge target dropdown + Review / Merge-all buttons to planning detail **Goal:** On the existing detail pane for a Planning task, show a merge target dropdown + `[Review combined diff]` + `[Merge all subtasks]`. Merge-all disabled with tooltip when children aren't all `Done` or any worktree is `Discarded`/`Kept`. **Files:** - Modify: the existing task-detail view + viewmodel that renders a Planning task. Search under `src/ClaudeDo.Ui/Views/` and `src/ClaudeDo.Ui/ViewModels/` for the Planning detail pane (grep for `IsPlanningParent` or `PlanningSession`). - [ ] **Step 1: Locate the detail view for Planning tasks.** Add observable properties to its ViewModel: ```csharp [ObservableProperty] private ObservableCollection mergeTargetBranches = new(); [ObservableProperty] private string? selectedMergeTarget; [ObservableProperty] private bool canMergeAll; [ObservableProperty] private string? mergeAllDisabledReason; ``` - [ ] **Step 2: Populate branches on load** — call `WorkerHubClient.GetMergeTargets()` (reuse existing), fill `MergeTargetBranches`, set `SelectedMergeTarget` to the returned default. - [ ] **Step 3: Compute `CanMergeAll` and `MergeAllDisabledReason`** — iterate children: ```csharp private void RecomputeCanMergeAll(IEnumerable children) { var notDone = children.Count(c => c.Status != TaskStatus.Done); if (notDone > 0) { CanMergeAll = false; MergeAllDisabledReason = $"{notDone} subtask(s) not done"; return; } var badWt = children.FirstOrDefault(c => c.WorktreeState == WorktreeState.Discarded || c.WorktreeState == WorktreeState.Kept); if (badWt is not null) { CanMergeAll = false; MergeAllDisabledReason = "at least one worktree was discarded/kept"; return; } CanMergeAll = true; MergeAllDisabledReason = null; } ``` (If `TaskRowViewModel.WorktreeState` doesn't exist, add it — bind from `TaskEntity.Worktree?.State`.) - [ ] **Step 4: Add `[RelayCommand]` for the two buttons** ```csharp [RelayCommand(CanExecute = nameof(CanReviewDiff))] private async Task ReviewCombinedDiffAsync() { // Open PlanningDiffView (Task 14) as a dialog or dedicated window, passing planningTaskId + SelectedMergeTarget. } [RelayCommand(CanExecute = nameof(CanMergeAll))] private async Task MergeAllAsync() { try { await _hub.MergeAllPlanning(PlanningTaskId, SelectedMergeTarget ?? "main"); } catch (HubException ex) { /* show inline error */ } } private bool CanReviewDiff() => true; // enabled once any child has a diff ``` - [ ] **Step 5: Add XAML controls** to the Planning detail view (follow existing styling). Example: ```xml