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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user