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.ComponentModel;
using System.Text.Json; using System.Text.Json;
using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Lifecycle;
using ModelContextProtocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External; namespace ClaudeDo.Worker.External;
@@ -92,7 +94,8 @@ public sealed class BatchMcpTools
"descriptionMaxChars (default 1500 — was unlimited); check *Truncated/*FullLength before assuming " + "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 " + "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 " + "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( public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks(
string[] taskIds, string[] taskIds,
[Description("If true, return the full task (incl. Description/Result) in `taskFull`; if false " + [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']) " + [Description("Restrict `taskFull` to these field names (e.g. ['title','status','roadblockText']) " +
"instead of returning all of them; unknown names are rejected. Ignored when " + "instead of returning all of them; unknown names are rejected. Ignored when " +
"includeDescription=false.")] string[]? fields = null, "includeDescription=false.")] string[]? fields = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{ {
EnsureWithinCap(taskIds, nameof(taskIds)); EnsureWithinCap(taskIds, nameof(taskIds));
if (descriptionMaxChars < 0) if (descriptionMaxChars < 0)
@@ -111,8 +115,9 @@ public sealed class BatchMcpTools
ValidateFields(fields); ValidateFields(fields);
var results = new List<BatchGetTaskResult>(taskIds.Length); 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 try
{ {
if (includeDescription) if (includeDescription)
@@ -135,6 +140,7 @@ public sealed class BatchMcpTools
{ {
results.Add(new BatchGetTaskResult(id, false, null, null, ex.Message)); 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; 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. " + "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 " + "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." + "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( public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks(
string listId, string listId,
BatchAddTaskInput[] tasks, BatchAddTaskInput[] tasks,
string? createdBy = null, string? createdBy = null,
[Description("If true, enqueue every created task immediately instead of leaving it Idle.")] bool queueImmediately = false, [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)); EnsureWithinCap(tasks, nameof(tasks));
@@ -230,6 +237,7 @@ public sealed class BatchMcpTools
{ {
results.Add(new BatchAddTaskResult(i, item.Title, false, null, null, ex.Message)); results.Add(new BatchAddTaskResult(i, item.Title, false, null, null, ex.Message));
} }
ProgressReporter.ReportItem(progress, "Adding tasks", i + 1, tasks.Length);
} }
return results; 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 " + "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 " + "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." + "checking before assuming a fresh worktree starts from what's on disk." +
McpToolDocs.MaxBatch)] McpToolDocs.MaxBatch + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus( public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
string[] taskIds, string[] taskIds,
[Description("One of 'Idle', 'Queued', 'Cancelled', or 'Done'.")] string status, [Description("One of 'Idle', 'Queued', 'Cancelled', or 'Done'.")] string status,
CancellationToken cancellationToken) CancellationToken cancellationToken,
IProgress<ProgressNotificationValue>? progress = null)
{ {
EnsureWithinCap(taskIds, nameof(taskIds)); EnsureWithinCap(taskIds, nameof(taskIds));
var results = new List<BatchTaskResult>(taskIds.Length); 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 try
{ {
var task = await _svc.UpdateTaskStatus(id, status, cancellationToken); var task = await _svc.UpdateTaskStatus(id, status, cancellationToken);
@@ -261,21 +271,24 @@ public sealed class BatchMcpTools
{ {
results.Add(new BatchTaskResult(id, false, ex.Message)); results.Add(new BatchTaskResult(id, false, ex.Message));
} }
ProgressReporter.ReportItem(progress, "Updating task status", i + 1, taskIds.Length);
} }
return results; return results;
} }
[McpServerTool, Description( [McpServerTool, Description(
"Cancel many running tasks at once — use to bulk-stop tasks instead of calling cancel_task per id. " + "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( public async Task<IReadOnlyList<BatchCancelResult>> BatchCancelTasks(
string[] taskIds, CancellationToken cancellationToken) string[] taskIds, CancellationToken cancellationToken, IProgress<ProgressNotificationValue>? progress = null)
{ {
EnsureWithinCap(taskIds, nameof(taskIds)); EnsureWithinCap(taskIds, nameof(taskIds));
var results = new List<BatchCancelResult>(taskIds.Length); 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 try
{ {
var r = await _svc.CancelTask(id, cancellationToken); var r = await _svc.CancelTask(id, cancellationToken);
@@ -286,33 +299,35 @@ public sealed class BatchMcpTools
{ {
results.Add(new BatchCancelResult(id, false, false, ex.Message)); results.Add(new BatchCancelResult(id, false, false, ex.Message));
} }
ProgressReporter.ReportItem(progress, "Cancelling tasks", i + 1, taskIds.Length);
} }
return results; return results;
} }
[McpServerTool, Description( [McpServerTool, Description(
"Delete many tasks at once — use for bulk cleanup instead of calling delete_task per id." + "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( public async Task<IReadOnlyList<BatchTaskResult>> BatchDeleteTasks(
string[] taskIds, CancellationToken cancellationToken) string[] taskIds, CancellationToken cancellationToken, IProgress<ProgressNotificationValue>? progress = null)
{ {
EnsureWithinCap(taskIds, nameof(taskIds)); EnsureWithinCap(taskIds, nameof(taskIds));
return await RunPerTaskAsync(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( [McpServerTool, Description(
"Set or clear MyDay (daily prep) for many tasks at once — use instead of calling set_my_day per task. " + "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 " + "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( public async Task<IReadOnlyList<BatchTaskResult>> BatchSetMyDay(
BatchSetMyDayInput[] items, CancellationToken cancellationToken) BatchSetMyDayInput[] items, CancellationToken cancellationToken, IProgress<ProgressNotificationValue>? progress = null)
{ {
EnsureWithinCap(items, nameof(items)); EnsureWithinCap(items, nameof(items));
var results = new List<BatchTaskResult>(items.Length); 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 try
{ {
var task = await _svc.SetMyDay(item.TaskId, item.IsMyDay, item.SortOrder, cancellationToken); 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)); results.Add(new BatchTaskResult(item.TaskId, false, ex.Message));
} }
ProgressReporter.ReportItem(progress, "Setting My Day", i + 1, items.Length);
} }
return results; return results;
} }
[McpServerTool, Description( [McpServerTool, Description(
"Remove the worktrees (directory + git branch) of many tasks at once — use for bulk cleanup instead " + "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( public async Task<IReadOnlyList<BatchCleanupResult>> BatchCleanupTaskWorktrees(
string[] taskIds, string[] taskIds,
[Description("If true, also remove a dirty worktree, losing uncommitted changes; a Running task " + [Description("If true, also remove a dirty worktree, losing uncommitted changes; a Running task " +
"is still refused either way.")] bool force = false, "is still refused either way.")] bool force = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{ {
EnsureWithinCap(taskIds, nameof(taskIds)); EnsureWithinCap(taskIds, nameof(taskIds));
var results = new List<BatchCleanupResult>(taskIds.Length); 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 try
{ {
var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken); 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)); results.Add(new BatchCleanupResult(id, false, false, false, ex.Message));
} }
ProgressReporter.ReportItem(progress, "Cleaning up worktrees", i + 1, taskIds.Length);
} }
return results; return results;
} }
private static async Task<IReadOnlyList<BatchTaskResult>> RunPerTaskAsync( 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); 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 try
{ {
var number = await op(id, cancellationToken); var number = await op(id, cancellationToken);
@@ -371,6 +393,7 @@ public sealed class BatchMcpTools
{ {
results.Add(new BatchTaskResult(id, false, ex.Message)); results.Add(new BatchTaskResult(id, false, ex.Message));
} }
ProgressReporter.ReportItem(progress, progressAction, i + 1, taskIds.Length);
} }
return results; 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 " + " 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 " + "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."; "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.Usage;
using ClaudeDo.Worker.Worktrees; using ClaudeDo.Worker.Worktrees;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External; 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 public sealed class BatchMcpToolsTests : IDisposable
{ {
private readonly DbFixture _db = new(); private readonly DbFixture _db = new();
@@ -498,4 +507,118 @@ public sealed class BatchMcpToolsTests : IDisposable
() => sut.BatchGetTasks(ids, cancellationToken: CancellationToken.None)); () => sut.BatchGetTasks(ids, cancellationToken: CancellationToken.None));
Assert.Contains("max", ex.Message, StringComparison.OrdinalIgnoreCase); 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);
}
} }