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>
This commit is contained in:
Mika Kuns
2026-04-27 14:16:12 +02:00
parent 064a903076
commit 4ab906ff0b
17 changed files with 315 additions and 206 deletions

View File

@@ -34,7 +34,9 @@ public sealed class PlanningHubTests : IDisposable
var cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
var settingsRepo = new AppSettingsRepository(_ctx);
settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
_planning = new PlanningSessionManager(_tasks, _lists, settingsRepo, git, cfg, _rootDir);
var built = TaskStateServiceBuilder.Build(_db.CreateFactory());
_planning = new PlanningSessionManager(
_tasks, _lists, settingsRepo, git, cfg, _rootDir, built.State, built.Chain);
_launcher = new FakePlanningLauncher();
_proxy = new RecordingClientProxy();
}

View File

@@ -32,7 +32,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
public void Dispose() => _db.Dispose();
private async Task SeedPlanningFamilyAsync(string parentId, int childCount)
private async Task SeedPlanningFamilyAsync(string parentId, int childCount, TaskStatus childStatus = TaskStatus.Manual)
{
await using var ctx = _factory.CreateDbContext();
ctx.Tasks.Add(new TaskEntity
@@ -51,7 +51,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
ListId = _listId,
Title = $"Child {i}",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Manual,
Status = childStatus,
ParentTaskId = parentId,
SortOrder = i,
});
@@ -64,31 +64,67 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
await using var ctx = _factory.CreateDbContext();
return await ctx.Tasks
.AsNoTracking()
.Include(t => t.Tags)
.Where(t => t.ParentTaskId == parentId)
.OrderBy(t => t.SortOrder)
.ToListAsync();
}
[Fact]
public async Task QueueSubtasksSequentially_SetsFirstQueued_RestWaiting()
public async Task SetupChain_FirstChildQueuedUnblocked_RestQueuedBlockedByPredecessor()
{
await SeedPlanningFamilyAsync("P", 3);
await _sut.QueueSubtasksSequentiallyAsync("P", default);
var count = await _sut.SetupChainAsync("P", default);
Assert.Equal(3, count);
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);
Assert.Null(kids[0].BlockedByTaskId);
Assert.Equal(TaskStatus.Queued, kids[1].Status);
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
Assert.Equal(TaskStatus.Queued, kids[2].Status);
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
}
[Fact]
public async Task OnChildDone_FlipsNextWaitingToQueued()
public async Task SetupChain_AttachesAgentTagToAllChildren()
{
await SeedPlanningFamilyAsync("P", 2);
await _sut.SetupChainAsync("P", default);
var kids = await GetChildrenAsync("P");
Assert.All(kids, k => Assert.Contains(k.Tags, t => t.Name == "agent"));
}
[Fact]
public async Task SetupChain_AcceptsIdleChildren()
{
await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Idle);
var count = await _sut.SetupChainAsync("P", default);
Assert.Equal(2, count);
}
[Fact]
public async Task SetupChain_AcceptsDraftChildren()
{
await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Draft);
var count = await _sut.SetupChainAsync("P", default);
Assert.Equal(2, count);
}
[Fact]
public async Task OnChildDone_UnblocksTheSuccessor()
{
await SeedPlanningFamilyAsync("P", 3);
await _sut.QueueSubtasksSequentiallyAsync("P", default);
await _sut.SetupChainAsync("P", default);
// Simulate first child finishing Done.
// Mark the head child Done before announcing.
await using (var ctx = _factory.CreateDbContext())
{
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
@@ -101,15 +137,19 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
Assert.Equal("P-c1", advanced);
var kids = await GetChildrenAsync("P");
Assert.Equal(TaskStatus.Done, kids[0].Status);
// c1 was Queued+BlockedBy=c0; UnblockAsync clears the block.
Assert.Equal(TaskStatus.Queued, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
Assert.Null(kids[1].BlockedByTaskId);
// c2 still blocked on c1.
Assert.Equal(TaskStatus.Queued, kids[2].Status);
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
}
[Fact]
public async Task OnChildFailed_DoesNotAdvanceChain()
{
await SeedPlanningFamilyAsync("P", 3);
await _sut.QueueSubtasksSequentiallyAsync("P", default);
await _sut.SetupChainAsync("P", default);
await using (var ctx = _factory.CreateDbContext())
{
@@ -123,17 +163,17 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
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);
// Successors remain blocked on the failed predecessor.
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
}
[Fact]
public async Task OnChildDone_LastChild_ReturnsNull()
{
await SeedPlanningFamilyAsync("P", 2);
await _sut.QueueSubtasksSequentiallyAsync("P", default);
await _sut.SetupChainAsync("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"))
@@ -147,18 +187,17 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
}
[Fact]
public async Task QueueSubtasksSequentially_RejectsNonManualChildren()
public async Task SetupChain_RejectsRunningChildren()
{
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;
first.Status = TaskStatus.Running;
await ctx.SaveChangesAsync();
}
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.QueueSubtasksSequentiallyAsync("P", default));
() => _sut.SetupChainAsync("P", default));
}
}

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
@@ -5,6 +6,7 @@ using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
@@ -50,6 +52,11 @@ public sealed class PlanningEndToEndTests : IDisposable
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly AppSettingsRepository _settingsRepo;
private readonly GitService _git;
private readonly WorkerConfig _cfg;
private readonly string _root;
private readonly TaskStateServiceBuilder.Built _built;
private readonly PlanningSessionManager _manager;
private readonly DefaultHttpContext _httpContext;
private readonly PlanningMcpContextAccessor _accessor;
@@ -61,17 +68,20 @@ public sealed class PlanningEndToEndTests : IDisposable
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
var root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}");
var git = new GitService();
var cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(root, "central") };
var settingsRepo = new AppSettingsRepository(_ctx);
settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
_manager = new PlanningSessionManager(_tasks, _lists, settingsRepo, git, cfg, root);
_root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}");
_git = new GitService();
_cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_root, "central") };
_settingsRepo = new AppSettingsRepository(_ctx);
_settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
_built = TaskStateServiceBuilder.Build(_db.CreateFactory());
_manager = new PlanningSessionManager(
_tasks, _lists, _settingsRepo, _git, _cfg, _root, _built.State, _built.Chain);
_httpContext = new DefaultHttpContext();
_accessor = new PlanningMcpContextAccessor(new E2EFakeHttpContextAccessor { HttpContext = _httpContext });
var broadcaster = new HubBroadcaster(new E2EFakeHubContext());
_svc = new PlanningMcpService(_tasks, _accessor, broadcaster);
_svc = new PlanningMcpService(_tasks, _accessor, broadcaster, _built.State, _built.Chain);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
@@ -108,9 +118,69 @@ public sealed class PlanningEndToEndTests : IDisposable
Assert.Equal(2, count);
var reload = await _tasks.GetByIdAsync(parent.Id);
Assert.Equal(TaskStatus.Planned, reload!.Status);
Assert.Equal(PlanningPhase.Finalized, reload!.PlanningPhase);
var kids = await _tasks.GetChildrenAsync(parent.Id);
Assert.All(kids, k => Assert.Equal(TaskStatus.Manual, k.Status));
// SetupChainAsync auto-attaches agent tag and queues all children;
// the first one is unblocked, the rest are BlockedBy their predecessor.
Assert.Equal(TaskStatus.Queued, kids[0].Status);
Assert.Null(kids[0].BlockedByTaskId);
Assert.Equal(TaskStatus.Queued, kids[1].Status);
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
}
// Regression: original bug was "queue never picks up planning tasks". After Finalize
// with queueAgentTasks=true, the first child must be claimable by the queue picker
// automatically — without anyone calling WakeQueue() manually.
[Fact]
public async Task FinalizeAsync_FirstChildIsClaimedByPicker_WithinDeadline()
{
var listId = Guid.NewGuid().ToString();
var wd = Path.Combine(Path.GetTempPath(), $"cd_e2e_wd_{Guid.NewGuid():N}");
GitRepoFixture.InitRepoWithInitialCommit(wd);
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
var parent = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "Parent",
Status = TaskStatus.Manual,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
await _tasks.AddAsync(parent);
await _manager.StartAsync(parent.Id, CancellationToken.None);
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
await _svc.CreateChildTask("c1", null, null, null, CancellationToken.None);
await _svc.CreateChildTask("c2", null, null, null, CancellationToken.None);
await _svc.CreateChildTask("c3", null, null, null, CancellationToken.None);
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
var firstChildId = kidsBefore[0].Id;
var wakesBefore = _built.WakeCount();
await _manager.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
// The picker should pick the first child immediately. Auto-wake fires inside
// _state.EnqueueAsync; we don't need a manual WakeQueue() for the bug to be fixed.
var picker = new QueuePicker(_db.CreateFactory());
TaskEntity? claimed = null;
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < 200)
{
claimed = await picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
if (claimed is not null) break;
await Task.Delay(10);
}
Assert.NotNull(claimed);
Assert.Equal(firstChildId, claimed!.Id);
Assert.Equal(TaskStatus.Running, claimed.Status);
Assert.True(_built.WakeCount() > wakesBefore,
"TaskStateService.EnqueueAsync should auto-wake the queue.");
}
}

