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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -46,4 +46,55 @@ public class TaskRowViewModelPlanningTests
|
||||
Assert.False(vm.IsPlanningParent);
|
||||
Assert.Null(vm.PlanningBadge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DraftChild_CannotSendToQueue()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id");
|
||||
vm.ParentFinalized = false;
|
||||
Assert.True(vm.IsDraft);
|
||||
Assert.False(vm.IsPlanned);
|
||||
Assert.False(vm.CanSendToQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlannedChild_CanSendToQueue()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id");
|
||||
vm.ParentFinalized = true;
|
||||
Assert.False(vm.IsDraft);
|
||||
Assert.True(vm.IsPlanned);
|
||||
Assert.True(vm.CanSendToQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandaloneIdle_CanSendToQueue()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle);
|
||||
Assert.False(vm.IsChild);
|
||||
Assert.True(vm.CanSendToQueue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinalizedParentWithChildren_CanQueuePlan()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||
vm.HasPlanningChildren = true;
|
||||
Assert.True(vm.CanQueuePlan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveParentWithChildren_CannotQueuePlan()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
|
||||
vm.HasPlanningChildren = true;
|
||||
Assert.False(vm.CanQueuePlan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinalizedParentWithoutChildren_CannotQueuePlan()
|
||||
{
|
||||
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
||||
Assert.False(vm.CanQueuePlan);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user