using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Queue; /// OverrideSlotService.RunNow's own precheck — distinct from QueueService.EnsureNotInQueueSlot, /// which only guards a task already tracked in its in-memory queue-slot dict. This covers a /// task the DB already shows Running (e.g. the queue picker's atomic claim landed) regardless /// of local slot bookkeeping. The real race-safety net is TaskRunner.RunAsync claiming Running /// before creating any resources; this precheck just fails fast for the common case. public sealed class OverrideSlotServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly string _tempDir; private readonly WorkerConfig _cfg; public OverrideSlotServiceTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"cd_override_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); _cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir }; } public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } } private OverrideSlotService BuildService(RunCancellationRegistry? runCancels = null) { var dbFactory = _db.CreateFactory(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); runCancels ??= new RunCancellationRegistry(NullLogger.Instance); return new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); } [Fact] public async Task RunNow_TaskAlreadyRunningInDb_ThrowsWithoutDispatching() { string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString(); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running, StartedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var service = BuildService(); var ex = await Assert.ThrowsAsync(() => service.RunNow(taskId)); Assert.Contains("already running", ex.Message); Assert.Null(service.CurrentSlot); } [Fact] public async Task RunNow_TaskQueued_DoesNotThrow() { string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString(); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var service = BuildService(); // The dispatch itself races with FakeClaudeProcess's immediate completion in the // background, so this only asserts the precheck doesn't reject a claimable task. await service.RunNow(taskId); } // Regression for the RunCancellationRegistry.Register-return double-dispatch bug: the // queue picker's atomic Queued->Running claim can land between RunNow's DB precheck and // its registry registration. If the queue side already holds the registration, RunNow // must fail loudly instead of silently registering over it (or being ignored and then // unregistering the winner's CTS during its own cleanup). [Fact] public async Task RunNow_LosesRegistrationRaceToQueue_ThrowsAndLeavesWinnersCtsUntouched() { string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString(); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var runCancels = new RunCancellationRegistry(NullLogger.Instance); var service = BuildService(runCancels); // Simulate the queue picker having already won the registration race for this task id. using var winnerCts = new CancellationTokenSource(); Assert.True(runCancels.Register(taskId, winnerCts)); var ex = await Assert.ThrowsAsync(() => service.RunNow(taskId)); Assert.Contains("lost the double-dispatch race", ex.Message); Assert.Null(service.CurrentSlot); // The loser must not have cancelled or displaced the winner's registration. Assert.False(winnerCts.IsCancellationRequested); Assert.True(runCancels.TryCancel(taskId)); Assert.True(winnerCts.IsCancellationRequested); } }