fix(worker): surface empty review ranges and blocked children over MCP
preview_merge/preview_merge_set now report isEmpty (ahead==0, or HandlerBaseCommit==HandlerHeadCommit for a worktree-less list-handler task) instead of leaving an empty branch indistinguishable from a small one. preview_merge also stops throwing for worktree-less handler tasks, falling back to their fixed commit range. review_task's parent approve returns emptyChildren, naming the Done children whose review range contributed nothing before the unit merge lands. TaskRefDto/TaskDto now expose roadblockCount so a CLAUDEDO_BLOCKED child is identifiable over MCP, since it still reaches Done per the unified parent model.
This commit is contained in:
@@ -367,6 +367,71 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReviewTask_Approve_ParentWithChildren_ReportsEmptyChildByName()
|
||||
{
|
||||
// A blocked child (CLAUDEDO_BLOCKED, committed nothing) still gets an active worktree at
|
||||
// its base commit -- TaskRunner creates one before the run starts -- so "empty" shows up
|
||||
// as zero commits ahead of base, not as a missing worktree.
|
||||
if (!GitAvailable) return;
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
var parentId = Guid.NewGuid().ToString();
|
||||
var normalChildId = Guid.NewGuid().ToString();
|
||||
var blockedChildId = Guid.NewGuid().ToString();
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized });
|
||||
ctx.Tasks.Add(new TaskEntity { Id = normalChildId, ListId = listId, Title = "normal child", CreatedAt = DateTime.UtcNow,
|
||||
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 });
|
||||
ctx.Tasks.Add(new TaskEntity { Id = blockedChildId, ListId = listId, Title = "blocked child", CreatedAt = DateTime.UtcNow,
|
||||
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2, RoadblockCount = 1 });
|
||||
|
||||
var normalWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_worktreeCleanups.Add((repo.RepoDir, normalWtPath));
|
||||
var normalBranch = $"claudedo/{normalChildId[..8]}";
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", normalBranch, normalWtPath, repo.BaseCommit);
|
||||
File.WriteAllText(Path.Combine(normalWtPath, "child.txt"), "c\n");
|
||||
GitRepoFixture.RunGit(normalWtPath, "add", "child.txt");
|
||||
GitRepoFixture.RunGit(normalWtPath, "commit", "-m", "add child.txt");
|
||||
ctx.Worktrees.Add(new WorktreeEntity
|
||||
{
|
||||
TaskId = normalChildId, Path = normalWtPath, BranchName = normalBranch,
|
||||
BaseCommit = repo.BaseCommit,
|
||||
HeadCommit = GitRepoFixture.RunGit(normalWtPath, "rev-parse", "HEAD").Trim(),
|
||||
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
var blockedWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_worktreeCleanups.Add((repo.RepoDir, blockedWtPath));
|
||||
var blockedBranch = $"claudedo/{blockedChildId[..8]}";
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", blockedBranch, blockedWtPath, repo.BaseCommit);
|
||||
ctx.Worktrees.Add(new WorktreeEntity
|
||||
{
|
||||
TaskId = blockedChildId, Path = blockedWtPath, BranchName = blockedBranch,
|
||||
BaseCommit = repo.BaseCommit,
|
||||
HeadCommit = repo.BaseCommit,
|
||||
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
||||
Assert.Equal("Done", result.Task.Status);
|
||||
Assert.NotNull(result.EmptyChildren);
|
||||
var empty = Assert.Single(result.EmptyChildren!);
|
||||
Assert.Equal(blockedChildId, empty.Id);
|
||||
Assert.DoesNotContain(result.EmptyChildren!, c => c.Id == normalChildId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_RemovesTask()
|
||||
{
|
||||
@@ -1424,10 +1489,89 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Empty(result.ConflictFiles);
|
||||
Assert.Equal(1, result.ChangedFileCount);
|
||||
Assert.Equal(0, result.Behind);
|
||||
Assert.False(result.IsEmpty);
|
||||
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
||||
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
// Worktree created but nothing committed beyond base -- e.g. a child that reported
|
||||
// CLAUDEDO_BLOCKED before writing any code.
|
||||
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||
Assert.Equal(0, result.ChangedFileCount);
|
||||
Assert.True(result.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
// Worktree-less list-handler host task whose handler range never actually committed
|
||||
// anything: HandlerBaseCommit == HandlerHeadCommit, not "no worktree -> unknown".
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
var t = await ctx.Tasks.FindAsync(task.Id);
|
||||
t!.HandlerBaseCommit = repo.BaseCommit;
|
||||
t.HandlerHeadCommit = repo.BaseCommit;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||
Assert.Equal(0, result.ChangedFileCount);
|
||||
Assert.True(result.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
||||
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
var t = await ctx.Tasks.FindAsync(task.Id);
|
||||
t!.HandlerBaseCommit = repo.BaseCommit;
|
||||
t.HandlerHeadCommit = headCommit;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
||||
Assert.Equal(1, result.ChangedFileCount);
|
||||
Assert.False(result.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo()
|
||||
{
|
||||
@@ -1546,6 +1690,49 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Empty(result.Overlaps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var emptyTask = await SeedTaskAsync(listId, "empty", TaskStatus.WaitingForReview);
|
||||
var fullTask = await SeedTaskAsync(listId, "full", TaskStatus.WaitingForReview);
|
||||
|
||||
var emptyBranch = $"claudedo/{emptyTask.Id[..8]}";
|
||||
var emptyWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||
_worktreeCleanups.Add((repo.RepoDir, emptyWtPath));
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", emptyBranch, emptyWtPath, repo.BaseCommit);
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Worktrees.Add(new WorktreeEntity
|
||||
{
|
||||
TaskId = emptyTask.Id, Path = emptyWtPath, BranchName = emptyBranch,
|
||||
BaseCommit = repo.BaseCommit,
|
||||
HeadCommit = repo.BaseCommit,
|
||||
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n");
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", CancellationToken.None);
|
||||
|
||||
var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id);
|
||||
var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id);
|
||||
Assert.Null(emptyEntry.Error);
|
||||
Assert.True(emptyEntry.IsEmpty);
|
||||
Assert.Null(fullEntry.Error);
|
||||
Assert.False(fullEntry.IsEmpty);
|
||||
}
|
||||
|
||||
// ── AddTask model override ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user