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>
238 lines
8.6 KiB
C#
238 lines
8.6 KiB
C#
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.Services;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Xunit;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Hub;
|
|
|
|
public sealed class PlanningHubTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly string _rootDir;
|
|
private readonly PlanningSessionManager _planning;
|
|
private readonly FakePlanningLauncher _launcher;
|
|
private readonly RecordingClientProxy _proxy;
|
|
|
|
public PlanningHubTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
_rootDir = Path.Combine(Path.GetTempPath(), $"cd_hub_planning_{Guid.NewGuid():N}");
|
|
var git = new GitService();
|
|
var cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
|
|
var settingsRepo = new AppSettingsRepository(_ctx);
|
|
settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
|
|
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();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_ctx.Dispose();
|
|
_db.Dispose();
|
|
try { Directory.Delete(_rootDir, recursive: true); } catch { }
|
|
}
|
|
|
|
private WorkerHub CreateHub()
|
|
{
|
|
var hub = new WorkerHub(
|
|
null!, null!, null!, null!, null!, null!, null!, null!, null!,
|
|
_planning, _launcher, null!, null!, null!);
|
|
hub.Clients = new FakeHubCallerClients(_proxy);
|
|
hub.Context = new FakeHubCallerContext();
|
|
return hub;
|
|
}
|
|
|
|
private async Task<(string listId, string taskId)> SeedAsync()
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
|
GitRepoFixture.InitRepoWithInitialCommit(wd);
|
|
await _lists.AddAsync(new ListEntity
|
|
{
|
|
Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "Do something",
|
|
Status = TaskStatus.Manual,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "feat",
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return (listId, task.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
|
|
{
|
|
var (_, taskId) = await SeedAsync();
|
|
var hub = CreateHub();
|
|
|
|
var ctx = await hub.StartPlanningSessionAsync(taskId);
|
|
|
|
Assert.Equal(taskId, ctx.ParentTaskId);
|
|
Assert.Equal(1, _launcher.LaunchStartCalls);
|
|
Assert.Equal(0, _launcher.LaunchResumeCalls);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
|
|
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartPlanningSessionAsync_LauncherFails_Discards()
|
|
{
|
|
var (_, taskId) = await SeedAsync();
|
|
_launcher.ShouldThrow = true;
|
|
var hub = CreateHub();
|
|
|
|
await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
|
hub.StartPlanningSessionAsync(taskId));
|
|
|
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
|
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
|
|
|
var sessionDir = Path.Combine(_rootDir, taskId);
|
|
Assert.False(Directory.Exists(sessionDir));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardPlanningSessionAsync_ResetsTask_AndBroadcasts()
|
|
{
|
|
var (_, taskId) = await SeedAsync();
|
|
// Put task into Planning state first
|
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
|
_proxy.Sent.Clear();
|
|
|
|
var hub = CreateHub();
|
|
await hub.DiscardPlanningSessionAsync(taskId);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
|
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FinalizePlanningSessionAsync_PromotesDraftsAndBroadcasts()
|
|
{
|
|
var (_, taskId) = await SeedAsync();
|
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
|
await _tasks.CreateChildAsync(taskId, "child 1", null, null, null);
|
|
await _tasks.CreateChildAsync(taskId, "child 2", null, null, null);
|
|
_proxy.Sent.Clear();
|
|
|
|
var hub = CreateHub();
|
|
var count = await hub.FinalizePlanningSessionAsync(taskId, queueAgentTasks: false);
|
|
|
|
Assert.Equal(2, count);
|
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPendingDraftCountAsync_ReturnsCount()
|
|
{
|
|
var (_, taskId) = await SeedAsync();
|
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
|
await _tasks.CreateChildAsync(taskId, "c1", null, null, null);
|
|
await _tasks.CreateChildAsync(taskId, "c2", null, null, null);
|
|
|
|
var hub = CreateHub();
|
|
var count = await hub.GetPendingDraftCountAsync(taskId);
|
|
|
|
Assert.Equal(2, count);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fakes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
|
|
{
|
|
public bool ShouldThrow { get; set; }
|
|
public int LaunchStartCalls { get; private set; }
|
|
public int LaunchResumeCalls { get; private set; }
|
|
public int LaunchInteractiveCalls { get; private set; }
|
|
|
|
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
|
{
|
|
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
|
|
LaunchStartCalls++;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
|
{
|
|
LaunchResumeCalls++;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken)
|
|
{
|
|
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
|
|
LaunchInteractiveCalls++;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
internal sealed class RecordingClientProxy : IClientProxy
|
|
{
|
|
public List<(string method, object?[] args)> Sent { get; } = new();
|
|
|
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
|
{
|
|
Sent.Add((method, args));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
internal sealed class FakeHubCallerClients : IHubCallerClients
|
|
{
|
|
private readonly IClientProxy _all;
|
|
public FakeHubCallerClients(IClientProxy proxy) => _all = proxy;
|
|
|
|
public IClientProxy All => _all;
|
|
public IClientProxy Caller => _all;
|
|
public IClientProxy Others => _all;
|
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => _all;
|
|
public IClientProxy Client(string connectionId) => _all;
|
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => _all;
|
|
public IClientProxy Group(string groupName) => _all;
|
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => _all;
|
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => _all;
|
|
public IClientProxy OthersInGroup(string groupName) => _all;
|
|
public IClientProxy User(string userId) => _all;
|
|
public IClientProxy Users(IReadOnlyList<string> userIds) => _all;
|
|
}
|
|
|
|
internal sealed class FakeHubCallerContext : HubCallerContext
|
|
{
|
|
public override string ConnectionId => "test-conn";
|
|
public override string? UserIdentifier => null;
|
|
public override System.Security.Claims.ClaimsPrincipal? User => null;
|
|
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
|
public override Microsoft.AspNetCore.Http.Features.IFeatureCollection Features { get; } =
|
|
new Microsoft.AspNetCore.Http.Features.FeatureCollection();
|
|
public override CancellationToken ConnectionAborted => CancellationToken.None;
|
|
public override void Abort() { }
|
|
}
|