feat(claude-do): feat(queue): Option pro Liste — Tasks mit überlappendem Date
## Befund (Batch-Lauf 2026-08-06, Liste "Bandel.Hub") maxParallelExecutions = 5, 23 Geschwister-Tasks auf demselben Plugin. Die Parallelitaet hat die Ausfuehrung verkuerzt, aber die Konfliktlast erhoeht: eine CSS-Datei wurde von 10 Tasks angefasst, drei Razor-Dateien von je 6. Der Engpass des Gesamtdurchlaufs war nicht die Ausfuehrung, sondern Review und Merge — die sind zwingend seriell. Netto wa ClaudeDo-Task: 89f989e0-4fcf-43f5-a802-11d63df7a6d1
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
@@ -13,14 +14,30 @@ public sealed class QueuePicker : IQueuePicker
|
||||
|
||||
public async Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct)
|
||||
{
|
||||
// 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, not manual, and due (or unscheduled).
|
||||
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
|
||||
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");
|
||||
|
||||
var serializingListIds = await ctx.ListConfigs
|
||||
.Where(c => c.SerializeOnFileOverlap)
|
||||
.Select(c => c.ListId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Fast path: no list has opted into file-scope serialization, so behavior and cost stay
|
||||
// identical to before that option existed -- single atomic UPDATE...RETURNING.
|
||||
if (serializingListIds.Count == 0)
|
||||
return await ClaimTopEligibleAsync(ctx, nowStr, startedAtStr, ct);
|
||||
|
||||
return await ClaimRespectingScopeAsync(ctx, now, startedAtStr, serializingListIds.ToHashSet(StringComparer.Ordinal), ct);
|
||||
}
|
||||
|
||||
private static async Task<TaskEntity?> ClaimTopEligibleAsync(
|
||||
ClaudeDoDbContext ctx, string nowStr, string startedAtStr, CancellationToken ct)
|
||||
{
|
||||
// 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, not manual, and due (or unscheduled).
|
||||
// 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 = (
|
||||
@@ -37,4 +54,41 @@ public sealed class QueuePicker : IQueuePicker
|
||||
|
||||
return rows.FirstOrDefault();
|
||||
}
|
||||
|
||||
// At least one list wants overlapping-scope tasks serialized: walk eligible candidates in the
|
||||
// usual order and skip any whose declared scope overlaps a running or awaiting-merge sibling
|
||||
// in the same list. A candidate with no declared scope, or belonging to a list not in
|
||||
// serializingListIds, is claimed exactly as before -- there is no attempt to predict a scope
|
||||
// that was never declared and no finished sibling to infer it from.
|
||||
private async Task<TaskEntity?> ClaimRespectingScopeAsync(
|
||||
ClaudeDoDbContext ctx, DateTime now, string startedAtStr, HashSet<string> serializingListIds, CancellationToken ct)
|
||||
{
|
||||
var candidates = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Status == TaskStatus.Queued
|
||||
&& t.BlockedByTaskId == null
|
||||
&& !t.IsManual
|
||||
&& (t.ScheduledFor == null || t.ScheduledFor <= now))
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (serializingListIds.Contains(candidate.ListId)
|
||||
&& await ScopeOverlap.FindBlockingSiblingAsync(ctx, candidate, ct) is not null)
|
||||
continue;
|
||||
|
||||
var rows = await ctx.Tasks.FromSqlRaw("""
|
||||
UPDATE tasks SET status = 'running', started_at = {1}
|
||||
WHERE id = {0} AND status = 'queued'
|
||||
RETURNING *
|
||||
""", candidate.Id, startedAtStr).ToListAsync(ct);
|
||||
|
||||
var claimed = rows.FirstOrDefault();
|
||||
if (claimed is not null) return claimed;
|
||||
// Lost the race for this row to a concurrent picker -- try the next candidate.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
// Conservative overlap check between two sets of declared/observed file scopes. A scope entry may
|
||||
// be an exact path or a glob (e.g. "src/Foo/*.cs"); comparing the literal prefix before the first
|
||||
// wildcard is enough to catch real collisions without a full glob-matching engine, and it never
|
||||
// under-reports -- worst case it serializes two tasks that would not actually have collided.
|
||||
public static class ScopeOverlap
|
||||
{
|
||||
public static IReadOnlyList<string> ParseScopeGlobs(string? scopeGlobs)
|
||||
=> string.IsNullOrWhiteSpace(scopeGlobs)
|
||||
? []
|
||||
: scopeGlobs.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
public static IReadOnlyList<string> ParseDiffStatFiles(string? diffStat)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(diffStat)) return [];
|
||||
var files = new List<string>();
|
||||
foreach (var line in diffStat.Split('\n'))
|
||||
{
|
||||
var idx = line.IndexOf('|');
|
||||
if (idx > 0) files.Add(line[..idx].Trim());
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
public static bool Overlaps(IEnumerable<string> a, IEnumerable<string> b)
|
||||
{
|
||||
var bPrefixes = b.Select(LiteralPrefix).Where(p => p.Length > 0).ToList();
|
||||
if (bPrefixes.Count == 0) return false;
|
||||
|
||||
foreach (var pa in a.Select(LiteralPrefix))
|
||||
{
|
||||
if (pa.Length == 0) continue;
|
||||
foreach (var pb in bPrefixes)
|
||||
{
|
||||
if (PrefixesOverlap(pa, pb)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool PrefixesOverlap(string a, string b)
|
||||
=> a.Length <= b.Length
|
||||
? b.StartsWith(a, StringComparison.OrdinalIgnoreCase)
|
||||
: a.StartsWith(b, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string LiteralPrefix(string pattern)
|
||||
{
|
||||
var normalized = pattern.Trim().Replace('\\', '/').TrimStart('/');
|
||||
var idx = normalized.IndexOfAny(['*', '?']);
|
||||
return idx < 0 ? normalized : normalized[..idx];
|
||||
}
|
||||
|
||||
// Finds the first currently-running or awaiting-merge sibling in the candidate's list whose
|
||||
// scope overlaps the candidate's declared ScopeGlobs. Returns null (no basis to hold it back)
|
||||
// when the candidate declares no scope at all -- this never predicts scope for a task that
|
||||
// hasn't run and has none declared.
|
||||
public static async Task<string?> FindBlockingSiblingAsync(ClaudeDoDbContext ctx, TaskEntity candidate, CancellationToken ct)
|
||||
{
|
||||
var scope = ParseScopeGlobs(candidate.ScopeGlobs);
|
||||
if (scope.Count == 0) return null;
|
||||
|
||||
var siblings = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.Where(t => t.ListId == candidate.ListId
|
||||
&& t.Id != candidate.Id
|
||||
&& (t.Status == TaskStatus.Running
|
||||
|| (t.Status == TaskStatus.WaitingForReview && t.Worktree != null && t.Worktree.State == WorktreeState.Active)))
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var sibling in siblings)
|
||||
{
|
||||
var siblingScope = sibling.Status == TaskStatus.Running
|
||||
? ParseScopeGlobs(sibling.ScopeGlobs)
|
||||
: ParseDiffStatFiles(sibling.Worktree?.DiffStat);
|
||||
|
||||
if (siblingScope.Count > 0 && Overlaps(scope, siblingScope))
|
||||
return sibling.Id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user