Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs
Mika Kuns 4ab906ff0b feat(planning): consolidate finalize+chain via TaskStateService, fix queue pickup
Slice 4 of the worker state consolidation refactor. Eliminates the
"queue never picks up planning tasks" bug structurally by routing both
the manager and MCP finalize paths through TaskStateService and
PlanningChainCoordinator.SetupChainAsync, where the auto-wake on enqueue
guarantees the queue picker claims the first child immediately.

- Delete TaskRepository.FinalizePlanningAsync; PlanningSessionManager
  now orchestrates via _state.FinalizePlanningAsync + _chain.SetupChainAsync.
- Rename QueueSubtasksSequentiallyAsync to SetupChainAsync (internal);
  layout is now Status=Queued + BlockedByTaskId, with auto-attached agent tag.
- OnChildFinishedAsync looks up the successor by BlockedByTaskId, drops
  the legacy Waiting status lookup.
- PlanningMcpService.Finalize routes through state+chain; EditableStatuses
  drops Waiting and adds Idle; gate uses PlanningPhase==Active.
- TaskStateService.FinalizePlanningAsync clears the planning session token.
- UI: TaskRowViewModel adds BlockedByTaskId; IsQueued/IsWaiting reflect
  the new layout; TasksIslandViewModel.RemoveFromQueueAsync clears
  BlockedByTaskId on dequeue.
- New regression test PlanningEndToEndTests.FinalizeAsync_FirstChildIs
  ClaimedByPicker_WithinDeadline asserts the picker claims the first
  child within 200ms with no manual WakeQueue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:16:12 +02:00

