chore(claude-do): MCP: review_task/merge_task senden Progress während des Veri

## Symptom (real aufgetreten, 2026-08-11)

Ein `review_task(decision="approve")` über MCP lief >5 Min. Claude Codes MCP-Client brach den Call ab mit:

> MCP server "claudedo" tool "review_task" sent no response or progress for 300s; aborting.

Ergebnis: der Merge war **schon gelaufen und comitted** (`0e12be4`, `mergeCommit` am Worktree gesetzt), aber der Task blieb auf `WaitingForReview` hängen —

ClaudeDo-Task: d8199f1f-3df3-447f-8de2-e7aca9ec5064
This commit is contained in:
mika kuns
2026-08-11 17:45:51 +02:00
parent 79b35801ae
commit ad6af68895
5 changed files with 168 additions and 27 deletions
@@ -16,6 +16,7 @@ using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Worktrees;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
@@ -566,6 +567,79 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == task.Id).State);
}
// Regression coverage for the incident this fixes: an approve whose post-merge verify gate
// runs long enough gets no MCP traffic at all until RunVerifyGateAsync returns, so Claude
// Code's ~300s idle-silence abort kills the call -- the merge had already landed and
// committed, but the task never reached Done and every dependsOn successor stayed blocked
// until someone noticed and force-set the status by hand. Shrinks TaskMergeService's own
// progress interval (not TaskWaitMcpTools') to observe a report without waiting 30s real time.
[Fact]
public async Task ReviewTask_ApproveWithSlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var original = TaskMergeService.ProgressReportInterval;
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
try
{
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
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.ReviewTask(
task.Id, "approve", null, null, cancellationToken: CancellationToken.None, progress: progress);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
Assert.Equal("Done", result.Task.Status);
// Progress<T> marshals via the SynchronizationContext captured at construction; give
// any queued callbacks a beat to run before asserting on `reports`.
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_ApproveWithVerifyCommand_NoProgressToken_DoesNotThrow()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
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") };
var sut = BuildSut(CreateQueue(), fakeVerify);
var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
Assert.Equal("Done", result.Task.Status);
}
[Fact]
public async Task ReviewTask_Approve_ParentWithChildren_RunsUnitMerge()
{
@@ -1675,13 +1675,19 @@ internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
public string? FileToCheck { get; set; }
public bool? WorkingDirHadFile { get; private set; }
public Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
// Simulates a slow build/test run so a progress-reporting test can observe at least one
// report without the real verify command ever taking that long.
public TimeSpan? Delay { get; set; }
public async Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
{
CapturedWorkingDir = workingDir;
CapturedCommand = command;
if (FileToCheck is not null)
WorkingDirHadFile = File.Exists(Path.Combine(workingDir, FileToCheck));
return Task.FromResult(Result);
if (Delay is { } delay)
await Task.Delay(delay, ct);
return Result;
}
}