From 5a592c4be67df72e149b0e5dad5a4d9aa79c7e33 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Sat, 30 May 2026 14:07:59 +0200 Subject: [PATCH] feat(worker): add external MCP reset-failed-task tool Co-Authored-By: Claude Opus 4.7 --- .../External/LifecycleMcpTools.cs | 31 +++++++ .../External/LifecycleMcpToolsTests.cs | 90 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/ClaudeDo.Worker/External/LifecycleMcpTools.cs create mode 100644 tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs diff --git a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs new file mode 100644 index 0000000..5fe2338 --- /dev/null +++ b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Lifecycle; +using ModelContextProtocol.Server; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.External; + +[McpServerToolType] +public sealed class LifecycleMcpTools +{ + private readonly TaskRepository _tasks; + private readonly TaskResetService _reset; + + public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset) + { + _tasks = tasks; + _reset = reset; + } + + [McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")] + public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken) + { + var task = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status != TaskStatus.Failed) + throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool."); + + await _reset.ResetAsync(taskId, cancellationToken); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs new file mode 100644 index 0000000..03f678b --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs @@ -0,0 +1,90 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.State; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Data.Git; +using ClaudeDo.Worker.Config; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class LifecycleMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public LifecycleMcpToolsTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private LifecycleMcpTools BuildSut() + { + var cfg = new WorkerConfig + { + SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"), + LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"), + }; + 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 reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger.Instance); + return new LifecycleMcpTools(_tasks, reset); + } + + private async Task SeedTaskAsync(TaskStatus status) + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", + Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", + }; + await _tasks.AddAsync(task); + return task; + } + + [Fact] + public async Task ResetFailedTask_OnFailed_ResetsToIdle() + { + var task = await SeedTaskAsync(TaskStatus.Failed); + var sut = BuildSut(); + + await sut.ResetFailedTask(task.Id, CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Idle, loaded!.Status); + } + + [Fact] + public async Task ResetFailedTask_OnNonFailed_Throws() + { + var task = await SeedTaskAsync(TaskStatus.Done); + var sut = BuildSut(); + + await Assert.ThrowsAsync(() => + sut.ResetFailedTask(task.Id, CancellationToken.None)); + } + + [Fact] + public async Task ResetFailedTask_NotFound_Throws() + { + var sut = BuildSut(); + await Assert.ThrowsAsync(() => + sut.ResetFailedTask("missing", CancellationToken.None)); + } +}