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

@@ -498,43 +498,4 @@ public sealed class TaskRepository
}
#endregion
#region Queue selection
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
{
// Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
// Uses raw SQL because EF cannot express UPDATE...RETURNING.
// Includes both task-level and list-level "agent" tag so lists tagged "agent"
// automatically enqueue all their tasks without per-task tagging.
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
var result = await _context.Tasks.FromSqlRaw("""
UPDATE tasks SET status = 'running'
WHERE id = (
SELECT t.id FROM tasks t
WHERE t.status = 'queued'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
AND (
EXISTS (
SELECT 1 FROM task_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.task_id = t.id AND tg.name = 'agent'
)
OR EXISTS (
SELECT 1 FROM list_tags lt
JOIN tags tg ON tg.id = lt.tag_id
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
)
)
ORDER BY t.sort_order ASC, t.created_at ASC
LIMIT 1
)
RETURNING *
""", nowStr).ToListAsync(ct);
return result.FirstOrDefault();
}
#endregion
}