feat(worker): report progress for cleanup_task_worktree, get_task_diff, preview_merge_set

Wraps the single-element git/worktree long-runners in ProgressReporter.RunAsync
(time-based, ExternalMcpService.ProgressReportInterval) and adds i/n reporting
to preview_merge_set's per-task loop, so these MCP calls survive Claude Code's
~300s idle-silence abort instead of leaving the caller with no signal that work
is still happening.
This commit is contained in:
Mika Kuns
2026-08-17 09:29:51 +02:00
parent 0de3816fd0
commit aeb1a5eb82
2 changed files with 116 additions and 8 deletions
+25 -8
View File
@@ -184,6 +184,12 @@ public sealed record DailyPrepDataDto(
[McpServerToolType]
public sealed class ExternalMcpService
{
// Mirrors TaskMergeService.ProgressReportInterval / TaskWaitMcpTools.ProgressReportInterval:
// a separate field (not shared) so shrinking one for a test can't race another's tests. Used
// by the single-element long-runners here (GetTaskDiff, CleanupTaskWorktree) that wrap a bare
// GitService/WorktreeMaintenanceService call with no natural i/n to report instead.
internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30);
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly QueueService _queue;
@@ -897,7 +903,8 @@ public sealed class ExternalMcpService
"conflict report already narrowed down which files matter. Omit/empty for every changed file " +
"— the default and the only prior behavior. Works in both stat and full-diff mode.")]
IReadOnlyList<string>? paths = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
@@ -906,13 +913,17 @@ public sealed class ExternalMcpService
if (stat)
{
var diffStat = await _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", paths, cancellationToken);
var diffStat = await ProgressReporter.RunAsync(
_git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", paths, cancellationToken),
ProgressReportInterval, progress, "computing diff stat");
return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
}
var diff = headCommit is null
? await _git.GetBranchDiffAsync(repoPath, baseCommit, paths, cancellationToken)
: await _git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, paths, cancellationToken);
? await ProgressReporter.RunAsync(
_git.GetBranchDiffAsync(repoPath, baseCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff")
: await ProgressReporter.RunAsync(
_git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff");
var files = ParseDiffFileNames(diff);
if (diff.Length <= maxBytes)
@@ -1283,8 +1294,9 @@ public sealed class ExternalMcpService
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
var numbersByTask = new Dictionary<string, int>();
foreach (var taskId in taskIds)
for (var i = 0; i < taskIds.Count; i++)
{
var taskId = taskIds[i];
try
{
var (preview, behind, changedFiles, isEmpty, staleFiles, number) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
@@ -1301,6 +1313,8 @@ public sealed class ExternalMcpService
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message,
Number: task?.Number));
}
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
}
var overlaps = filesByTask
@@ -1475,7 +1489,8 @@ public sealed class ExternalMcpService
[Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " +
"those changes.")]
bool force = false,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
@@ -1490,14 +1505,16 @@ public sealed class ExternalMcpService
if (!force && Directory.Exists(wt.Path))
{
var isDirty = await _git.HasChangesAsync(wt.Path, cancellationToken);
var isDirty = await ProgressReporter.RunAsync(
_git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes");
if (isDirty)
throw new InvalidOperationException(
"Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost).");
}
var path = wt.Path;
var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken);
var result = await ProgressReporter.RunAsync(
_maintenance.ForceRemoveAsync(taskId, cancellationToken), ProgressReportInterval, progress, "removing worktree");
return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted, task.Number);
}