using ClaudeDo.Data; using ClaudeDo.Data.Git; 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.Runner; /// Regression test for the RunNow/queue-picker double-dispatch race: a Queued task /// dispatched both via the override slot (RunNow, unclaimed) and the queue picker (atomic /// SQL Queued->Running claim) must not create its worktree twice. Before the fix, /// TaskRunner.RunAsync created the worktree (PrepareRunDirectoryAsync) BEFORE claiming /// Running, so the losing dispatch reached WorktreeManager's "branch already exists" /// self-heal and force-removed the winner's live worktree out from under its still-running /// process. The fix claims Running first and bails out before touching git when the claim /// is rejected. public sealed class ConcurrentDispatchRaceTests : IDisposable { private readonly DbFixture _db = new(); private readonly GitRepoFixture? _repo; private readonly string _tempDir; private readonly WorkerConfig _cfg; private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public ConcurrentDispatchRaceTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"cd_race_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); _cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir }; if (GitAvailable) _repo = new GitRepoFixture(); } public void Dispose() { _db.Dispose(); _repo?.Dispose(); try { Directory.Delete(_tempDir, true); } catch { /* best effort */ } } [Fact] public async Task RunNow_RacingQueuePicker_LoserBailsBeforeTouchingWorktree() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var dbFactory = _db.CreateFactory(); string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString(); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = _repo!.RepoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = taskId, ListId = listId, Title = "Racing task", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var processStarted = new TaskCompletionSource(); var releaseProcess = new TaskCompletionSource(); var fake = new FakeClaudeProcess(async (_, _, _, _, _) => { processStarted.TrySetResult(); await releaseProcess.Task; return new RunResult { ExitCode = 0, ResultMarkdown = "ok" }; }); var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); // Winner: mirrors QueuePicker's atomic Queued->Running claim, then a queue-slot // dispatch (alreadyClaimed: true, matching QueueService.RunInSlotAsync). var picker = new QueuePicker(dbFactory); var claimed = await picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.NotNull(claimed); var winnerTask = runner.RunAsync(claimed!, "queue", CancellationToken.None, alreadyClaimed: true); await processStarted.Task; // worktree exists, "Claude" is now running in it WorktreeEntity? wtRowBefore; using (var ctx = _db.CreateContext()) wtRowBefore = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId); Assert.NotNull(wtRowBefore); var markerPath = Path.Combine(wtRowBefore!.Path, "in-progress.marker"); File.WriteAllText(markerPath, "winner's work"); // Loser: a RunNow-style dispatch (unclaimed) of the SAME task arriving after the // picker already claimed it. Before the fix this would recreate the worktree via // WorktreeManager's self-heal, wiping the winner's live directory (and this marker // file) out from under the still-running process. TaskEntity taskForOverride; using (var ctx = _db.CreateContext()) taskForOverride = (await new TaskRepository(ctx).GetByIdAsync(taskId))!; await runner.RunAsync(taskForOverride, "override", CancellationToken.None); Assert.True(File.Exists(markerPath), "loser dispatch touched the winner's live worktree"); Assert.Equal(1, fake.CallCount); releaseProcess.TrySetResult(); await winnerTask; using var verify = _db.CreateContext(); Assert.Single(await new TaskRunRepository(verify).GetByTaskIdAsync(taskId)); var wtRowAfter = await new WorktreeRepository(verify).GetByTaskIdAsync(taskId); Assert.NotNull(wtRowAfter); Assert.Equal(WorktreeState.Active, wtRowAfter!.State); Assert.Equal(wtRowBefore.Path, wtRowAfter.Path); Assert.True(Directory.Exists(wtRowAfter.Path)); } }