- UnifiedDiffParser detects added/deleted/renamed/binary files; diff modal shows a file list, binary/empty placeholders, and can diff a merged task by commit range after its worktree is gone - DetailsIslandViewModel flags children needing attention (failed, cancelled, awaiting review, or with roadblocks) on the parent - GitService gains worktree head-commit/range support; planning chain, merge orchestration, and session manager tweaks with updated tests - refresh app/installer/worker icons Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
194 lines
8.3 KiB
C#
194 lines
8.3 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, CancellationToken.None);
|
|
await _svc.CreateChildTask("sub 2", 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);
|
|
// Finalize no longer auto-queues. Children stay Idle and chain-linked
|
|
// (head unblocked, rest BlockedBy their predecessor) until the user
|
|
// explicitly queues the plan.
|
|
Assert.Equal(TaskStatus.Idle, kids[0].Status);
|
|
Assert.Null(kids[0].BlockedByTaskId);
|
|
Assert.Equal(TaskStatus.Idle, kids[1].Status);
|
|
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
|
}
|
|
|
|
// Regression: original bug was "queue never picks up planning tasks". Finalize
|
|
// leaves children Idle; queueing the plan (the explicit user gate) must make the
|
|
// first child claimable by the picker automatically — without a manual WakeQueue().
|
|
[Fact]
|
|
public async Task QueuePlanAfterFinalize_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, CancellationToken.None);
|
|
await _svc.CreateChildTask("c2", null, null, CancellationToken.None);
|
|
await _svc.CreateChildTask("c3", null, null, CancellationToken.None);
|
|
|
|
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
|
|
var firstChildId = kidsBefore[0].Id;
|
|
|
|
await _manager.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
|
|
|
// Finalize leaves every child Idle — nothing is claimable yet.
|
|
var afterFinalize = await _tasks.GetChildrenAsync(parent.Id);
|
|
Assert.All(afterFinalize, k => Assert.Equal(TaskStatus.Idle, k.Status));
|
|
|
|
// Queueing the plan is the gate that enqueues + auto-wakes. The picker should
|
|
// then pick the first child immediately, without a manual WakeQueue().
|
|
var wakesBefore = _built.WakeCount();
|
|
await _built.Chain.QueuePlanAsync(parent.Id, CancellationToken.None);
|
|
|
|
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,
|
|
"QueuePlanAsync → EnqueueAsync should auto-wake the queue.");
|
|
}
|
|
}
|