Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
Mika Kuns 4ab906ff0b feat(planning): consolidate finalize+chain via TaskStateService, fix queue pickup
Slice 4 of the worker state consolidation refactor. Eliminates the
"queue never picks up planning tasks" bug structurally by routing both
the manager and MCP finalize paths through TaskStateService and
PlanningChainCoordinator.SetupChainAsync, where the auto-wake on enqueue
guarantees the queue picker claims the first child immediately.

- Delete TaskRepository.FinalizePlanningAsync; PlanningSessionManager
  now orchestrates via _state.FinalizePlanningAsync + _chain.SetupChainAsync.
- Rename QueueSubtasksSequentiallyAsync to SetupChainAsync (internal);
  layout is now Status=Queued + BlockedByTaskId, with auto-attached agent tag.
- OnChildFinishedAsync looks up the successor by BlockedByTaskId, drops
  the legacy Waiting status lookup.
- PlanningMcpService.Finalize routes through state+chain; EditableStatuses
  drops Waiting and adds Idle; gate uses PlanningPhase==Active.
- TaskStateService.FinalizePlanningAsync clears the planning session token.
- UI: TaskRowViewModel adds BlockedByTaskId; IsQueued/IsWaiting reflect
  the new layout; TasksIslandViewModel.RemoveFromQueueAsync clears
  BlockedByTaskId on dequeue.
- New regression test PlanningEndToEndTests.FinalizeAsync_FirstChildIs
  ClaimedByPicker_WithinDeadline asserts the picker claims the first
  child within 200ms with no manual WakeQueue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:16:12 +02:00

