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

1919 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Planning Merge-All & Subtask Visibility — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix three Planning-feature problems: keep subtasks visible under their Planning parent until it is Done, roll up children's status onto the Planning parent in the Queue List, and provide an aggregated diff viewer plus a single "Merge all subtasks" action with VS-Code-assisted conflict resolution.
**Architecture:** Add two Worker services (`PlanningAggregator`, `PlanningMergeOrchestrator`) that compose the existing `TaskMergeService` (which gains conflict-resume support). Expose via `WorkerHub`. On the UI side, change `TasksIslandViewModel.Regroup` to treat Planning parents as roll-ups, and add two new Avalonia views (aggregated diff viewer + conflict resolution dialog).
**Tech Stack:** .NET 8, ASP.NET Core, SignalR, Avalonia 12, EF Core Sqlite, CommunityToolkit.Mvvm, xUnit with real git + SQLite fixtures.
**Spec:** `docs/superpowers/specs/2026-04-24-planning-merge-all-design.md`
---
## File Structure
**New files:**
- `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` — integration branch build + per-subtask diff aggregation.
- `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` — singleton, owns in-memory Merge-all state, coordinates sequential merges.
- `src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs` — record types for SignalR payloads.
- `src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs`
- `src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs`
- `src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml` + `.axaml.cs`
- `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` + `.axaml.cs`
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs`
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs`
**Modified files:**
- `src/ClaudeDo.Worker/Services/TaskMergeService.cs` — new `leaveConflictsInTree` param, `ContinueMergeAsync`, `AbortMergeAsync`.
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — new hub methods.
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — new broadcast events for the orchestrator.
- `src/ClaudeDo.Worker/Program.cs` — DI registration.
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs``Regroup()` changes + virtual-list filter change.
- `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — extend with conflict-leave tests.
**Sequence:** backend first (Tasks 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.
```csharp
[Fact]
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
{
var db = NewDb();
var repo = NewRepo();
// On main: modify README
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
// Worktree branch: conflicting change to README
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t1", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t1", repo.BaseCommit);
var (svc, _) = BuildService(db);
var result = await svc.MergeAsync(
task.Id, "main", removeWorktree: false, "msg",
leaveConflictsInTree: true,
CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
// Repo should STILL be mid-merge (we asked it not to abort).
var midMerge = File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD"));
Assert.True(midMerge, "repo should be left in mid-merge state");
// Cleanup: abort so subsequent tests can run.
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
}
```
(If `SeedWorktree` helper does not yet exist, add it — look at how existing tests seed worktrees. The pattern follows `SeedListAndTask`.)
- [ ] **Step 2: Run test — expect fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~MergeAsync_LeaveConflicts" -v minimal`
Expected: FAIL — compile error: "MergeAsync does not take 6 arguments".
- [ ] **Step 3: Add parameter and skip the abort when requested**
Modify `src/ClaudeDo.Worker/Services/TaskMergeService.cs`:
Change the `MergeAsync` signature to add `leaveConflictsInTree`:
```csharp
public async Task<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:
```csharp
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:
```csharp
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**
```bash
git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(worker): add leaveConflictsInTree option to TaskMergeService.MergeAsync"
```
---
### Task 2: Add `ContinueMergeAsync`
**Goal:** When the repo is mid-merge (after a conflicted `MergeAsync` with `leaveConflictsInTree: true`), this method stages all conflicted files and commits the merge, then flips the worktree to `Merged`.
**Files:**
- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
- [ ] **Step 1: Write failing test**
```csharp
[Fact]
public async Task ContinueMergeAsync_AfterUserResolves_CompletesMergeAndSetsWorktreeMerged()
{
var db = NewDb();
var repo = NewRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t2", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t2", repo.BaseCommit);
var (svc, _) = BuildService(db);
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
// Simulate the user resolving the conflict in VS Code.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")));
using var ctx = db.CreateContext();
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
Assert.Equal(WorktreeState.Merged, wt.State);
}
```
- [ ] **Step 2: Run test — expect fail** (ContinueMergeAsync does not exist).
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ContinueMergeAsync_After" -v minimal`
Expected: FAIL (compile error).
- [ ] **Step 3: Implement `ContinueMergeAsync`**
Add to `TaskMergeService.cs`:
```csharp
public async Task<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**
```bash
git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(worker): add ContinueMergeAsync to resume a conflicted merge"
```
---
### Task 3: Add `AbortMergeAsync`
**Goal:** Cancel an in-progress conflicted merge (`git merge --abort`), restoring the repo to its pre-merge state. Worktree state is unchanged (still `Active`).
**Files:**
- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
- [ ] **Step 1: Write failing test**
```csharp
[Fact]
public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive()
{
var db = NewDb();
var repo = NewRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t3", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t3", repo.BaseCommit);
var (svc, _) = BuildService(db);
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
var result = await svc.AbortMergeAsync(task.Id, CancellationToken.None);
Assert.Equal("aborted", result.Status);
Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")));
using var ctx = db.CreateContext();
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
Assert.Equal(WorktreeState.Active, wt.State);
}
```
- [ ] **Step 2: Run test — expect fail.**
- [ ] **Step 3: Implement `AbortMergeAsync` + add `StatusAborted` constant**
Add to `TaskMergeService.cs`:
```csharp
public const string StatusAborted = "aborted";
public async Task<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**
```bash
git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(worker): add AbortMergeAsync to cancel a conflicted merge"
```
---
## Phase 2 — `PlanningAggregator`
### Task 4: `PlanningAggregator.GetAggregatedDiffAsync`
**Goal:** Return per-subtask diff entries (title, branch, base commit, head commit, file stats, raw unified diff) for all children of a Planning task.
**Files:**
- Create: `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs`
- [ ] **Step 1: Define the result records**
Create `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` with:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Planning;
public sealed record SubtaskDiff(
string SubtaskId,
string Title,
string BranchName,
string BaseCommit,
string HeadCommit,
string? DiffStat,
string UnifiedDiff);
public sealed record CombinedDiffSuccess(string IntegrationBranch, string UnifiedDiff);
public sealed record CombinedDiffFailure(string FirstConflictSubtaskId, IReadOnlyList<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`:
```csharp
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Planning;
public class PlanningAggregatorTests : IDisposable
{
private readonly List<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**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningAggregator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs
git commit -m "feat(worker): add PlanningAggregator.GetAggregatedDiffAsync"
```
---
### Task 5: `PlanningAggregator.BuildIntegrationBranchAsync`
**Goal:** Create/reset `planning/<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`:
```csharp
[Fact]
public async Task BuildIntegrationBranchAsync_NonConflictingChildren_ReturnsOkWithCombinedDiff()
{
var db = NewDb();
var repo = NewRepo();
// Two children that edit DIFFERENT files, so no conflict.
var (parentId, _, _) = await SeedPlanningWithChildrenTouchingDifferentFiles(db, repo);
var git = new GitService(NullLogger<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`:
```csharp
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**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningAggregator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs
git commit -m "feat(worker): add PlanningAggregator.BuildIntegrationBranchAsync"
```
---
### Task 6: `PlanningAggregator.CleanupIntegrationBranchAsync`
**Goal:** Delete the integration branch if it exists.
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs`
- [ ] **Step 1: Write failing test**
```csharp
[Fact]
public async Task CleanupIntegrationBranchAsync_RemovesBranchIfPresent()
{
var db = NewDb();
var repo = NewRepo();
var (parentId, _, _) = await SeedPlanningWithChildrenTouchingDifferentFiles(db, repo);
var git = new GitService(NullLogger<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**
```csharp
public async Task CleanupIntegrationBranchAsync(string planningTaskId, CancellationToken ct)
{
var (planning, repoDir, _) = await LoadPlanningContextAsync(planningTaskId, ct);
var branch = BuildIntegrationBranchName(planning);
// Ensure we're not on the integration branch when deleting it.
var current = await _git.GetCurrentBranchAsync(repoDir, ct);
if (string.Equals(current, branch, StringComparison.Ordinal))
{
// Checkout any other branch before deleting current.
var branches = await _git.ListLocalBranchesAsync(repoDir, ct);
var target = branches.FirstOrDefault(b => b != branch) ?? "main";
await _git.CheckoutBranchAsync(repoDir, target, ct);
}
try { await _git.BranchDeleteAsync(repoDir, branch, force: true, ct); } catch { }
}
```
- [ ] **Step 4: Run — expect pass.**
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningAggregator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs
git commit -m "feat(worker): add PlanningAggregator.CleanupIntegrationBranchAsync"
```
---
## Phase 3 — `PlanningMergeOrchestrator`
### Task 7: Orchestrator happy path (`StartAsync` with no conflicts)
**Goal:** Sequentially merge all child subtasks via `TaskMergeService.MergeAsync` with `leaveConflictsInTree: true`. On all-success, set Planning to `Done`, cleanup integration branch, emit `PlanningCompleted`.
**Files:**
- Create: `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`
- Create: `src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs`
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs`
- [ ] **Step 1: Define event records**
Create `src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs`:
```csharp
namespace ClaudeDo.Worker.Planning;
public sealed record PlanningMergeStarted(string PlanningTaskId, string TargetBranch);
public sealed record PlanningSubtaskMerged(string PlanningTaskId, string SubtaskId);
public sealed record PlanningMergeConflict(
string PlanningTaskId, string SubtaskId, IReadOnlyList<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:
```csharp
public Task PlanningMergeStarted(string planningTaskId, string targetBranch) =>
_hub.Clients.All.SendAsync("PlanningMergeStarted", planningTaskId, targetBranch);
public Task PlanningSubtaskMerged(string planningTaskId, string subtaskId) =>
_hub.Clients.All.SendAsync("PlanningSubtaskMerged", planningTaskId, subtaskId);
public Task PlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<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`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Services;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
// ... usings for fixtures and NullLogger
public class PlanningMergeOrchestratorTests : IDisposable
{
// ... same fixture management as PlanningAggregatorTests ...
[Fact]
public async Task StartAsync_AllChildrenMergeCleanly_MarksPlanningDoneAndEmitsCompleted()
{
var db = NewDb();
var repo = NewRepo();
var (parentId, subA, subB) = await SeedPlanningWithChildrenReadyToMerge(db, repo);
var (orch, broadcasterSpy) = BuildOrchestrator(db, repo);
await orch.StartAsync(parentId, targetBranch: "main", CancellationToken.None);
using var ctx = db.CreateContext();
var planning = ctx.Tasks.Single(t => t.Id == parentId);
Assert.Equal(TaskStatus.Done, planning.Status);
var wtA = ctx.Worktrees.Single(w => w.TaskId == subA);
var wtB = ctx.Worktrees.Single(w => w.TaskId == subB);
Assert.Equal(WorktreeState.Merged, wtA.State);
Assert.Equal(WorktreeState.Merged, wtB.State);
Assert.Contains(broadcasterSpy.Events, e => e is PlanningCompleted pc && pc.PlanningTaskId == parentId);
}
// BuildOrchestrator: wires real TaskMergeService + PlanningAggregator + a
// fake broadcaster that records events into a list.
}
```
(Implement `BuildOrchestrator` and `SeedPlanningWithChildrenReadyToMerge` — the children must have non-conflicting commits on their branches and their `TaskEntity.Status = Done`, worktrees in `Active` state.)
- [ ] **Step 4: Run — expect fail.**
- [ ] **Step 5: Implement orchestrator happy path**
Create `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`:
```csharp
using System.Collections.Concurrent;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Services;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Planning;
public sealed class PlanningMergeOrchestrator
{
private readonly IDbContextFactory<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**
```bash
git add src/ClaudeDo.Worker/Planning/ src/ClaudeDo.Worker/Hub/HubBroadcaster.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs
git commit -m "feat(worker): add PlanningMergeOrchestrator happy path"
```
---
### Task 8: Orchestrator `ContinueAsync`
**Goal:** Resume a halted Merge-all after the user resolves conflicts.
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs`
- [ ] **Step 1: Write failing test**
```csharp
[Fact]
public async Task ContinueAsync_AfterConflict_ResumesRemainingMergesAndCompletes()
{
var db = NewDb();
var repo = NewRepo();
// Seed: subA non-conflicting, subB conflicting, subC non-conflicting
var (parentId, subA, subB, subC) = await SeedPlanningThreeChildrenMiddleConflicts(db, repo);
var (orch, spy) = BuildOrchestrator(db, repo);
await orch.StartAsync(parentId, "main", CancellationToken.None);
// subA merged, subB halted in conflict
Assert.Contains(spy.Events, e => e is PlanningSubtaskMerged m && m.SubtaskId == subA);
Assert.Contains(spy.Events, e => e is PlanningMergeConflict c && c.SubtaskId == subB);
// Simulate user resolving in VS Code
var readme = Path.Combine(repo.RepoDir, "README.md");
File.WriteAllText(readme, "resolved\n");
await orch.ContinueAsync(parentId, CancellationToken.None);
using var ctx = db.CreateContext();
Assert.Equal(TaskStatus.Done, ctx.Tasks.Single(t => t.Id == parentId).Status);
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subB).State);
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subC).State);
}
```
- [ ] **Step 2: Run — expect fail.**
- [ ] **Step 3: Implement `ContinueAsync`**
```csharp
public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
{
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
throw new InvalidOperationException("no in-progress merge to continue");
var current = state.CurrentSubtaskId;
var result = await _merge.ContinueMergeAsync(current, ct);
if (result.Status != TaskMergeService.StatusMerged)
{
await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles);
return;
}
await _broadcaster.PlanningSubtaskMerged(planningTaskId, current);
state.CurrentSubtaskId = null;
await DrainAsync(planningTaskId, ct);
}
```
- [ ] **Step 4: Run — expect pass.**
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs
git commit -m "feat(worker): add PlanningMergeOrchestrator.ContinueAsync"
```
---
### Task 9: Orchestrator `AbortAsync`
**Goal:** Cancel an in-progress Merge-all after a conflict. Earlier merged subtasks stay merged. Planning stays `Planned`.
- [ ] **Step 1: Write failing test**
```csharp
[Fact]
public async Task AbortAsync_AfterConflict_RestoresCleanRepoAndClearsState()
{
var db = NewDb();
var repo = NewRepo();
var (parentId, subA, subB, _) = await SeedPlanningThreeChildrenMiddleConflicts(db, repo);
var (orch, spy) = BuildOrchestrator(db, repo);
await orch.StartAsync(parentId, "main", CancellationToken.None);
await orch.AbortAsync(parentId, CancellationToken.None);
using var ctx = db.CreateContext();
Assert.Equal(TaskStatus.Planned, ctx.Tasks.Single(t => t.Id == parentId).Status);
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subA).State);
Assert.Equal(WorktreeState.Active, ctx.Worktrees.Single(w => w.TaskId == subB).State);
Assert.Contains(spy.Events, e => e is PlanningMergeAborted pma && pma.PlanningTaskId == parentId);
var git = new GitService(NullLogger<GitService>.Instance);
Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None));
}
```
- [ ] **Step 2: Run — expect fail.**
- [ ] **Step 3: Implement `AbortAsync`**
```csharp
public async Task AbortAsync(string planningTaskId, CancellationToken ct)
{
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
throw new InvalidOperationException("no in-progress merge to abort");
await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct);
_states.TryRemove(planningTaskId, out _);
await _broadcaster.PlanningMergeAborted(planningTaskId);
}
```
- [ ] **Step 4: Run — expect pass.**
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs
git commit -m "feat(worker): add PlanningMergeOrchestrator.AbortAsync"
```
---
### Task 10: Pre-flight checks + idempotent restart
**Goal:** Verify every subtask is `Done`, every worktree is `Active` or `Merged`, repo is clean, no mid-merge in progress. Already-`Merged` worktrees are skipped (idempotent).
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs`
- [ ] **Step 1: Write failing tests for each pre-flight failure**
```csharp
[Fact]
public async Task StartAsync_SubtaskStillRunning_ThrowsWithoutSideEffects()
{
var db = NewDb();
var repo = NewRepo();
var (parentId, runningSub) = await SeedPlanningWithOneRunningChild(db, repo);
var (orch, _) = BuildOrchestrator(db, repo);
var ex = await Assert.ThrowsAsync<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`:
```csharp
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`:
```csharp
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**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs
git commit -m "feat(worker): add pre-flight checks and idempotent restart to PlanningMergeOrchestrator"
```
---
## Phase 4 — Hub wiring + DI
### Task 11: Register services + add hub methods
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- [ ] **Step 1: Register services in DI**
Add to `src/ClaudeDo.Worker/Program.cs` (near the existing `AddSingleton<TaskMergeService>` line):
```csharp
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:
```csharp
PlanningAggregator planningAggregator,
PlanningMergeOrchestrator planningMergeOrchestrator,
```
Add these methods (follow the existing `MergeTask` pattern with `try/catch` for `KeyNotFoundException` and `InvalidOperationException`):
```csharp
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**
```bash
git add src/ClaudeDo.Worker/Program.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(worker): register planning services and add Merge-all hub methods"
```
---
## Phase 5 — UI Visibility (`TasksIslandViewModel.Regroup`)
### Task 12: Planning parents roll up their children's statuses
**Goal:** Subtasks (`ParentTaskId != null`) never appear as standalone rows in virtual lists. A Planning/Planned parent is included in `virtual:queued` if any child is `Queued` and in `virtual:running` if any child is `Running`. Children stay attached under the parent in the task tree. When Planning transitions to `Done`, parent + children move to Completed together.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs` (create if absent — mirror the structure of existing ViewModel tests)
- [ ] **Step 1: Write failing test — queued child with Planning parent is not in virtual:queued standalone**
(Inspect how the existing test project resolves `TasksIslandViewModel` — it takes DB input. Follow that pattern. Seed a planning task with status `Planning` and a child with status `Queued`.)
```csharp
[Fact]
public async Task VirtualQueued_QueuedChildOfPlanningParent_IsNotStandaloneRow()
{
var db = NewDb();
await SeedPlanningWithQueuedChild(db, parentId: "p1", childId: "c1");
var vm = BuildViewModel(db);
await vm.SelectListAsync("virtual:queued");
// Child is NOT present as a top-level row.
Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild);
// Planning parent IS present (roll-up).
Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild);
}
[Fact]
public async Task VirtualQueued_PlanningParentWithAnyQueuedChild_IsIncluded()
{
var db = NewDb();
await SeedPlanningParent(db, "p1", status: TaskStatus.Planned);
await SeedChild(db, parentId: "p1", childId: "c1", status: TaskStatus.Queued);
var vm = BuildViewModel(db);
await vm.SelectListAsync("virtual:queued");
Assert.Contains(vm.Items, r => r.Id == "p1");
}
[Fact]
public async Task Regroup_DoneChild_StaysNestedUnderPlanningParent()
{
var db = NewDb();
await SeedPlanningParent(db, "p1", status: TaskStatus.Planned);
await SeedChild(db, parentId: "p1", childId: "c1", status: TaskStatus.Done);
var vm = BuildViewModel(db);
await vm.SelectListAsync("virtual:queued"); // parent is queued-rollup
vm.Items.Single(r => r.Id == "p1").IsExpanded = true;
vm.Regroup();
// Done child should NOT be in CompletedItems while parent is Planned.
Assert.DoesNotContain(vm.CompletedItems, r => r.Id == "c1");
// Should be nested under parent.
Assert.Contains(vm.OpenItems, r => r.Id == "c1" && r.IsChild);
}
```
- [ ] **Step 2: Run — expect fail.**
- [ ] **Step 3: Update virtual-list filter and `Regroup`**
In `TasksIslandViewModel.cs`, change the filtered switch around line 160:
```csharp
IEnumerable<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`:
```csharp
var today = DateTime.Today;
foreach (var r in flat)
{
// A child of a still-open Planning parent is grouped with its parent, not moved to Completed.
var underOpenPlanningParent = r.IsChild &&
flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done);
if (r.Done && !underOpenPlanningParent)
CompletedItems.Add(r);
else if (r.ScheduledFor is { } d && d.Date < today)
OverdueItems.Add(r);
else
OpenItems.Add(r);
}
```
(If `TaskRowViewModel.IsPlanningParent` currently requires status `Planning`, extend it to also return `true` for `Planned` — inspect the existing getter and adjust.)
- [ ] **Step 4: Run — expect pass. Run the full UI test suite.**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -v minimal`
Expected: all pass.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Ui.Tests/
git commit -m "fix(ui): planning parents roll up child status; children stay nested until parent Done"
```
---
## Phase 6 — UI Planning Detail Panel extensions
### Task 13: Add Merge target dropdown + Review / Merge-all buttons to planning detail
**Goal:** On the existing detail pane for a Planning task, show a merge target dropdown + `[Review combined diff]` + `[Merge all subtasks]`. Merge-all disabled with tooltip when children aren't all `Done` or any worktree is `Discarded`/`Kept`.
**Files:**
- Modify: the existing task-detail view + viewmodel that renders a Planning task. Search under `src/ClaudeDo.Ui/Views/` and `src/ClaudeDo.Ui/ViewModels/` for the Planning detail pane (grep for `IsPlanningParent` or `PlanningSession`).
- [ ] **Step 1: Locate the detail view for Planning tasks.** Add observable properties to its ViewModel:
```csharp
[ObservableProperty] private ObservableCollection<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:
```csharp
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**
```csharp
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
private async Task ReviewCombinedDiffAsync()
{
// Open PlanningDiffView (Task 14) as a dialog or dedicated window, passing planningTaskId + SelectedMergeTarget.
}
[RelayCommand(CanExecute = nameof(CanMergeAll))]
private async Task MergeAllAsync()
{
try { await _hub.MergeAllPlanning(PlanningTaskId, SelectedMergeTarget ?? "main"); }
catch (HubException ex) { /* show inline error */ }
}
private bool CanReviewDiff() => true; // enabled once any child has a diff
```
- [ ] **Step 5: Add XAML controls** to the Planning detail view (follow existing styling). Example:
```xml
<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:
```bash
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**
```bash
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`:
```csharp
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`:
```xml
<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`):
```csharp
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**
```bash
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**
```csharp
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**
```xml
<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):
```csharp
_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**
```bash
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:
```bash
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.