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).
410 lines
20 KiB
C#
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.");
|
|
}
|
|
}
|