feat(worker): add claude-cli runner, queue service, and hub api

Runner stack (non-worktree path): IClaudeProcess + ClaudeProcess spawning the
CLI with --output-format stream-json, prompt via stdin, parses the final
type:"result" line into RunResult. LogWriter appends ndjson to
~/.todo-app/logs/<taskId>.ndjson. TaskRunner orchestrates DB transitions
(MarkRunning -> MarkDone/Failed) and pushes TaskStarted/Message/Finished/
Updated via HubBroadcaster. Worktree-backed lists short-circuit with a
"Slice E" failure message until git support lands.

QueueService (BackgroundService) holds two in-memory slots (_queueSlot +
_overrideSlot) guarded by a lock. Uses PeriodicTimer + SemaphoreSlim wake
signal so WakeQueue() triggers an instant pickup. RunNow throws
InvalidOperationException when override busy; CancelTask cancels the linked
CTS which kills the child process tree.

WorkerHub extended with GetActive, RunNow (translated to HubException
variants), CancelTask, WakeQueue. HubBroadcaster exposes typed push methods.

Tests: 26 pass (12 new). QueueServiceTests cover override-busy,
schedule-filter, FIFO sequentiality, cancellation, plus a FakeClaudeProcess
that blocks on a TCS for deterministic slot-state assertions.
MessageParserTests cover result extraction + malformed/non-result lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-13 12:14:00 +02:00
parent 9f51ff0b17
commit e5038d7e16
14 changed files with 884 additions and 4 deletions

View File

@@ -0,0 +1,162 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Runner;
namespace ClaudeDo.Worker.Services;
public sealed class QueueSlotState
{
public required string TaskId { get; init; }
public required DateTime StartedAt { get; init; }
public required CancellationTokenSource Cts { get; init; }
}
public sealed class QueueService : BackgroundService
{
private readonly TaskRepository _taskRepo;
private readonly TaskRunner _runner;
private readonly WorkerConfig _cfg;
private readonly ILogger<QueueService> _logger;
private readonly object _lock = new();
private volatile QueueSlotState? _queueSlot;
private volatile QueueSlotState? _overrideSlot;
private readonly SemaphoreSlim _wakeSignal = new(0, 1);
public QueueService(
TaskRepository taskRepo,
TaskRunner runner,
WorkerConfig cfg,
ILogger<QueueService> logger)
{
_taskRepo = taskRepo;
_runner = runner;
_cfg = cfg;
_logger = logger;
}
public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive()
{
var list = new List<(string, string, DateTime)>();
var q = _queueSlot;
if (q is not null) list.Add(("queue", q.TaskId, q.StartedAt));
var o = _overrideSlot;
if (o is not null) list.Add(("override", o.TaskId, o.StartedAt));
return list;
}
public void WakeQueue()
{
// Release if not already signalled.
try { _wakeSignal.Release(); }
catch (SemaphoreFullException) { /* already signalled */ }
}
public async Task RunNow(string taskId)
{
var task = await _taskRepo.GetByIdAsync(taskId);
if (task is null)
throw new KeyNotFoundException($"Task '{taskId}' not found.");
lock (_lock)
{
if (_overrideSlot is not null)
throw new InvalidOperationException("override slot busy");
var cts = new CancellationTokenSource();
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunInSlotAsync(task, "override", cts.Token).ContinueWith(_ =>
{
lock (_lock) { _overrideSlot = null; }
}, TaskScheduler.Default);
}
}
public bool CancelTask(string taskId)
{
lock (_lock)
{
if (_queueSlot is not null && _queueSlot.TaskId == taskId)
{
_queueSlot.Cts.Cancel();
return true;
}
if (_overrideSlot is not null && _overrideSlot.TaskId == taskId)
{
_overrideSlot.Cts.Cancel();
return true;
}
}
return false;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("QueueService started");
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_cfg.QueueBackstopIntervalMs));
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Wait for wake signal or backstop timer.
var wakeTask = _wakeSignal.WaitAsync(stoppingToken);
var timerTask = timer.WaitForNextTickAsync(stoppingToken).AsTask();
await Task.WhenAny(wakeTask, timerTask);
// Drain wake signal if it fired.
if (wakeTask.IsCompletedSuccessfully)
{
// Good — signal consumed.
}
if (_queueSlot is not null) continue;
var task = await _taskRepo.GetNextQueuedAgentTaskAsync(DateTime.UtcNow, stoppingToken);
if (task is null) continue;
lock (_lock)
{
if (_queueSlot is not null) continue;
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
_queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunInSlotAsync(task, "queue", cts.Token).ContinueWith(_ =>
{
lock (_lock) { _queueSlot = null; }
WakeQueue(); // Check for next task immediately.
}, TaskScheduler.Default);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "QueueService loop error");
}
}
_logger.LogInformation("QueueService stopping");
}
private async Task RunInSlotAsync(TaskEntity task, string slot, CancellationToken ct)
{
try
{
_logger.LogInformation("Starting task {TaskId} in {Slot} slot", task.Id, slot);
await _runner.RunAsync(task, slot, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Slot runner error for task {TaskId}", task.Id);
}
}
}