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

@@ -853,7 +853,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
} }
private bool CanEnqueue() => private bool CanEnqueue() =>
Task != null && _worker.IsConnected && IsIdle; Task != null && _worker.IsConnected && IsIdle
&& (!Task.IsChild || Task.ParentFinalized);
[RelayCommand(CanExecute = nameof(CanDequeue))] [RelayCommand(CanExecute = nameof(CanDequeue))]
private async System.Threading.Tasks.Task DequeueAsync() private async System.Threading.Tasks.Task DequeueAsync()

View File

@@ -29,6 +29,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private bool _hasPlanningChildren; [ObservableProperty] private bool _hasPlanningChildren;
[ObservableProperty] private bool _hasQueuedSubtasks; [ObservableProperty] private bool _hasQueuedSubtasks;
[ObservableProperty] private bool _showListChip = true; [ObservableProperty] private bool _showListChip = true;
[ObservableProperty] private bool _parentFinalized;
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
@@ -39,7 +40,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId); public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|| HasPlanningChildren; || HasPlanningChildren;
public bool IsDraft => IsChild && Status == TaskStatus.Idle; // A subtask is Draft until its planning parent is finalized, then Planned (queueable).
public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized;
public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized;
public bool CanOpenPlanningSession => Status == TaskStatus.Idle public bool CanOpenPlanningSession => Status == TaskStatus.Idle
&& PlanningPhase == PlanningPhase.None && PlanningPhase == PlanningPhase.None
@@ -61,7 +64,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId); public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId); public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks; public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks; public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks
&& (!IsChild || ParentFinalized);
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
public bool CanQueuePlan => !IsChild && HasPlanningChildren
&& PlanningPhase == PlanningPhase.Finalized
&& !HasQueuedSubtasks;
public bool HasSchedule => ScheduledFor.HasValue; public bool HasSchedule => ScheduledFor.HasValue;
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail); public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
@@ -87,6 +95,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsWaiting)); OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(HasLiveTail)); OnPropertyChanged(nameof(HasLiveTail));
OnPropertyChanged(nameof(IsDraft)); OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanOpenPlanningSession)); OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanRemoveFromQueue)); OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue)); OnPropertyChanged(nameof(CanSendToQueue));
@@ -96,21 +105,32 @@ public sealed partial class TaskRowViewModel : ViewModelBase
{ {
OnPropertyChanged(nameof(IsChild)); OnPropertyChanged(nameof(IsChild));
OnPropertyChanged(nameof(IsDraft)); OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession)); OnPropertyChanged(nameof(CanOpenPlanningSession));
} }
partial void OnParentFinalizedChanged(bool value)
{
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
}
partial void OnPlanningPhaseChanged(PlanningPhase value) partial void OnPlanningPhaseChanged(PlanningPhase value)
{ {
OnPropertyChanged(nameof(IsPlanningParent)); OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(PlanningBadge)); OnPropertyChanged(nameof(PlanningBadge));
OnPropertyChanged(nameof(CanOpenPlanningSession)); OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning)); OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
OnPropertyChanged(nameof(CanQueuePlan));
} }
partial void OnHasQueuedSubtasksChanged(bool value) partial void OnHasQueuedSubtasksChanged(bool value)
{ {
OnPropertyChanged(nameof(CanRemoveFromQueue)); OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue)); OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanQueuePlan));
} }
partial void OnBlockedByTaskIdChanged(string? value) partial void OnBlockedByTaskIdChanged(string? value)
@@ -121,7 +141,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
} }
partial void OnHasPlanningChildrenChanged(bool value) partial void OnHasPlanningChildrenChanged(bool value)
=> OnPropertyChanged(nameof(IsPlanningParent)); {
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(CanQueuePlan));
}
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch)); partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail)); partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));

View File

@@ -244,6 +244,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
foreach (var r in Items) foreach (var r in Items)
r.HasQueuedSubtasks = parentsWithQueuedKids.Contains(r.Id); r.HasQueuedSubtasks = parentsWithQueuedKids.Contains(r.Id);
// A subtask is "Planned" (queueable) once its planning parent is finalized;
// until then it is a "Draft".
var finalizedParents = Items
.Where(r => r.PlanningPhase == PlanningPhase.Finalized)
.Select(r => r.Id)
.ToHashSet();
foreach (var r in Items)
r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId)
&& finalizedParents.Contains(r.ParentTaskId!);
Regroup(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
} }
@@ -645,7 +655,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
await _worker.ResumePlanningSessionAsync(row.Id); await _worker.ResumePlanningSessionAsync(row.Id);
break; break;
case UnfinishedPlanningModalResult.FinalizeNow: case UnfinishedPlanningModalResult.FinalizeNow:
await _worker.FinalizePlanningSessionAsync(row.Id); await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
break; break;
case UnfinishedPlanningModalResult.Discard: case UnfinishedPlanningModalResult.Discard:
await TryDiscardPlanningWithRetryAsync(row.Id); await TryDiscardPlanningWithRetryAsync(row.Id);
@@ -713,7 +723,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row) private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
{ {
if (row is null) return; if (row is null) return;
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); } try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
catch { } catch { }
} }

View File

