Files
ClaudeDo/src/ClaudeDo.Worker/Queue/OverrideSlotService.cs
T
mika kuns 109e85da83 fix(worker): honor RunCancellationRegistry.Register's return value at both dispatch sites
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.
2026-08-06 14:34:48 +02:00

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);
}
}
}