Files
ClaudeDo/docs/superpowers/plans/2026-04-24-planning-merge-all.md
mika kuns 8afbf20613 docs(planning): add spec and plan for planning merge-all feature
Covers subtask visibility fix, aggregated diff viewer, and single
Merge-all action with VS-Code-assisted conflict resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:55:11 +02:00

73 KiB
Raw Permalink Blame History

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.csRegroup() changes + virtual-list filter change.
  • tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs — extend with conflict-leave tests.

Sequence: backend first (Tasks 111), then UI visibility fix (Task 12), then UI views (Tasks 1315). 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.

[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:

public async Task<MergeResult> MergeAsync(
    string taskId,
    string targetBranch,
    bool removeWorktree,
    string commitMessage,
    bool leaveConflictsInTree,
    CancellationToken ct)

Inside the conflict branch (currently lines 86108), change the abort logic:

var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct);
if (exitCode != 0)
{
    List<string> 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<string>(), $"merge failed: {stderr}");
    }

    return new MergeResult(StatusConflict, files, null);
}

Also add a backward-compat overload so existing callers compile without change:

public Task<MergeResult> 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
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

[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:

public async Task<MergeResult> 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<string>(), 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
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

[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:

public const string StatusAborted  = "aborted";

public async Task<MergeResult> 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<string>(), null);
}
  • Step 4: Run test — expect pass.

  • Step 5: Commit

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:

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<string> 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<ClaudeDoDbContext> _dbFactory;
    private readonly GitService _git;
    private readonly ILogger<PlanningAggregator> _logger;

    public PlanningAggregator(
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        GitService git,
        ILogger<PlanningAggregator> logger)
    {
        _dbFactory = dbFactory;
        _git = git;
        _logger = logger;
    }

    public async Task<IReadOnlyList<SubtaskDiff>> 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<SubtaskDiff>();
        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:

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<DbFixture> _dbs = new();
    private readonly List<GitRepoFixture> _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<GitService>.Instance),
            NullLogger<PlanningAggregator>.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/<id> <path> <baseCommit>;
        //      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

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/<slug>-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:

