Files
ClaudeDo/src/ClaudeDo.Worker/External/BatchMcpTools.cs
T
Mika Kuns 53e26f0340 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).
2026-08-17 08:56:32 +02:00

410 lines
20 KiB
C#

using System.ComponentModel;
using System.Text.Json;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Lifecycle;
using ModelContextProtocol;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record BatchAddTaskInput(
string Title,
[property: Description("Task description/instructions for the agent.")] string? Description = null,
[property: Description("Model override: haiku|sonnet|opus. Blank inherits the list/global default.")] string? Model = null);
public sealed record BatchSetMyDayInput(
string TaskId,
[property: Description("true to add the task to My Day, false to remove it.")] bool IsMyDay,
[property: Description("Position within My Day; omit to append at the end.")] int? SortOrder = null);
// task is populated when found and includeDescription=false (the default, lean reference);
// taskFull is populated when found and includeDescription=true (full task incl.
// Description/Result, possibly narrowed by `fields` and/or truncated). Both are null when
// found=false.
public sealed record BatchGetTaskResult(string Id, bool Found, TaskRefDto? Task, BatchTaskDetailDto? TaskFull, string? Error);
// Batch-only counterpart to TaskDto (deliberately not TaskDto itself — get_task's own shape stays
// untouched). Every field defaults to null; BatchGetTasks only populates the ones the caller asked
// for via `fields` (or all of them when `fields` is omitted). Description/Result are cut to
// descriptionMaxChars with the *Truncated/*FullLength pair telling the caller it happened and how
// much text there really is. RoadblockText is the tail of Result after TaskRunner's roadblock
// marker (see ComposeReviewResult in Runner/TaskRunner.cs) — the few bullet lines a roadblocked
// run reported, without pulling in the rest of Result/Description to get them.
public sealed record BatchTaskDetailDto(
string Id,
int Number,
string? ListId = null,
string? Title = null,
string? Description = null,
string? Status = null,
string? Result = null,
string? CreatedBy = null,
DateTime? CreatedAt = null,
DateTime? StartedAt = null,
DateTime? FinishedAt = null,
bool? IsMyDay = null,
int? SortOrder = null,
int? RoadblockCount = null,
string? RoadblockText = null,
bool DescriptionTruncated = false,
int? DescriptionFullLength = null,
bool ResultTruncated = false,
int? ResultFullLength = null);
public sealed record BatchAddTaskResult(
int Index, string Title, bool Ok, TaskRefDto? Task,
IReadOnlyList<PossibleDuplicateDto>? PossibleDuplicates, string? Error);
// BaseDirty mirrors TaskRefDto.BaseDirty: populated only for BatchUpdateTaskStatus items that
// just transitioned to Queued against a list whose working dir has uncommitted changes.
public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error, DirtyBaseWarning? BaseDirty = null, int? Number = null);
public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error, int? Number = null);
public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error, int? Number = null);
/// <summary>
/// Batch variants of the single-entity tools on <see cref="ExternalMcpService"/>.
/// Every tool is best-effort: each item is attempted independently and reported in a
/// per-item result array (a failing item never aborts the rest). Operations run
/// sequentially — the underlying repositories share one scoped DbContext, which is not
/// thread-safe. Merge/review stay single-task (conflicts need the interactive resolver).
/// </summary>
[McpServerToolType]
public sealed class BatchMcpTools
{
private const int MaxBatchSize = 100;
// Roughly 6k tokens of headroom under a typical 25k-token tool-result limit — the incident
// this guards against was a single 51k-char line from 8 tasks' full Description/Result.
private const int MaxResponseChars = 25_000;
private const string RoadblockMarker = "Roadblocks reported during the run:";
private static readonly string[] KnownDetailFields =
{
"listId", "title", "description", "status", "result", "createdBy", "createdAt",
"startedAt", "finishedAt", "isMyDay", "sortOrder", "roadblockCount", "roadblockText",
};
private readonly ExternalMcpService _svc;
public BatchMcpTools(ExternalMcpService svc) => _svc = svc;
[McpServerTool, Description(
"Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " +
"calling get_task per id. A missing id comes back as found=false, not an error; error is only set " +
"for an unexpected failure. A Failed task's task/taskFull carries failureReason (see get_task). " +
"When includeDescription=true, Description/Result are cut to " +
"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 + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks(
string[] taskIds,
[Description("If true, return the full task (incl. Description/Result) in `taskFull`; if false " +
"(default), return a lean reference in `task`.")] bool includeDescription = false,
[Description("Max chars kept for Description and for Result (each independently) when " +
"includeDescription=true; longer text is cut and flagged via *Truncated/*FullLength. Ignored " +
"when includeDescription=false.")] int descriptionMaxChars = 1500,
[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,
IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(taskIds, nameof(taskIds));
if (descriptionMaxChars < 0)
throw new InvalidOperationException($"{nameof(descriptionMaxChars)} must be >= 0.");
ValidateFields(fields);
var results = new List<BatchGetTaskResult>(taskIds.Length);
for (var i = 0; i < taskIds.Length; i++)
{
var id = taskIds[i];
try
{
if (includeDescription)
{
var task = await _svc.GetTask(id, cancellationToken);
results.Add(new BatchGetTaskResult(id, true, null, BuildDetail(task, fields, descriptionMaxChars), null));
}
else
{
var taskRef = await _svc.GetTaskRefAsync(id, cancellationToken);
results.Add(new BatchGetTaskResult(id, true, taskRef, null, null));
}
}
catch (OperationCanceledException) { throw; }
catch (InvalidOperationException)
{
results.Add(new BatchGetTaskResult(id, false, null, null, null));
}
catch (Exception ex)
{
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;
if (responseLength > MaxResponseChars)
throw new InvalidOperationException(
$"Response too large: {responseLength} chars (max {MaxResponseChars}). Lower " +
$"{nameof(descriptionMaxChars)}, narrow `fields`, or split {nameof(taskIds)} into a smaller batch.");
return results;
}
private static void ValidateFields(string[]? fields)
{
if (fields is null) return;
var unknown = fields.Where(f => !KnownDetailFields.Contains(f, StringComparer.OrdinalIgnoreCase)).ToArray();
if (unknown.Length > 0)
throw new InvalidOperationException(
$"Unknown field(s) in `fields`: {string.Join(", ", unknown)}. Known fields: " +
string.Join(", ", KnownDetailFields));
}
private static BatchTaskDetailDto BuildDetail(TaskDto t, string[]? fields, int descriptionMaxChars)
{
bool Want(string name) => fields is null || fields.Contains(name, StringComparer.OrdinalIgnoreCase);
var (description, descTruncated, descFullLength) =
Want("description") ? Truncate(t.Description, descriptionMaxChars) : (null, false, null);
var (result, resultTruncated, resultFullLength) =
Want("result") ? Truncate(t.Result, descriptionMaxChars) : (null, false, null);
return new BatchTaskDetailDto(
Id: t.Id,
Number: t.Number,
ListId: Want("listId") ? t.ListId : null,
Title: Want("title") ? t.Title : null,
Description: description,
Status: Want("status") ? t.Status : null,
Result: result,
CreatedBy: Want("createdBy") ? t.CreatedBy : null,
CreatedAt: Want("createdAt") ? t.CreatedAt : null,
StartedAt: Want("startedAt") ? t.StartedAt : null,
FinishedAt: Want("finishedAt") ? t.FinishedAt : null,
IsMyDay: Want("isMyDay") ? t.IsMyDay : null,
SortOrder: Want("sortOrder") ? t.SortOrder : null,
RoadblockCount: Want("roadblockCount") ? t.RoadblockCount : null,
RoadblockText: Want("roadblockText") ? ExtractRoadblockText(t.Result) : null,
DescriptionTruncated: descTruncated,
DescriptionFullLength: descFullLength,
ResultTruncated: resultTruncated,
ResultFullLength: resultFullLength);
}
private static (string? Text, bool Truncated, int? FullLength) Truncate(string? text, int maxChars)
{
if (text is null || text.Length <= maxChars) return (text, false, null);
return (text[..maxChars], true, text.Length);
}
private static string? ExtractRoadblockText(string? result)
{
if (string.IsNullOrEmpty(result)) return null;
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
return idx < 0 ? null : result[(idx + RoadblockMarker.Length)..].Trim();
}
[McpServerTool, Description(
"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.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,
IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(tasks, nameof(tasks));
var results = new List<BatchAddTaskResult>(tasks.Length);
for (var i = 0; i < tasks.Length; i++)
{
var item = tasks[i];
try
{
var created = await _svc.AddTask(
listId, item.Title, item.Description, createdBy,
queueImmediately, item.Model, cancellationToken: cancellationToken);
results.Add(new BatchAddTaskResult(i, item.Title, true, created.Task, created.PossibleDuplicates, null));
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results.Add(new BatchAddTaskResult(i, item.Title, false, null, null, ex.Message));
}
ProgressReporter.ReportItem(progress, "Adding tasks", i + 1, tasks.Length);
}
return results;
}
[McpServerTool, Description(
"Set the status of many tasks at once — use for bulk queue/cancel/done actions instead of calling " +
"update_task_status per task. 'Done' is refused per-item for a task with an active worktree. " +
"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.BatchProgressHint)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
string[] taskIds,
[Description("One of 'Idle', 'Queued', 'Cancelled', or 'Done'.")] string status,
CancellationToken cancellationToken,
IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(taskIds, nameof(taskIds));
var results = new List<BatchTaskResult>(taskIds.Length);
for (var i = 0; i < taskIds.Length; i++)
{
var id = taskIds[i];
try
{
var task = await _svc.UpdateTaskStatus(id, status, cancellationToken);
results.Add(new BatchTaskResult(id, true, null, task.BaseDirty, task.Number));
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
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 + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchCancelResult>> BatchCancelTasks(
string[] taskIds, CancellationToken cancellationToken, IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(taskIds, nameof(taskIds));
var results = new List<BatchCancelResult>(taskIds.Length);
for (var i = 0; i < taskIds.Length; i++)
{
var id = taskIds[i];
try
{
var r = await _svc.CancelTask(id, cancellationToken);
results.Add(new BatchCancelResult(id, true, r.Cancelled, null, r.Number));
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
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.BatchProgressHint)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchDeleteTasks(
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, 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 + McpToolDocs.BatchProgressHint)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchSetMyDay(
BatchSetMyDayInput[] items, CancellationToken cancellationToken, IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(items, nameof(items));
var results = new List<BatchTaskResult>(items.Length);
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);
results.Add(new BatchTaskResult(item.TaskId, true, null, Number: task.Number));
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
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 + 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,
IProgress<ProgressNotificationValue>? progress = null)
{
EnsureWithinCap(taskIds, nameof(taskIds));
var results = new List<BatchCleanupResult>(taskIds.Length);
for (var i = 0; i < taskIds.Length; i++)
{
var id = taskIds[i];
try
{
var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken);
results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null, r.Number));
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
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,
IProgress<ProgressNotificationValue>? progress, string progressAction)
{
var results = new List<BatchTaskResult>(taskIds.Length);
for (var i = 0; i < taskIds.Length; i++)
{
var id = taskIds[i];
try
{
var number = await op(id, cancellationToken);
results.Add(new BatchTaskResult(id, true, null, Number: number));
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results.Add(new BatchTaskResult(id, false, ex.Message));
}
ProgressReporter.ReportItem(progress, progressAction, i + 1, taskIds.Length);
}
return results;
}
private static void EnsureWithinCap<T>(IReadOnlyCollection<T>? items, string name)
{
if (items is null || items.Count == 0)
throw new InvalidOperationException($"{name} is required and must contain at least one item.");
if (items.Count > MaxBatchSize)
throw new InvalidOperationException(
$"Batch too large: {items.Count} items (max {MaxBatchSize}). Split into smaller batches.");
}
}