refactor(worker): extract ProgressReporter from TaskMergeService

Pulls the MCP idle-timeout progress loop out of TaskMergeService into a
standalone ProgressReporter (Lifecycle namespace) shared by the verify
gate and preview-verify call sites, and adds a per-item i/n overload
(ReportItem) for upcoming batch progress in D2/D3.
This commit is contained in:
Mika Kuns
2026-08-17 08:36:21 +02:00
parent ec9ad1b0e1
commit 555933a93c
3 changed files with 110 additions and 28 deletions
@@ -77,7 +77,7 @@ public sealed class TaskMergeService
// Mirrors TaskWaitMcpTools.ProgressReportInterval (External/TaskWaitMcpTools.cs): a verify
// run can take up to VerifyTimeout, well past Claude Code's ~300s MCP idle-silence abort, so
// RunReportingProgressAsync below reports on this cadence to keep the calling review_task/
// ProgressReporter.RunAsync reports on this cadence to keep the calling review_task/
// merge_task/preview_merge* call alive. Not readonly -- tests shrink it to observe a report
// without waiting 30s. A separate field from TaskWaitMcpTools' own (rather than sharing it)
// so shrinking one for a test can't race the other's tests.
@@ -146,8 +146,8 @@ public sealed class TaskMergeService
VerifyCommandResult result;
try
{
result = await RunReportingProgressAsync(
_verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), progress, "verify gate running", onTick);
result = await ProgressReporter.RunAsync(
_verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), ProgressReportInterval, progress, "verify gate running", onTick);
}
catch (Exception ex)
{
@@ -164,29 +164,6 @@ public sealed class TaskMergeService
return new MergeResult(StatusVerifyFailed, Array.Empty<string>(), $"{reason}\n{TailOutput(result.Output)}");
}
/// <summary>
/// Awaits <paramref name="work"/> while reporting MCP progress every
/// <see cref="ProgressReportInterval"/> so a caller waiting on a long verify run doesn't hit
/// the MCP client's own idle-silence abort. <paramref name="onTick"/> rides the same cadence
/// for non-MCP callers (the Hub, which turns it into an OperationProgress broadcast). No-op
/// passthrough when both are null.
/// </summary>
private static async Task<T> RunReportingProgressAsync<T>(
Task<T> work, IProgress<ProgressNotificationValue>? progress, string message,
Action<TimeSpan>? onTick = null)
{
if (progress is null && onTick is null) return await work;
var sw = System.Diagnostics.Stopwatch.StartNew();
while (true)
{
var finished = await Task.WhenAny(work, Task.Delay(ProgressReportInterval));
if (finished == work) return await work;
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = $"{message} ({sw.Elapsed:mm\\:ss})" });
onTick?.Invoke(sw.Elapsed);
}
}
private static string TailOutput(string output, int maxChars = 4000)
{
var trimmed = output.Trim();
@@ -848,8 +825,8 @@ public sealed class TaskMergeService
VerifyCommandResult result;
try
{
result = await RunReportingProgressAsync(
_verify.RunAsync(scratchPath, verifyCommand, VerifyTimeout, ct), progress, "merge preview verify running");
result = await ProgressReporter.RunAsync(
_verify.RunAsync(scratchPath, verifyCommand, VerifyTimeout, ct), ProgressReportInterval, progress, "merge preview verify running");
}
catch (Exception ex)
{