diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index e9ba598..ceb8ac0 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -34,6 +34,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an ` - **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. - **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools()`. Organized by concern: - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree` + - `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items. - `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList` - `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig` - `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB) diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs new file mode 100644 index 0000000..d0960e2 --- /dev/null +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -0,0 +1,225 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record BatchAddTaskInput(string Title, string? Description = null, string? Model = null); +public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null); + +public sealed record BatchGetTaskResult(string Id, bool Found, TaskDto? Task, string? Error); +public sealed record BatchAddTaskResult(int Index, string Title, bool Ok, TaskDto? Task, string? Error); +public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error); +public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error); +public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error); + +/// +/// Batch variants of the single-entity tools on . +/// 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). +/// +[McpServerToolType] +public sealed class BatchMcpTools +{ + private const int MaxBatchSize = 100; + + private readonly ExternalMcpService _svc; + + public BatchMcpTools(ExternalMcpService svc) => _svc = svc; + + [McpServerTool, Description( + "Fetch a snapshot of many tasks in one call (overview / polling a fan-out). " + + "Returns one result per id: { id, found, task, error }. A missing id is found=false " + + "(not an error); error is only set for an unexpected failure. Max 100 ids.")] + public async Task> BatchGetTasks( + string[] taskIds, CancellationToken cancellationToken) + { + EnsureWithinCap(taskIds, nameof(taskIds)); + + var results = new List(taskIds.Length); + foreach (var id in taskIds) + { + try + { + var task = await _svc.GetTask(id, cancellationToken); + results.Add(new BatchGetTaskResult(id, true, task, null)); + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException) + { + results.Add(new BatchGetTaskResult(id, false, null, null)); + } + catch (Exception ex) + { + results.Add(new BatchGetTaskResult(id, false, null, ex.Message)); + } + } + return results; + } + + [McpServerTool, Description( + "Create many tasks in one list at once. Each item: { title, description?, model? } " + + "(model: haiku|sonnet|opus, blank = inherit list/global default). " + + "queueImmediately enqueues every created task. " + + "Returns one result per item: { index, title, ok, task, error }. Max 100 items.")] + public async Task> BatchAddTasks( + string listId, + BatchAddTaskInput[] tasks, + string? createdBy = null, + bool queueImmediately = false, + CancellationToken cancellationToken = default) + { + EnsureWithinCap(tasks, nameof(tasks)); + + var results = new List(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); + results.Add(new BatchAddTaskResult(i, item.Title, true, created, null)); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + results.Add(new BatchAddTaskResult(i, item.Title, false, null, ex.Message)); + } + } + return results; + } + + [McpServerTool, Description( + "Set the status of many tasks at once. status is 'Idle' (reset to editable) or " + + "'Queued' (enqueue for execution) only — same rule as update_task_status. " + + "Returns one result per id: { taskId, ok, error }. Max 100 ids.")] + public async Task> BatchUpdateTaskStatus( + string[] taskIds, string status, CancellationToken cancellationToken) + { + EnsureWithinCap(taskIds, nameof(taskIds)); + return await RunPerTaskAsync(taskIds, + (id, ct) => _svc.UpdateTaskStatus(id, status, ct), cancellationToken); + } + + [McpServerTool, Description( + "Cancel many running tasks at once. Returns one result per id: " + + "{ taskId, ok, cancelled, error }. cancelled=false means the task was not running. " + + "Max 100 ids.")] + public async Task> BatchCancelTasks( + string[] taskIds, CancellationToken cancellationToken) + { + EnsureWithinCap(taskIds, nameof(taskIds)); + + var results = new List(taskIds.Length); + foreach (var id in taskIds) + { + try + { + var r = await _svc.CancelTask(id, cancellationToken); + results.Add(new BatchCancelResult(id, true, r.Cancelled, null)); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + results.Add(new BatchCancelResult(id, false, false, ex.Message)); + } + } + return results; + } + + [McpServerTool, Description( + "Delete many tasks at once. A Running task is refused (cancel it first) and reported " + + "as ok=false with its error. Returns one result per id: { taskId, ok, error }. Max 100 ids.")] + public async Task> BatchDeleteTasks( + string[] taskIds, CancellationToken cancellationToken) + { + EnsureWithinCap(taskIds, nameof(taskIds)); + return await RunPerTaskAsync(taskIds, + (id, ct) => _svc.DeleteTask(id, ct), cancellationToken); + } + + [McpServerTool, Description( + "Daily prep: set/clear MyDay for many tasks at once. Each item: { taskId, isMyDay, sortOrder? }. " + + "Still cap-guarded — items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " + + "(ok=false) without blocking the rest. Returns one result per item: { taskId, ok, error }. Max 100 items.")] + public async Task> BatchSetMyDay( + BatchSetMyDayInput[] items, CancellationToken cancellationToken) + { + EnsureWithinCap(items, nameof(items)); + + var results = new List(items.Length); + foreach (var item in items) + { + try + { + await _svc.SetMyDay(item.TaskId, item.IsMyDay, item.SortOrder, cancellationToken); + results.Add(new BatchTaskResult(item.TaskId, true, null)); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + results.Add(new BatchTaskResult(item.TaskId, false, ex.Message)); + } + } + return results; + } + + [McpServerTool, Description( + "Remove the worktrees of many tasks at once (directory + git branch). " + + "force=false refuses a dirty or Running worktree (reported ok=false); force=true removes " + + "even a dirty worktree (uncommitted changes lost), still refusing Running tasks. " + + "Returns one result per id: { taskId, ok, removed, branchDeleted, error }. Max 100 ids.")] + public async Task> BatchCleanupTaskWorktrees( + string[] taskIds, bool force = false, CancellationToken cancellationToken = default) + { + EnsureWithinCap(taskIds, nameof(taskIds)); + + var results = new List(taskIds.Length); + foreach (var id in taskIds) + { + try + { + var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken); + results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null)); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + results.Add(new BatchCleanupResult(id, false, false, false, ex.Message)); + } + } + return results; + } + + private static async Task> RunPerTaskAsync( + string[] taskIds, Func op, CancellationToken cancellationToken) + { + var results = new List(taskIds.Length); + foreach (var id in taskIds) + { + try + { + await op(id, cancellationToken); + results.Add(new BatchTaskResult(id, true, null)); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + results.Add(new BatchTaskResult(id, false, ex.Message)); + } + } + return results; + } + + private static void EnsureWithinCap(IReadOnlyCollection? 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."); + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 07343d0..aab7c5c 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -264,6 +264,7 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); @@ -276,6 +277,7 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddMcpServer() .WithHttpTransport() .WithTools() + .WithTools() .WithTools() .WithTools() .WithTools() diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs new file mode 100644 index 0000000..6126760 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -0,0 +1,204 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Queue; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Worktrees; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class BatchMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly HubBroadcaster _broadcaster; + + public BatchMcpToolsTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _broadcaster = new HubBroadcaster(new CapturingHubContext()); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } + + private async Task SeedListAsync() + { + var id = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); + return id; + } + + private async Task SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Idle) + { + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = title, + Status = status, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(task); + return task; + } + + private BatchMcpTools BuildSut() + { + var git = new GitService(); + var factory = _db.CreateFactory(); + var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance); + var merge = new TaskMergeService(factory, git, _broadcaster, TaskStateServiceBuilder.Build(factory).State, NullLogger.Instance); + var svc = new ExternalMcpService( + _tasks, _lists, CreateQueue(), _broadcaster, + TaskStateServiceBuilder.Build(factory).State, + git, factory, maintenance, merge); + return new BatchMcpTools(svc); + } + + private QueueService CreateQueue() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_batch_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var cfg = new WorkerConfig + { + SandboxRoot = Path.Combine(tempDir, "sandbox"), + LogRoot = Path.Combine(tempDir, "logs"), + QueueBackstopIntervalMs = 50, + }; + var dbFactory = _db.CreateFactory(); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); + var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg, + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); + var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); + return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, + new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state); + } + + [Fact] + public async Task BatchAddTasks_CreatesAll_AndReportsOkPerItem() + { + var listId = await SeedListAsync(); + var sut = BuildSut(); + + var results = await sut.BatchAddTasks(listId, new[] + { + new BatchAddTaskInput("a"), + new BatchAddTaskInput("b"), + new BatchAddTaskInput("c"), + }, cancellationToken: CancellationToken.None); + + Assert.Equal(3, results.Count); + Assert.All(results, r => Assert.True(r.Ok)); + Assert.Equal(new[] { 0, 1, 2 }, results.Select(r => r.Index).ToArray()); + var inList = await _tasks.GetByListIdAsync(listId); + Assert.Equal(3, inList.Count); + } + + [Fact] + public async Task BatchAddTasks_FailingItem_DoesNotAbortTheRest() + { + var listId = await SeedListAsync(); + var sut = BuildSut(); + + var results = await sut.BatchAddTasks(listId, new[] + { + new BatchAddTaskInput("ok-1"), + new BatchAddTaskInput(" "), // blank title → AddTask throws + new BatchAddTaskInput("ok-2"), + }, cancellationToken: CancellationToken.None); + + Assert.True(results[0].Ok); + Assert.False(results[1].Ok); + Assert.NotNull(results[1].Error); + Assert.True(results[2].Ok); + var inList = await _tasks.GetByListIdAsync(listId); + Assert.Equal(2, inList.Count); + } + + [Fact] + public async Task BatchGetTasks_MissingId_IsFoundFalseNotError() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(); + + var results = await sut.BatchGetTasks(new[] { task.Id, "nope" }, CancellationToken.None); + + var found = results.Single(r => r.Id == task.Id); + var missing = results.Single(r => r.Id == "nope"); + Assert.True(found.Found); + Assert.NotNull(found.Task); + Assert.False(missing.Found); + Assert.Null(missing.Task); + Assert.Null(missing.Error); + } + + [Fact] + public async Task BatchDeleteTasks_RunningTask_ReportedNotOk_OthersDeleted() + { + var listId = await SeedListAsync(); + var deletable = await SeedTaskAsync(listId, "del", TaskStatus.Idle); + var running = await SeedTaskAsync(listId, "run", TaskStatus.Running); + var sut = BuildSut(); + + var results = await sut.BatchDeleteTasks(new[] { deletable.Id, running.Id }, CancellationToken.None); + + Assert.True(results.Single(r => r.TaskId == deletable.Id).Ok); + Assert.False(results.Single(r => r.TaskId == running.Id).Ok); + Assert.Null(await _tasks.GetByIdAsync(deletable.Id)); + Assert.NotNull(await _tasks.GetByIdAsync(running.Id)); + } + + [Fact] + public async Task BatchUpdateTaskStatus_QueuesIdleTasks() + { + var listId = await SeedListAsync(); + var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle); + var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle); + var sut = BuildSut(); + + var results = await sut.BatchUpdateTaskStatus(new[] { t1.Id, t2.Id }, "Queued", CancellationToken.None); + + Assert.All(results, r => Assert.True(r.Ok)); + Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t1.Id))!.Status); + Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status); + } + + [Fact] + public async Task BatchTools_RejectEmptyBatch() + { + var sut = BuildSut(); + await Assert.ThrowsAsync( + () => sut.BatchGetTasks(Array.Empty(), CancellationToken.None)); + } + + [Fact] + public async Task BatchTools_RejectOversizedBatch() + { + var sut = BuildSut(); + var ids = Enumerable.Range(0, 101).Select(i => i.ToString()).ToArray(); + + var ex = await Assert.ThrowsAsync( + () => sut.BatchGetTasks(ids, CancellationToken.None)); + Assert.Contains("max", ex.Message, StringComparison.OrdinalIgnoreCase); + } +}