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>
187 lines
8.0 KiB
C#
187 lines
8.0 KiB
C#
using System.Diagnostics;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
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;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Planning;
|
|
|
|
// Inline fakes — test isolation beats DRY; mirrors PlanningMcpServiceTests pattern.
|
|
file sealed class E2EFakeHttpContextAccessor : IHttpContextAccessor
|
|
{
|
|
public HttpContext? HttpContext { get; set; }
|
|
}
|
|
|
|
file sealed class E2ENullHubClients : IHubClients
|
|
{
|
|
public IClientProxy All => E2ENullClientProxy.Instance;
|
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
|
|
public IClientProxy Client(string connectionId) => E2ENullClientProxy.Instance;
|
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => E2ENullClientProxy.Instance;
|
|
public IClientProxy Group(string groupName) => E2ENullClientProxy.Instance;
|
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
|
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => E2ENullClientProxy.Instance;
|
|
public IClientProxy User(string userId) => E2ENullClientProxy.Instance;
|
|
public IClientProxy Users(IReadOnlyList<string> userIds) => E2ENullClientProxy.Instance;
|
|
}
|
|
|
|
file sealed class E2ENullClientProxy : IClientProxy
|
|
{
|
|
public static readonly E2ENullClientProxy Instance = new();
|
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
|
=> Task.CompletedTask;
|
|
}
|
|
|
|
file sealed class E2EFakeHubContext : IHubContext<WorkerHub>
|
|
{
|
|
public IHubClients Clients { get; } = new E2ENullHubClients();
|
|
public IGroupManager Groups => throw new NotImplementedException();
|
|
}
|
|
|
|
public sealed class PlanningEndToEndTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
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;
|
|
private readonly PlanningMcpService _svc;
|
|
|
|
public PlanningEndToEndTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
|
|
_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, _built.State, _built.Chain);
|
|
}
|
|
|
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
|
|
|
[Fact]
|
|
public async Task StartThenCreateThenFinalize_FullFlow()
|
|
{
|
|
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 = "Big Task",
|
|
Status = TaskStatus.Manual,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(parent);
|
|
|
|
var startCtx = await _manager.StartAsync(parent.Id, CancellationToken.None);
|
|
Assert.True(File.Exists(Path.Combine(startCtx.WorktreePath, ".mcp.json")));
|
|
|
|
// Wire the ambient context so _svc reads the correct parent
|
|
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
|
|
|
await _svc.CreateChildTask("sub 1", null, null, null, CancellationToken.None);
|
|
await _svc.CreateChildTask("sub 2", null, null, null, CancellationToken.None);
|
|
|
|
var count = await _svc.Finalize(true, CancellationToken.None);
|
|
Assert.Equal(2, count);
|
|
|
|
var reload = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(PlanningPhase.Finalized, reload!.PlanningPhase);
|
|
|
|
var kids = await _tasks.GetChildrenAsync(parent.Id);
|
|
// 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.");
|
|
}
|
|
}
|