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 chain over a planning parent's children. // - First non-terminal child gets BlockedByTaskId=null. // - Each subsequent non-terminal child gets BlockedByTaskId=, // so the picker skips them until the predecessor finishes. // - When enqueue is true, each non-terminal child is also set to Status=Queued // (the user-driven "Queue plan"). When false (finalize), children are left // Idle and only the blocked-by links are established, so nothing runs until // the user queues the plan. // - 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, bool enqueue, 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++) { if (enqueue) 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, enqueue: true, ct); } public async Task OnChildFinishedAsync( string childTaskId, TaskStatus finalStatus, CancellationToken ct = default) { var nextId = await FindSuccessorAsync(childTaskId, ct); if (nextId is null) return null; if (finalStatus == TaskStatus.Done) { await _state().UnblockAsync(nextId, ct); return nextId; } // Child failed or was cancelled: cancel the immediate successor so the chain is // not left wedged. If it's cancellable, CancelAsync's own OnChildTerminalAsync // callback recurses into this method for its successor, cascading cancellation // through the rest of the chain. If it's not (e.g. it was parked back to Idle // out of band), CancelAsync is a no-op and nothing will ever call back for it — // keep walking the chain ourselves so the tail isn't left wedged forever. var predecessorId = nextId; while (true) { var result = await _state().CancelAsync(predecessorId, DateTime.UtcNow, ct); if (result.Ok) return null; var following = await FindSuccessorAsync(predecessorId, ct); if (following is null) return null; predecessorId = following; } } // The successor is whichever sibling explicitly blocks on this task. private async Task FindSuccessorAsync(string taskId, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); return await ctx.Tasks .AsNoTracking() .Where(t => t.BlockedByTaskId == taskId) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .Select(t => t.Id) .FirstOrDefaultAsync(ct); } }