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>
73 KiB
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.cssrc/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cssrc/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml+.axaml.cssrc/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml+.axaml.cstests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cstests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs
Modified files:
src/ClaudeDo.Worker/Services/TaskMergeService.cs— newleaveConflictsInTreeparam,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.
[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 86–108), 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+ addStatusAbortedconstant
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/andsrc/ClaudeDo.Ui/ViewModels/for the Planning detail pane (grep forIsPlanningParentorPlanningSession). -
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), fillMergeTargetBranches, setSelectedMergeTargetto the returned default. -
Step 3: Compute
CanMergeAllandMergeAllDisabledReason— 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 (insideReviewCombinedDiffAsync):
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.csor similar) — wirePlanningMergeConflictto 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
PlanningMergeConflictevent 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
codewith 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
codetemporarily, 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):
- Create a Planning task, let it generate 2–3 subtasks, finalize.
- Watch the Queue List — Planning row shows the child-queued roll-up; expand to see subtasks inline.
- Subtasks run and transition to Done; they stay nested under the Planning parent.
- Open Planning detail: click Review combined diff, toggle between grouped and combined preview.
- Happy path: click Merge all — all subtasks merge, Planning transitions to Done, subtree moves to Completed.
- 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.