View File

@@ -64,6 +64,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private List<(string Method, object?[] Args)> _hubCalls = new();
private TaskStateServiceBuilder.Built? _built;
private PlanningMcpService BuildSut(string parentTaskId)
{
@@ -73,7 +74,8 @@ public sealed class PlanningMcpServiceTests : IDisposable
var hub = new FakeHubContext();
_hubCalls = hub.RecordingClients.Proxy.Calls;
var broadcaster = new HubBroadcaster(hub);
return new PlanningMcpService(_tasks, accessor, broadcaster);
_built = TaskStateServiceBuilder.Build(_db.CreateFactory());
return new PlanningMcpService(_tasks, accessor, broadcaster, _built.State, _built.Chain);
}
private IReadOnlyList<string> TaskUpdatedIds() =>
@@ -146,9 +148,12 @@ public sealed class PlanningMcpServiceTests : IDisposable
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false);
// Simulate post-finalize state directly: parent.PlanningPhase=Finalized
// is the gate the MCP service checks.
var sut = BuildSut(parent.Id);
var result = await _built!.State.FinalizePlanningAsync(parent.Id, CancellationToken.None);
Assert.True(result.Ok, result.Reason);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, "new", null, null, null, null, CancellationToken.None));
}
@@ -258,7 +263,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
Assert.Equal(2, count);
var loaded = await _tasks.GetByIdAsync(parent.Id);
Assert.Equal(TaskStatus.Planned, loaded!.Status);
Assert.Equal(PlanningPhase.Finalized, loaded!.PlanningPhase);
Assert.Null(loaded.PlanningSessionToken);
}

