feat(worker): report per-item progress on all 7 batch_* MCP tools

Each batch tool now sends an i/n progress ping via ProgressReporter.ReportItem
after processing every item, so a waiting agent doesn't see the MCP client's
300s idle-silence abort while the worker keeps looping. Covers BatchGetTasks,
BatchAddTasks, BatchUpdateTaskStatus, BatchCancelTasks, BatchDeleteTasks,
BatchSetMyDay, and BatchCleanupTaskWorktrees (the slowest of the seven, since
it does git work per task).
This commit is contained in:
Mika Kuns
2026-08-17 08:56:32 +02:00
parent ce1a26d407
commit 53e26f0340
3 changed files with 177 additions and 22 deletions
@@ -14,10 +14,19 @@ using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Worktrees;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
// 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 BatchMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
@@ -498,4 +507,118 @@ public sealed class BatchMcpToolsTests : IDisposable
() => sut.BatchGetTasks(ids, cancellationToken: CancellationToken.None));
Assert.Contains("max", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task BatchGetTasks_ReportsProgressPerItem()
{
var listId = await SeedListAsync();
var a = await SeedTaskAsync(listId);
var b = await SeedTaskAsync(listId);
var c = await SeedTaskAsync(listId);
var sut = BuildSut();
var progress = new SyncProgress<ProgressNotificationValue>();
await sut.BatchGetTasks(new[] { a.Id, b.Id, c.Id }, cancellationToken: CancellationToken.None, progress: progress);
Assert.Equal(3, progress.Reports.Count);
Assert.Equal(3, progress.Reports[2].Total);
}
[Fact]
public async Task BatchAddTasks_ReportsProgressPerItem()
{
var listId = await SeedListAsync();
var sut = BuildSut();
var progress = new SyncProgress<ProgressNotificationValue>();
await sut.BatchAddTasks(listId, new[]
{
new BatchAddTaskInput("a"),
new BatchAddTaskInput("b"),
}, cancellationToken: CancellationToken.None, progress: progress);
Assert.Equal(2, progress.Reports.Count);
Assert.Equal(2, progress.Reports[1].Total);
}
[Fact]
public async Task BatchUpdateTaskStatus_ReportsProgressPerItem()
{
var listId = await SeedListAsync();
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
var sut = BuildSut();
var progress = new SyncProgress<ProgressNotificationValue>();
await sut.BatchUpdateTaskStatus(new[] { t1.Id, t2.Id }, "Queued", CancellationToken.None, progress);
Assert.Equal(2, progress.Reports.Count);
Assert.Equal(2, progress.Reports[1].Total);
}
[Fact]
public async Task BatchCancelTasks_ReportsProgressPerItem()
{
var listId = await SeedListAsync();
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
var sut = BuildSut();
var progress = new SyncProgress<ProgressNotificationValue>();
await sut.BatchCancelTasks(new[] { t1.Id, t2.Id }, CancellationToken.None, progress);
Assert.Equal(2, progress.Reports.Count);
Assert.Equal(2, progress.Reports[1].Total);
}
[Fact]
public async Task BatchDeleteTasks_ReportsProgressPerItem()
{
var listId = await SeedListAsync();
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
var sut = BuildSut();
var progress = new SyncProgress<ProgressNotificationValue>();
await sut.BatchDeleteTasks(new[] { t1.Id, t2.Id }, CancellationToken.None, progress);
Assert.Equal(2, progress.Reports.Count);
Assert.Equal(2, progress.Reports[1].Total);
}
[Fact]
public async Task BatchSetMyDay_ReportsProgressPerItem()
{
var listId = await SeedListAsync();
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
var sut = BuildSut();
var progress = new SyncProgress<ProgressNotificationValue>();
await sut.BatchSetMyDay(new[]
{
new BatchSetMyDayInput(t1.Id, true),
new BatchSetMyDayInput(t2.Id, true),
}, CancellationToken.None, progress);
Assert.Equal(2, progress.Reports.Count);
Assert.Equal(2, progress.Reports[1].Total);
}
[Fact]
public async Task BatchCleanupTaskWorktrees_ReportsProgressPerItem()
{
var listId = await SeedListAsync();
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
var sut = BuildSut();
var progress = new SyncProgress<ProgressNotificationValue>();
// Neither task has a worktree -- each item fails individually (ok=false), but the batch
// still reports progress per item since the loop doesn't abort on a per-item error.
await sut.BatchCleanupTaskWorktrees(new[] { t1.Id, t2.Id }, cancellationToken: CancellationToken.None, progress: progress);
Assert.Equal(2, progress.Reports.Count);
Assert.Equal(2, progress.Reports[1].Total);
}
}