refactor(worker/queue): split queue waker and picker, auto-wake on enqueue

Slice 3 of the worker state and queue consolidation refactor.

- Add IQueueWaker / QueueWaker (singleton holding the wake semaphore).
- Add IQueuePicker / QueuePicker; raw SQL UPDATE...RETURNING moves out of
  TaskRepository.GetNextQueuedAgentTaskAsync (deleted) and now also filters
  on blocked_by_task_id IS NULL and writes started_at on claim.
- TaskStateService takes IQueueWaker directly; the Func<QueueService>
  indirection is gone. State transitions to Queued auto-wake the dispatcher.
- QueueService waits via the shared waker and dispatches via the picker.
- Drop explicit _queue.WakeQueue() calls in WorkerHub.QueuePlanningSubtasksAsync
  and ExternalMcpService.AddTask. The hub WakeQueue endpoint stays for
  diagnostics, delegating to _waker.Wake().
- Migrate tests; pre-existing flaky AppSettings/ExternalMcp tests untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-27 12:05:54 +02:00
parent 8823265e5a
commit 064a903076
18 changed files with 354 additions and 191 deletions

View File

@@ -302,28 +302,4 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
Assert.NotNull(stillThere);
}
[Fact]
public async Task GetNextQueuedAgentTask_SkipsDraftPlanningPlanned()
{
var listId = await CreateListAsync();
var agentTagId = await _tags.GetOrCreateAsync("agent");
async Task<TaskEntity> T(TaskStatus s, bool withTag, string? parent = null)
{
var t = MakeTask(listId, s, parentId: parent);
await _tasks.AddAsync(t);
if (withTag) await _tasks.AddTagAsync(t.Id, agentTagId);
return t;
}
var planning = await T(TaskStatus.Planning, withTag: true);
var planned = await T(TaskStatus.Planned, withTag: true);
var draft = await T(TaskStatus.Draft, withTag: true, parent: planning.Id);
var queued = await T(TaskStatus.Queued, withTag: true);
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
Assert.NotNull(picked);
Assert.Equal(queued.Id, picked!.Id);
}
}

View File

@@ -87,64 +87,6 @@ public sealed class TaskRepositoryTests : IDisposable
Assert.Equal(entity.CommitType, loaded.CommitType);
}
[Fact]
public async Task GetNextQueuedAgentTaskAsync_Returns_OldestWithAgentTag_ViaTaskTag()
{
var listId = await CreateListAsync();
var agentTagId = await _tags.GetOrCreateAsync("agent");
var older = MakeTask(listId, createdAt: DateTime.UtcNow.AddMinutes(-10));
var newer = MakeTask(listId, createdAt: DateTime.UtcNow);
await _tasks.AddAsync(older);
await _tasks.AddAsync(newer);
await _tasks.AddTagAsync(older.Id, agentTagId);
await _tasks.AddTagAsync(newer.Id, agentTagId);
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
Assert.NotNull(picked);
Assert.Equal(older.Id, picked.Id);
}
[Fact]
public async Task GetNextQueuedAgentTaskAsync_Returns_TaskWithAgentTag_ViaListTag()
{
var listId = await CreateListAsync();
var agentTagId = await _tags.GetOrCreateAsync("agent");
await _lists.AddTagAsync(listId, agentTagId);
var task = MakeTask(listId);
await _tasks.AddAsync(task);
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
Assert.NotNull(picked);
Assert.Equal(task.Id, picked.Id);
}
[Fact]
public async Task GetNextQueuedAgentTaskAsync_ReturnsNull_WhenNoAgentTag()
{
var listId = await CreateListAsync();
var task = MakeTask(listId);
await _tasks.AddAsync(task);
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
Assert.Null(picked);
}
[Fact]
public async Task GetNextQueuedAgentTaskAsync_Skips_FutureScheduledFor()
{
var listId = await CreateListAsync();
var agentTagId = await _tags.GetOrCreateAsync("agent");
var task = MakeTask(listId, scheduledFor: DateTime.UtcNow.AddHours(1));
await _tasks.AddAsync(task);
await _tasks.AddTagAsync(task.Id, agentTagId);
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
Assert.Null(picked);
}
[Fact]
public async Task Transitions_MarkRunning_ThenMarkDone()
{
@@ -297,26 +239,6 @@ public sealed class TaskRepositoryTests : IDisposable
Assert.Equal(0, reloadB!.SortOrder);
}
[Fact]
public async Task GetNextQueuedAgentTaskAsync_Picks_ByUserSortOrder()
{
var listId = await CreateListAsync();
var agentTagId = await _tags.GetOrCreateAsync("agent");
await _lists.AddTagAsync(listId, agentTagId);
// created in order first, second; then user reorders to put second on top.
var first = MakeTask(listId, createdAt: DateTime.UtcNow.AddMinutes(-10));
var second = MakeTask(listId, createdAt: DateTime.UtcNow);
await _tasks.AddAsync(first);
await _tasks.AddAsync(second);
await _tasks.ReorderAsync(listId, new[] { second.Id, first.Id });
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
Assert.NotNull(picked);
Assert.Equal(second.Id, picked!.Id);
}
[Fact]
public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags()
{