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:
mika kuns
2026-10-01 11:25:47 +02:00
parent 724ad90f88
commit 9da522b0c9
8 changed files with 489 additions and 17 deletions
@@ -117,6 +117,7 @@ public sealed class TaskMergeService
private readonly ITaskStateService _state; private readonly ITaskStateService _state;
private readonly IVerifyCommandRunner _verify; private readonly IVerifyCommandRunner _verify;
private readonly ILogger<TaskMergeService> _logger; private readonly ILogger<TaskMergeService> _logger;
private readonly UnitBranchService? _unitBranch;
public TaskMergeService( public TaskMergeService(
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
@@ -124,7 +125,8 @@ public sealed class TaskMergeService
HubBroadcaster broadcaster, HubBroadcaster broadcaster,
ITaskStateService state, ITaskStateService state,
IVerifyCommandRunner verify, IVerifyCommandRunner verify,
ILogger<TaskMergeService> logger) ILogger<TaskMergeService> logger,
UnitBranchService? unitBranch = null)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_git = git; _git = git;
@@ -132,6 +134,7 @@ public sealed class TaskMergeService
_state = state; _state = state;
_verify = verify; _verify = verify;
_logger = logger; _logger = logger;
_unitBranch = unitBranch;
} }
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync( 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) if (task.Status != TaskStatus.WaitingForReview)
return Blocked("task is not waiting for review"); 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<string>(), null)
: Blocked(approved.Reason ?? "approve failed");
}
}
if (wt is not null && wt.State == WorktreeState.Kept) if (wt is not null && wt.State == WorktreeState.Kept)
{ {
// Kept means either RevertMergeAsync undid a previous merge, or the user manually // Kept means either RevertMergeAsync undid a previous merge, or the user manually
@@ -1,5 +1,6 @@
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State; using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -10,13 +11,16 @@ public sealed class PlanningChainCoordinator
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly Func<ITaskStateService> _state; private readonly Func<ITaskStateService> _state;
private readonly UnitBranchService? _unitBranch;
public PlanningChainCoordinator( public PlanningChainCoordinator(
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
Func<ITaskStateService> state) Func<ITaskStateService> state,
UnitBranchService? unitBranch = null)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_state = state; _state = state;
_unitBranch = unitBranch;
} }
// Sets up a sequential chain over a planning parent's children. // Sets up a sequential chain over a planning parent's children.
@@ -86,6 +90,9 @@ public sealed class PlanningChainCoordinator
if (phase != PlanningPhase.Finalized) if (phase != PlanningPhase.Finalized)
throw new InvalidOperationException("Plan must be finalized before it can be queued."); 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); return await SetupChainAsync(parentTaskId, enqueue: true, ct);
} }
+1
View File
@@ -112,6 +112,7 @@ builder.Services.AddSingleton<HubBroadcaster>();
builder.Services.AddSingleton<ConnectedClientTracker>(); builder.Services.AddSingleton<ConnectedClientTracker>();
builder.Services.AddSingleton<GitService>(); builder.Services.AddSingleton<GitService>();
builder.Services.AddSingleton<WorktreeManager>(); builder.Services.AddSingleton<WorktreeManager>();
builder.Services.AddSingleton<UnitBranchService>();
builder.Services.AddSingleton<ClaudeArgsBuilder>(); builder.Services.AddSingleton<ClaudeArgsBuilder>();
builder.Services.AddSingleton<TaskRunTokenRegistry>(); builder.Services.AddSingleton<TaskRunTokenRegistry>();
builder.Services.AddSingleton<PendingQuestionRegistry>(); builder.Services.AddSingleton<PendingQuestionRegistry>();
+14 -1
View File
@@ -28,6 +28,7 @@ public sealed class TaskRunner
private readonly AttachmentStore _attachments; private readonly AttachmentStore _attachments;
private readonly ISessionSkillSeeder _skillSeeder; private readonly ISessionSkillSeeder _skillSeeder;
private readonly ITranscriptUsageReader _usageReader; private readonly ITranscriptUsageReader _usageReader;
private readonly UnitBranchService? _unitBranch;
public TaskRunner( public TaskRunner(
IClaudeProcess claude, IClaudeProcess claude,
@@ -41,7 +42,8 @@ public sealed class TaskRunner
TaskRunTokenRegistry tokens, TaskRunTokenRegistry tokens,
AttachmentStore attachments, AttachmentStore attachments,
ISessionSkillSeeder skillSeeder, ISessionSkillSeeder skillSeeder,
ITranscriptUsageReader usageReader) ITranscriptUsageReader usageReader,
UnitBranchService? unitBranch = null)
{ {
_claude = claude; _claude = claude;
_dbFactory = dbFactory; _dbFactory = dbFactory;
@@ -55,6 +57,7 @@ public sealed class TaskRunner
_attachments = attachments; _attachments = attachments;
_skillSeeder = skillSeeder; _skillSeeder = skillSeeder;
_usageReader = usageReader; _usageReader = usageReader;
_unitBranch = unitBranch;
} }
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false) public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false)
@@ -568,6 +571,16 @@ public sealed class TaskRunner
} }
else 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); var transition = await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
if (!transition.Ok) if (!transition.Ok)
_logger.LogWarning("CompleteAsync rejected for task {TaskId}: {Reason}", task.Id, transition.Reason); _logger.LogWarning("CompleteAsync rejected for task {TaskId}: {Reason}", task.Id, transition.Reason);
@@ -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;
/// <summary>Outcome of <see cref="UnitBranchService.AbsorbChildAsync"/>. <c>Applicable</c> = the child belongs to a unit branch.</summary>
public sealed record AbsorbResult(bool Applicable, string? Error)
{
/// <summary>The child has no unit branch to land in (no parent worktree, or no own worktree).</summary>
public static readonly AbsorbResult NotApplicable = new(false, null);
/// <summary>The unit branch was fast-forwarded to the child.</summary>
public static readonly AbsorbResult Absorbed = new(true, null);
/// <summary>True when the fast-forward was impossible; <see cref="Error"/> says why.</summary>
public bool Failed => Error is not null;
}
/// <summary>
/// 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.
/// </summary>
public sealed class UnitBranchService
{
private readonly GitService _git;
private readonly WorktreeManager _worktrees;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly ILogger<UnitBranchService> _logger;
private readonly HubBroadcaster? _broadcaster;
public UnitBranchService(
GitService git, WorktreeManager worktrees, IDbContextFactory<ClaudeDoDbContext> dbFactory,
ILogger<UnitBranchService> logger, HubBroadcaster? broadcaster = null)
{
_git = git;
_worktrees = worktrees;
_dbFactory = dbFactory;
_logger = logger;
_broadcaster = broadcaster;
}
/// <summary>Gives a parent its own worktree (= unit branch) unless it already has an Active one or its list has no working dir.</summary>
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);
}
/// <summary>
/// Fast-forwards the parent's unit branch to the child's branch, then marks the child row
/// <c>Merged</c> (no merge commit) and removes its worktree and branch.
/// </summary>
public async Task<AbsorbResult> 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;
}
/// <summary>Legacy unit: a Done child whose worktree is still Active was never absorbed and needs the unit merge.</summary>
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);
}
+7 -11
View File
@@ -256,23 +256,19 @@ public sealed class WorktreeManager
return TicketStatusMap.ParseBandelTicketId(parentRef); return TicketStatusMap.ParseBandelTicketId(parentRef);
} }
// Improvement children (parent is a non-planning task with its own worktree) branch /// <summary>
// from the parent's recorded HEAD so they build on the parent's not-yet-merged work. /// A child of a parent with an Active worktree starts from the unit branch's current head, so it
// Planning children and standalone tasks base off list_config.base_branch, else the current HEAD. /// sees every sibling absorbed before it. Unit roots (and legacy children) base off
/// <c>list_config.base_branch</c>, else the current HEAD.
/// </summary>
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct) private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
{ {
if (task.ParentTaskId is not null) if (task.ParentTaskId is not null)
{ {
using var ctx = _dbFactory.CreateDbContext(); 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 parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
var parentHead = parentWt?.HeadCommit ?? parentWt?.BaseCommit; if (parentWt is { State: WorktreeState.Active })
if (parentHead is not null) return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
return parentHead;
}
} }
using var cfgCtx = _dbFactory.CreateDbContext(); using var cfgCtx = _dbFactory.CreateDbContext();
var baseBranch = await cfgCtx.ListConfigs.AsNoTracking() var baseBranch = await cfgCtx.ListConfigs.AsNoTracking()
@@ -26,7 +26,8 @@ public static class TaskStateServiceBuilder
public static Built Build( public static Built Build(
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
Func<IActiveMergeState>? mergeState = null, Func<IActiveMergeState>? mergeState = null,
BaseDirtyChecker? baseDirtyChecker = null) BaseDirtyChecker? baseDirtyChecker = null,
ClaudeDo.Worker.Runner.UnitBranchService? unitBranch = null)
{ {
var hub = new CapturingHubContext(); var hub = new CapturingHubContext();
var broadcaster = new HubBroadcaster(hub); var broadcaster = new HubBroadcaster(hub);
@@ -46,7 +47,7 @@ public static class TaskStateServiceBuilder
NullLogger<TicketStatusSync>.Instance); NullLogger<TicketStatusSync>.Instance);
TaskStateService? state = null; TaskStateService? state = null;
var chain = new PlanningChainCoordinator(dbFactory, () => state!); var chain = new PlanningChainCoordinator(dbFactory, () => state!, unitBranch);
state = new TaskStateService( state = new TaskStateService(
dbFactory, dbFactory,
broadcaster, 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);
}
}