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).
This commit is contained in:
@@ -26,7 +26,8 @@ public static class TaskStateServiceBuilder
|
||||
public static Built Build(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
Func<IActiveMergeState>? 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<TicketStatusSync>.Instance);
|
||||
|
||||
TaskStateService? state = null;
|
||||
var chain = new PlanningChainCoordinator(dbFactory, () => state!);
|
||||
var chain = new PlanningChainCoordinator(dbFactory, () => state!, unitBranch);
|
||||
state = new TaskStateService(
|
||||
dbFactory,
|
||||
broadcaster,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Unit branch (spec 2026-10-01, section 2): children fork from and fast-forward into the parent's worktree. Real git + SQLite.</summary>
|
||||
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<WorktreeManager>.Instance);
|
||||
_unit = new UnitBranchService(_git, _wt, factory, NullLogger<UnitBranchService>.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<WorktreeEntity?> WorktreeOf(string taskId)
|
||||
{
|
||||
using var ctx = _db.CreateContext();
|
||||
return await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId);
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> 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<TaskRunner>.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<TaskMergeService>.Instance, _unit);
|
||||
|
||||
private static FakeClaudeProcess Writes(Action<string>? 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);
|
||||
}
|
||||
|
||||
/// <summary>Runs both children serially; child 1 writes a.txt, child 2 records whether it saw it and writes b.txt.</summary>
|
||||
private async Task<bool> 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<PlanningAggregator>.Instance),
|
||||
_broadcaster, _git, _built.State, NullLogger<PlanningMergeOrchestrator>.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<PlanningAggregator>.Instance);
|
||||
|
||||
var combined = await aggregator.BuildIntegrationBranchAsync(Parent, "main", default);
|
||||
|
||||
var ok = Assert.IsType<CombinedDiffResult.Ok>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user