refactor(claude-do): merge [D1] ProgressReporter aus TaskMergeService extrahieren (+ i/
ClaudeDo-Task: 47c76093-633e-4f3b-b49b-b34873de0469
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
using ModelContextProtocol;
|
||||
|
||||
namespace ClaudeDo.Worker.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Reports MCP/hub progress for a long-running operation so a waiting caller doesn't hit an
|
||||
/// idle-silence abort (Claude Code's MCP client aborts an MCP call after ~300s of silence).
|
||||
/// </summary>
|
||||
internal static class ProgressReporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Awaits <paramref name="work"/> while reporting progress every <paramref name="interval"/>.
|
||||
/// <paramref name="onTick"/> rides the same cadence for non-MCP callers (e.g. the Hub, which
|
||||
/// turns it into an OperationProgress broadcast). No-op passthrough when both are null.
|
||||
/// </summary>
|
||||
public static async Task<T> RunAsync<T>(
|
||||
Task<T> work, TimeSpan interval, 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(interval));
|
||||
if (finished == work) return await work;
|
||||
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = $"{message} ({sw.Elapsed:mm\\:ss})" });
|
||||
onTick?.Invoke(sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports progress for one processed item out of a known total, e.g. "cleaning worktrees
|
||||
/// (3/12)". For callers iterating a batch (cleanup_task_worktree, get_task_diff,
|
||||
/// preview_merge_set) instead of awaiting one long-running task.
|
||||
/// </summary>
|
||||
public static void ReportItem(
|
||||
IProgress<ProgressNotificationValue>? progress, string action, int current, int total)
|
||||
{
|
||||
progress?.Report(new ProgressNotificationValue
|
||||
{
|
||||
Progress = current,
|
||||
Total = total,
|
||||
Message = $"{action} ({current}/{total})",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ModelContextProtocol;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Lifecycle;
|
||||
|
||||
// A synchronously-collecting IProgress<T>: Progress<T> marshals through the SynchronizationContext
|
||||
// captured at construction, which is unreliable to assert on immediately in a test.
|
||||
file sealed class SyncProgress<T> : IProgress<T>
|
||||
{
|
||||
public readonly List<T> Reports = new();
|
||||
public void Report(T value) => Reports.Add(value);
|
||||
}
|
||||
|
||||
public sealed class ProgressReporterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_NoProgressOrOnTick_ReturnsWorkResultWithoutTicking()
|
||||
{
|
||||
var result = await ProgressReporter.RunAsync(Task.FromResult(42), TimeSpan.FromMilliseconds(10), null, "message");
|
||||
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_SlowWork_ReportsProgressOnEachTick()
|
||||
{
|
||||
var progress = new SyncProgress<ProgressNotificationValue>();
|
||||
var work = Task.Delay(250).ContinueWith(_ => "done");
|
||||
|
||||
var result = await ProgressReporter.RunAsync(work, TimeSpan.FromMilliseconds(50), progress, "waiting");
|
||||
|
||||
Assert.Equal("done", result);
|
||||
Assert.NotEmpty(progress.Reports);
|
||||
Assert.All(progress.Reports, r => Assert.Contains("waiting", r.Message));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportItem_ReportsMessageAndProgressTotalForEachItem()
|
||||
{
|
||||
var progress = new SyncProgress<ProgressNotificationValue>();
|
||||
|
||||
for (var i = 1; i <= 3; i++)
|
||||
ProgressReporter.ReportItem(progress, "cleaning worktrees", i, 3);
|
||||
|
||||
Assert.Equal(3, progress.Reports.Count);
|
||||
Assert.Equal("cleaning worktrees (1/3)", progress.Reports[0].Message);
|
||||
Assert.Equal("cleaning worktrees (2/3)", progress.Reports[1].Message);
|
||||
Assert.Equal("cleaning worktrees (3/3)", progress.Reports[2].Message);
|
||||
Assert.Equal(3, progress.Reports[2].Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportItem_NoProgress_DoesNotThrow()
|
||||
{
|
||||
ProgressReporter.ReportItem(null, "cleaning worktrees", 1, 3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user