236 lines
8.1 KiB
C#
236 lines
8.1 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.Runner;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
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 IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
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(
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
TaskRunner runner,
|
|
WorkerConfig cfg,
|
|
ILogger<QueueService> logger)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_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)
|
|
{
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var taskRepo = new TaskRepository(context);
|
|
var exists = await taskRepo.GetByIdAsync(taskId);
|
|
if (exists is null)
|
|
throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
|
}
|
|
|
|
lock (_lock)
|
|
{
|
|
if (_queueSlot?.TaskId == taskId)
|
|
throw new InvalidOperationException("task is already running in queue slot");
|
|
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(taskId, "override", cts.Token).ContinueWith(t =>
|
|
{
|
|
if (t.IsFaulted)
|
|
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId}", taskId);
|
|
lock (_lock) { _overrideSlot = null; }
|
|
cts.Dispose();
|
|
}, TaskScheduler.Default);
|
|
}
|
|
}
|
|
|
|
public async Task<string> ContinueTask(string taskId, string followUpPrompt)
|
|
{
|
|
using var context = _dbFactory.CreateDbContext();
|
|
var taskRepo = new TaskRepository(context);
|
|
var task = await taskRepo.GetByIdAsync(taskId)
|
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
|
|
|
if (task.Status == Data.Models.TaskStatus.Running)
|
|
throw new InvalidOperationException("task is already running");
|
|
|
|
lock (_lock)
|
|
{
|
|
if (_queueSlot?.TaskId == taskId)
|
|
throw new InvalidOperationException("task is already running in queue slot");
|
|
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 };
|
|
|
|
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(t =>
|
|
{
|
|
if (t.IsFaulted)
|
|
_logger.LogError(t.Exception, "RunContinueInSlotAsync failed for task {TaskId}", taskId);
|
|
lock (_lock) { _overrideSlot = null; }
|
|
cts.Dispose();
|
|
}, TaskScheduler.Default);
|
|
}
|
|
|
|
return taskId;
|
|
}
|
|
|
|
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;
|
|
|
|
TaskEntity? task;
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var taskRepo = new TaskRepository(context);
|
|
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.Id, "queue", cts.Token).ContinueWith(t =>
|
|
{
|
|
if (t.IsFaulted)
|
|
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
|
|
lock (_lock) { _queueSlot = null; }
|
|
cts.Dispose();
|
|
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(string taskId, string slot, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInformation("Starting task {TaskId} in {Slot} slot", taskId, slot);
|
|
|
|
TaskEntity task;
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var taskRepo = new TaskRepository(context);
|
|
task = await taskRepo.GetByIdAsync(taskId, ct)
|
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
|
}
|
|
|
|
await _runner.RunAsync(task, slot, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
|
|
}
|
|
}
|
|
|
|
private async Task RunContinueInSlotAsync(string taskId, string followUpPrompt, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInformation("Continuing task {TaskId} in override slot", taskId);
|
|
await _runner.ContinueAsync(taskId, followUpPrompt, "override", ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Continue runner error for task {TaskId}", taskId);
|
|
}
|
|
}
|
|
}
|