Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.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

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));
}
}