feat(claude-do): merge [D2] Die 7 batch_*-MCP-Tools mit Element-Fortschritt (i/n)

ClaudeDo-Task: 286efaaf-b38e-47d4-9655-0c2d87e4b329
This commit is contained in:
Mika Kuns
2026-08-17 09:06:27 +02:00
3 changed files with 177 additions and 22 deletions
+45 -22
View File
@@ -1,6 +1,8 @@
using System.ComponentModel;
using System.Text.Json;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Lifecycle;
using ModelContextProtocol;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
@@ -92,7 +94,8 @@ public sealed class BatchMcpTools
"descriptionMaxChars (default 1500 — was unlimited); check *Truncated/*FullLength before assuming " +
"you got the whole text. Use `fields` to fetch only what you need (e.g. just roadblockText) instead " +
"of raising the cap. The whole response is still capped — if it's too big even so, lower " +
"descriptionMaxChars, narrow `fields`, or split taskIds into a smaller batch." + McpToolDocs.MaxBatch)]
"descriptionMaxChars, narrow `fields`, or split taskIds into a smaller batch." +
McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks(
string[] taskIds,
[Description("If true, return the full task (incl. Description/Result) in `taskFull`; if false " +
@@ -103,7 +106,8 @@ public sealed class BatchMcpTools
[Description("Restrict `taskFull` to these field names (e.g. ['title','status','roadblockText']) " +
"instead of returning all of them; unknown names are rejected. Ignored when " +
"includeDescription=false.")] string[]? fields = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(taskIds, nameof(taskIds));
if (descriptionMaxChars < 0)
@@ -111,8 +115,9 @@ public sealed class BatchMcpTools
ValidateFields(fields);
var results = new List<BatchGetTaskResult>(taskIds.Length);
foreach (var id in taskIds)
for (var i = 0; i < taskIds.Length; i++)
{
var id = taskIds[i];
try
{
if (includeDescription)
@@ -135,6 +140,7 @@ public sealed class BatchMcpTools
{
results.Add(new BatchGetTaskResult(id, false, null, null, ex.Message));
}
ProgressReporter.ReportItem(progress, "Fetching tasks", i + 1, taskIds.Length);
}
var responseLength = JsonSerializer.Serialize(results).Length;
@@ -204,13 +210,14 @@ public sealed class BatchMcpTools
"Create many tasks in one list at once — use instead of repeated add_task calls when seeding a list. " +
"Every item is still created even if it looks like a duplicate; possibleDuplicates is a non-blocking " +
"heads-up (up to 3 similar open tasks in the list) worth mentioning to the caller, not an error." +
McpToolDocs.LeanTaskRef + McpToolDocs.MaxBatch)]
McpToolDocs.LeanTaskRef + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks(
string listId,
BatchAddTaskInput[] tasks,
string? createdBy = null,
[Description("If true, enqueue every created task immediately instead of leaving it Idle.")] bool queueImmediately = false,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(tasks, nameof(tasks));
@@ -230,6 +237,7 @@ public sealed class BatchMcpTools
{
results.Add(new BatchAddTaskResult(i, item.Title, false, null, null, ex.Message));
}
ProgressReporter.ReportItem(progress, "Adding tasks", i + 1, tasks.Length);
}
return results;
}
@@ -240,17 +248,19 @@ public sealed class BatchMcpTools
"baseDirty on a 'Queued' item means that task's list has uncommitted changes in its working dir right " +
"now — a new worktree forks from the last commit and won't include them; non-blocking, but worth " +
"checking before assuming a fresh worktree starts from what's on disk." +
McpToolDocs.MaxBatch)]
McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
string[] taskIds,
[Description("One of 'Idle', 'Queued', 'Cancelled', or 'Done'.")] string status,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(taskIds, nameof(taskIds));
var results = new List<BatchTaskResult>(taskIds.Length);
foreach (var id in taskIds)
for (var i = 0; i < taskIds.Length; i++)
{
var id = taskIds[i];
try
{
var task = await _svc.UpdateTaskStatus(id, status, cancellationToken);
@@ -261,21 +271,24 @@ public sealed class BatchMcpTools
{
results.Add(new BatchTaskResult(id, false, ex.Message));
}
ProgressReporter.ReportItem(progress, "Updating task status", i + 1, taskIds.Length);
}
return results;
}
[McpServerTool, Description(
"Cancel many running tasks at once — use to bulk-stop tasks instead of calling cancel_task per id. " +
"ok=true with cancelled=false just means the task wasn't running." + McpToolDocs.MaxBatch)]
"ok=true with cancelled=false just means the task wasn't running." +
McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchCancelResult>> BatchCancelTasks(
string[] taskIds, CancellationToken cancellationToken)
string[] taskIds, CancellationToken cancellationToken, IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(taskIds, nameof(taskIds));
var results = new List<BatchCancelResult>(taskIds.Length);
foreach (var id in taskIds)
for (var i = 0; i < taskIds.Length; i++)
{
var id = taskIds[i];
try
{
var r = await _svc.CancelTask(id, cancellationToken);
@@ -286,33 +299,35 @@ public sealed class BatchMcpTools
{
results.Add(new BatchCancelResult(id, false, false, ex.Message));
}
ProgressReporter.ReportItem(progress, "Cancelling tasks", i + 1, taskIds.Length);
}
return results;
}
[McpServerTool, Description(
"Delete many tasks at once — use for bulk cleanup instead of calling delete_task per id." +
McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)]
McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchDeleteTasks(
string[] taskIds, CancellationToken cancellationToken)
string[] taskIds, CancellationToken cancellationToken, IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(taskIds, nameof(taskIds));
return await RunPerTaskAsync(taskIds,
async (id, ct) => (await _svc.DeleteTask(id, ct)).Number, cancellationToken);
async (id, ct) => (await _svc.DeleteTask(id, ct)).Number, cancellationToken, progress, "Deleting tasks");
}
[McpServerTool, Description(
"Set or clear MyDay (daily prep) for many tasks at once — use instead of calling set_my_day per task. " +
"Still cap-guarded: items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " +
"(ok=false) without blocking the rest." + McpToolDocs.MaxBatch)]
"(ok=false) without blocking the rest." + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchSetMyDay(
BatchSetMyDayInput[] items, CancellationToken cancellationToken)
BatchSetMyDayInput[] items, CancellationToken cancellationToken, IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(items, nameof(items));
var results = new List<BatchTaskResult>(items.Length);
foreach (var item in items)
for (var i = 0; i < items.Length; i++)
{
var item = items[i];
try
{
var task = await _svc.SetMyDay(item.TaskId, item.IsMyDay, item.SortOrder, cancellationToken);
@@ -323,24 +338,28 @@ public sealed class BatchMcpTools
{
results.Add(new BatchTaskResult(item.TaskId, false, ex.Message));
}
ProgressReporter.ReportItem(progress, "Setting My Day", i + 1, items.Length);
}
return results;
}
[McpServerTool, Description(
"Remove the worktrees (directory + git branch) of many tasks at once — use for bulk cleanup instead " +
"of calling cleanup_task_worktree per id." + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)]
"of calling cleanup_task_worktree per id." +
McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchCleanupResult>> BatchCleanupTaskWorktrees(
string[] taskIds,
[Description("If true, also remove a dirty worktree, losing uncommitted changes; a Running task " +
"is still refused either way.")] bool force = false,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(taskIds, nameof(taskIds));
var results = new List<BatchCleanupResult>(taskIds.Length);
foreach (var id in taskIds)
for (var i = 0; i < taskIds.Length; i++)
{
var id = taskIds[i];
try
{
var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken);
@@ -351,16 +370,19 @@ public sealed class BatchMcpTools
{
results.Add(new BatchCleanupResult(id, false, false, false, ex.Message));
}
ProgressReporter.ReportItem(progress, "Cleaning up worktrees", i + 1, taskIds.Length);
}
return results;
}
private static async Task<IReadOnlyList<BatchTaskResult>> RunPerTaskAsync(
string[] taskIds, Func<string, CancellationToken, Task<int?>> op, CancellationToken cancellationToken)
string[] taskIds, Func<string, CancellationToken, Task<int?>> op, CancellationToken cancellationToken,
IProgress<ProgressNotificationValue>? progress, string progressAction)
{
var results = new List<BatchTaskResult>(taskIds.Length);
foreach (var id in taskIds)
for (var i = 0; i < taskIds.Length; i++)
{
var id = taskIds[i];
try
{
var number = await op(id, cancellationToken);
@@ -371,6 +393,7 @@ public sealed class BatchMcpTools
{
results.Add(new BatchTaskResult(id, false, ex.Message));
}
ProgressReporter.ReportItem(progress, progressAction, i + 1, taskIds.Length);
}
return results;
}
+9
View File
@@ -56,4 +56,13 @@ internal static class McpToolDocs
" Sends MCP progress pings periodically while a merge or verify gate runs, so a long call survives the " +
"calling client's own idle-silence abort (Claude Code defaults to killing an MCP call after ~300s of " +
"silence) -- this is not guaranteed by every possible MCP client.";
/// <summary>
/// Batch tools loop per item and send one MCP progress ping per item (i/n) instead of only at
/// the end -- same idle-abort rationale as <see cref="ProgressHint"/>, worded for a batch loop.
/// </summary>
public const string BatchProgressHint =
" Sends an MCP progress ping after each item is processed (i/n), so a long batch survives the calling " +
"client's own idle-silence abort (Claude Code defaults to killing an MCP call after ~300s of silence) -- " +
"this is not guaranteed by every possible MCP client.";
}
@@ -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);
}
}