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 excludedConnectionIds) => E2ENullClientProxy.Instance; public IClientProxy Client(string connectionId) => E2ENullClientProxy.Instance; public IClientProxy Clients(IReadOnlyList connectionIds) => E2ENullClientProxy.Instance; public IClientProxy Group(string groupName) => E2ENullClientProxy.Instance; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => E2ENullClientProxy.Instance; public IClientProxy Groups(IReadOnlyList groupNames) => E2ENullClientProxy.Instance; public IClientProxy User(string userId) => E2ENullClientProxy.Instance; public IClientProxy Users(IReadOnlyList 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 { 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."); } }