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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user