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>
1919 lines
73 KiB
Markdown
1919 lines
73 KiB
Markdown
# Planning Merge-All & Subtask Visibility — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Fix three Planning-feature problems: keep subtasks visible under their Planning parent until it is Done, roll up children's status onto the Planning parent in the Queue List, and provide an aggregated diff viewer plus a single "Merge all subtasks" action with VS-Code-assisted conflict resolution.
|
||
|
||
**Architecture:** Add two Worker services (`PlanningAggregator`, `PlanningMergeOrchestrator`) that compose the existing `TaskMergeService` (which gains conflict-resume support). Expose via `WorkerHub`. On the UI side, change `TasksIslandViewModel.Regroup` to treat Planning parents as roll-ups, and add two new Avalonia views (aggregated diff viewer + conflict resolution dialog).
|
||
|
||
**Tech Stack:** .NET 8, ASP.NET Core, SignalR, Avalonia 12, EF Core Sqlite, CommunityToolkit.Mvvm, xUnit with real git + SQLite fixtures.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-24-planning-merge-all-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**New files:**
|
||
- `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` — integration branch build + per-subtask diff aggregation.
|
||
- `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` — singleton, owns in-memory Merge-all state, coordinates sequential merges.
|
||
- `src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs` — record types for SignalR payloads.
|
||
- `src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs`
|
||
- `src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs`
|
||
- `src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml` + `.axaml.cs`
|
||
- `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` + `.axaml.cs`
|
||
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs`
|
||
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs`
|
||
|
||
**Modified files:**
|
||
- `src/ClaudeDo.Worker/Services/TaskMergeService.cs` — new `leaveConflictsInTree` param, `ContinueMergeAsync`, `AbortMergeAsync`.
|
||
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — new hub methods.
|
||
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — new broadcast events for the orchestrator.
|
||
- `src/ClaudeDo.Worker/Program.cs` — DI registration.
|
||
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — `Regroup()` changes + virtual-list filter change.
|
||
- `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — extend with conflict-leave tests.
|
||
|
||
**Sequence:** backend first (Tasks 1–11), then UI visibility fix (Task 12), then UI views (Tasks 13–15). Each task is independently committable and testable.
|
||
|
||
---
|
||
|
||
## Phase 1 — `TaskMergeService` extensions
|
||
|
||
### Task 1: Add `leaveConflictsInTree` parameter to `MergeAsync`
|
||
|
||
**Goal:** When the caller sets `leaveConflictsInTree: true`, a conflict leaves the repo mid-merge (no `git merge --abort`) and the conflicted files are returned on the result. Default remains `false` so all existing callers are unchanged.
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
|
||
|
||
- [ ] **Step 1: Write failing test for `leaveConflictsInTree: true`**
|
||
|
||
Add to `TaskMergeServiceTests.cs` (follow existing patterns in the file; use `NewRepo()`, `NewDb()`, `SeedListAndTask`, `BuildService` helpers already present). Create a real conflict by adding a worktree branch and target branch that both modify the same file.
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
|
||
{
|
||
var db = NewDb();
|
||
var repo = NewRepo();
|
||
|
||
// On main: modify README
|
||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||
|
||
// Worktree branch: conflicting change to README
|
||
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t1", wtPath, repo.BaseCommit);
|
||
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||
|
||
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||
await SeedWorktree(db, task.Id, wtPath, "claudedo/t1", repo.BaseCommit);
|
||
|
||
var (svc, _) = BuildService(db);
|
||
|
||
var result = await svc.MergeAsync(
|
||
task.Id, "main", removeWorktree: false, "msg",
|
||
leaveConflictsInTree: true,
|
||
CancellationToken.None);
|
||
|
||
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
|
||
Assert.Contains("README.md", result.ConflictFiles);
|
||
|
||
// Repo should STILL be mid-merge (we asked it not to abort).
|
||
var midMerge = File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD"));
|
||
Assert.True(midMerge, "repo should be left in mid-merge state");
|
||
|
||
// Cleanup: abort so subsequent tests can run.
|
||
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
||
}
|
||
```
|
||
|
||
(If `SeedWorktree` helper does not yet exist, add it — look at how existing tests seed worktrees. The pattern follows `SeedListAndTask`.)
|
||
|
||
- [ ] **Step 2: Run test — expect fail**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~MergeAsync_LeaveConflicts" -v minimal`
|
||
Expected: FAIL — compile error: "MergeAsync does not take 6 arguments".
|
||
|
||
- [ ] **Step 3: Add parameter and skip the abort when requested**
|
||
|
||
Modify `src/ClaudeDo.Worker/Services/TaskMergeService.cs`:
|
||
|
||
Change the `MergeAsync` signature to add `leaveConflictsInTree`:
|
||
|
||
```csharp
|
||
public async Task<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:
|
||
|
||
```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 2–3 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.
|