328 lines
12 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Planning;
// Minimal fakes — avoids Moq dependency.
file sealed class FakeHttpContextAccessor : IHttpContextAccessor
{
public HttpContext? HttpContext { get; set; }
}
file sealed class RecordingHubClients : IHubClients
{
public RecordingClientProxy Proxy { get; } = new();
public IClientProxy All => Proxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Client(string connectionId) => Proxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
public IClientProxy Group(string groupName) => Proxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
public IClientProxy User(string userId) => Proxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
}
file sealed class RecordingClientProxy : IClientProxy
{
public List<(string Method, object?[] Args)> Calls { get; } = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Calls.Add((method, args));
return Task.CompletedTask;
}
}
file sealed class FakeHubContext : IHubContext<WorkerHub>
{
public RecordingHubClients RecordingClients { get; } = new();
public IHubClients Clients => RecordingClients;
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class PlanningMcpServiceTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public PlanningMcpServiceTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private List<(string Method, object?[] Args)> _hubCalls = new();
private TaskStateServiceBuilder.Built? _built;
private PlanningMcpService BuildSut(string parentTaskId)
{
var httpContext = new DefaultHttpContext();
httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId };
var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext });
var hub = new FakeHubContext();
_hubCalls = hub.RecordingClients.Proxy.Calls;
var broadcaster = new HubBroadcaster(hub);
_built = TaskStateServiceBuilder.Build(_db.CreateFactory());
return new PlanningMcpService(_tasks, accessor, broadcaster, _built.State, _built.Chain);
}
private IReadOnlyList<string> TaskUpdatedIds() =>
_hubCalls
.Where(c => c.Method == "TaskUpdated")
.Select(c => (string)c.Args[0]!)
.ToList();
private async Task<TaskEntity> SeedPlanningParentAsync()
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
var parent = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "p",
Status = TaskStatus.Manual,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
await _tasks.AddAsync(parent);
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
return (await _tasks.GetByIdAsync(parent.Id))!;
}
[Fact]
public async Task CreateChildTask_CreatesDraft()
{
var parent = await SeedPlanningParentAsync();
var sut = BuildSut(parent.Id);
var result = await sut.CreateChildTask("My child", "desc", null, null, CancellationToken.None);
Assert.Equal("Draft", result.Status);
var child = await _tasks.GetByIdAsync(result.TaskId);
Assert.Equal("My child", child!.Title);
Assert.Equal(TaskStatus.Draft, child.Status);
}
[Fact]
public async Task ListChildTasks_ReturnsOnlyThisParentsChildren()
{
var parent = await SeedPlanningParentAsync();
var other = await SeedPlanningParentAsync();
await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null);
await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null);
var sut = BuildSut(parent.Id);
var list = await sut.ListChildTasks(CancellationToken.None);
Assert.Single(list);
Assert.Equal("mine", list[0].Title);
}
[Fact]
public async Task UpdateChildTask_NotAChild_Throws()
{
var parent = await SeedPlanningParentAsync();
var other = await SeedPlanningParentAsync();
var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null);
var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(otherChild.Id, "new", null, null, null, null, CancellationToken.None));
}
[Fact]
public async Task UpdateChildTask_AfterFinalize_Throws()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
// Simulate post-finalize state directly: parent.PlanningPhase=Finalized
// is the gate the MCP service checks.
var sut = BuildSut(parent.Id);
var result = await _built!.State.FinalizePlanningAsync(parent.Id, CancellationToken.None);
Assert.True(result.Ok, result.Reason);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, "new", null, null, null, null, CancellationToken.None));
}
[Fact]
public async Task UpdateChildTask_SetsTags()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "agent", "custom-tag" }, null, null, CancellationToken.None);
Assert.Contains("agent", result.Tags);
Assert.Contains("custom-tag", result.Tags);
Assert.Equal(2, result.Tags.Count);
}
[Fact]
public async Task UpdateChildTask_ReplacesTagSet()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, new[] { "agent" }, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "manual" }, null, null, CancellationToken.None);
Assert.Single(result.Tags);
Assert.Equal("manual", result.Tags[0]);
}
[Fact]
public async Task UpdateChildTask_SetsStatus()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, null, null, "Queued", CancellationToken.None);
Assert.Equal("Queued", result.Status);
var loaded = await _tasks.GetByIdAsync(c.Id);
Assert.Equal(TaskStatus.Queued, loaded!.Status);
}
[Fact]
public async Task UpdateChildTask_DisallowedStatus_Throws()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, null, null, null, null, "Running", CancellationToken.None));
}
[Fact]
public async Task UpdateChildTask_UnknownStatus_Throws()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, null, null, null, null, "NotARealStatus", CancellationToken.None));
}
[Fact]
public async Task DeleteChildTask_RemovesDraft()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
var sut = BuildSut(parent.Id);
await sut.DeleteChildTask(c.Id, CancellationToken.None);
Assert.Null(await _tasks.GetByIdAsync(c.Id));
}
[Fact]
public async Task UpdatePlanningTask_SetsTitleAndDescription()
{
var parent = await SeedPlanningParentAsync();
var sut = BuildSut(parent.Id);
await sut.UpdatePlanningTask("new title", "new desc", CancellationToken.None);
var loaded = await _tasks.GetByIdAsync(parent.Id);
Assert.Equal("new title", loaded!.Title);
Assert.Equal("new desc", loaded.Description);
}
[Fact]
public async Task Finalize_PromotesDraftsAndInvalidatesToken()
{
var parent = await SeedPlanningParentAsync();
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
var sut = BuildSut(parent.Id);
var count = await sut.Finalize(true, CancellationToken.None);
Assert.Equal(2, count);
var loaded = await _tasks.GetByIdAsync(parent.Id);
Assert.Equal(PlanningPhase.Finalized, loaded!.PlanningPhase);
Assert.Null(loaded.PlanningSessionToken);
}
[Fact]
public async Task CreateChildTask_BroadcastsBothChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var sut = BuildSut(parent.Id);
var result = await sut.CreateChildTask("c", null, null, null, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(result.TaskId, ids);
Assert.Contains(parent.Id, ids);
}
[Fact]
public async Task UpdateChildTask_BroadcastsBothChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
await sut.UpdateChildTask(c.Id, "new title", null, null, null, null, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(c.Id, ids);
Assert.Contains(parent.Id, ids);
}
[Fact]
public async Task DeleteChildTask_BroadcastsBothChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
var sut = BuildSut(parent.Id);
await sut.DeleteChildTask(c.Id, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(c.Id, ids);
Assert.Contains(parent.Id, ids);
}
[Fact]
public async Task Finalize_BroadcastsEachChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
var sut = BuildSut(parent.Id);
await sut.Finalize(true, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(c1.Id, ids);
Assert.Contains(c2.Id, ids);
Assert.Contains(parent.Id, ids);
}
}