feat(planning): gate subtask queueing behind plan finalization

Planning subtasks are now "Draft" until their parent plan is finalized,
then "Planned" (queueable). Finalizing a plan no longer auto-queues the
child chain; the user sends the plan to the queue explicitly.

- TaskStateService rejects a child entering Queued/Running unless its parent
  is Finalized; this single invariant covers UI, queue, RunNow and MCP paths
- WorkerHub.SetTaskStatus routes Queued through the gated EnqueueAsync
- Finalize call sites pass queueAgentTasks: false
- PlanningChainCoordinator.QueuePlanAsync guards the chain build on Finalized
- TaskRowViewModel derives Draft/Planned from ParentFinalized; gates
  CanSendToQueue / CanQueuePlan; view shows a PLANNED badge

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-29 14:41:48 +02:00
parent 09a930e28e
commit ce79a2d0fe
10 changed files with 223 additions and 9 deletions

View File

@@ -239,4 +239,37 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
Assert.Equal(TaskStatus.Queued, kids[3].Status);
Assert.Equal("P-c2", kids[3].BlockedByTaskId);
}
[Fact]
public async Task QueuePlan_WhenParentFinalized_BuildsChain()
{
await SeedPlanningFamilyAsync("P", 2);
var count = await _sut.QueuePlanAsync("P", default);
Assert.Equal(2, 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("P-c0", kids[1].BlockedByTaskId);
}
[Fact]
public async Task QueuePlan_WhenParentNotFinalized_Throws_AndLeavesChildrenIdle()
{
await SeedPlanningFamilyAsync("P", 2);
await using (var ctx = _factory.CreateDbContext())
{
var parent = await ctx.Tasks.FirstAsync(t => t.Id == "P");
parent.PlanningPhase = PlanningPhase.Active;
await ctx.SaveChangesAsync();
}
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.QueuePlanAsync("P", default));
var kids = await GetChildrenAsync("P");
Assert.All(kids, k => Assert.Equal(TaskStatus.Idle, k.Status));
}
}