Slice 2 of the worker state consolidation refactor (spec sections 2 and 8). Adds Worker/State/ITaskStateService + TaskStateService as the single component that mutates Status, PlanningPhase, and BlockedByTaskId. Each transition is one atomic ExecuteUpdate with a WHERE filter on the expected source status, so parallel claims are TOCTOU-free. Side effects (queue wake on -> Queued, hub TaskUpdated broadcast, chain advance + parent completion on terminal child) are owned by the service so callers no longer need to remember them. Migrated callers (mechanical, behavior preserved): - TaskRunner: HandleSuccess/HandleFailure/MarkFailed/RunAsync/ContinueAsync - StaleTaskRecovery: bulk recover stale Running tasks - TaskResetService: status flip (worktree cleanup stays in service) - PlanningSessionManager.StartAsync: status flip via state, token write via repo - PlanningChainCoordinator.OnChildFinishedAsync: routes the next-sibling write through state.UnblockAsync (Slice 4 finishes the rewrite) - ExternalMcpService.UpdateTaskStatus: Queued case via state.EnqueueAsync Repo Mark*Async helpers (MarkRunning/MarkDone/MarkFailed/FlipAllRunningToFailed) are now internal; ClaudeDo.Data grants InternalsVisibleTo to ClaudeDo.Worker and ClaudeDo.Worker.Tests for the existing repo-level tests. DI: TaskStateService is registered as Singleton in both the main app and the external-MCP app; the queue-wake delegate captures sp -> QueueService.WakeQueue to break the TaskStateService -> QueueService -> TaskRunner -> TaskStateService construction cycle. PlanningChainCoordinator takes Func<ITaskStateService> for the same reason; Slice 3 will replace both with IQueueWaker. Tests: TaskStateServiceTests covers happy + reject for every transition, the parallel StartRunningAsync claim race, child-terminal chain advancement, and stale recovery. Existing service/repo tests are updated to construct the new state-service via a TaskStateServiceBuilder helper. Pre-existing constructor drift in QueueService/ExternalMcp/PlanningHub tests is patched to keep the test project building (the surrounding test logic is otherwise untouched). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
5.3 KiB
C#
165 lines
5.3 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Planning;
|
|
|
|
public sealed class PlanningChainCoordinatorTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly TestDbContextFactory _factory;
|
|
private readonly PlanningChainCoordinator _sut;
|
|
private readonly string _listId;
|
|
|
|
public PlanningChainCoordinatorTests()
|
|
{
|
|
_factory = _db.CreateFactory();
|
|
_sut = TaskStateServiceBuilder.Build(_factory).Chain;
|
|
_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 SeedPlanningFamilyAsync(string parentId, int childCount)
|
|
{
|
|
await using var ctx = _factory.CreateDbContext();
|
|
ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = parentId,
|
|
ListId = _listId,
|
|
Title = "Parent",
|
|
CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.Planned,
|
|
});
|
|
for (int i = 0; i < childCount; i++)
|
|
{
|
|
ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = $"{parentId}-c{i}",
|
|
ListId = _listId,
|
|
Title = $"Child {i}",
|
|
CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.Manual,
|
|
ParentTaskId = parentId,
|
|
SortOrder = i,
|
|
});
|
|
}
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task<List<TaskEntity>> GetChildrenAsync(string parentId)
|
|
{
|
|
await using var ctx = _factory.CreateDbContext();
|
|
return await ctx.Tasks
|
|
.AsNoTracking()
|
|
.Where(t => t.ParentTaskId == parentId)
|
|
.OrderBy(t => t.SortOrder)
|
|
.ToListAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueueSubtasksSequentially_SetsFirstQueued_RestWaiting()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 3);
|
|
|
|
await _sut.QueueSubtasksSequentiallyAsync("P", default);
|
|
|
|
var kids = await GetChildrenAsync("P");
|
|
Assert.Equal(TaskStatus.Queued, kids[0].Status);
|
|
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
|
|
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnChildDone_FlipsNextWaitingToQueued()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 3);
|
|
await _sut.QueueSubtasksSequentiallyAsync("P", default);
|
|
|
|
// Simulate first child finishing Done.
|
|
await using (var ctx = _factory.CreateDbContext())
|
|
{
|
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
|
first.Status = TaskStatus.Done;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Done, default);
|
|
|
|
Assert.Equal("P-c1", advanced);
|
|
var kids = await GetChildrenAsync("P");
|
|
Assert.Equal(TaskStatus.Done, kids[0].Status);
|
|
Assert.Equal(TaskStatus.Queued, kids[1].Status);
|
|
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnChildFailed_DoesNotAdvanceChain()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 3);
|
|
await _sut.QueueSubtasksSequentiallyAsync("P", default);
|
|
|
|
await using (var ctx = _factory.CreateDbContext())
|
|
{
|
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
|
first.Status = TaskStatus.Failed;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
|
|
|
|
Assert.Null(advanced);
|
|
var kids = await GetChildrenAsync("P");
|
|
Assert.Equal(TaskStatus.Failed, kids[0].Status);
|
|
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
|
|
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnChildDone_LastChild_ReturnsNull()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 2);
|
|
await _sut.QueueSubtasksSequentiallyAsync("P", default);
|
|
|
|
// Mark both done, simulating chain reaching the end.
|
|
await using (var ctx = _factory.CreateDbContext())
|
|
{
|
|
foreach (var t in ctx.Tasks.Where(t => t.ParentTaskId == "P"))
|
|
t.Status = TaskStatus.Done;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var advanced = await _sut.OnChildFinishedAsync("P-c1", TaskStatus.Done, default);
|
|
|
|
Assert.Null(advanced);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueueSubtasksSequentially_RejectsNonManualChildren()
|
|
{
|
|
await SeedPlanningFamilyAsync("P", 2);
|
|
// Corrupt one child to be already Queued.
|
|
await using (var ctx = _factory.CreateDbContext())
|
|
{
|
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
|
first.Status = TaskStatus.Queued;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => _sut.QueueSubtasksSequentiallyAsync("P", default));
|
|
}
|
|
}
|