feat(worker): add wait_for_task_change MCP tool
Replaces the list handler's Start-Sleep + blind get_task poll (Phase 3 of the merge-helper prompt) with a blocking MCP tool that returns as soon as a task leaves Queued/Running, or times out. Implemented as an async DB poll (short-lived DbContext, 500ms delay, no held connection) rather than hooking HubBroadcaster, keeping the existing broadcast callers untouched. timeoutSeconds is clamped server-side to 170s, under the list handler's 200s MCP_TOOL_TIMEOUT.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
using System.Diagnostics;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class TaskWaitMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
|
||||
public TaskWaitMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private TaskWaitMcpTools BuildSut() => new(_db.CreateFactory());
|
||||
|
||||
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 WaitForTaskChange_AlreadyOutOfQueuedRunning_ReturnsImmediately()
|
||||
{
|
||||
var task = await SeedTaskAsync(TaskStatus.WaitingForReview);
|
||||
var sut = BuildSut();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var result = await sut.WaitForTaskChange([task.Id], timeoutSeconds: 30, CancellationToken.None);
|
||||
|
||||
sw.Stop();
|
||||
Assert.False(result.TimedOut);
|
||||
Assert.Equal(task.Id, Assert.Single(result.Changed).TaskId);
|
||||
Assert.Equal("WaitingForReview", result.Changed[0].Status);
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForTaskChange_UnknownId_ReturnsImmediatelyAsNotFound()
|
||||
{
|
||||
var sut = BuildSut();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var result = await sut.WaitForTaskChange(["missing-id"], timeoutSeconds: 30, CancellationToken.None);
|
||||
|
||||
sw.Stop();
|
||||
Assert.False(result.TimedOut);
|
||||
Assert.Equal("NotFound", Assert.Single(result.Changed).Status);
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForTaskChange_StatusChangesWhileWaiting_ReturnsBeforeTimeout()
|
||||
{
|
||||
var task = await SeedTaskAsync(TaskStatus.Running);
|
||||
var sut = BuildSut();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var waitTask = sut.WaitForTaskChange([task.Id], timeoutSeconds: 10, CancellationToken.None);
|
||||
|
||||
await Task.Delay(150);
|
||||
// Simulate the status change a broadcast would announce, via a separate context
|
||||
// (mirrors what TaskStateService does from a different scope/process).
|
||||
await using (var writeCtx = _db.CreateContext())
|
||||
{
|
||||
var writeRepo = new TaskRepository(writeCtx);
|
||||
var loaded = await writeRepo.GetByIdAsync(task.Id);
|
||||
loaded!.Status = TaskStatus.Done;
|
||||
await writeRepo.UpdateAsync(loaded);
|
||||
}
|
||||
|
||||
var result = await waitTask;
|
||||
sw.Stop();
|
||||
|
||||
Assert.False(result.TimedOut);
|
||||
Assert.Equal("Done", Assert.Single(result.Changed).Status);
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForTaskChange_NoChange_TimesOut()
|
||||
{
|
||||
var task = await SeedTaskAsync(TaskStatus.Running);
|
||||
var sut = BuildSut();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var result = await sut.WaitForTaskChange([task.Id], timeoutSeconds: 1, CancellationToken.None);
|
||||
|
||||
sw.Stop();
|
||||
Assert.True(result.TimedOut);
|
||||
Assert.Empty(result.Changed);
|
||||
Assert.True(sw.Elapsed >= TimeSpan.FromMilliseconds(900), $"took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForTaskChange_EmptyTaskIds_Throws()
|
||||
{
|
||||
var sut = BuildSut();
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
sut.WaitForTaskChange([], timeoutSeconds: 5, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxTimeoutSeconds_StaysComfortablyUnderMcpToolTimeout()
|
||||
{
|
||||
// InteractiveLaunchSpecService sets MCP_TOOL_TIMEOUT=200000ms for the list handler.
|
||||
Assert.True(TaskWaitMcpTools.MaxTimeoutSeconds < 200);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user