feat(worker): report progress for continue_merge and unit-merge verify gate

continue_merge and the parent/children unit-merge drain (PlanningMergeOrchestrator)
re-run the post-merge verify gate but never forwarded their IProgress token into it,
so a slow verify command on either path went silent past Claude Code's 300s MCP
idle-abort even though D1-D3 already fixed this for merge_task/review_task's
childless path. list_worktrees also gets elapsed-time progress: many tracked
worktrees means many concurrent git subprocess spawns.

Worker CLAUDE.md's existing progress rule now points at ProgressReporter as the
one implementation instead of a fresh polling loop.
This commit is contained in:
Mika Kuns
2026-08-17 09:56:59 +02:00
parent 1341c5f4f3
commit 23aab26daf
5 changed files with 189 additions and 27 deletions
@@ -694,6 +694,76 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
}
// Regression coverage for the parent/children unit-merge path specifically: PlanningMergeOrchestrator
// used to drain every child's merge (and verify gate) without ever forwarding review_task's own
// progress token, so a multi-child approve with a slow verify command reported only the single
// "review_task started" tick and then went silent for as long as every child's gate took combined.
[Fact]
public async Task ReviewTask_Approve_ParentWithChildren_SlowVerifyCommand_ReportsProgress()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = TaskMergeService.ProgressReportInterval;
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
try
{
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 childId = 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, Number = ++_numberSeed });
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_worktreeCleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{childId[..8]}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n");
GitRepoFixture.RunGit(wtPath, "add", "child.txt");
GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt");
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = childId, Path = wtPath, BranchName = branch,
BaseCommit = repo.BaseCommit,
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
await ctx.SaveChangesAsync();
}
var fakeVerify = new FakeVerifyCommandRunner
{
Result = new VerifyCommandResult(0, false, "ok"),
Delay = TimeSpan.FromMilliseconds(300),
};
var sut = BuildSut(CreateQueue(), fakeVerify);
var reports = new List<ProgressNotificationValue>();
var progress = new Progress<ProgressNotificationValue>(reports.Add);
var result = await sut.ReviewTask(
parentId, "approve", null, "main", cancellationToken: CancellationToken.None, progress: progress);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
Assert.Equal("Done", result.Task.Status);
await Task.Delay(200);
Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
}
finally
{
TaskMergeService.ProgressReportInterval = original;
}
}
[Fact]
public async Task ReviewTask_Approve_ParentWithChildren_ReportsEmptyChildByName()
{
@@ -1316,6 +1386,33 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Contains(rows, r => r.TaskId == task.Id);
}
// list_worktrees fans out one git status + one rev-parse per tracked worktree under
// Task.WhenAll -- no natural i/n since rows finish out of order, so it reports on the same
// elapsed-time cadence as CleanupTaskWorktree above.
[Fact]
public async Task ListWorktrees_ReportsProgressWhileSurveying()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = ExternalMcpService.ProgressReportInterval;
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
try
{
await SeedWorktreeAsync();
var sut = BuildSut(CreateQueue());
var progress = new SyncProgress<ProgressNotificationValue>();
var rows = await sut.ListWorktrees(CancellationToken.None, progress);
Assert.NotEmpty(rows);
Assert.NotEmpty(progress.Reports);
}
finally
{
ExternalMcpService.ProgressReportInterval = original;
}
}
// ── CleanupTaskWorktree ────────────────────────────────────────────────────
[Fact]
@@ -1711,6 +1808,52 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
}
// Regression coverage for the same class of incident as ReviewTask_ApproveWithSlowVerifyCommand
// above, but for the resume path: continue_merge re-runs the verify gate too, and until this
// fix it never forwarded its progress token into RunVerifyGateAsync at all.
[Fact]
public async Task ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = TaskMergeService.ProgressReportInterval;
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
try
{
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var plainSut = BuildSut(CreateQueue());
await plainSut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, CancellationToken.None);
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
using (var ctx = _db.CreateContext())
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
var fakeVerify = new FakeVerifyCommandRunner
{
Result = new VerifyCommandResult(0, false, "ok"),
Delay = TimeSpan.FromMilliseconds(300),
};
var sut = BuildSut(CreateQueue(), fakeVerify);
var reports = new List<ProgressNotificationValue>();
var progress = new Progress<ProgressNotificationValue>(reports.Add);
var result = await sut.ContinueMerge(task.Id, CancellationToken.None, progress);
Assert.True(result.Merged);
Assert.Equal("Done", result.TaskStatus);
await Task.Delay(200);
Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
}
finally
{
TaskMergeService.ProgressReportInterval = original;
}
}
[Fact]
public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts()
{