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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user