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