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:
12
src/ClaudeDo.Worker/Queue/IQueuePicker.cs
Normal file
12
src/ClaudeDo.Worker/Queue/IQueuePicker.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Atomic queue claim. Returns the claimed task (already flipped to Running with
|
||||
/// StartedAt set) or null if no eligible task is available.
|
||||
/// </summary>
|
||||
public interface IQueuePicker
|
||||
{
|
||||
Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct);
|
||||
}
|
||||
11
src/ClaudeDo.Worker/Queue/IQueueWaker.cs
Normal file
11
src/ClaudeDo.Worker/Queue/IQueueWaker.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Signals the queue dispatcher to check for new work. Wake() is non-blocking and
|
||||
/// idempotent — multiple calls before the dispatcher consumes the signal collapse
|
||||
/// into a single wake-up.
|
||||
/// </summary>
|
||||
public interface IQueueWaker
|
||||
{
|
||||
void Wake();
|
||||
}
|
||||
52
src/ClaudeDo.Worker/Queue/QueuePicker.cs
Normal file
52
src/ClaudeDo.Worker/Queue/QueuePicker.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
public sealed class QueuePicker : IQueuePicker
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
|
||||
public QueuePicker(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
=> _dbFactory = dbFactory;
|
||||
|
||||
public async Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct)
|
||||
{
|
||||
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
||||
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
||||
// Eligible task must be Queued, unblocked, due (or unscheduled), and tagged 'agent'
|
||||
// either directly or via its list. EF SQLite stores DateTime as
|
||||
// "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
var startedAtStr = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
|
||||
var rows = await ctx.Tasks.FromSqlRaw("""
|
||||
UPDATE tasks SET status = 'running', started_at = {1}
|
||||
WHERE id = (
|
||||
SELECT t.id FROM tasks t
|
||||
WHERE t.status = 'queued'
|
||||
AND t.blocked_by_task_id IS NULL
|
||||
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, startedAtStr).ToListAsync(ct);
|
||||
|
||||
return rows.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
18
src/ClaudeDo.Worker/Queue/QueueWaker.cs
Normal file
18
src/ClaudeDo.Worker/Queue/QueueWaker.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the wake semaphore. Producers (state mutations, hub) call Wake();
|
||||
/// the queue dispatcher awaits WaitAsync.
|
||||
/// </summary>
|
||||
public sealed class QueueWaker : IQueueWaker
|
||||
{
|
||||
private readonly SemaphoreSlim _signal = new(0, 1);
|
||||
|
||||
public void Wake()
|
||||
{
|
||||
try { _signal.Release(); }
|
||||
catch (SemaphoreFullException) { /* already signalled */ }
|
||||
}
|
||||
|
||||
public Task WaitAsync(CancellationToken ct) => _signal.WaitAsync(ct);
|
||||
}
|
||||
Reference in New Issue
Block a user