393 lines
14 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.State;
public sealed class TaskStateServiceTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly TestDbContextFactory _factory;
private readonly TaskStateServiceBuilder.Built _built;
private readonly ITaskStateService _sut;
private readonly string _listId;
public TaskStateServiceTests()
{
_factory = _db.CreateFactory();
_built = TaskStateServiceBuilder.Build(_factory);
_sut = _built.State;
_listId = Guid.NewGuid().ToString();
using var ctx = _factory.CreateDbContext();
ctx.Lists.Add(new ListEntity
{
Id = _listId,
Name = "Test",
CreatedAt = DateTime.UtcNow,
DefaultCommitType = "chore",
});
ctx.SaveChanges();
}
public void Dispose() => _db.Dispose();
private async Task<string> SeedTaskAsync(
TaskStatus status,
string? parentId = null,
int sortOrder = 0,
string? blockedBy = null)
{
var id = Guid.NewGuid().ToString();
await using var ctx = _factory.CreateDbContext();
ctx.Tasks.Add(new TaskEntity
{
Id = id,
ListId = _listId,
Title = "task",
Status = status,
CreatedAt = DateTime.UtcNow,
ParentTaskId = parentId,
SortOrder = sortOrder,
BlockedByTaskId = blockedBy,
});
await ctx.SaveChangesAsync();
return id;
}
private async Task<TaskStatus> GetStatusAsync(string id)
{
await using var ctx = _factory.CreateDbContext();
return await ctx.Tasks.Where(t => t.Id == id).Select(t => t.Status).FirstAsync();
}
private async Task<TaskEntity> GetTaskAsync(string id)
{
await using var ctx = _factory.CreateDbContext();
return await new TaskRepository(ctx).GetByIdAsync(id) ?? throw new InvalidOperationException($"task {id} not found");
}
// ─── EnqueueAsync ─────────────────────────────────────────────────────
[Fact]
public async Task EnqueueAsync_FromIdle_TransitionsToQueued_AndWakesQueue()
{
var id = await SeedTaskAsync(TaskStatus.Idle);
var wakesBefore = _built.WakeCount();
var result = await _sut.EnqueueAsync(id, default);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Queued, await GetStatusAsync(id));
Assert.True(_built.WakeCount() > wakesBefore);
Assert.Contains(_built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated");
}
[Fact]
public async Task EnqueueAsync_FromRunning_Rejects_AndDoesNotMutate()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.EnqueueAsync(id, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
// ─── StartRunningAsync ────────────────────────────────────────────────
[Fact]
public async Task StartRunningAsync_FromQueued_TransitionsToRunning_AndStampsStartedAt()
{
var id = await SeedTaskAsync(TaskStatus.Queued);
var startedAt = new DateTime(2026, 4, 27, 10, 0, 0, DateTimeKind.Utc);
var result = await _sut.StartRunningAsync(id, startedAt, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Running, t.Status);
Assert.Equal(startedAt, t.StartedAt);
}
[Fact]
public async Task StartRunningAsync_FromRunning_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.StartRunningAsync(id, DateTime.UtcNow, default);
Assert.False(result.Ok);
}
[Fact]
public async Task StartRunningAsync_TwoParallelClaims_ExactlyOneWins()
{
var id = await SeedTaskAsync(TaskStatus.Queued);
var startedAt = DateTime.UtcNow;
// Two concurrent calls: only one ExecuteUpdate should affect a row.
var t1 = Task.Run(() => _sut.StartRunningAsync(id, startedAt, default));
var t2 = Task.Run(() => _sut.StartRunningAsync(id, startedAt, default));
var results = await Task.WhenAll(t1, t2);
var winners = results.Count(r => r.Ok);
Assert.Equal(1, winners);
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
// ─── CompleteAsync ────────────────────────────────────────────────────
[Fact]
public async Task CompleteAsync_FromRunning_TransitionsToDone()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.CompleteAsync(id, DateTime.UtcNow, "ok", default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Done, t.Status);
Assert.Equal("ok", t.Result);
Assert.NotNull(t.FinishedAt);
}
[Fact]
public async Task CompleteAsync_FromQueued_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Queued);
var result = await _sut.CompleteAsync(id, DateTime.UtcNow, "ok", default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Queued, await GetStatusAsync(id));
}
// ─── FailAsync ────────────────────────────────────────────────────────
[Fact]
public async Task FailAsync_FromRunning_TransitionsToFailed()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.FailAsync(id, DateTime.UtcNow, "boom", default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Failed, t.Status);
Assert.Equal("boom", t.Result);
}
[Fact]
public async Task FailAsync_FromDone_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Done);
var result = await _sut.FailAsync(id, DateTime.UtcNow, "boom", default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Done, await GetStatusAsync(id));
}
// ─── CancelAsync ──────────────────────────────────────────────────────
[Fact]
public async Task CancelAsync_FromRunning_TransitionsToCancelled()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.CancelAsync(id, DateTime.UtcNow, default);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(id));
}
[Fact]
public async Task CancelAsync_FromDone_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Done);
var result = await _sut.CancelAsync(id, DateTime.UtcNow, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Done, await GetStatusAsync(id));
}
// ─── ResetToIdleAsync ─────────────────────────────────────────────────
[Fact]
public async Task ResetToIdleAsync_FromFailed_ClearsTimestamps()
{
var id = await SeedTaskAsync(TaskStatus.Failed);
await using (var ctx = _factory.CreateDbContext())
{
await ctx.Tasks.Where(t => t.Id == id)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.StartedAt, DateTime.UtcNow.AddMinutes(-5))
.SetProperty(t => t.FinishedAt, DateTime.UtcNow.AddMinutes(-1))
.SetProperty(t => t.Result, "old"));
}
var result = await _sut.ResetToIdleAsync(id, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Idle, t.Status);
Assert.Null(t.StartedAt);
Assert.Null(t.FinishedAt);
Assert.Null(t.Result);
}
[Fact]
public async Task ResetToIdleAsync_FromRunning_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.ResetToIdleAsync(id, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Running, await GetStatusAsync(id));
}
// ─── StartPlanningAsync ───────────────────────────────────────────────
[Fact]
public async Task StartPlanningAsync_FromManual_FlipsStatus_AndPlanningPhase()
{
var id = await SeedTaskAsync(TaskStatus.Manual);
var result = await _sut.StartPlanningAsync(id, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Planning, t.Status);
Assert.Equal(PlanningPhase.Active, t.PlanningPhase);
}
[Fact]
public async Task StartPlanningAsync_FromRunning_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.StartPlanningAsync(id, default);
Assert.False(result.Ok);
}
// ─── FinalizePlanningAsync ────────────────────────────────────────────
[Fact]
public async Task FinalizePlanningAsync_OnActivePhase_TransitionsToFinalized()
{
var id = await SeedTaskAsync(TaskStatus.Manual);
await _sut.StartPlanningAsync(id, default);
var result = await _sut.FinalizePlanningAsync(id, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(PlanningPhase.Finalized, t.PlanningPhase);
Assert.NotNull(t.PlanningFinalizedAt);
}
[Fact]
public async Task FinalizePlanningAsync_OnNonePhase_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Manual);
var result = await _sut.FinalizePlanningAsync(id, default);
Assert.False(result.Ok);
}
// ─── BlockOnAsync / UnblockAsync ─────────────────────────────────────
[Fact]
public async Task BlockOnAsync_SetsBlockedByTaskId()
{
var pred = await SeedTaskAsync(TaskStatus.Queued);
var task = await SeedTaskAsync(TaskStatus.Queued);
var result = await _sut.BlockOnAsync(task, pred, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(task);
Assert.Equal(pred, t.BlockedByTaskId);
}
[Fact]
public async Task UnblockAsync_ClearsBlockedByTaskId_AndWakesQueue()
{
var pred = await SeedTaskAsync(TaskStatus.Queued);
var task = await SeedTaskAsync(TaskStatus.Queued);
await _sut.BlockOnAsync(task, pred, default);
var wakesBefore = _built.WakeCount();
var result = await _sut.UnblockAsync(task, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(task);
Assert.Null(t.BlockedByTaskId);
Assert.True(_built.WakeCount() > wakesBefore);
}
[Fact]
public async Task UnblockAsync_OnWaitingTask_FlipsToQueued()
{
// Bridge to legacy chain layout: a Status=Waiting sibling becomes Queued on unblock.
var task = await SeedTaskAsync(TaskStatus.Waiting);
var result = await _sut.UnblockAsync(task, default);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Queued, await GetStatusAsync(task));
}
// ─── RecoverStaleRunningAsync ─────────────────────────────────────────
[Fact]
public async Task RecoverStaleRunningAsync_FlipsAllRunningToFailed_ReturnsCount()
{
var r1 = await SeedTaskAsync(TaskStatus.Running);
var r2 = await SeedTaskAsync(TaskStatus.Running);
var q = await SeedTaskAsync(TaskStatus.Queued);
var count = await _sut.RecoverStaleRunningAsync("worker restart", default);
Assert.Equal(2, count);
Assert.Equal(TaskStatus.Failed, await GetStatusAsync(r1));
Assert.Equal(TaskStatus.Failed, await GetStatusAsync(r2));
Assert.Equal(TaskStatus.Queued, await GetStatusAsync(q));
var t = await GetTaskAsync(r1);
Assert.StartsWith("[stale] ", t.Result);
}
// ─── Child terminal → chain advance ───────────────────────────────────
[Fact]
public async Task CompleteAsync_OnChild_AdvancesNextBlockedSibling()
{
var parent = await SeedTaskAsync(TaskStatus.Planned);
var c0 = await SeedTaskAsync(TaskStatus.Running, parentId: parent, sortOrder: 0);
var c1 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 1, blockedBy: c0);
var c2 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 2, blockedBy: c1);
var result = await _sut.CompleteAsync(c0, DateTime.UtcNow, "ok", default);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Done, await GetStatusAsync(c0));
// c1 was BlockedBy=c0 → chain coordinator unblocks → BlockedByTaskId cleared, still Queued.
var t1 = await GetTaskAsync(c1);
Assert.Equal(TaskStatus.Queued, t1.Status);
Assert.Null(t1.BlockedByTaskId);
// c2 still blocked on c1.
var t2 = await GetTaskAsync(c2);
Assert.Equal(TaskStatus.Queued, t2.Status);
Assert.Equal(c1, t2.BlockedByTaskId);
}
}