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

@@ -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");
}

View File

@@ -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)
{

View File

@@ -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