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:
mika kuns
2026-08-10 13:41:38 +02:00
parent 6a2a19cc9e
commit 07ee535b7d
14 changed files with 1292 additions and 10 deletions
@@ -172,4 +172,48 @@ public sealed class QueueStateMcpToolsTests : IDisposable
Assert.Equal(new[] { first.Id, second.Id }, result.WaitingTaskIds);
}
[Fact]
public async Task GetQueueState_ScopeBlockedTasks_Empty_WhenNoListSerializes()
{
var listId = await SeedListAsync();
var (_, sut) = CreateSut();
var running = await SeedTaskAsync(listId, TaskStatus.Running);
running.ScopeGlobs = "src/Foo.cs";
await _ctx.SaveChangesAsync();
var queued = await SeedTaskAsync(listId, TaskStatus.Queued);
queued.ScopeGlobs = "src/Foo.cs";
await _ctx.SaveChangesAsync();
var result = await sut.GetQueueState(CancellationToken.None);
Assert.Contains(queued.Id, result.WaitingTaskIds);
Assert.Empty(result.ScopeBlockedTasks);
}
[Fact]
public async Task GetQueueState_ScopeBlockedTasks_ReportsReasonAndBlocker_WhenListSerializes()
{
var listId = await SeedListAsync();
await _listRepo.SetConfigAsync(new ListConfigEntity { ListId = listId, SerializeOnFileOverlap = true });
var (_, sut) = CreateSut();
var running = await SeedTaskAsync(listId, TaskStatus.Running);
running.ScopeGlobs = "src/Foo.cs";
await _ctx.SaveChangesAsync();
var queued = await SeedTaskAsync(listId, TaskStatus.Queued);
queued.ScopeGlobs = "src/Foo.cs";
await _ctx.SaveChangesAsync();
var result = await sut.GetQueueState(CancellationToken.None);
Assert.Contains(queued.Id, result.WaitingTaskIds);
var reason = Assert.Single(result.ScopeBlockedTasks);
Assert.Equal(queued.Id, reason.TaskId);
Assert.Equal(running.Id, reason.BlockedByTaskId);
Assert.Equal("scope_overlap", reason.Reason);
}
}
@@ -49,7 +49,8 @@ public sealed class QueuePickerTests : IDisposable
string? blockedBy = null,
bool taskAgentTag = false,
int? sortOrder = null,
bool isManual = false)
bool isManual = false,
string? scopeGlobs = null)
{
var task = new TaskEntity
{
@@ -62,6 +63,7 @@ public sealed class QueuePickerTests : IDisposable
BlockedByTaskId = blockedBy,
CommitType = "feat",
IsManual = isManual,
ScopeGlobs = scopeGlobs,
};
await _tasks.AddAsync(task);
if (sortOrder is not null)
@@ -72,6 +74,25 @@ public sealed class QueuePickerTests : IDisposable
return task;
}
private async Task SetSerializeOnFileOverlapAsync(string listId, bool value)
=> await _lists.SetConfigAsync(new ListConfigEntity { ListId = listId, SerializeOnFileOverlap = value });
private async Task SeedWorktreeAsync(string taskId, WorktreeState state, string? diffStat)
{
_ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId,
Path = $"C:\\fake\\{taskId}",
BranchName = $"claudedo/{taskId[..8]}",
BaseCommit = "base",
HeadCommit = "head",
DiffStat = diffStat,
State = state,
CreatedAt = DateTime.UtcNow,
});
await _ctx.SaveChangesAsync();
}
[Fact]
public async Task ClaimNextAsync_Skips_ManualTasks()
{
@@ -194,4 +215,86 @@ public sealed class QueuePickerTests : IDisposable
var nonNull = results.Where(r => r is not null).ToList();
Assert.Single(nonNull);
}
[Fact]
public async Task ClaimNextAsync_SerializeOff_ClaimsOverlappingQueuedTask_EvenWithRunningOverlap()
{
// Default (option off): behavior is unaffected by ScopeGlobs, even when it overlaps a
// running sibling's declared scope.
var listId = await CreateListAsync();
await SeedAsync(listId, status: TaskStatus.Running, scopeGlobs: "src/Foo.cs");
var queued = await SeedAsync(listId, scopeGlobs: "src/Foo.cs");
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(queued.Id, picked!.Id);
}
[Fact]
public async Task ClaimNextAsync_SerializeOn_SkipsQueuedTask_OverlappingRunningSibling()
{
var listId = await CreateListAsync();
await SetSerializeOnFileOverlapAsync(listId, true);
await SeedAsync(listId, status: TaskStatus.Running, scopeGlobs: "src/Foo.cs");
var overlapping = await SeedAsync(listId, scopeGlobs: "src/Foo.cs", sortOrder: 0, createdAt: DateTime.UtcNow.AddMinutes(-5));
var clear = await SeedAsync(listId, scopeGlobs: "src/Bar.cs", sortOrder: 1, createdAt: DateTime.UtcNow);
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(clear.Id, picked!.Id);
var stillQueued = await _tasks.GetByIdAsync(overlapping.Id);
Assert.Equal(TaskStatus.Queued, stillQueued!.Status);
}
[Fact]
public async Task ClaimNextAsync_SerializeOn_NoDeclaredScope_StillClaimed()
{
var listId = await CreateListAsync();
await SetSerializeOnFileOverlapAsync(listId, true);
await SeedAsync(listId, status: TaskStatus.Running, scopeGlobs: "src/Foo.cs");
var queued = await SeedAsync(listId); // no ScopeGlobs declared -- no basis to hold it back
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(queued.Id, picked!.Id);
}
[Fact]
public async Task ClaimNextAsync_SerializeOn_SkipsQueuedTask_OverlappingUnmergedFinishedSiblingDiff()
{
var listId = await CreateListAsync();
await SetSerializeOnFileOverlapAsync(listId, true);
var finished = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedWorktreeAsync(finished.Id, WorktreeState.Active, "src/Foo.cs | 3 ++-");
await SeedAsync(listId, scopeGlobs: "src/Foo.cs");
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.Null(picked);
}
[Fact]
public async Task ClaimNextAsync_SerializeOn_IgnoresMergedSiblingDiff()
{
// A merged sibling's changes are already on the base branch -- not a live conflict risk,
// so it must not hold back an overlapping queued task.
var listId = await CreateListAsync();
await SetSerializeOnFileOverlapAsync(listId, true);
var merged = await SeedAsync(listId, status: TaskStatus.Done);
await SeedWorktreeAsync(merged.Id, WorktreeState.Merged, "src/Foo.cs | 3 ++-");
var queued = await SeedAsync(listId, scopeGlobs: "src/Foo.cs");
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(queued.Id, picked!.Id);
}
}