feat(merge): fold parent branch into tree-merge for improvement parents

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-04 16:09:44 +02:00
parent 06e3acd5ac
commit 519bfbe6b3
2 changed files with 187 additions and 29 deletions

View File

@@ -22,6 +22,7 @@ public sealed class PlanningMergeOrchestrator
{
public required string TargetBranch { get; init; }
public required Queue<string> RemainingSubtaskIds { get; init; }
public required bool IsPlanning { get; init; }
public string? CurrentSubtaskId { get; set; }
}
@@ -43,32 +44,40 @@ public sealed class PlanningMergeOrchestrator
_logger = logger;
}
public async Task StartAsync(string planningTaskId, string targetBranch, CancellationToken ct)
public async Task StartAsync(string parentTaskId, string targetBranch, CancellationToken ct)
{
string workingDir;
List<TaskEntity> children;
bool isPlanning;
bool parentHasWorktree;
using (var ctx = _dbFactory.CreateDbContext())
{
var planning = await ctx.Tasks
var parent = await ctx.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.Include(t => t.Children).ThenInclude(c => c.Worktree)
.SingleOrDefaultAsync(t => t.Id == planningTaskId, ct)
?? throw new KeyNotFoundException($"Planning task '{planningTaskId}' not found.");
workingDir = planning.List.WorkingDir
.SingleOrDefaultAsync(t => t.Id == parentTaskId, ct)
?? throw new KeyNotFoundException($"Planning task '{parentTaskId}' not found.");
workingDir = parent.List.WorkingDir
?? throw new InvalidOperationException("List has no working directory.");
children = planning.Children.OrderBy(c => c.SortOrder).ToList();
children = parent.Children.OrderBy(c => c.SortOrder).ToList();
isPlanning = parent.PlanningPhase != PlanningPhase.None;
parentHasWorktree = parent.Worktree is { State: WorktreeState.Active };
}
foreach (var c in children)
if (isPlanning)
{
if (c.Status != TaskStatus.Done)
throw new InvalidOperationException($"subtask {c.Id} is not Done (status {c.Status})");
if (c.Worktree is null)
throw new InvalidOperationException($"subtask {c.Id} has no worktree");
if (c.Worktree.State != WorktreeState.Active && c.Worktree.State != WorktreeState.Merged)
throw new InvalidOperationException(
$"subtask {c.Id} worktree state is {c.Worktree.State}");
foreach (var c in children)
{
if (c.Status != TaskStatus.Done)
throw new InvalidOperationException($"subtask {c.Id} is not Done (status {c.Status})");
if (c.Worktree is null)
throw new InvalidOperationException($"subtask {c.Id} has no worktree");
if (c.Worktree.State != WorktreeState.Active && c.Worktree.State != WorktreeState.Merged)
throw new InvalidOperationException(
$"subtask {c.Id} worktree state is {c.Worktree.State}");
}
}
if (await _git.IsMidMergeAsync(workingDir, ct))
@@ -76,17 +85,22 @@ public sealed class PlanningMergeOrchestrator
if (await _git.HasChangesAsync(workingDir, ct))
throw new InvalidOperationException("working tree has uncommitted changes");
var queue = new Queue<string>(
var idsToMerge = new List<string>();
if (!isPlanning && parentHasWorktree)
idsToMerge.Add(parentTaskId);
idsToMerge.AddRange(
children
.Where(c => c.Worktree!.State == WorktreeState.Active)
.Where(c => c.Status == TaskStatus.Done && c.Worktree is { State: WorktreeState.Active })
.Select(c => c.Id));
var state = new State { TargetBranch = targetBranch, RemainingSubtaskIds = queue };
if (!_states.TryAdd(planningTaskId, state))
throw new InvalidOperationException($"Merge already in progress for {planningTaskId}.");
var queue = new Queue<string>(idsToMerge);
await _broadcaster.PlanningMergeStarted(planningTaskId, targetBranch);
await DrainAsync(planningTaskId, ct);
var state = new State { TargetBranch = targetBranch, RemainingSubtaskIds = queue, IsPlanning = isPlanning };
if (!_states.TryAdd(parentTaskId, state))
throw new InvalidOperationException($"Merge already in progress for {parentTaskId}.");
await _broadcaster.PlanningMergeStarted(parentTaskId, targetBranch);
await DrainAsync(parentTaskId, ct);
}
public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
@@ -167,7 +181,7 @@ public sealed class PlanningMergeOrchestrator
}
state.CurrentSubtaskId = null;
await FinalizePlanningDoneAsync(planningTaskId, ct);
await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
await _broadcaster.PlanningCompleted(planningTaskId);
}
finally
@@ -176,16 +190,20 @@ public sealed class PlanningMergeOrchestrator
}
}
private async Task FinalizePlanningDoneAsync(string planningTaskId, CancellationToken ct)
private async Task FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var planning = await ctx.Tasks.SingleOrDefaultAsync(t => t.Id == planningTaskId, ct);
if (planning is null) return;
planning.Status = TaskStatus.Done;
planning.FinishedAt = DateTime.UtcNow;
var parent = await ctx.Tasks.SingleOrDefaultAsync(t => t.Id == parentTaskId, ct);
if (parent is null) return;
parent.Status = TaskStatus.Done;
parent.FinishedAt = DateTime.UtcNow;
await ctx.SaveChangesAsync(ct);
try { await _aggregator.CleanupIntegrationBranchAsync(planningTaskId, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
if (isPlanning)
{
try { await _aggregator.CleanupIntegrationBranchAsync(parentTaskId, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
}
}
}