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

@@ -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.");
}
}