@@ -60,7 +60,7 @@
IsVisible="{Binding CanResumeOrDiscardPlanning}"/> IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="Queue subtasks sequentially" <MenuItem Header="Queue subtasks sequentially"
Click="OnQueuePlanningSubtasksClick" Click="OnQueuePlanningSubtasksClick"
IsVisible="{Binding HasPlanningChildren}"/> IsVisible="{Binding CanQueuePlan}"/>
<Separator/> <Separator/>
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/> <MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
<MenuItem Header="Clear schedule" <MenuItem Header="Clear schedule"
@@ -113,6 +113,9 @@
<Border Classes="badge draft" IsVisible="{Binding IsDraft}"> <Border Classes="badge draft" IsVisible="{Binding IsDraft}">
<TextBlock Text="DRAFT"/> <TextBlock Text="DRAFT"/>
</Border> </Border>
<Border Classes="badge planning" IsVisible="{Binding IsPlanned}">
<TextBlock Text="PLANNED"/>
</Border>
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}"> <Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
<TextBlock Text="{Binding PlanningBadge}"/> <TextBlock Text="{Binding PlanningBadge}"/>
</Border> </Border>

View File

@@ -115,7 +115,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{ {
try try
{ {
await _planningChain.SetupChainAsync(parentTaskId, Context.ConnectionAborted); await _planningChain.QueuePlanAsync(parentTaskId, Context.ConnectionAborted);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
@@ -380,7 +380,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{ {
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed)) if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
throw new HubException($"unknown status: {status}"); throw new HubException($"unknown status: {status}");
var result = await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted); // Queueing goes through the gated transition so draft subtasks can't be queued;
// other statuses keep the unconditional "set status freely" affordance.
var result = parsed == TaskStatus.Queued
? await _state.EnqueueAsync(taskId, Context.ConnectionAborted)
: await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted);
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed"); if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
} }

View File

@@ -66,6 +66,24 @@ public sealed class PlanningChainCoordinator
return sequenceable.Count; return sequenceable.Count;
} }
// User-triggered "send plan to queue". Only valid once the plan is finalized
// (children are "Planned"); otherwise the children are still drafts.
public async Task<int> QueuePlanAsync(string parentTaskId, CancellationToken ct = default)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var phase = await ctx.Tasks.AsNoTracking()
.Where(t => t.Id == parentTaskId)
.Select(t => (PlanningPhase?)t.PlanningPhase)
.FirstOrDefaultAsync(ct);
if (phase is null)
throw new InvalidOperationException($"Task {parentTaskId} not found.");
if (phase != PlanningPhase.Finalized)
throw new InvalidOperationException("Plan must be finalized before it can be queued.");
return await SetupChainAsync(parentTaskId, ct);
}
public async Task<string?> OnChildFinishedAsync( public async Task<string?> OnChildFinishedAsync(
string childTaskId, TaskStatus finalStatus, CancellationToken ct = default) string childTaskId, TaskStatus finalStatus, CancellationToken ct = default)
{ {

View File

@@ -34,6 +34,10 @@ public sealed class TaskStateService : ITaskStateService
public async Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct) public async Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct)
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
if (await IsDraftChildAsync(ctx, taskId, ct))
return new TransitionResult(false, "Draft subtask: finalize the plan before queuing it.");
var affected = await ctx.Tasks var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running) .Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct); .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct);
@@ -49,6 +53,10 @@ public sealed class TaskStateService : ITaskStateService
public async Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct) public async Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct)
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
if (await IsDraftChildAsync(ctx, taskId, ct))
return new TransitionResult(false, "Draft subtask: finalize the plan before running it.");
var affected = await ctx.Tasks var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running) .Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
@@ -234,6 +242,21 @@ public sealed class TaskStateService : ITaskStateService
.SetProperty(t => t.Result, resultText), ct); .SetProperty(t => t.Result, resultText), ct);
} }
// A subtask is "draft" until its planning parent is finalized. Draft subtasks must not be
// queued or run by any path (UI, queue, RunNow, MCP). Standalone tasks are never draft.
private static async Task<bool> IsDraftChildAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct)
{
var parentId = await ctx.Tasks.AsNoTracking()
.Where(t => t.Id == taskId)
.Select(t => t.ParentTaskId)
.FirstOrDefaultAsync(ct);
if (parentId is null) return false;
var parentFinalized = await ctx.Tasks.AsNoTracking()
.AnyAsync(p => p.Id == parentId && p.PlanningPhase == PlanningPhase.Finalized, ct);
return !parentFinalized;
}
private async Task OnChildTerminalAsync(string taskId, TaskStatus finalStatus) private async Task OnChildTerminalAsync(string taskId, TaskStatus finalStatus)
{ {
// Terminal child writes are best-effort and use CancellationToken.None so the // Terminal child writes are best-effort and use CancellationToken.None so the

View File

@@ -239,4 +239,37 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
Assert.Equal(TaskStatus.Queued, kids[3].Status); Assert.Equal(TaskStatus.Queued, kids[3].Status);
Assert.Equal("P-c2", kids[3].BlockedByTaskId); 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));
}
} }

View File

@@ -100,6 +100,30 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id)); 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 ──────────────────────────────────────────────── // ─── StartRunningAsync ────────────────────────────────────────────────
[Fact] [Fact]
@@ -142,6 +166,30 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id)); 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 ──────────────────────────────────────────────────── // ─── CompleteAsync ────────────────────────────────────────────────────
[Fact] [Fact]

View File

@@ -46,4 +46,55 @@ public class TaskRowViewModelPlanningTests
Assert.False(vm.IsPlanningParent); Assert.False(vm.IsPlanningParent);
Assert.Null(vm.PlanningBadge); 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);
}
} }