Der Queue-Picker gatete eine task_dependencies-Kante auf status='done'. review_task(skipVerify) laesst einen erfolgreich gemergten Task aber bewusst auf WaitingForReview stehen, bis verify_merges einmal zentral laeuft -- damit blockierte jeder Nachfolger ueber den gesamten Batch plus Full-Suite-Verify hinweg, bei freien Slots. Eine Kante gilt jetzt als erfuellt, wenn der Vorgaenger 'done' ist ODER sein Merge gelandet ist (worktrees.state = 'merged'). TaskBlocking (get_task/list_tasks/wait_for_task_change) und der UI-Wartehinweis folgen derselben Regel, damit "blocked" nicht meldet, was der Picker claimt. Abgeleitet: revert_merge/abort_merge setzen den Worktree auf Active und blockieren wieder; reject_rerun/reject_park haben nie gemergt und bleiben unerfuellt; ein Task ohne Worktree wird nur ueber 'done' erfuellt; ein fehlgeschlagener verify_merges gibt Nachfolger frei, weil der Code so oder so auf main liegt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
68 lines
3.6 KiB
C#
68 lines
3.6 KiB
C#
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<ClaudeDoDbContext> _dbFactory;
|
|
|
|
public QueuePicker(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
|
=> _dbFactory = dbFactory;
|
|
|
|
public async Task<TaskEntity?> 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();
|
|
}
|
|
}
|