feat(worker): add PlanningChainCoordinator for sequential subtask execution
Coordinates Waiting -> Queued transitions between sibling subtasks: when a child finishes Done, the next Waiting sibling is promoted to Queued. WorkerHub.QueuePlanningSubtasksAsync exposes this to the UI; TaskRunner advances the chain on completion. Also tightens the planning-session prompt: planner must use MCP tools, not direct edits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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 = new PlanningChainCoordinator(_factory);
|
||||
_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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user