Register(taskId, cts) already refuses (and logs) a double-registration, but both call sites discarded the bool and dispatched anyway under an unregistered CTS. If the loser then unregistered the winner's CTS during its own cleanup, TryCancel could silently no-op against a live process. - OverrideSlotService.StartInSlot now fails RunNow/ContinueTask loudly (throws) when it loses the registration race instead of registering over — or silently proceeding despite losing to — the queue picker. - QueueService's picker loop retries registration briefly (the loser's own claim-attempt-then-unregister resolves fast) before dispatching; if registration never resolves it marks the already-claimed row Failed instead of running it unregistered. - RunCancellationRegistry.Unregister already had compare-and-remove semantics (TryRemove(KeyValuePair)), so a loser's cleanup could not have removed the winner's CTS once registration correctly failed. Added regression tests exercising the real registry through both dispatch paths: RunNow losing the registration race throws without disturbing the winner, the picker's retry succeeds and TryCancel reaches the live run when the loser unregisters in time, and the picker fails the task instead of running unregistered when it never does.
145 lines
5.0 KiB
C#
145 lines
5.0 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Runner;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Worker.Queue;
|
|
|
|
public sealed class OverrideSlotService
|
|
{
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly TaskRunner _runner;
|
|
private readonly ILogger<OverrideSlotService> _logger;
|
|
private readonly RunCancellationRegistry _runCancels;
|
|
|
|
private readonly object _lock = new();
|
|
private volatile QueueSlotState? _slot;
|
|
|
|
public OverrideSlotService(
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
TaskRunner runner,
|
|
ILogger<OverrideSlotService> logger,
|
|
RunCancellationRegistry runCancels)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_runner = runner;
|
|
_logger = logger;
|
|
_runCancels = runCancels;
|
|
}
|
|
|
|
public QueueSlotState? CurrentSlot => _slot;
|
|
|
|
public async Task RunNow(string taskId)
|
|
{
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var task = await new TaskRepository(context).GetByIdAsync(taskId);
|
|
if (task is null)
|
|
throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
|
|
|
// Fast-fail precheck only — not the race guard. A task sitting Queued still
|
|
// passes here and can race the queue picker's atomic claim; TaskRunner.RunAsync
|
|
// resolves that by claiming Running before creating any resources.
|
|
if (task.Status == Data.Models.TaskStatus.Running)
|
|
throw new InvalidOperationException("task is already running");
|
|
}
|
|
|
|
StartInSlot(taskId, ct => RunInSlotAsync(taskId, ct), "RunInSlotAsync failed for task {TaskId}");
|
|
}
|
|
|
|
public async Task<string> ContinueTask(string taskId, string followUpPrompt)
|
|
{
|
|
using (var context = _dbFactory.CreateDbContext())
|
|
{
|
|
var task = await new TaskRepository(context).GetByIdAsync(taskId)
|
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
|
|
|
if (task.Status == Data.Models.TaskStatus.Running)
|
|
throw new InvalidOperationException("task is already running");
|
|
}
|
|
|
|
StartInSlot(taskId, ct => RunContinueInSlotAsync(taskId, followUpPrompt, ct),
|
|
"RunContinueInSlotAsync failed for task {TaskId}");
|
|
|
|
return taskId;
|
|
}
|
|
|
|
// Claims the single override slot under lock, runs <work> in the background,
|
|
// and releases the slot when it completes. Throws if the slot is already busy.
|
|
private void StartInSlot(string taskId, Func<CancellationToken, Task> work, string faultMessage)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_slot is not null)
|
|
throw new InvalidOperationException("override slot busy");
|
|
|
|
var cts = new CancellationTokenSource();
|
|
if (!_runCancels.Register(taskId, cts))
|
|
{
|
|
cts.Dispose();
|
|
throw new InvalidOperationException(
|
|
$"Task '{taskId}' lost the double-dispatch race to the queue picker; it is already running there.");
|
|
}
|
|
|
|
_slot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
|
|
|
|
_ = work(cts.Token).ContinueWith(t =>
|
|
{
|
|
if (t.IsFaulted)
|
|
_logger.LogError(t.Exception, faultMessage, taskId);
|
|
lock (_lock) { _slot = null; }
|
|
_runCancels.Unregister(taskId, cts);
|
|
cts.Dispose();
|
|
}, TaskScheduler.Default);
|
|
}
|
|
}
|
|
|
|
public bool TryCancel(string taskId)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_slot is not null && _slot.TaskId == taskId)
|
|
{
|
|
_slot.Cts.Cancel();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private async Task RunInSlotAsync(string taskId, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInformation("Starting task {TaskId} in override slot", taskId);
|
|
|
|
Data.Models.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, "override", ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Override 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);
|
|
}
|
|
}
|
|
}
|