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:
@@ -853,7 +853,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
private bool CanEnqueue() =>
|
||||
Task != null && _worker.IsConnected && IsIdle;
|
||||
Task != null && _worker.IsConnected && IsIdle
|
||||
&& (!Task.IsChild || Task.ParentFinalized);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDequeue))]
|
||||
private async System.Threading.Tasks.Task DequeueAsync()
|
||||
|
||||
@@ -29,6 +29,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _hasPlanningChildren;
|
||||
[ObservableProperty] private bool _hasQueuedSubtasks;
|
||||
[ObservableProperty] private bool _showListChip = true;
|
||||
[ObservableProperty] private bool _parentFinalized;
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
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 IsPlanningParent => PlanningPhase != PlanningPhase.None
|
||||
|| 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
|
||||
&& PlanningPhase == PlanningPhase.None
|
||||
@@ -61,7 +64,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
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 HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||
|
||||
@@ -87,6 +95,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
OnPropertyChanged(nameof(HasLiveTail));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
@@ -96,21 +105,32 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
}
|
||||
|
||||
partial void OnParentFinalizedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
}
|
||||
|
||||
partial void OnPlanningPhaseChanged(PlanningPhase value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsPlanningParent));
|
||||
OnPropertyChanged(nameof(PlanningBadge));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||
OnPropertyChanged(nameof(CanQueuePlan));
|
||||
}
|
||||
|
||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanQueuePlan));
|
||||
}
|
||||
|
||||
partial void OnBlockedByTaskIdChanged(string? value)
|
||||
@@ -121,7 +141,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
partial void OnHasPlanningChildrenChanged(bool value)
|
||||
=> OnPropertyChanged(nameof(IsPlanningParent));
|
||||
{
|
||||
OnPropertyChanged(nameof(IsPlanningParent));
|
||||
OnPropertyChanged(nameof(CanQueuePlan));
|
||||
}
|
||||
|
||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
||||
|
||||
@@ -244,6 +244,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
foreach (var r in Items)
|
||||
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();
|
||||
UpdateSubtitle();
|
||||
}
|
||||
@@ -645,7 +655,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
await _worker.ResumePlanningSessionAsync(row.Id);
|
||||
break;
|
||||
case UnfinishedPlanningModalResult.FinalizeNow:
|
||||
await _worker.FinalizePlanningSessionAsync(row.Id);
|
||||
await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
|
||||
break;
|
||||
case UnfinishedPlanningModalResult.Discard:
|
||||
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||
@@ -713,7 +723,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); }
|
||||
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||
<MenuItem Header="Queue subtasks sequentially"
|
||||
Click="OnQueuePlanningSubtasksClick"
|
||||
IsVisible="{Binding HasPlanningChildren}"/>
|
||||
IsVisible="{Binding CanQueuePlan}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
||||
<MenuItem Header="Clear schedule"
|
||||
@@ -113,6 +113,9 @@
|
||||
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||
<TextBlock Text="DRAFT"/>
|
||||
</Border>
|
||||
<Border Classes="badge planning" IsVisible="{Binding IsPlanned}">
|
||||
<TextBlock Text="PLANNED"/>
|
||||
</Border>
|
||||
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
|
||||
<TextBlock Text="{Binding PlanningBadge}"/>
|
||||
</Border>
|
||||
|
||||
@@ -115,7 +115,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
{
|
||||
try
|
||||
{
|
||||
await _planningChain.SetupChainAsync(parentTaskId, Context.ConnectionAborted);
|
||||
await _planningChain.QueuePlanAsync(parentTaskId, Context.ConnectionAborted);
|
||||
}
|
||||
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))
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,24 @@ public sealed class PlanningChainCoordinator
|
||||
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(
|
||||
string childTaskId, TaskStatus finalStatus, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -34,6 +34,10 @@ public sealed class TaskStateService : ITaskStateService
|
||||
public async Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken 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
|
||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||
.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)
|
||||
{
|
||||
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
|
||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
@@ -234,6 +242,21 @@ public sealed class TaskStateService : ITaskStateService
|
||||
.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)
|
||||
{
|
||||
// Terminal child writes are best-effort and use CancellationToken.None so the
|
||||
|
||||
Reference in New Issue
Block a user