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

@@ -100,6 +100,30 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
[Fact]
public async Task EnqueueAsync_DraftChild_Rejected_WhenParentNotFinalized()
{
var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Active);
var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent);
var result = await _sut.EnqueueAsync(child, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(child));
}
[Fact]
public async Task EnqueueAsync_PlannedChild_Succeeds_WhenParentFinalized()
{
var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized);
var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent);
var result = await _sut.EnqueueAsync(child, default);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Queued, await GetStatusAsync(child));
}
// ─── StartRunningAsync ────────────────────────────────────────────────
[Fact]
@@ -142,6 +166,30 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
[Fact]
public async Task StartRunningAsync_DraftChild_Rejected_WhenParentNotFinalized()
{
var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Active);
var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent);
var result = await _sut.StartRunningAsync(child, DateTime.UtcNow, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(child));
}
[Fact]
public async Task StartRunningAsync_PlannedChild_Succeeds_WhenParentFinalized()
{
var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized);
var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent);
var result = await _sut.StartRunningAsync(child, DateTime.UtcNow, default);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Running, await GetStatusAsync(child));
}
// ─── CompleteAsync ────────────────────────────────────────────────────
[Fact]