View File

@@ -20,6 +20,7 @@ public sealed class PlanningSessionManagerTests : IDisposable
private readonly WorkerConfig _cfg;
private readonly AppSettingsRepository _settingsRepo;
private readonly PlanningSessionManager _sut;
private readonly TaskStateServiceBuilder.Built _built;
public PlanningSessionManagerTests()
{
@@ -31,7 +32,9 @@ public sealed class PlanningSessionManagerTests : IDisposable
_cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
_settingsRepo = new AppSettingsRepository(_ctx);
_settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
_sut = new PlanningSessionManager(_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir);
_built = TaskStateServiceBuilder.Build(_db.CreateFactory());
_sut = new PlanningSessionManager(
_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir, _built.State, _built.Chain);
}
public void Dispose()
@@ -173,7 +176,7 @@ public sealed class PlanningSessionManagerTests : IDisposable
}
[Fact]
public async Task FinalizeAsync_PromotesDraftsAndMarksPlanned()
public async Task FinalizeAsync_PromotesDraftsAndMarksPlanningFinalized()
{
var (listId, _) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
@@ -185,7 +188,9 @@ public sealed class PlanningSessionManagerTests : IDisposable
Assert.Equal(2, count);
var loaded = await _tasks.GetByIdAsync(parent.Id);
Assert.Equal(TaskStatus.Planned, loaded!.Status);
Assert.Equal(PlanningPhase.Finalized, loaded!.PlanningPhase);
Assert.NotNull(loaded.PlanningFinalizedAt);
Assert.Null(loaded.PlanningSessionToken);
}
[Fact]

