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
+30 -17
View File
@@ -703,7 +703,7 @@ public sealed class ExternalMcpService
// externallyDriven: true — this call came from an MCP session, not the UI's
// Approve button. A unit-merge conflict must not auto-open the in-app resolver;
// the driving session resolves it via continue_merge/abort_merge instead.
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true);
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true, progress);
var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done;
mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict;
if (!parentDone)
@@ -1037,8 +1037,12 @@ public sealed class ExternalMcpService
"Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " +
"(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " +
"merged=false with conflicts listed means markers are still present — resolve them and call again. " +
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken cancellationToken)
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead." +
McpToolDocs.ProgressHint)]
public async Task<MergeContinuationResultDto> ContinueMerge(
string taskId,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
@@ -1053,7 +1057,7 @@ public sealed class ExternalMcpService
if (_planningMerge.HasActiveMerge(taskId))
{
await _planningMerge.ContinueAsync(taskId, cancellationToken);
await _planningMerge.ContinueAsync(taskId, cancellationToken, progress);
var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
if (parent.Status == TaskStatus.Done)
{
@@ -1080,7 +1084,7 @@ public sealed class ExternalMcpService
}
else
{
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken);
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress);
if (r.Status == TaskMergeService.StatusMerged)
{
merged = true;
@@ -1464,20 +1468,29 @@ public sealed class ExternalMcpService
[McpServerTool, Description(
"Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " +
"the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk.")]
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(CancellationToken cancellationToken)
"the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk." +
McpToolDocs.ProgressHint)]
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
var results = await Task.WhenAll(rows.Select(async row =>
{
var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
var headCommit = row.PathExistsOnDisk
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
: "";
return new WorktreeListItemDto(
row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit,
isDirty, row.State == WorktreeState.Merged);
}));
// One git status + one rev-parse per row, run concurrently -- with many tracked worktrees
// (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a
// while, so this reports on the same elapsed-time cadence as a single long git call rather
// than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n).
var results = await ProgressReporter.RunAsync(
Task.WhenAll(rows.Select(async row =>
{
var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
var headCommit = row.PathExistsOnDisk
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
: "";
return new WorktreeListItemDto(
row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit,
isDirty, row.State == WorktreeState.Merged);
})),
ProgressReportInterval, progress, "surveying worktrees");
return results;
}