feat(worker): add external MCP reset-failed-task tool
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
31
src/ClaudeDo.Worker/External/LifecycleMcpTools.cs
vendored
Normal file
31
src/ClaudeDo.Worker/External/LifecycleMcpTools.cs
vendored
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs
vendored
Normal file
90
tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs
vendored
Normal file
@@ -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<WorktreeManager>.Instance);
|
||||||
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
|
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
|
||||||
|
return new LifecycleMcpTools(_tasks, reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> 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<InvalidOperationException>(() =>
|
||||||
|
sut.ResetFailedTask(task.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetFailedTask_NotFound_Throws()
|
||||||
|
{
|
||||||
|
var sut = BuildSut();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
sut.ResetFailedTask("missing", CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user