diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 1d7808a6..ff9b9879 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -129,6 +129,14 @@ public sealed class TaskRunner return; } } + else + { + // Queue-claimed dispatches skip StartRunningAsync (the atomic SQL claim in + // QueuePicker already flipped the row to Running), so it never broadcasts + // TaskUpdated for this transition. Send it here so the task-list badge flips + // live instead of staying on "Queued" until the run finishes. + await _broadcaster.TaskUpdated(task.Id); + } await _broadcaster.TaskStarted(slot, task.Id, now); await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct); @@ -251,7 +259,14 @@ public sealed class TaskRunner return; } } + else + { + // Queue-claimed dispatches skip StartRunningAsync, so broadcast TaskUpdated here + // (see RunAsync for the full rationale). + await _broadcaster.TaskUpdated(taskId); + } await _broadcaster.TaskStarted(slot, taskId, now); + await _broadcaster.TaskUpdated(taskId); await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs new file mode 100644 index 00000000..d1ba695f --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs @@ -0,0 +1,82 @@ +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.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Runner; + +/// Regression test for the queue-claim badge bug: QueuePicker's raw-SQL Queued->Running claim +/// bypasses TaskStateService.StartRunningAsync (which normally broadcasts TaskUpdated), so +/// alreadyClaimed=true dispatches must broadcast it themselves — otherwise the UI task-list +/// badge stays on "Queued" until the run finishes instead of flipping live. +public sealed class QueueClaimTaskUpdatedBroadcastTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _tempDir; + private readonly WorkerConfig _cfg; + private readonly CapturingHubContext _hubContext = new(); + + public QueueClaimTaskUpdatedBroadcastTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"cd_queueclaim_{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 TaskRunner BuildRunner(IClaudeProcess claude) + { + var dbFactory = _db.CreateFactory(); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); + return new TaskRunner(claude, dbFactory, new HubBroadcaster(_hubContext), wt, + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), + new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); + } + + [Fact] + public async Task RunAsync_with_alreadyClaimed_broadcasts_TaskUpdated_before_the_run_finishes() + { + string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + // Mirrors what QueuePicker's raw SQL claim already did before dispatch. + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running, + StartedAt = DateTime.UtcNow, 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 runner = BuildRunner(fake); + + Task runTask; + using (var ctx = _db.CreateContext()) + runTask = runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "queue", CancellationToken.None, alreadyClaimed: true); + + await processStarted.Task; + + Assert.Contains(_hubContext.Proxy.Calls, c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId); + + releaseProcess.TrySetResult(); + await runTask; + } +}