From 9da522b0c9d7ee1068ed7a9ad1ee3215f613f0c1 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 11:25:47 +0200 Subject: [PATCH] feat(worker): fast-forward finished children into the parent's unit branch Planning parents get their own worktree on Queue plan, children fork from the unit head and are absorbed via merge --ff-only before Done (ff failure -> Failed, interactive child approve -> absorb instead of merge to the target). --- .../Lifecycle/TaskMergeService.cs | 20 +- .../Planning/PlanningChainCoordinator.cs | 9 +- src/ClaudeDo.Worker/Program.cs | 1 + src/ClaudeDo.Worker/Runner/TaskRunner.cs | 15 +- .../Runner/UnitBranchService.cs | 122 +++++++ src/ClaudeDo.Worker/Runner/WorktreeManager.cs | 20 +- .../Infrastructure/TaskStateServiceBuilder.cs | 5 +- .../Runner/UnitBranchTests.cs | 314 ++++++++++++++++++ 8 files changed, 489 insertions(+), 17 deletions(-) create mode 100644 src/ClaudeDo.Worker/Runner/UnitBranchService.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Runner/UnitBranchTests.cs diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index af3ba7bb..9d99bbbd 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -117,6 +117,7 @@ public sealed class TaskMergeService private readonly ITaskStateService _state; private readonly IVerifyCommandRunner _verify; private readonly ILogger _logger; + private readonly UnitBranchService? _unitBranch; public TaskMergeService( IDbContextFactory dbFactory, @@ -124,7 +125,8 @@ public sealed class TaskMergeService HubBroadcaster broadcaster, ITaskStateService state, IVerifyCommandRunner verify, - ILogger logger) + ILogger logger, + UnitBranchService? unitBranch = null) { _dbFactory = dbFactory; _git = git; @@ -132,6 +134,7 @@ public sealed class TaskMergeService _state = state; _verify = verify; _logger = logger; + _unitBranch = unitBranch; } private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync( @@ -1010,6 +1013,21 @@ public sealed class TaskMergeService if (task.Status != TaskStatus.WaitingForReview) return Blocked("task is not waiting for review"); + // An interactive child of a unit lands in the unit branch, not on the target; the parent's + // own approve merges the unit. A failed fast-forward keeps the child in review. + 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(), null) + : Blocked(approved.Reason ?? "approve failed"); + } + } + if (wt is not null && wt.State == WorktreeState.Kept) { // Kept means either RevertMergeAsync undid a previous merge, or the user manually diff --git a/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs b/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs index cf1f19f8..85bda745 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs @@ -1,5 +1,6 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.State; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -10,13 +11,16 @@ public sealed class PlanningChainCoordinator { private readonly IDbContextFactory _dbFactory; private readonly Func _state; + private readonly UnitBranchService? _unitBranch; public PlanningChainCoordinator( IDbContextFactory dbFactory, - Func state) + Func state, + UnitBranchService? unitBranch = null) { _dbFactory = dbFactory; _state = state; + _unitBranch = unitBranch; } // Sets up a sequential chain over a planning parent's children. @@ -86,6 +90,9 @@ public sealed class PlanningChainCoordinator if (phase != PlanningPhase.Finalized) throw new InvalidOperationException("Plan must be finalized before it can be queued."); + // The parent's worktree is the unit branch every child forks from and is absorbed into. + if (_unitBranch is not null) + await _unitBranch.EnsureUnitWorktreeAsync(parentTaskId, ct); return await SetupChainAsync(parentTaskId, enqueue: true, ct); } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 7e356985..74ee597a 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -112,6 +112,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index ca14055e..ad8fbc77 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -28,6 +28,7 @@ public sealed class TaskRunner private readonly AttachmentStore _attachments; private readonly ISessionSkillSeeder _skillSeeder; private readonly ITranscriptUsageReader _usageReader; + private readonly UnitBranchService? _unitBranch; public TaskRunner( IClaudeProcess claude, @@ -41,7 +42,8 @@ public sealed class TaskRunner TaskRunTokenRegistry tokens, AttachmentStore attachments, ISessionSkillSeeder skillSeeder, - ITranscriptUsageReader usageReader) + ITranscriptUsageReader usageReader, + UnitBranchService? unitBranch = null) { _claude = claude; _dbFactory = dbFactory; @@ -55,6 +57,7 @@ public sealed class TaskRunner _attachments = attachments; _skillSeeder = skillSeeder; _usageReader = usageReader; + _unitBranch = unitBranch; } public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false) @@ -568,6 +571,16 @@ public sealed class TaskRunner } else { + // Land the child in its unit branch before Done unblocks the successor, which forks from that head. + 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; + } + } var transition = await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None); if (!transition.Ok) _logger.LogWarning("CompleteAsync rejected for task {TaskId}: {Reason}", task.Id, transition.Reason); diff --git a/src/ClaudeDo.Worker/Runner/UnitBranchService.cs b/src/ClaudeDo.Worker/Runner/UnitBranchService.cs new file mode 100644 index 00000000..98d66b32 --- /dev/null +++ b/src/ClaudeDo.Worker/Runner/UnitBranchService.cs @@ -0,0 +1,122 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Runner; + +/// Outcome of . Applicable = the child belongs to a unit branch. +public sealed record AbsorbResult(bool Applicable, string? Error) +{ + /// The child has no unit branch to land in (no parent worktree, or no own worktree). + public static readonly AbsorbResult NotApplicable = new(false, null); + /// The unit branch was fast-forwarded to the child. + public static readonly AbsorbResult Absorbed = new(true, null); + /// True when the fast-forward was impossible; says why. + public bool Failed => Error is not null; +} + +/// +/// Git side of the unit branch: the parent's worktree owns it, children are fast-forwarded into it. +/// Never writes task status — callers own the transitions, which keeps the DI graph acyclic. +/// +public sealed class UnitBranchService +{ + private readonly GitService _git; + private readonly WorktreeManager _worktrees; + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + private readonly HubBroadcaster? _broadcaster; + + public UnitBranchService( + GitService git, WorktreeManager worktrees, IDbContextFactory dbFactory, + ILogger logger, HubBroadcaster? broadcaster = null) + { + _git = git; + _worktrees = worktrees; + _dbFactory = dbFactory; + _logger = logger; + _broadcaster = broadcaster; + } + + /// Gives a parent its own worktree (= unit branch) unless it already has an Active one or its list has no working dir. + public async Task EnsureUnitWorktreeAsync(string parentId, CancellationToken ct) + { + TaskEntity parent; + ListEntity list; + using (var ctx = _dbFactory.CreateDbContext()) + { + parent = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct) + ?? throw new KeyNotFoundException($"Task '{parentId}' not found."); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(parentId, ct); + if (wt is { State: WorktreeState.Active }) return; + list = await new ListRepository(ctx).GetByIdAsync(parent.ListId, ct) + ?? throw new InvalidOperationException("List not found."); + } + if (string.IsNullOrWhiteSpace(list.WorkingDir)) return; + + await _worktrees.CreateAsync(parent, list, ct); + if (_broadcaster is not null) await _broadcaster.WorktreeUpdated(parentId); + } + + /// + /// Fast-forwards the parent's unit branch to the child's branch, then marks the child row + /// Merged (no merge commit) and removes its worktree and branch. + /// + public async Task AbsorbChildAsync(string childId, CancellationToken ct) + { + WorktreeEntity? childWt, parentWt; + string? workingDir; + using (var ctx = _dbFactory.CreateDbContext()) + { + var child = await ctx.Tasks.AsNoTracking().Include(t => t.List) + .FirstOrDefaultAsync(t => t.Id == childId, ct); + if (child?.ParentTaskId is null) return AbsorbResult.NotApplicable; + var repo = new WorktreeRepository(ctx); + childWt = await repo.GetByTaskIdAsync(childId, ct); + parentWt = await repo.GetByTaskIdAsync(child.ParentTaskId, ct); + workingDir = child.List.WorkingDir; + } + if (childWt is not { State: WorktreeState.Active } || parentWt is not { State: WorktreeState.Active } + || string.IsNullOrWhiteSpace(workingDir)) + return AbsorbResult.NotApplicable; + + // ponytail: kein Merge-Fallback; einbauen, falls parallele Children ein echter Fall werden. + var (code, stderr) = await _git.MergeFastForwardOnlyAsync(parentWt.Path, childWt.BranchName, ct); + if (code != 0) + return new AbsorbResult(true, + $"Unit branch '{parentWt.BranchName}' cannot be fast-forwarded to '{childWt.BranchName}' — the child " + + $"did not start from the unit's current head (e.g. it was run in parallel via Run now). git: {stderr}"); + + var head = await _git.RevParseHeadAsync(parentWt.Path, ct); + var diffStat = await _git.DiffStatAsync(parentWt.Path, parentWt.BaseCommit, head, ct: ct); + using (var ctx = _dbFactory.CreateDbContext()) + { + var repo = new WorktreeRepository(ctx); + await repo.UpdateHeadAsync(parentWt.TaskId, head, diffStat, ct); + await repo.SetMergedAsync(childId, null, ct); + } + + // The work is in the unit branch now; a leftover folder or branch is only clutter. + try { await _git.WorktreeRemoveAsync(workingDir, childWt.Path, force: true, CancellationToken.None); } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to remove absorbed worktree {Path}", childWt.Path); } + try { await _git.BranchDeleteAsync(workingDir, childWt.BranchName, force: true, CancellationToken.None); } + catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete absorbed branch {Branch}", childWt.BranchName); } + + _logger.LogInformation("Absorbed {Child} into unit branch {Branch} at {Head}", childWt.BranchName, parentWt.BranchName, head); + if (_broadcaster is not null) + { + await _broadcaster.WorktreeUpdated(childId); + await _broadcaster.WorktreeUpdated(parentWt.TaskId); + } + return AbsorbResult.Absorbed; + } + + /// Legacy unit: a Done child whose worktree is still Active was never absorbed and needs the unit merge. + public static Task 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); +} diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 7b6540a2..420e1c25 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -256,23 +256,19 @@ public sealed class WorktreeManager return TicketStatusMap.ParseBandelTicketId(parentRef); } - // Improvement children (parent is a non-planning task with its own worktree) branch - // from the parent's recorded HEAD so they build on the parent's not-yet-merged work. - // Planning children and standalone tasks base off list_config.base_branch, else the current HEAD. + /// + /// A child of a parent with an Active worktree starts from the unit branch's current head, so it + /// sees every sibling absorbed before it. Unit roots (and legacy children) base off + /// list_config.base_branch, else the current HEAD. + /// private async Task ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct) { if (task.ParentTaskId is not null) { using var ctx = _dbFactory.CreateDbContext(); - var parent = await ctx.Tasks.AsNoTracking() - .FirstOrDefaultAsync(t => t.Id == task.ParentTaskId, ct); - if (parent is not null && parent.PlanningPhase == PlanningPhase.None) - { - var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct); - var parentHead = parentWt?.HeadCommit ?? parentWt?.BaseCommit; - if (parentHead is not null) - return parentHead; - } + var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct); + if (parentWt is { State: WorktreeState.Active }) + return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct); } using var cfgCtx = _dbFactory.CreateDbContext(); var baseBranch = await cfgCtx.ListConfigs.AsNoTracking() diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs index b247e391..36ae7265 100644 --- a/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs @@ -26,7 +26,8 @@ public static class TaskStateServiceBuilder public static Built Build( IDbContextFactory dbFactory, Func? mergeState = null, - BaseDirtyChecker? baseDirtyChecker = null) + BaseDirtyChecker? baseDirtyChecker = null, + ClaudeDo.Worker.Runner.UnitBranchService? unitBranch = null) { var hub = new CapturingHubContext(); var broadcaster = new HubBroadcaster(hub); @@ -46,7 +47,7 @@ public static class TaskStateServiceBuilder NullLogger.Instance); TaskStateService? state = null; - var chain = new PlanningChainCoordinator(dbFactory, () => state!); + var chain = new PlanningChainCoordinator(dbFactory, () => state!, unitBranch); state = new TaskStateService( dbFactory, broadcaster, diff --git a/tests/ClaudeDo.Worker.Tests/Runner/UnitBranchTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/UnitBranchTests.cs new file mode 100644 index 00000000..a360478e --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/UnitBranchTests.cs @@ -0,0 +1,314 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Git; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tests.Services; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Runner; + +/// Unit branch (spec 2026-10-01, section 2): children fork from and fast-forward into the parent's worktree. Real git + SQLite. +public sealed class UnitBranchTests : IDisposable +{ + private const string Parent = "p"; + private const string Child1 = "c1"; + private const string Child2 = "c2"; + + private readonly DbFixture _db = new(); + private readonly GitRepoFixture _repo = new(); + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"cd_unit_{Guid.NewGuid():N}"); + private readonly string _listName = $"ub{Guid.NewGuid():N}"[..12]; + private readonly WorkerConfig _cfg; + private readonly GitService _git = new(); + private readonly WorktreeManager _wt; + private readonly UnitBranchService _unit; + private readonly TaskStateServiceBuilder.Built _built; + private readonly HubBroadcaster _broadcaster = new(new CapturingHubContext()); + + public UnitBranchTests() + { + Directory.CreateDirectory(_tempDir); + GitRepoFixture.RunGit(_repo.RepoDir, "branch", "-m", "main"); + _cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir, WorktreeRootStrategy = "sibling" }; + var factory = _db.CreateFactory(); + _wt = new WorktreeManager(_git, factory, _cfg, NullLogger.Instance); + _unit = new UnitBranchService(_git, _wt, factory, NullLogger.Instance); + _built = TaskStateServiceBuilder.Build(factory, unitBranch: _unit); + Seed(); + } + + public void Dispose() + { + try + { + foreach (var line in GitRepoFixture.RunGit(_repo.RepoDir, "worktree", "list", "--porcelain").Split('\n')) + if (line.StartsWith("worktree ") && !Path.GetFullPath(line[9..].Trim()).Equals(Path.GetFullPath(_repo.RepoDir), StringComparison.OrdinalIgnoreCase)) + try { GitRepoFixture.RunGit(_repo.RepoDir, "worktree", "remove", "--force", line[9..].Trim()); } catch { } + } + catch { } + try { Directory.Delete(Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(_repo.RepoDir), ".claudedo-worktrees", _listName), true); } catch { } + _db.Dispose(); + _repo.Dispose(); + try { Directory.Delete(_tempDir, true); } catch { } + } + + private void Seed() + { + using var ctx = _db.CreateContext(); + ctx.Lists.Add(new ListEntity { Id = "l1", Name = _listName, WorkingDir = _repo.RepoDir, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = Parent, ListId = "l1", Title = "Plan", Number = 1, CommitType = "feat", + Status = TaskStatus.WaitingForChildren, PlanningPhase = PlanningPhase.Finalized, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = Child1, ListId = "l1", Title = "First", Number = 2, ParentTaskId = Parent, + SortOrder = 1, Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = Child2, ListId = "l1", Title = "Second", Number = 3, ParentTaskId = Parent, + SortOrder = 2, Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow.AddSeconds(1) }); + ctx.SaveChanges(); + } + + private async Task WorktreeOf(string taskId) + { + using var ctx = _db.CreateContext(); + return await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId); + } + + private async Task TaskOf(string taskId) + { + using var ctx = _db.CreateContext(); + return (await new TaskRepository(ctx).GetByIdAsync(taskId))!; + } + + private async Task SetStatus(string taskId, TaskStatus status) + { + using var ctx = _db.CreateContext(); + var t = ctx.Tasks.Single(x => x.Id == taskId); + t.Status = status; + await ctx.SaveChangesAsync(); + } + + private ListEntity List() { using var ctx = _db.CreateContext(); return ctx.Lists.Single(); } + + private TaskRunner Runner(FakeClaudeProcess fake) => new(fake, _db.CreateFactory(), _broadcaster, _wt, + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, _built.State, new TaskRunTokenRegistry(), + new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader(), _unit); + + private TaskMergeService MergeService() => new(_db.CreateFactory(), _git, _broadcaster, _built.State, + new FakeVerifyCommandRunner(), NullLogger.Instance, _unit); + + private static FakeClaudeProcess Writes(Action? before = null) => new((prompt, dir, _, _, _) => + { + before?.Invoke(dir); + File.WriteAllText(Path.Combine(dir, $"{Guid.NewGuid():N}.txt"), "x"); + return Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" }); + }); + + private async Task RunChild(string childId, FakeClaudeProcess fake) + { + await SetStatus(childId, TaskStatus.Running); + await Runner(fake).RunAsync(await TaskOf(childId), "slot-1", default, alreadyClaimed: true); + } + + /// Runs both children serially; child 1 writes a.txt, child 2 records whether it saw it and writes b.txt. + private async Task RunTwoSerialChildren() + { + await _unit.EnsureUnitWorktreeAsync(Parent, default); + await RunChild(Child1, new FakeClaudeProcess((_, dir, _, _, _) => + { + File.WriteAllText(Path.Combine(dir, "a.txt"), "a"); + return Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" }); + })); + var sawA = false; + await RunChild(Child2, new FakeClaudeProcess((_, dir, _, _, _) => + { + sawA = File.Exists(Path.Combine(dir, "a.txt")); + File.WriteAllText(Path.Combine(dir, "b.txt"), "b"); + return Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" }); + })); + return sawA; + } + + [Fact] + public async Task QueuePlan_gives_planning_parent_its_unit_worktree() + { + await _built.Chain.QueuePlanAsync(Parent); + + var wt = await WorktreeOf(Parent); + Assert.NotNull(wt); + Assert.Equal(WorktreeState.Active, wt!.State); + Assert.Equal(_repo.BaseCommit, wt.BaseCommit); + Assert.StartsWith("feature/1-plan", wt.BranchName); + Assert.Equal(TaskStatus.Queued, (await TaskOf(Child1)).Status); + + await _unit.EnsureUnitWorktreeAsync(Parent, default); + Assert.Equal(wt.Path, (await WorktreeOf(Parent))!.Path); + } + + [Fact] + public async Task Child_starts_from_current_unit_head() + { + await _unit.EnsureUnitWorktreeAsync(Parent, default); + var unit = (await WorktreeOf(Parent))!; + File.WriteAllText(Path.Combine(unit.Path, "unit.txt"), "u"); + GitRepoFixture.RunGit(unit.Path, "add", "-A"); + GitRepoFixture.RunGit(unit.Path, "commit", "-m", "unit work"); + var unitHead = GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", unit.BranchName).Trim(); + + var ctx = await _wt.CreateAsync(await TaskOf(Child1), List(), default); + + Assert.Equal(unitHead, ctx.BaseCommit); + Assert.NotEqual(_repo.BaseCommit, ctx.BaseCommit); + } + + [Fact] + public async Task Serial_children_are_fast_forwarded_and_child2_sees_child1() + { + var sawA = await RunTwoSerialChildren(); + + Assert.True(sawA); + Assert.Equal(TaskStatus.Done, (await TaskOf(Child1)).Status); + Assert.Equal(TaskStatus.Done, (await TaskOf(Child2)).Status); + Assert.Equal(TaskStatus.WaitingForReview, (await TaskOf(Parent)).Status); + + var unit = (await WorktreeOf(Parent))!; + Assert.True(File.Exists(Path.Combine(unit.Path, "a.txt"))); + Assert.True(File.Exists(Path.Combine(unit.Path, "b.txt"))); + Assert.Equal(GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", unit.BranchName).Trim(), unit.HeadCommit); + + var branches = await _git.ListLocalBranchesAsync(_repo.RepoDir); + foreach (var child in new[] { Child1, Child2 }) + { + var wt = (await WorktreeOf(child))!; + Assert.Equal(WorktreeState.Merged, wt.State); + Assert.Null(wt.MergeCommit); + Assert.False(Directory.Exists(wt.Path)); + Assert.DoesNotContain(wt.BranchName, branches); + } + // Nothing lands on main before the parent's approve. + Assert.Equal(_repo.BaseCommit, GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", "main").Trim()); + } + + [Fact] + public async Task Child_that_cannot_fast_forward_fails_and_leaves_unit_alone() + { + await _unit.EnsureUnitWorktreeAsync(Parent, default); + var unit = (await WorktreeOf(Parent))!; + string? movedHead = null; + + // The unit moves while the child runs -- what a parallel "Run now" sibling would do. + await RunChild(Child1, Writes(_ => + { + File.WriteAllText(Path.Combine(unit.Path, "sibling.txt"), "s"); + GitRepoFixture.RunGit(unit.Path, "add", "-A"); + GitRepoFixture.RunGit(unit.Path, "commit", "-m", "sibling"); + movedHead = GitRepoFixture.RunGit(unit.Path, "rev-parse", "HEAD").Trim(); + })); + + var child = await TaskOf(Child1); + Assert.Equal(TaskStatus.Failed, child.Status); + Assert.Equal(movedHead, GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", unit.BranchName).Trim()); + Assert.Contains("fast-forward", child.Result); + Assert.Equal(WorktreeState.Active, (await WorktreeOf(Child1))!.State); + } + + [Fact] + public async Task Approving_a_new_parent_merges_the_unit_branch_onto_main() + { + await RunTwoSerialChildren(); + using (var ctx = _db.CreateContext()) + Assert.False(await UnitBranchService.HasUnabsorbedChildrenAsync(ctx, Parent, default)); + + var result = await MergeService().ApproveAndMergeAsync(Parent, "main", default); + + Assert.Equal(TaskMergeService.StatusMerged, result.Status); + Assert.Equal(TaskStatus.Done, (await TaskOf(Parent)).Status); + Assert.NotNull(await _git.ShowFileAsync(_repo.RepoDir, "main", "a.txt")); + Assert.NotNull(await _git.ShowFileAsync(_repo.RepoDir, "main", "b.txt")); + Assert.Equal(WorktreeState.Merged, (await WorktreeOf(Parent))!.State); + } + + [Fact] + public async Task Approving_an_interactive_child_absorbs_it_instead_of_merging_to_main() + { + await _unit.EnsureUnitWorktreeAsync(Parent, default); + var child = await TaskOf(Child1); + var ctx = await _wt.CreateAsync(child, List(), default); + File.WriteAllText(Path.Combine(ctx.WorktreePath, "i.txt"), "i"); + await _wt.CommitIfChangedAsync(ctx, child, List(), default); + await SetStatus(Child1, TaskStatus.WaitingForReview); + + var result = await MergeService().ApproveAndMergeAsync(Child1, "", default); + + Assert.Equal(TaskMergeService.StatusMerged, result.Status); + Assert.Equal(TaskStatus.Done, (await TaskOf(Child1)).Status); + Assert.True(File.Exists(Path.Combine((await WorktreeOf(Parent))!.Path, "i.txt"))); + Assert.Null(await _git.ShowFileAsync(_repo.RepoDir, "main", "i.txt")); + } + + [Fact] + public async Task Legacy_unit_still_goes_through_the_orchestrator() + { + // Pre-rollout shape: the planning parent has no worktree, a Done child still owns an Active one. + var child = await TaskOf(Child1); + var ctx = await _wt.CreateAsync(child, List(), default); + File.WriteAllText(Path.Combine(ctx.WorktreePath, "legacy.txt"), "l"); + await _wt.CommitIfChangedAsync(ctx, child, List(), default); + await SetStatus(Child1, TaskStatus.Done); + await SetStatus(Child2, TaskStatus.Cancelled); + await SetStatus(Parent, TaskStatus.WaitingForReview); + + using (var db = _db.CreateContext()) + Assert.True(await UnitBranchService.HasUnabsorbedChildrenAsync(db, Parent, default)); + // The legacy child is not absorbed by a late approve call either: it has no parent worktree. + Assert.False((await _unit.AbsorbChildAsync(Child1, default)).Applicable); + + var factory = _db.CreateFactory(); + var orch = new PlanningMergeOrchestrator(factory, MergeService(), + new PlanningAggregator(factory, _git, NullLogger.Instance), + _broadcaster, _git, _built.State, NullLogger.Instance); + await SetStatus(Child2, TaskStatus.Done); // StartAsync requires every planning child Done + using (var db = _db.CreateContext()) + { + db.Worktrees.Add(new WorktreeEntity { TaskId = Child2, Path = Path.Combine(_tempDir, "gone"), + BranchName = "gone", BaseCommit = _repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + + var result = await orch.StartAsync(Parent, "main", default); + + Assert.Equal(TaskMergeService.StatusMerged, result.Status); + Assert.NotNull(await _git.ShowFileAsync(_repo.RepoDir, "main", "legacy.txt")); + Assert.Equal(TaskStatus.Done, (await TaskOf(Parent)).Status); + } + + [Fact] + public async Task Combined_diff_of_a_unit_is_base_to_head_without_an_integration_branch() + { + await RunTwoSerialChildren(); + var factory = _db.CreateFactory(); + var aggregator = new PlanningAggregator(factory, _git, NullLogger.Instance); + + var combined = await aggregator.BuildIntegrationBranchAsync(Parent, "main", default); + + var ok = Assert.IsType(combined); + Assert.Equal((await WorktreeOf(Parent))!.BranchName, ok.Value.IntegrationBranch); + Assert.Contains("a.txt", ok.Value.UnifiedDiff); + Assert.Contains("b.txt", ok.Value.UnifiedDiff); + Assert.Equal("main", await _git.GetCurrentBranchAsync(_repo.RepoDir)); + Assert.DoesNotContain(await _git.ListLocalBranchesAsync(_repo.RepoDir), b => b.StartsWith("planning/")); + + var perChild = await aggregator.GetAggregatedDiffAsync(Parent, default); + var d1 = perChild.Single(d => d.SubtaskId == Child1).UnifiedDiff; + var d2 = perChild.Single(d => d.SubtaskId == Child2).UnifiedDiff; + Assert.Contains("a.txt", d1); + Assert.DoesNotContain("b.txt", d1); + Assert.Contains("b.txt", d2); + Assert.DoesNotContain("a.txt", d2); + } +}