11 KiB
Unit-Branch Teil 2: Children per fast-forward — 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: Children of a unit start from the unit branch head and are fast-forwarded into it as soon as they finish; a parent is approved like a single task.
Architecture: New UnitBranchService (Worker/Runner) owns the git side: it creates the planning parent's worktree on "Queue plan" and absorbs a finished child (merge --ff-only in the parent worktree, child worktree removed, row Merged, MergeCommit null). It never touches task status — TaskRunner (FailAsync on a ff failure) and TaskMergeService (approve of an interactive child) own the transitions, so TaskStateService/PlanningChainCoordinator stay git-free and the DI graph stays acyclic. Legacy units (a Done child with an Active worktree) keep going through PlanningMergeOrchestrator.
Tech Stack: .NET 8, EF Core SQLite, git CLI, xUnit (real git + real SQLite in Worker.Tests).
Spec: docs/superpowers/specs/2026-10-01-unit-branch-draft-pr-design.md, section 2. Private behaviour only — company mode (push/PR) is part 3.
File map
| File | Change |
|---|---|
src/ClaudeDo.Data/Git/GitService.cs |
+ MergeFastForwardOnlyAsync |
src/ClaudeDo.Data/Repositories/WorktreeRepository.cs |
SetMergedAsync(…, string? mergeCommit) |
src/ClaudeDo.Worker/Runner/WorktreeManager.cs |
ResolveBaseCommitAsync: child → rev-parse <parentWt.BranchName> for any parent with an Active worktree |
src/ClaudeDo.Worker/Runner/UnitBranchService.cs |
new — EnsureUnitWorktreeAsync, AbsorbChildAsync, HasUnabsorbedChildrenAsync |
src/ClaudeDo.Worker/Program.cs |
register UnitBranchService (+ Share<> for the external container is not needed — the MCP side only uses the static helper) |
src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs |
QueuePlanAsync calls EnsureUnitWorktreeAsync |
src/ClaudeDo.Worker/Runner/TaskRunner.cs |
HandleSuccess: absorb before CompleteAsync, ff failure → MarkFailed |
src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs |
ApproveAndMergeAsync: child of a unit worktree → absorb instead of merge |
src/ClaudeDo.Worker/Hub/WorkerHub.cs, src/ClaudeDo.Worker/External/ExternalMcpService.cs |
approve routing: orchestrator only for unabsorbed (legacy) children |
src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs |
also merges a planning parent's own Active worktree (mixed legacy unit) |
src/ClaudeDo.Worker/Planning/PlanningAggregator.cs |
combined diff = unitBase..unitBranch without checkout -b when the parent owns the unit; per-child diff of absorbed children from the commit range |
tests/ClaudeDo.Worker.Tests/Runner/UnitBranchTests.cs |
new — all unit-branch tests |
src/ClaudeDo.Worker/CLAUDE.md, docs/explore-notes/review-merge.md |
docs |
Task 1: Git primitive + nullable MergeCommit
Files: Modify src/ClaudeDo.Data/Git/GitService.cs (after MergeNoFfAsync), src/ClaudeDo.Data/Repositories/WorktreeRepository.cs:39
- Step 1: Add the method
/// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary>
public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync(
string worktreePath, string sourceRef, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["merge", "--ff-only", sourceRef], ct);
return (exitCode, stderr);
}
- Step 2:
SetMergedAsync(string taskId, string? mergeCommit, …)— an absorbed child has no merge commit. - Step 3: Covered by the Task 3 tests (no standalone test for a one-line git wrapper).
Task 2: Child base = unit head
Files: Modify src/ClaudeDo.Worker/Runner/WorktreeManager.cs (ResolveBaseCommitAsync)
- Step 1: Failing test (
UnitBranchTests.Child_starts_from_unit_head): planning parent (PlanningPhase.Finalized) with a worktree, commit a file in the parent worktree (git -C <wt> commit), thenCreateAsync(child)→ctx.BaseCommit == rev-parse <parentBranch>. Fails today because planning children fall through toHEADof the main checkout. - Step 2: Implement
if (task.ParentTaskId is not null)
{
using var ctx = _dbFactory.CreateDbContext();
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
if (parentWt is { State: WorktreeState.Active })
return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
}
- Step 3: run
--filter "FullyQualifiedName~UnitBranchTests|FullyQualifiedName~WorktreeManagerTests", commit.
Task 3: UnitBranchService
Files: Create src/ClaudeDo.Worker/Runner/UnitBranchService.cs, register in Program.cs next to WorktreeManager.
public sealed record AbsorbResult(bool Applicable, string? Error)
{
public static readonly AbsorbResult NotApplicable = new(false, null);
public static readonly AbsorbResult Absorbed = new(true, null);
public bool Failed => Error is not null;
}
public sealed class UnitBranchService
{
// ctor: GitService, WorktreeManager, IDbContextFactory, HubBroadcaster?, ILogger
/// Planning parent → own worktree = unit branch; no-op if it already has an Active one or the list has no git working dir.
public async Task EnsureUnitWorktreeAsync(string parentId, CancellationToken ct);
/// ff the parent worktree to the child branch; child row Merged (MergeCommit null), child worktree + branch removed.
/// NotApplicable when the child or its parent has no Active worktree. Never changes task status.
public async Task<AbsorbResult> AbsorbChildAsync(string childId, CancellationToken ct);
/// Legacy routing: a Done child whose worktree is still Active was never absorbed.
public static Task<bool> HasUnabsorbedChildrenAsync(ClaudeDoDbContext ctx, string parentId, CancellationToken ct) =>
ctx.Tasks.AnyAsync(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Done
&& t.Worktree != null && t.Worktree.State == WorktreeState.Active, ct);
}
Absorb order: ff → parent UpdateHeadAsync(head, diffstat base..head) → child SetMergedAsync(null) → best-effort worktree remove --force + branch -D → WorktreeUpdated for both. ff failure returns Error with a clear message and a ponytail: comment (no merge fallback).
- Tests (real git): two serial children absorbed, child 2 sees child 1's file, parent branch head == child 2 head, child rows
Mergedwith nullMergeCommit, child branches gone; a child forked from the old head after the unit moved →Failed, unit branch unchanged. - Commit.
Task 4: Queue plan creates the parent worktree
Files: PlanningChainCoordinator.cs (optional ctor param UnitBranchService? unitBranch = null), TaskStateServiceBuilder.cs (optional pass-through).
if (_unitBranch is not null) await _unitBranch.EnsureUnitWorktreeAsync(parentTaskId, ct);
return await SetupChainAsync(parentTaskId, enqueue: true, ct);
- Test: finalized planning parent + 1 child in a git list → after
QueuePlanAsyncthe parent has an Active worktree row whose base = repo HEAD; a second call keeps the same row. - Commit.
Task 5: TaskRunner absorbs before CompleteAsync
Files: TaskRunner.cs (optional trailing ctor param UnitBranchService? unitBranch = null; in the child branch of HandleSuccess):
if (_unitBranch is not null)
{
var absorb = await _unitBranch.AbsorbChildAsync(task.Id, CancellationToken.None);
if (absorb.Failed)
{
await MarkFailed(task.Id, task.Number, task.Title, slot, absorb.Error, result.TurnCount);
return;
}
}
- Test: TaskRunner with
FakeClaudeProcesswriting a file into its working dir, two queued children of a unit → both Done, unit head has both files; a child whose base was overtaken →Failedwith the ff message. - Commit.
Task 6: Interactive child approve absorbs
Files: TaskMergeService.cs (optional ctor param UnitBranchService? unitBranch = null); in ApproveAndMergeAsync after the WaitingForReview guard:
if (task.ParentTaskId is not null && _unitBranch is not null)
{
var absorb = await _unitBranch.AbsorbChildAsync(taskId, ct);
if (absorb.Failed) return Blocked(absorb.Error!);
if (absorb.Applicable)
{
var approved = await _state.ApproveReviewAsync(taskId, ct);
return approved.Ok ? new MergeResult(StatusMerged, Array.Empty<string>(), null) : Blocked(approved.Reason ?? "approve failed");
}
}
A blocked approve keeps the child WaitingForReview (the reviewer can reject/re-run) — the Failed rule applies to autonomous runs.
- Test: child in
WaitingForReviewwith a commit → approve → child Done, unit branch contains the file, main untouched. - Commit.
Task 7: Approve routing
Files: WorkerHub.ApproveReview, ExternalMcpService.ReviewTask, PlanningMergeOrchestrator.StartAsync.
- Replace
hasChildrenwithUnitBranchService.HasUnabsorbedChildrenAsyncfor the orchestrator branch (MCP still computesemptyChildrenwhenever children exist; absorbed children count as empty when their row has noHeadCommit). - Orchestrator:
if (parentHasWorktree) idsToMerge.Add(parentTaskId)(drop!isPlanning) so a mixed legacy unit still lands the unit branch. - Tests: approve of a new parent (all children absorbed) merges the unit branch onto
mainand the parent goes Done; a legacy parent (Done child with Active worktree, no parent worktree) still goes through the orchestrator and lands the child. - Commit.
Task 8: Combined diff without the integration branch
Files: PlanningAggregator.cs.
BuildIntegrationBranchAsync: parent with Active worktree and no unabsorbed child →Ok(parentWt.BranchName, git diff <BaseCommit> <BranchName>), no checkout in the main checkout. Otherwise legacy path.GetAggregatedDiffAsync: a node whose worktree directory is gone but hasHeadCommit→GetCommitRangeDiffAsync(repoDir, BaseCommit, HeadCommit); an improvement parent's own node is limited toBaseCommit..firstChild.BaseCommitonce children were forked from it (else it would repeat the absorbed children).- Test: unit with an absorbed child → combined diff contains the child's file, main checkout stays on
main, noplanning/*-integrationbranch exists. - Commit.
Task 9: Docs
src/ClaudeDo.Worker/CLAUDE.md (Planning Flow, Unified parent model, WorktreeManager base rule), docs/explore-notes/review-merge.md (Approve section, MergeCommit/revert note, Unit-Branch section). Commit.
Verification
dotnet build ClaudeDo.slnx -c Release, then
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~UnitBranch|FullyQualifiedName~WorktreeManager|FullyQualifiedName~PlanningMerge|FullyQualifiedName~PlanningAggregator|FullyQualifiedName~StandaloneChildren|FullyQualifiedName~TaskMergeService|FullyQualifiedName~ReviewTask".