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 _dbFactory; private readonly Func _state; public PlanningChainCoordinator( IDbContextFactory dbFactory, Func 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=, // 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 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 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 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; } }