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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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()
|
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
|
||||||
.FirstOrDefaultAsync(t => t.Id == task.ParentTaskId, ct);
|
if (parentWt is { State: WorktreeState.Active })
|
||||||
if (parent is not null && parent.PlanningPhase == PlanningPhase.None)
|
return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
|
||||||
{
|
|
||||||
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
|
|
||||||
var parentHead = parentWt?.HeadCommit ?? parentWt?.BaseCommit;
|
|
||||||
if (parentHead is not null)
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user