View File

@@ -188,65 +188,6 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
Assert.Null(found);
}
[Fact]
public async Task FinalizePlanningAsync_TransitionsDraftsAndParent()
{
var listId = await CreateListAsync();
var parent = MakeTask(listId, TaskStatus.Manual);
await _tasks.AddAsync(parent);
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, tagNames: new[] { "agent" }, commitType: null);
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, tagNames: null, commitType: null);
var count = await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true);
Assert.Equal(2, count);
var c1Loaded = await _tasks.GetByIdAsync(c1.Id);
var c2Loaded = await _tasks.GetByIdAsync(c2.Id);
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
Assert.Equal(TaskStatus.Queued, c1Loaded!.Status);
Assert.Equal(TaskStatus.Manual, c2Loaded!.Status);
Assert.Equal(TaskStatus.Planned, parentLoaded!.Status);
Assert.NotNull(parentLoaded.PlanningFinalizedAt);
Assert.Null(parentLoaded.PlanningSessionToken);
}
[Fact]
public async Task FinalizePlanningAsync_QueueAgentTasksFalse_AllToManual()
{
var listId = await CreateListAsync();
var parent = MakeTask(listId, TaskStatus.Manual);
await _tasks.AddAsync(parent);
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: new[] { "agent" }, commitType: null);
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false);
var cLoaded = await _tasks.GetByIdAsync(c.Id);
Assert.Equal(TaskStatus.Manual, cLoaded!.Status);
}
[Fact]
public async Task FinalizePlanningAsync_ParentWithAgentListTag_ChildIsQueued()
{
var listId = await CreateListAsync();
var agentTagId = await _tags.GetOrCreateAsync("agent");
await _lists.AddTagAsync(listId, agentTagId);
var parent = MakeTask(listId, TaskStatus.Manual);
await _tasks.AddAsync(parent);
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: null, commitType: null);
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true);
var cLoaded = await _tasks.GetByIdAsync(c.Id);
Assert.Equal(TaskStatus.Queued, cLoaded!.Status);
}
[Fact]
public async Task DiscardPlanningAsync_DeletesDraftsAndResetsParent()
{

View File

@@ -36,7 +36,11 @@ public sealed class TaskStateServiceTests : IDisposable
public void Dispose() => _db.Dispose();
private async Task<string> SeedTaskAsync(TaskStatus status, string? parentId = null, int sortOrder = 0)
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();
@@ -49,6 +53,7 @@ public sealed class TaskStateServiceTests : IDisposable
CreatedAt = DateTime.UtcNow,
ParentTaskId = parentId,
SortOrder = sortOrder,
BlockedByTaskId = blockedBy,
});
await ctx.SaveChangesAsync();
return id;
@@ -364,20 +369,24 @@ public sealed class TaskStateServiceTests : IDisposable
// ─── Child terminal → chain advance ───────────────────────────────────
[Fact]
public async Task CompleteAsync_OnChild_AdvancesNextWaitingSibling()
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.Waiting, parentId: parent, sortOrder: 1);
var c2 = await SeedTaskAsync(TaskStatus.Waiting, parentId: parent, sortOrder: 2);
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));
// Next sibling was Waiting → chain coordinator unblocks → Queued.
Assert.Equal(TaskStatus.Queued, await GetStatusAsync(c1));
// Subsequent sibling untouched.
Assert.Equal(TaskStatus.Waiting, await GetStatusAsync(c2));
// 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);
}
}