[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<GitService>.Instance);
    var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger<PlanningAggregator>.Instance);

    var result = await svc.BuildIntegrationBranchAsync(
        parentId, targetBranch: "main", CancellationToken.None);

    var ok = Assert.IsType<CombinedDiffResult.Ok>(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<GitService>.Instance);
    var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger<PlanningAggregator>.Instance);

    var result = await svc.BuildIntegrationBranchAsync(
        parentId, targetBranch: "main", CancellationToken.None);

    var failed = Assert.IsType<CombinedDiffResult.Failed>(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:

public async Task<CombinedDiffResult> 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<string> 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<TaskEntity> 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
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

[Fact]
public async Task CleanupIntegrationBranchAsync_RemovesBranchIfPresent()
{
    var db = NewDb();
    var repo = NewRepo();
    var (parentId, _, _) = await SeedPlanningWithChildrenTouchingDifferentFiles(db, repo);

    var git = new GitService(NullLogger<GitService>.Instance);
    var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger<PlanningAggregator>.Instance);

    var built = await svc.BuildIntegrationBranchAsync(parentId, "main", CancellationToken.None);
    var ok = Assert.IsType<CombinedDiffResult.Ok>(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

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

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:

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<string> 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:

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<string> 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:

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:

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<ClaudeDoDbContext> _dbFactory;
    private readonly TaskMergeService _merge;
    private readonly PlanningAggregator _aggregator;
    private readonly HubBroadcaster _broadcaster;
    private readonly ILogger<PlanningMergeOrchestrator> _logger;

    private sealed class State
    {
        public required string TargetBranch { get; init; }
        public required Queue<string> RemainingSubtaskIds { get; init; }
        public string? CurrentSubtaskId { get; set; }
    }

    private readonly ConcurrentDictionary<string, State> _states = new();

    public PlanningMergeOrchestrator(
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        TaskMergeService merge,
        PlanningAggregator aggregator,
        HubBroadcaster broadcaster,
        ILogger<PlanningMergeOrchestrator> 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<string>(
                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

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

[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

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

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
[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<GitService>.Instance);
    Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None));
}
  • Step 2: Run — expect fail.

  • Step 3: Implement AbortAsync

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

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

[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<InvalidOperationException>(
        () => 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:

public async Task StartAsync(string planningTaskId, string targetBranch, CancellationToken ct)
{
    string workingDir;
    List<TaskEntity> 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<string>(
        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:

private readonly ClaudeDo.Data.Git.GitService _git;

public PlanningMergeOrchestrator(
    IDbContextFactory<ClaudeDoDbContext> dbFactory,
    TaskMergeService merge,
    PlanningAggregator aggregator,
    ClaudeDo.Data.Git.GitService git,
    HubBroadcaster broadcaster,
    ILogger<PlanningMergeOrchestrator> 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

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<TaskMergeService> line):

builder.Services.AddSingleton<PlanningAggregator>();
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
  • Step 2: Add hub methods

Modify src/ClaudeDo.Worker/Hub/WorkerHub.cs:

Add to constructor parameter list and store as fields:

PlanningAggregator planningAggregator,
PlanningMergeOrchestrator planningMergeOrchestrator,

Add these methods (follow the existing MergeTask pattern with try/catch for KeyNotFoundException and InvalidOperationException):

public async Task<IReadOnlyList<SubtaskDiff>> GetPlanningAggregate(string planningTaskId)
{
    try { return await _planningAggregator.GetAggregatedDiffAsync(planningTaskId, CancellationToken.None); }
    catch (KeyNotFoundException) { throw new HubException("planning task not found"); }
}

public async Task<CombinedDiffResult> 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
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.)

[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:

IEnumerable<TaskEntity> 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<TaskEntity>(),
};

// 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:

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
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:

[ObservableProperty] private ObservableCollection<string> mergeTargetBranches = new();
[ObservableProperty] private string? selectedMergeTarget;
[ObservableProperty] private bool canMergeAll;
[ObservableProperty] private string? mergeAllDisabledReason;
  • Step 2: Populate branches on load — call WorkerHubClient.GetMergeTargets(<anySubtaskId>) (reuse existing), fill MergeTargetBranches, set SelectedMergeTarget to the returned default.

  • Step 3: Compute CanMergeAll and MergeAllDisabledReason — iterate children:

private void RecomputeCanMergeAll(IEnumerable<TaskRowViewModel> 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
[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:
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,8,0,0">
    <ComboBox ItemsSource="{Binding MergeTargetBranches}"
              SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
              MinWidth="160"/>
    <Button Content="Review combined diff"
            Command="{Binding ReviewCombinedDiffCommand}"/>
    <Button Content="Merge all subtasks"
            Command="{Binding MergeAllCommand}"
            IsEnabled="{Binding CanMergeAll}"
            ToolTip.Tip="{Binding MergeAllDisabledReason}"/>
</StackPanel>
  • Step 6: Manual smoke test — start the worker and UI:
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
# Run worker in one terminal, app in another, and visually verify:
#  - Planning task without all subtasks done: button disabled, tooltip shown
#  - Dropdown populated with local branches
  • Step 7: Commit
git add src/ClaudeDo.Ui/
git commit -m "feat(ui): add merge target dropdown and Review/Merge-all buttons to planning detail"

Phase 7 — Aggregated Diff Viewer

Task 14: PlanningDiffView + ViewModel

Goal: Two-pane view: subtask list on the left, selected diff on the right. Toggle button flips between grouped and flat combined.

Files:

  • Create: src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs

  • Create: src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml + .axaml.cs

  • Modify: whatever window factory or DI you use to resolve views (e.g., App.xaml.cs, if DI used for views).

  • Step 1: ViewModel with hub calls

Create src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs:

using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace ClaudeDo.Ui.ViewModels.Planning;

public partial class PlanningDiffViewModel : ObservableObject
{
    private readonly IWorkerHubClient _hub;
    private readonly string _planningTaskId;
    private readonly string _targetBranch;

    public ObservableCollection<SubtaskDiffRow> Subtasks { get; } = new();

    [ObservableProperty] private SubtaskDiffRow? selectedSubtask;
    [ObservableProperty] private string displayedDiff = "";
    [ObservableProperty] private bool isCombinedMode;
    [ObservableProperty] private string? combinedWarning;

    public PlanningDiffViewModel(IWorkerHubClient hub, string planningTaskId, string targetBranch)
    {
        _hub = hub;
        _planningTaskId = planningTaskId;
        _targetBranch = targetBranch;
    }

    public async Task InitializeAsync()
    {
        var entries = await _hub.GetPlanningAggregate(_planningTaskId);
        Subtasks.Clear();
        foreach (var e in entries)
            Subtasks.Add(new SubtaskDiffRow(e.SubtaskId, e.Title, e.DiffStat, e.UnifiedDiff));
        SelectedSubtask = Subtasks.FirstOrDefault();
    }

    partial void OnSelectedSubtaskChanged(SubtaskDiffRow? value)
    {
        if (!IsCombinedMode && value is not null) DisplayedDiff = value.UnifiedDiff;
    }

    [RelayCommand]
    private async Task ToggleCombinedAsync()
    {
        if (IsCombinedMode)
        {
            IsCombinedMode = false;
            DisplayedDiff = SelectedSubtask?.UnifiedDiff ?? "";
            CombinedWarning = null;
            return;
        }

        var result = await _hub.BuildPlanningIntegrationBranch(_planningTaskId, _targetBranch);
        if (result is CombinedDiffResult.Ok ok)
        {
            IsCombinedMode = true;
            DisplayedDiff = ok.Value.UnifiedDiff;
            CombinedWarning = null;
        }
        else if (result is CombinedDiffResult.Failed failed)
        {
            CombinedWarning =
                $"Cannot build combined preview: subtask {failed.Value.FirstConflictSubtaskId} conflicts with an earlier subtask ({failed.Value.ConflictedFiles.Count} files).";
        }
    }
}

public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff);
  • Step 2: XAML view

Create src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml:

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:vm="using:ClaudeDo.Ui.ViewModels.Planning"
             x:DataType="vm:PlanningDiffViewModel"
             x:Class="ClaudeDo.Ui.Views.Planning.PlanningDiffView">
  <DockPanel>
    <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="8">
      <ToggleButton Content="Preview combined"
                    IsChecked="{Binding IsCombinedMode}"
                    Command="{Binding ToggleCombinedCommand}"/>
      <TextBlock Text="{Binding CombinedWarning}" Foreground="Orange"
                 IsVisible="{Binding CombinedWarning, Converter={x:Static ObjectConverters.IsNotNull}}"/>
    </StackPanel>
    <Grid ColumnDefinitions="240,*">
      <ListBox Grid.Column="0"
               ItemsSource="{Binding Subtasks}"
               SelectedItem="{Binding SelectedSubtask}"
               IsEnabled="{Binding !IsCombinedMode}">
        <ListBox.ItemTemplate>
          <DataTemplate x:DataType="vm:SubtaskDiffRow">
            <StackPanel>
              <TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
              <TextBlock Text="{Binding DiffStat}" Opacity="0.7"/>
            </StackPanel>
          </DataTemplate>
        </ListBox.ItemTemplate>
      </ListBox>
      <ScrollViewer Grid.Column="1">
        <TextBox Text="{Binding DisplayedDiff, Mode=OneWay}"
                 IsReadOnly="True" AcceptsReturn="True"
                 FontFamily="Consolas,Menlo,monospace"/>
      </ScrollViewer>
    </Grid>
  </DockPanel>
</UserControl>
  • Step 3: Wire the [Review combined diff] button from Task 13 to open this view as a dialog. Example (inside ReviewCombinedDiffAsync):
var vm = new PlanningDiffViewModel(_hub, PlanningTaskId, SelectedMergeTarget ?? "main");
await vm.InitializeAsync();
var window = new Window
{
    Title = "Planning — Combined diff",
    Width = 1100, Height = 700,
    Content = new PlanningDiffView { DataContext = vm },
};
await window.ShowDialog(App.MainWindow);

(Match the pattern used for any other existing modal window in the project; the snippet above is indicative.)

  • Step 4: Manual smoke test — finalize a small planning session with two non-conflicting subtasks, click Review. Verify grouped view shows both diffs, toggle builds the integration branch.

  • Step 5: Commit

git add src/ClaudeDo.Ui/Views/Planning/ src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs
git commit -m "feat(ui): add aggregated diff viewer for planning tasks"

Phase 8 — Conflict Resolution Dialog

Task 15: ConflictResolutionView + ViewModel + VS Code launch

Goal: Modal dialog opened when SignalR PlanningMergeConflict fires. Lists conflicted files, provides three actions: Open in VS Code, Continue, Abort.

Files:

  • Create: src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs

  • Create: src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml + .axaml.cs

  • Modify: the SignalR event handler surface (likely WorkerHubClient.cs or similar) — wire PlanningMergeConflict to open this dialog.

  • Step 1: ViewModel

using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace ClaudeDo.Ui.ViewModels.Planning;

public partial class ConflictResolutionViewModel : ObservableObject
{
    private readonly IWorkerHubClient _hub;
    private readonly string _planningTaskId;

    public string SubtaskTitle { get; }
    public string TargetBranch { get; }
    public ObservableCollection<string> ConflictedFiles { get; } = new();

    [ObservableProperty] private string? vsCodeError;
    [ObservableProperty] private string? actionError;

    public event Action? CloseRequested;

    public ConflictResolutionViewModel(
        IWorkerHubClient hub,
        string planningTaskId,
        string subtaskTitle,
        string targetBranch,
        IEnumerable<string> conflictedFiles)
    {
        _hub = hub;
        _planningTaskId = planningTaskId;
        SubtaskTitle = subtaskTitle;
        TargetBranch = targetBranch;
        foreach (var f in conflictedFiles) ConflictedFiles.Add(f);
    }

    [RelayCommand]
    private void OpenInVsCode()
    {
        VsCodeError = null;
        foreach (var f in ConflictedFiles)
        {
            try
            {
                Process.Start(new ProcessStartInfo("code", $"\"{f}\"")
                {
                    UseShellExecute = true,
                });
            }
            catch (Exception ex)
            {
                VsCodeError = $"Could not launch VS Code: {ex.Message}. Paths are listed above — copy them manually.";
                return;
            }
        }
    }

    [RelayCommand]
    private async Task ContinueAsync()
    {
        try { await _hub.ContinuePlanningMerge(_planningTaskId); CloseRequested?.Invoke(); }
        catch (Exception ex) { ActionError = ex.Message; }
    }

    [RelayCommand]
    private async Task AbortAsync()
    {
        try { await _hub.AbortPlanningMerge(_planningTaskId); CloseRequested?.Invoke(); }
        catch (Exception ex) { ActionError = ex.Message; }
    }
}
  • Step 2: XAML
<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:vm="using:ClaudeDo.Ui.ViewModels.Planning"
             x:DataType="vm:ConflictResolutionViewModel"
             x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView">
  <StackPanel Spacing="12" Margin="16" MinWidth="520">
    <TextBlock FontWeight="SemiBold" FontSize="16"
               Text="{Binding SubtaskTitle, StringFormat='Conflicts in subtask: {0}'}"/>
    <TextBlock Text="{Binding TargetBranch, StringFormat='Merging into: {0}'}" Opacity="0.7"/>
    <ItemsControl ItemsSource="{Binding ConflictedFiles}">
      <ItemsControl.ItemTemplate>
        <DataTemplate>
          <TextBlock Text="{Binding}" FontFamily="Consolas,Menlo,monospace"/>
        </DataTemplate>
      </ItemsControl.ItemTemplate>
    </ItemsControl>
    <TextBlock Text="{Binding VsCodeError}" Foreground="OrangeRed"
               IsVisible="{Binding VsCodeError, Converter={x:Static ObjectConverters.IsNotNull}}"/>
    <TextBlock Text="{Binding ActionError}" Foreground="OrangeRed"
               IsVisible="{Binding ActionError, Converter={x:Static ObjectConverters.IsNotNull}}"/>
    <StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
      <Button Content="Open all in VS Code" Command="{Binding OpenInVsCodeCommand}"/>
      <Button Content="I've resolved — continue" Command="{Binding ContinueCommand}"/>
      <Button Content="Abort this merge" Command="{Binding AbortCommand}"/>
    </StackPanel>
  </StackPanel>
</UserControl>
  • Step 3: Hook SignalR PlanningMergeConflict event to open the dialog

In your SignalR client setup (search for where WorktreeUpdated or TaskFinished is subscribed):

_connection.On<string, string, IReadOnlyList<string>>(
    "PlanningMergeConflict",
    (planningTaskId, subtaskId, files) =>
        Dispatcher.UIThread.Post(() => OpenConflictDialog(planningTaskId, subtaskId, files)));

Where OpenConflictDialog builds the VM, resolves the subtask title (lookup from cached tasks), and opens the view as a modal Window.

  • Step 4: Manual smoke test — create a planning session where two subtasks touch the same line, finalize and let both run, click Merge all, verify:

    • Dialog opens at the first conflict
    • "Open in VS Code" launches code with the conflicted file
    • Saving resolution in VS Code + clicking Continue completes the merge
    • Repeat but click Abort — confirm repo is clean and Planning stays Planned
    • "code not on PATH" smoke test: rename code temporarily, click Open — verify inline error appears, not a popup
  • Step 5: Commit

git add src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml.cs src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs src/ClaudeDo.Ui/
git commit -m "feat(ui): add conflict resolution dialog with VS Code integration"

Final verification

  • Run all tests once more:
    dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
    dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
    
  • Manual end-to-end smoke test (from spec §Testing → Manual smoke test):
    1. Create a Planning task, let it generate 23 subtasks, finalize.
    2. Watch the Queue List — Planning row shows the child-queued roll-up; expand to see subtasks inline.
    3. Subtasks run and transition to Done; they stay nested under the Planning parent.
    4. Open Planning detail: click Review combined diff, toggle between grouped and combined preview.
    5. Happy path: click Merge all — all subtasks merge, Planning transitions to Done, subtree moves to Completed.
    6. Conflict path: set up a planning session with intentionally-conflicting subtasks; on Merge all, verify Conflict Resolution dialog opens, VS Code launches on button click, Continue completes the flow, Abort restores state.