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>
This commit is contained in:
Mika Kuns
2026-04-27 14:16:12 +02:00
parent 064a903076
commit 4ab906ff0b
17 changed files with 315 additions and 206 deletions

View File

@@ -32,7 +32,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
public void Dispose() => _db.Dispose();
private async Task SeedPlanningFamilyAsync(string parentId, int childCount)
private async Task SeedPlanningFamilyAsync(string parentId, int childCount, TaskStatus childStatus = TaskStatus.Manual)
{
await using var ctx = _factory.CreateDbContext();
ctx.Tasks.Add(new TaskEntity
@@ -51,7 +51,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
ListId = _listId,
Title = $"Child {i}",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Manual,
Status = childStatus,
ParentTaskId = parentId,
SortOrder = i,
});
@@ -64,31 +64,67 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
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 QueueSubtasksSequentially_SetsFirstQueued_RestWaiting()
public async Task SetupChain_FirstChildQueuedUnblocked_RestQueuedBlockedByPredecessor()
{
await SeedPlanningFamilyAsync("P", 3);
await _sut.QueueSubtasksSequentiallyAsync("P", default);
var count = await _sut.SetupChainAsync("P", default);
Assert.Equal(3, count);
var kids = await GetChildrenAsync("P");
Assert.Equal(TaskStatus.Queued, kids[0].Status);
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].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 OnChildDone_FlipsNextWaitingToQueued()
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.QueueSubtasksSequentiallyAsync("P", default);
await _sut.SetupChainAsync("P", default);
// Simulate first child finishing Done.
// Mark the head child Done before announcing.
await using (var ctx = _factory.CreateDbContext())
{
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
@@ -101,15 +137,19 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
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.Equal(TaskStatus.Waiting, kids[2].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.QueueSubtasksSequentiallyAsync("P", default);
await _sut.SetupChainAsync("P", default);
await using (var ctx = _factory.CreateDbContext())
{
@@ -123,17 +163,17 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
Assert.Null(advanced);
var kids = await GetChildrenAsync("P");
Assert.Equal(TaskStatus.Failed, kids[0].Status);
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].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.QueueSubtasksSequentiallyAsync("P", default);
await _sut.SetupChainAsync("P", default);
// Mark both done, simulating chain reaching the end.
await using (var ctx = _factory.CreateDbContext())
{
foreach (var t in ctx.Tasks.Where(t => t.ParentTaskId == "P"))
@@ -147,18 +187,17 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
}
[Fact]
public async Task QueueSubtasksSequentially_RejectsNonManualChildren()
public async Task SetupChain_RejectsRunningChildren()
{
await SeedPlanningFamilyAsync("P", 2);
// Corrupt one child to be already Queued.
await using (var ctx = _factory.CreateDbContext())
{
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
first.Status = TaskStatus.Queued;
first.Status = TaskStatus.Running;
await ctx.SaveChangesAsync();
}
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.QueueSubtasksSequentiallyAsync("P", default));
() => _sut.SetupChainAsync("P", default));
}
}