Slice 6 of the worker state and queue consolidation refactor. * Drop Manual, Planning, Planned, Draft, Waiting from the TaskStatus enum and from the EF value converter; only the lifecycle values remain (Idle, Queued, Running, Done, Failed, Cancelled). * Add migration RetireLegacyTaskStatus that rewrites existing rows: manual/draft -> idle, planning -> idle+planning_phase=active, planned -> idle+planning_phase=finalized, waiting -> queued+blocked_by derived from sort_order via a CTE with LAG(). * Reroute every call site that compared/set legacy values to the new three-field model (Status + PlanningPhase + BlockedByTaskId), including the planning repo helpers, MCP services, the planning chain coordinator, and the UI view-models. TaskRowViewModel now exposes PlanningPhase to drive the planning badge. * Refresh Worker/CLAUDE.md and Data/CLAUDE.md, the docs/plan.md status section, and the planning verification notes in docs/open.md.
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.Idle,
|
|
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.Idle,
|
|
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.");
|
|
}
|
|
}
|