using ClaudeDo.Data; using ClaudeDo.Data.Models; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Queue; public sealed class QueuePicker : IQueuePicker { private readonly IDbContextFactory _dbFactory; public QueuePicker(IDbContextFactory dbFactory) => _dbFactory = dbFactory; public async Task ClaimNextAsync(DateTime now, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff"); var startedAtStr = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fffffff"); // Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races. // Raw SQL because EF cannot express UPDATE...RETURNING. // Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due // (or unscheduled). A task_dependencies edge is satisfied once the predecessor is 'done' // OR its merge has landed (worktrees.state = 'merged'), because review_task(skipVerify) // deliberately parks a successfully merged task in WaitingForReview until the batch // verify_merges runs -- gating on 'done' alone stalled every successor for the whole // batch. Consequences, all deliberate: a failing verify_merges leaves the merges on the // target branch, so successors do run (the predecessor's code is there either way); // revert_merge/abort_merge flip the worktree back to 'active' and re-block; a rejected // task (reject_rerun/reject_park) never reached 'merged' and stays unmet; a task with no // worktree (handler task, empty diff) is only ever satisfied via 'done'. Unlike // blocked_by_task_id there is no cascade on the dependency failing, so a task with a // Failed dependency simply stays here, skipped, until someone intervenes. The depends-on // clause is doubly negated on purpose: "no edge whose predecessor isn't satisfied" means // a task with zero edges is eligible, ALL edges must be satisfied, and an edge pointing // at a vanished task keeps blocking (same as the old single-column form did). // EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison. var rows = await ctx.Tasks.FromSqlRaw(""" UPDATE tasks SET status = 'running', started_at = {1} WHERE id = ( SELECT t.id FROM tasks t WHERE t.status = 'queued' AND t.blocked_by_task_id IS NULL AND t.is_manual = 0 AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0}) AND NOT EXISTS ( SELECT 1 FROM task_dependencies td WHERE td.task_id = t.id AND NOT EXISTS ( SELECT 1 FROM tasks d WHERE d.id = td.depends_on_task_id AND (d.status = 'done' OR EXISTS ( SELECT 1 FROM worktrees w WHERE w.task_id = d.id AND w.state = 'merged' )) ) ) ORDER BY t.sort_order ASC, t.created_at ASC LIMIT 1 ) RETURNING * """, nowStr, startedAtStr).ToListAsync(ct); return rows.FirstOrDefault(); } }