using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Queue; public sealed class QueuePickerTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly QueuePicker _picker; public QueuePickerTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _picker = new QueuePicker(_db.CreateFactory()); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private async Task CreateListAsync(bool listAgentTag = false) { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow, }); return listId; } private async Task SeedAsync( string listId, TaskStatus status = TaskStatus.Queued, DateTime? createdAt = null, DateTime? scheduledFor = null, string? blockedBy = null, bool taskAgentTag = false, int? sortOrder = null, bool isManual = false, string? scopeGlobs = null, string? dependsOn = null) { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "T", Status = status, CreatedAt = createdAt ?? DateTime.UtcNow, ScheduledFor = scheduledFor, BlockedByTaskId = blockedBy, DependsOnTaskId = dependsOn, CommitType = "feat", IsManual = isManual, ScopeGlobs = scopeGlobs, }; await _tasks.AddAsync(task); if (sortOrder is not null) { task.SortOrder = sortOrder.Value; await _tasks.UpdateAsync(task); } 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() { var listId = await CreateListAsync(); await SeedAsync(listId, isManual: true); Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None)); } [Fact] public async Task ClaimNextAsync_Skips_ManualTask_ButClaimsTheNextOne() { var listId = await CreateListAsync(); await SeedAsync(listId, createdAt: DateTime.UtcNow.AddMinutes(-5), isManual: true); var claudeTask = await SeedAsync(listId, createdAt: DateTime.UtcNow); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.NotNull(picked); Assert.Equal(claudeTask.Id, picked!.Id); } [Fact] public async Task ClaimNextAsync_Skips_TasksWithBlockedByTaskId() { var listId = await CreateListAsync(listAgentTag: true); var blocker = await SeedAsync(listId); await SeedAsync(listId, blockedBy: blocker.Id); // Only `blocker` is unblocked → it should be claimed; the second pick is null. var first = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.NotNull(first); Assert.Equal(blocker.Id, first!.Id); var second = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.Null(second); } [Fact] public async Task ClaimNextAsync_Skips_TasksWithUnmetDependsOn() { var listId = await CreateListAsync(); var dependency = await SeedAsync(listId, status: TaskStatus.Idle); await SeedAsync(listId, dependsOn: dependency.Id); // The dependency is only Idle (not Done), so both stay unclaimed: the dependent is // gated on depends_on_task_id, and the dependency itself was never queued. Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None)); } [Fact] public async Task ClaimNextAsync_Claims_TaskWhoseDependsOnIsDone() { var listId = await CreateListAsync(); var dependency = await SeedAsync(listId, status: TaskStatus.Done); var dependent = await SeedAsync(listId, dependsOn: dependency.Id); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.NotNull(picked); Assert.Equal(dependent.Id, picked!.Id); } [Fact] public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnFailed_ButKeepsClaimingOthers() { var listId = await CreateListAsync(); var dependency = await SeedAsync(listId, status: TaskStatus.Failed); await SeedAsync(listId, dependsOn: dependency.Id); var independent = await SeedAsync(listId, createdAt: DateTime.UtcNow.AddMinutes(1)); // A Failed dependency does not cascade-cancel or auto-unblock the dependent (unlike the // planning chain's BlockedByTaskId) -- it just stays Queued and skipped forever. var first = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.NotNull(first); Assert.Equal(independent.Id, first!.Id); var second = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.Null(second); } [Fact] public async Task ClaimNextAsync_Picks_TasksWithoutAgentTag() { // Queueing a task is itself the explicit "run me" signal — no tag gate. var listId = await CreateListAsync(listAgentTag: false); var task = await SeedAsync(listId); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.NotNull(picked); Assert.Equal(task.Id, picked!.Id); } [Fact] public async Task ClaimNextAsync_Skips_FutureScheduledFor() { var listId = await CreateListAsync(listAgentTag: true); await SeedAsync(listId, scheduledFor: DateTime.UtcNow.AddHours(1)); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.Null(picked); } [Fact] public async Task ClaimNextAsync_Skips_NonQueuedStatuses() { var listId = await CreateListAsync(listAgentTag: true); await SeedAsync(listId, status: TaskStatus.Idle); await SeedAsync(listId, status: TaskStatus.Running); await SeedAsync(listId, status: TaskStatus.Done); await SeedAsync(listId, status: TaskStatus.Failed); await SeedAsync(listId, status: TaskStatus.Cancelled); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.Null(picked); } [Fact] public async Task ClaimNextAsync_Picks_ByUserSortOrder_ThenCreatedAt() { var listId = await CreateListAsync(listAgentTag: true); // Created in order first, second; reorder so second is sort-order 0. var first = await SeedAsync(listId, createdAt: DateTime.UtcNow.AddMinutes(-10)); var second = await SeedAsync(listId, createdAt: DateTime.UtcNow); await _tasks.ReorderAsync(listId, new[] { second.Id, first.Id }); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.NotNull(picked); Assert.Equal(second.Id, picked!.Id); } [Fact] public async Task ClaimNextAsync_FlipsToRunning_WithStartedAt() { var listId = await CreateListAsync(listAgentTag: true); var task = await SeedAsync(listId); var before = DateTime.UtcNow; var picked = await _picker.ClaimNextAsync(before, CancellationToken.None); Assert.NotNull(picked); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Running, loaded!.Status); Assert.NotNull(loaded.StartedAt); } [Fact] public async Task ClaimNextAsync_TwoParallelPickers_OnlyOneClaimsRow() { var listId = await CreateListAsync(listAgentTag: true); await SeedAsync(listId); // Two pickers, same DB factory, racing each other. var picker1 = new QueuePicker(_db.CreateFactory()); var picker2 = new QueuePicker(_db.CreateFactory()); var t1 = Task.Run(() => picker1.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None)); var t2 = Task.Run(() => picker2.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None)); var results = await Task.WhenAll(t1, t2); 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); } }