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>
328 lines
12 KiB
C#
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);
|
|
}
|
|
}
|