diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 029c524..532e148 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -69,7 +69,7 @@ Allowed transitions (enforced by `TaskStateService`): ``` Idle → Queued | Running (RunNow) -Queued → Running | Cancelled | Idle | Failed (runner guard) +Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap: RunAsync can fail before StartRunningAsync is called) Running → WaitingForReview (standalone success, no children) | WaitingForChildren (parent with pending children) | Done (planning/improvement child success) | Failed | Cancelled diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index 8f0f445..d616219 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -198,6 +198,9 @@ public sealed class TaskStateService : ITaskStateService { await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) { + // Queued is intentional: OverrideSlotService dispatches RunAsync before calling + // StartRunningAsync, so a preflight failure (list not found, worktree setup) can + // reach MarkFailed while the task is still Queued in the DB. var affected = await ctx.Tasks .Where(t => t.Id == taskId && (t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued)) diff --git a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs index 531a6e3..41f06f2 100644 --- a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs @@ -232,6 +232,21 @@ public sealed class TaskStateServiceTests : IDisposable Assert.Equal("boom", t.Result); } + [Fact] + public async Task FailAsync_FromQueued_TransitionsToFailed() + { + // OverrideSlotService can call MarkFailed before StartRunningAsync when a + // preflight step (list lookup, worktree setup) fails — the task is still Queued. + var id = await SeedTaskAsync(TaskStatus.Queued); + + var result = await _sut.FailAsync(id, DateTime.UtcNow, "list not found", default); + + Assert.True(result.Ok); + var t = await GetTaskAsync(id); + Assert.Equal(TaskStatus.Failed, t.Status); + Assert.Equal("list not found", t.Result); + } + [Fact] public async Task FailAsync_FromDone_Rejects() {