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>
204 lines
6.5 KiB
C#
204 lines
6.5 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Planning;
|
|
|
|
public sealed class PlanningChainCoordinatorTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly TestDbContextFactory _factory;
|
|
private readonly PlanningChainCoordinator _sut;
|
|
private readonly string _listId;
|
|
|
|
public PlanningChainCoordinatorTests()
|
|
{
|
|
_factory = _db.CreateFactory();
|
|
_sut = TaskStateServiceBuilder.Build(_factory).Chain;
|
|
_listId = Guid.NewGuid().ToString();
|
|
using var ctx = _factory.CreateDbContext();
|
|
ctx.Lists.Add(new ListEntity
|
|
{
|
|
Id = _listId,
|
|
Name = "Test",
|
|
CreatedAt = DateTime.UtcNow,
|
|
DefaultCommitType = "chore",
|
|
});
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
public void Dispose() => _db.Dispose();
|
|
|
|
private async Task SeedPlanningFamilyAsync(string parentId, int childCount, TaskStatus childStatus = TaskStatus.Manual)
|
|
{
|
|
await using var ctx = _factory.CreateDbContext();
|
|
ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = parentId,
|
|
ListId = _listId,
|
|
Title = "Parent",
|
|
CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.Planned,
|
|
});
|
|
for (int i = 0; i < childCount; i++)
|
|
{
|
|
ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = $"{parentId}-c{i}",
|
|
ListId = _listId,
|
|
Title = $"Child {i}",
|
|
CreatedAt = DateTime.UtcNow,
|
|
Status = childStatus,
|
|
ParentTaskId = parentId,
|
|
SortOrder = i,
|
|
});
|
|
}
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task<List<TaskEntity>> GetChildrenAsync(string parentId)
|
|
{
|
|
await using var ctx = _factory.CreateDbContext();
|
|
return await ctx.Tasks
|
|
.AsNoTracking()
|
|
.Include(t => t.Tags)
|
|
.Where(t => t.ParentTaskId == parentId)
|
|
.OrderBy(t => t.SortOrder)
|
|
.ToListAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetupChain_FirstChildQueuedUnblocked_RestQueuedBlockedByPredecessor()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 3);
|
|
|
|
var count = await _sut.SetupChainAsync("P", default);
|
|
|
|
Assert.Equal(3, count);
|
|
var kids = await GetChildrenAsync("P");
|
|
Assert.Equal(TaskStatus.Queued, kids[0].Status);
|
|
Assert.Null(kids[0].BlockedByTaskId);
|
|
Assert.Equal(TaskStatus.Queued, kids[1].Status);
|
|
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
|
Assert.Equal(TaskStatus.Queued, kids[2].Status);
|
|
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetupChain_AttachesAgentTagToAllChildren()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 2);
|
|
|
|
await _sut.SetupChainAsync("P", default);
|
|
|
|
var kids = await GetChildrenAsync("P");
|
|
Assert.All(kids, k => Assert.Contains(k.Tags, t => t.Name == "agent"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetupChain_AcceptsIdleChildren()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Idle);
|
|
|
|
var count = await _sut.SetupChainAsync("P", default);
|
|
|
|
Assert.Equal(2, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetupChain_AcceptsDraftChildren()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Draft);
|
|
|
|
var count = await _sut.SetupChainAsync("P", default);
|
|
|
|
Assert.Equal(2, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnChildDone_UnblocksTheSuccessor()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 3);
|
|
await _sut.SetupChainAsync("P", default);
|
|
|
|
// Mark the head child Done before announcing.
|
|
await using (var ctx = _factory.CreateDbContext())
|
|
{
|
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
|
first.Status = TaskStatus.Done;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Done, default);
|
|
|
|
Assert.Equal("P-c1", advanced);
|
|
var kids = await GetChildrenAsync("P");
|
|
Assert.Equal(TaskStatus.Done, kids[0].Status);
|
|
// c1 was Queued+BlockedBy=c0; UnblockAsync clears the block.
|
|
Assert.Equal(TaskStatus.Queued, kids[1].Status);
|
|
Assert.Null(kids[1].BlockedByTaskId);
|
|
// c2 still blocked on c1.
|
|
Assert.Equal(TaskStatus.Queued, kids[2].Status);
|
|
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnChildFailed_DoesNotAdvanceChain()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 3);
|
|
await _sut.SetupChainAsync("P", default);
|
|
|
|
await using (var ctx = _factory.CreateDbContext())
|
|
{
|
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
|
first.Status = TaskStatus.Failed;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
|
|
|
|
Assert.Null(advanced);
|
|
var kids = await GetChildrenAsync("P");
|
|
Assert.Equal(TaskStatus.Failed, kids[0].Status);
|
|
// Successors remain blocked on the failed predecessor.
|
|
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
|
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnChildDone_LastChild_ReturnsNull()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 2);
|
|
await _sut.SetupChainAsync("P", default);
|
|
|
|
await using (var ctx = _factory.CreateDbContext())
|
|
{
|
|
foreach (var t in ctx.Tasks.Where(t => t.ParentTaskId == "P"))
|
|
t.Status = TaskStatus.Done;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var advanced = await _sut.OnChildFinishedAsync("P-c1", TaskStatus.Done, default);
|
|
|
|
Assert.Null(advanced);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetupChain_RejectsRunningChildren()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 2);
|
|
await using (var ctx = _factory.CreateDbContext())
|
|
{
|
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
|
first.Status = TaskStatus.Running;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => _sut.SetupChainAsync("P", default));
|
|
}
|
|
}
|