Files
ClaudeDo/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs
mika kuns ce79a2d0fe 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>
2026-05-29 14:41:48 +02:00

108 lines
4.5 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Planning;
public sealed class PlanningChainCoordinator
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly Func<ITaskStateService> _state;
public PlanningChainCoordinator(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
Func<ITaskStateService> state)
{
_dbFactory = dbFactory;
_state = state;
}
// Sets up a sequential queue chain over a planning parent's children.
// - First non-terminal child gets Status=Queued, BlockedByTaskId=null.
// - Each subsequent non-terminal child gets Status=Queued + BlockedByTaskId=<predecessor>,
// so the picker skips them until the predecessor finishes.
// - Terminal children (Done/Failed/Cancelled) are left untouched; they are
// skipped when computing predecessors so a re-run on a partially executed
// chain leaves history alone but still reshapes the tail.
// - Running children abort the operation — the chain cannot be reshaped while
// one of its members is mid-flight.
// Returns the number of children placed in the chain.
internal async Task<int> SetupChainAsync(string parentTaskId, CancellationToken ct = default)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var parent = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == parentTaskId, ct)
?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
var children = await ctx.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
if (children.Count == 0)
throw new InvalidOperationException("Parent has no subtasks.");
var running = children.FirstOrDefault(c => c.Status == TaskStatus.Running);
if (running is not null)
throw new InvalidOperationException(
$"Child {running.Id} is running; cannot reshape chain.");
// Re-shape over Idle and Queued children only; leave Done/Failed/Cancelled
// (terminal) results in place.
var sequenceable = children
.Where(c => c.Status == TaskStatus.Idle || c.Status == TaskStatus.Queued)
.ToList();
var state = _state();
for (int i = 0; i < sequenceable.Count; i++)
{
await state.EnqueueAsync(sequenceable[i].Id, ct);
if (i == 0)
await state.UnblockAsync(sequenceable[i].Id, ct);
else
await state.BlockOnAsync(sequenceable[i].Id, sequenceable[i - 1].Id, ct);
}
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)
{
if (finalStatus != TaskStatus.Done) return null;
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
// The successor is whichever sibling explicitly blocks on this child.
// No status check — UnblockAsync flips legacy Waiting to Queued and is a no-op
// for already-Queued rows in the new layout.
var nextId = await ctx.Tasks
.AsNoTracking()
.Where(t => t.BlockedByTaskId == childTaskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => t.Id)
.FirstOrDefaultAsync(ct);
if (nextId is null) return null;
await _state().UnblockAsync(nextId, ct);
return nextId;
}
}