using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Tests.Services; using ClaudeDo.Worker.Usage; using ClaudeDo.Worker.Worktrees; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.External; internal sealed class ExternalRecordingHubClients : IHubClients { public ExternalRecordingClientProxy Proxy { get; } = new(); public IClientProxy All => Proxy; public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy; public IClientProxy Client(string connectionId) => Proxy; public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy; public IClientProxy Group(string groupName) => Proxy; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Proxy; public IClientProxy Groups(IReadOnlyList groupNames) => Proxy; public IClientProxy User(string userId) => Proxy; public IClientProxy Users(IReadOnlyList userIds) => Proxy; } internal sealed class ExternalRecordingClientProxy : IClientProxy { public List<(string Method, object?[] Args)> Calls { get; } = new(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { Calls.Add((method, args)); return Task.CompletedTask; } } internal sealed class ExternalFakeHubContext : IHubContext { public ExternalRecordingHubClients RecordingClients { get; } = new(); public IHubClients Clients => RecordingClients; public IGroupManager Groups => throw new NotImplementedException(); } // A synchronously-collecting IProgress: Progress marshals through the SynchronizationContext // captured at construction, which is unreliable to assert on immediately in a test. file sealed class SyncProgress : IProgress { public readonly List Reports = new(); public void Report(T value) => Reports.Add(value); } public sealed class ExternalMcpServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly ExternalFakeHubContext _hub = new(); private readonly HubBroadcaster _broadcaster; private readonly List _repos = new(); private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new(); private int _numberSeed; private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public ExternalMcpServiceTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _broadcaster = new HubBroadcaster(_hub); } public void Dispose() { foreach (var (repoDir, wtPath) in _worktreeCleanups) { try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { } } foreach (var r in _repos) r.Dispose(); _ctx.Dispose(); _db.Dispose(); } private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedWorktreeAsync( TaskStatus status = TaskStatus.Done) { var repo = new GitRepoFixture(); _repos.Add(repo); var listId = Guid.NewGuid().ToString(); var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }; await _lists.AddAsync(list); var task = await SeedTaskAsync(listId, status: status); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); var wt = await mgr.CreateAsync(task, list, CancellationToken.None); _worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath)); return (task, list, wt); } private async Task SeedListAsync(string name = "L") { var id = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow }); return id; } private async Task SeedTaskAsync( string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null) { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Status = status, ParentTaskId = parentId, CreatedAt = DateTime.UtcNow, CommitType = "chore", Number = ++_numberSeed, }; await _tasks.AddAsync(task); return task; } private ExternalMcpService BuildSut(QueueService queue) => BuildSut(queue, new VerifyCommandRunner()); private ExternalMcpService BuildSut(QueueService queue, IVerifyCommandRunner verify) { var git = new GitService(); var factory = _db.CreateFactory(); var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(factory).State; var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger.Instance); var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); var planningMerge = new PlanningMergeOrchestrator( factory, merge, aggregator, _broadcaster, git, state, NullLogger.Instance); return new ExternalMcpService( _tasks, _lists, queue, _broadcaster, state, git, factory, maintenance, merge, planningMerge, new BaseDirtyChecker(git, NullLogger.Instance)); } private QueueService CreateQueue() { var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_ext_{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var cfg = new WorkerConfig { SandboxRoot = Path.Combine(tempDir, "sandbox"), LogRoot = Path.Combine(tempDir, "logs"), QueueBackstopIntervalMs = 50, }; var fake = new FakeClaudeProcess(); var hubCtx = new CapturingHubContext(); var broadcaster = new HubBroadcaster(hubCtx); var dbFactory = _db.CreateFactory(); var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var runCancels = new RunCancellationRegistry(NullLogger.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state, runCancels, new FakeUsageGate(), new UsageState(), broadcaster); } [Fact] public async Task SeededListAndTask_AreRetrievable() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); Assert.NotNull(await _tasks.GetByIdAsync(task.Id)); } [Fact] public async Task UpdateTask_PatchesNonNullFieldsOnly() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, "old title"); var queue = CreateQueue(); var sut = BuildSut(queue); var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None); Assert.Equal("new title", dto.Title); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal("new title", loaded!.Title); } [Fact] public async Task UpdateTask_ReturnsLeanReference_NotFullTaskDto() { // TaskRefDto has no Description/Result properties -- a writing tool that echoes it back // would re-send the (possibly long) description the caller just sent. Assert.DoesNotContain(nameof(TaskDto.Description), typeof(TaskRefDto).GetProperties().Select(p => p.Name)); Assert.DoesNotContain("Result", typeof(TaskRefDto).GetProperties().Select(p => p.Name)); var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, "old title"); task.Description = "a long description the caller already has"; await _tasks.UpdateAsync(task, CancellationToken.None); var sut = BuildSut(CreateQueue()); var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None); Assert.Equal(task.Id, dto.Id); Assert.Equal(listId, dto.ListId); Assert.Equal("new title", dto.Title); } [Fact] public async Task GetTask_ReturnsTaskNumber() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); Assert.Equal(task.Number, dto.Number); } [Fact] public async Task GetTask_ByHashNumber_ResolvesToTask() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask($"#{task.Number}", CancellationToken.None); Assert.Equal(task.Id, dto.Id); } [Fact] public async Task GetTask_ByBareNumber_ResolvesToTask() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Number.ToString(), CancellationToken.None); Assert.Equal(task.Id, dto.Id); } [Fact] public async Task GetTask_UnknownNumber_ThrowsWithNumberInMessage() { var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync(() => sut.GetTask("#999999", CancellationToken.None)); Assert.Contains("999999", ex.Message); } [Fact] public async Task GetTask_ReturnsFullDtoIncludingDescription() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, "with description"); task.Description = "the full description text"; await _tasks.UpdateAsync(task, CancellationToken.None); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); Assert.Equal("the full description text", dto.Description); } [Fact] public async Task GetTask_Failed_ReturnsClassifiedFailureReason() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Failed); task.FailureReason = "max_turns"; task.FailureTurnsUsed = 55; task.FailureMaxTurns = 60; await _tasks.UpdateAsync(task, CancellationToken.None); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); Assert.Equal("max_turns", dto.FailureReason); Assert.Equal(55, dto.FailureTurnsUsed); Assert.Equal(60, dto.FailureMaxTurns); } [Fact] public async Task GetTask_FailedWithoutStoredReason_ReturnsUnknownNotError() { // A Failed task written before this field existed has FailureReason=null in the DB — // the MCP surface must still hand back a defined value, not null or a throw. var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Failed); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); Assert.Equal("unknown", dto.FailureReason); Assert.Null(dto.FailureTurnsUsed); Assert.Null(dto.FailureMaxTurns); } [Fact] public async Task GetTask_NotFailed_FailureReasonIsNull() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); Assert.Null(dto.FailureReason); } [Fact] public async Task GetTaskRefAsync_Failed_ReturnsClassifiedFailureReason() { // GetTaskRefAsync backs batch_get_tasks' default (lean) path — the same field must be // present there, not just on the full get_task DTO. var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Failed); task.FailureReason = "error"; await _tasks.UpdateAsync(task, CancellationToken.None); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTaskRefAsync(task.Id, CancellationToken.None); Assert.Equal("error", dto.FailureReason); } [Fact] public async Task GetTask_QueuedWithUnmetDependsOn_ReportsBlockedTrueAndReason() { var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); task.DependsOnTaskId = predecessor.Id; await _tasks.UpdateAsync(task, CancellationToken.None); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); Assert.True(dto.Blocked); Assert.Contains(predecessor.Id, dto.BlockedReason); } [Fact] public async Task GetTask_QueuedWithDoneDependsOn_ReportsBlockedFalse() { var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Done); var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); task.DependsOnTaskId = predecessor.Id; await _tasks.UpdateAsync(task, CancellationToken.None); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); Assert.False(dto.Blocked); Assert.Null(dto.BlockedReason); } [Fact] public async Task GetTask_IdleWithDependsOnTaskId_ReportsBlockedFalse_NotYetQueued() { // Blocking is only meaningful once the picker would actually be gating the task -- // an Idle task hasn't been queued at all, so DependsOnTaskId doesn't apply yet. var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); task.DependsOnTaskId = predecessor.Id; await _tasks.UpdateAsync(task, CancellationToken.None); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); Assert.False(dto.Blocked); } [Fact] public async Task ListTasks_ReportsBlockedPerTask() { var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle); var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued); blocked.DependsOnTaskId = predecessor.Id; await _tasks.UpdateAsync(blocked, CancellationToken.None); var unblocked = await SeedTaskAsync(listId, "unblocked", TaskStatus.Queued); var sut = BuildSut(CreateQueue()); var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None); var blockedRef = result.Tasks!.Single(t => t.Id == blocked.Id); var unblockedRef = result.Tasks!.Single(t => t.Id == unblocked.Id); Assert.True(blockedRef.Blocked); Assert.Contains(predecessor.Id, blockedRef.BlockedReason); Assert.False(unblockedRef.Blocked); } [Fact] public async Task ListTasks_IncludeDescriptionTrue_ReportsBlockedOnFullDto() { var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle); var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued); blocked.DependsOnTaskId = predecessor.Id; await _tasks.UpdateAsync(blocked, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None); var dto = result.TasksFull!.Single(t => t.Id == blocked.Id); Assert.True(dto.Blocked); } [Fact] public async Task UpdateTask_OnRunning_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Running); var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None)); } [Fact] public async Task UpdateTask_NotFound_Throws() { var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None)); } [Fact] public async Task UpdateTask_WithDependsOnTaskId_SetsLinkAndReportsBlocked() { var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); var sut = BuildSut(CreateQueue()); var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None); Assert.Equal(predecessor.Id, dto.DependsOnTaskId); Assert.True(dto.Blocked); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId); } [Fact] public async Task UpdateTask_WithEmptyStringDependsOnTaskId_ClearsExistingLink() { var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); var sut = BuildSut(CreateQueue()); await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None); var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: "", cancellationToken: CancellationToken.None); Assert.Null(dto.DependsOnTaskId); Assert.False(dto.Blocked); } [Fact] public async Task UpdateTask_WithCyclicDependsOnTaskId_Throws() { var listId = await SeedListAsync(); var a = await SeedTaskAsync(listId, status: TaskStatus.Idle); var b = await SeedTaskAsync(listId, status: TaskStatus.Idle); var sut = BuildSut(CreateQueue()); await sut.UpdateTask(b.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None); await Assert.ThrowsAsync(() => sut.UpdateTask(a.Id, dependsOnTaskId: b.Id, cancellationToken: CancellationToken.None)); } [Fact] public async Task ReviewTask_Approve_SetsDone() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None); Assert.Equal("Done", result.Task.Status); Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); } [Fact] public async Task ReviewTask_RejectRerun_WithoutFeedback_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync(() => sut.ReviewTask(task.Id, "reject_rerun", null, null, cancellationToken: CancellationToken.None)); } [Fact] public async Task ReviewTask_RejectRerun_QueuesAndStoresFeedback() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var result = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", null, cancellationToken: CancellationToken.None); Assert.Equal("Queued", result.Task.Status); var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); Assert.Equal("fix it", loaded!.ReviewFeedback); } [Fact] public async Task ReviewTask_UnknownDecision_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync(() => sut.ReviewTask(task.Id, "bogus", null, null, cancellationToken: CancellationToken.None)); } [Fact] public async Task ReviewTask_Approve_WithActiveWorktree_MergesIntoTargetBranch() { if (!GitAvailable) return; var (task, list, wt) = await SeedWorktreeAsync(status: TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n"); GitRepoFixture.RunGit(wt.WorktreePath, "add", "feature.txt"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "add feature"); using (var ctx = _db.CreateContext()) { var row = ctx.Worktrees.Single(w => w.TaskId == task.Id); row.HeadCommit = GitRepoFixture.RunGit(wt.WorktreePath, "rev-parse", "HEAD").Trim(); ctx.SaveChanges(); } var sut = BuildSut(CreateQueue()); var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); Assert.Equal("Done", result.Task.Status); Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "feature.txt"))); using var verify = _db.CreateContext(); Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == task.Id).State); } // Regression coverage for the incident this fixes: an approve whose post-merge verify gate // runs long enough gets no MCP traffic at all until RunVerifyGateAsync returns, so Claude // Code's ~300s idle-silence abort kills the call -- the merge had already landed and // committed, but the task never reached Done and every dependsOn successor stayed blocked // until someone noticed and force-set the status by hand. Shrinks TaskMergeService's own // progress interval (not TaskWaitMcpTools') to observe a report without waiting 30s real time. [Fact] public async Task ReviewTask_ApproveWithSlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var original = TaskMergeService.ProgressReportInterval; TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50); try { var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok"), Delay = TimeSpan.FromMilliseconds(300), }; var sut = BuildSut(CreateQueue(), fakeVerify); var reports = new List(); var progress = new Progress(reports.Add); var result = await sut.ReviewTask( task.Id, "approve", null, null, cancellationToken: CancellationToken.None, progress: progress); Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); Assert.Equal("Done", result.Task.Status); // Progress marshals via the SynchronizationContext captured at construction; give // any queued callbacks a beat to run before asserting on `reports`. await Task.Delay(200); Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running")); } finally { TaskMergeService.ProgressReportInterval = original; } } [Fact] public async Task ReviewTask_ApproveWithVerifyCommand_NoProgressToken_DoesNotThrow() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; var sut = BuildSut(CreateQueue(), fakeVerify); var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); Assert.Equal("Done", result.Task.Status); } [Fact] public async Task ReviewTask_Approve_ParentWithChildren_RunsUnitMerge() { if (!GitAvailable) return; var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); var parentId = Guid.NewGuid().ToString(); var childId = Guid.NewGuid().ToString(); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed }); ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed }); var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, wtPath)); var branch = $"claudedo/{childId[..8]}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n"); GitRepoFixture.RunGit(wtPath, "add", "child.txt"); GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt"); ctx.Worktrees.Add(new WorktreeEntity { TaskId = childId, Path = wtPath, BranchName = branch, BaseCommit = repo.BaseCommit, HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); Assert.Equal("Done", result.Task.Status); Assert.True(File.Exists(Path.Combine(repo.RepoDir, "child.txt"))); using var verify = _db.CreateContext(); Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State); } // Regression coverage for the parent/children unit-merge path specifically: PlanningMergeOrchestrator // used to drain every child's merge (and verify gate) without ever forwarding review_task's own // progress token, so a multi-child approve with a slow verify command reported only the single // "review_task started" tick and then went silent for as long as every child's gate took combined. [Fact] public async Task ReviewTask_Approve_ParentWithChildren_SlowVerifyCommand_ReportsProgress() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var original = TaskMergeService.ProgressReportInterval; TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50); try { var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); var parentId = Guid.NewGuid().ToString(); var childId = Guid.NewGuid().ToString(); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed }); ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed }); var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, wtPath)); var branch = $"claudedo/{childId[..8]}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n"); GitRepoFixture.RunGit(wtPath, "add", "child.txt"); GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt"); ctx.Worktrees.Add(new WorktreeEntity { TaskId = childId, Path = wtPath, BranchName = branch, BaseCommit = repo.BaseCommit, HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" }); await ctx.SaveChangesAsync(); } var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok"), Delay = TimeSpan.FromMilliseconds(300), }; var sut = BuildSut(CreateQueue(), fakeVerify); var reports = new List(); var progress = new Progress(reports.Add); var result = await sut.ReviewTask( parentId, "approve", null, "main", cancellationToken: CancellationToken.None, progress: progress); Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); Assert.Equal("Done", result.Task.Status); await Task.Delay(200); Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running")); } finally { TaskMergeService.ProgressReportInterval = original; } } [Fact] public async Task ReviewTask_Approve_ParentWithChildren_ReportsEmptyChildByName() { // A blocked child (CLAUDEDO_BLOCKED, committed nothing) still gets an active worktree at // its base commit -- TaskRunner creates one before the run starts -- so "empty" shows up // as zero commits ahead of base, not as a missing worktree. if (!GitAvailable) return; var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); var parentId = Guid.NewGuid().ToString(); var normalChildId = Guid.NewGuid().ToString(); var blockedChildId = Guid.NewGuid().ToString(); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed }); ctx.Tasks.Add(new TaskEntity { Id = normalChildId, ListId = listId, Title = "normal child", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed }); ctx.Tasks.Add(new TaskEntity { Id = blockedChildId, ListId = listId, Title = "blocked child", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2, RoadblockCount = 1, Number = ++_numberSeed }); var normalWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, normalWtPath)); var normalBranch = $"claudedo/{normalChildId[..8]}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", normalBranch, normalWtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(normalWtPath, "child.txt"), "c\n"); GitRepoFixture.RunGit(normalWtPath, "add", "child.txt"); GitRepoFixture.RunGit(normalWtPath, "commit", "-m", "add child.txt"); ctx.Worktrees.Add(new WorktreeEntity { TaskId = normalChildId, Path = normalWtPath, BranchName = normalBranch, BaseCommit = repo.BaseCommit, HeadCommit = GitRepoFixture.RunGit(normalWtPath, "rev-parse", "HEAD").Trim(), State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); var blockedWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, blockedWtPath)); var blockedBranch = $"claudedo/{blockedChildId[..8]}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", blockedBranch, blockedWtPath, repo.BaseCommit); ctx.Worktrees.Add(new WorktreeEntity { TaskId = blockedChildId, Path = blockedWtPath, BranchName = blockedBranch, BaseCommit = repo.BaseCommit, HeadCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); Assert.Equal("Done", result.Task.Status); Assert.NotNull(result.EmptyChildren); var empty = Assert.Single(result.EmptyChildren!); Assert.Equal(blockedChildId, empty.Id); Assert.DoesNotContain(result.EmptyChildren!, c => c.Id == normalChildId); } [Fact] public async Task DeleteTask_RemovesTask() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var queue = CreateQueue(); var sut = BuildSut(queue); await sut.DeleteTask(task.Id, CancellationToken.None); Assert.Null(await _tasks.GetByIdAsync(task.Id)); } [Fact] public async Task DeleteTask_OnRunning_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Running); var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.DeleteTask(task.Id, CancellationToken.None)); } [Fact] public async Task DeleteTask_NotFound_Throws() { var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.DeleteTask("does-not-exist", CancellationToken.None)); } [Fact] public async Task DeleteTask_LastNonTerminalChild_AdvancesParentToWaitingForReview() { var listId = await SeedListAsync(); var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren); await SeedTaskAsync(listId, status: TaskStatus.Done, parentId: parent.Id); var lastChild = await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id); var sut = BuildSut(CreateQueue()); await sut.DeleteTask(lastChild.Id, CancellationToken.None); var reloadedParent = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status); } [Fact] public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); var sut = BuildSut(CreateQueue()); var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None); Assert.Equal("Cancelled", dto.Status); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Cancelled, loaded!.Status); } [Fact] public async Task UpdateTaskStatus_WaitingForReview_StillRejected() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.UpdateTaskStatus(task.Id, "WaitingForReview", CancellationToken.None)); Assert.Contains("not settable externally", ex.Message); } [Fact] public async Task UpdateTaskStatus_Done_NoWorktree_SetsDoneAndFinishedAt() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); var sut = BuildSut(CreateQueue()); var dto = await sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None); Assert.Equal("Done", dto.Status); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Done, loaded!.Status); Assert.NotNull(loaded.FinishedAt); } [Fact] public async Task UpdateTaskStatus_Done_WithActiveWorktree_Throws() { if (!GitAvailable) return; var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None)); Assert.Contains("active worktree", ex.Message, StringComparison.OrdinalIgnoreCase); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.WaitingForReview, loaded!.Status); } private ExternalMcpService NewService() => BuildSut(CreateQueue()); private async Task SeedIdleTask(string title = "t") { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, title, TaskStatus.Idle); return task.Id; } private async Task SeedAppSettingsAsync(string? reportExcludedPaths, int dailyPrepMaxTasks = 5) { var settings = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId, ReportExcludedPaths = reportExcludedPaths, DailyPrepMaxTasks = dailyPrepMaxTasks, }; // Upsert via AppSettingsRepository await using var ctx = _db.CreateContext(); var repo = new AppSettingsRepository(ctx); await repo.UpdateAsync(settings); } [Fact] public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo() { // List 1: included repo D:\work\repo — 4 tasks seeded var listId1 = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId1, Name = "Work", WorkingDir = @"D:\work\repo", CreatedAt = DateTime.UtcNow }); // idle, not blocked, not MyDay → should be candidate var idleUnblocked = new TaskEntity { Id = "idle-unblocked", ListId = listId1, Title = "Idle unblocked", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", Number = ++_numberSeed, }; await _tasks.AddAsync(idleUnblocked); // idle but blocked → excluded (BlockedByTaskId references idle-unblocked as its predecessor) var idleBlocked = new TaskEntity { Id = "idle-blocked", ListId = listId1, Title = "Idle blocked", Status = TaskStatus.Idle, BlockedByTaskId = "idle-unblocked", CreatedAt = DateTime.UtcNow, CommitType = "chore", Number = ++_numberSeed, }; await _tasks.AddAsync(idleBlocked); // Done → excluded var doneTask = new TaskEntity { Id = "done-task", ListId = listId1, Title = "Done task", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore", Number = ++_numberSeed, }; await _tasks.AddAsync(doneTask); // idle, IsMyDay → goes into currentMyDay, not candidates var myDayTask = new TaskEntity { Id = "myday-task", ListId = listId1, Title = "MyDay task", Status = TaskStatus.Idle, IsMyDay = true, CreatedAt = DateTime.UtcNow, CommitType = "chore", Number = ++_numberSeed, }; await _tasks.AddAsync(myDayTask); // List 2: excluded repo C:\Private\secret var listId2 = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId2, Name = "Secret", WorkingDir = @"C:\Private\secret", CreatedAt = DateTime.UtcNow }); var excludedRepoTask = new TaskEntity { Id = "excluded-repo-task", ListId = listId2, Title = "Excluded repo", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", Number = ++_numberSeed, }; await _tasks.AddAsync(excludedRepoTask); // List 3: no WorkingDir → excluded var listId3 = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId3, Name = "NoRepo", WorkingDir = null, CreatedAt = DateTime.UtcNow }); var noRepoTask = new TaskEntity { Id = "no-repo-task", ListId = listId3, Title = "No repo", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", Number = ++_numberSeed, }; await _tasks.AddAsync(noRepoTask); await SeedAppSettingsAsync(@"[""C:\\Private""]", dailyPrepMaxTasks: 5); var sut = BuildSut(CreateQueue()); var result = await sut.GetDailyPrepCandidates(CancellationToken.None); Assert.Single(result.Candidates); Assert.Equal("idle-unblocked", result.Candidates[0].Id); Assert.Single(result.CurrentMyDay); Assert.Equal("myday-task", result.CurrentMyDay[0].Id); Assert.Equal(5, result.MaxTasks); } [Fact] public async Task SetMyDay_sets_flag_and_sort_order() { var svc = NewService(); var id = await SeedIdleTask("My task"); var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None); Assert.True(dto.IsMyDay); Assert.Equal(3, dto.SortOrder); } [Fact] public async Task SetMyDay_rejects_when_cap_reached() { await SeedAppSettingsAsync(null, dailyPrepMaxTasks: 1); var svc = NewService(); var first = await SeedIdleTask("a"); var second = await SeedIdleTask("b"); await svc.SetMyDay(first, true, null, CancellationToken.None); var ex = await Assert.ThrowsAsync( () => svc.SetMyDay(second, true, null, CancellationToken.None)); Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task SetMyDay_unset_is_always_allowed() { var svc = NewService(); var id = await SeedIdleTask("a"); await svc.SetMyDay(id, true, null, CancellationToken.None); var dto = await svc.SetMyDay(id, false, null, CancellationToken.None); Assert.False(dto.IsMyDay); } // ── GetTaskWorktree ──────────────────────────────────────────────────────── [Fact] public async Task GetTaskWorktree_NoWorktree_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.GetTaskWorktree(task.Id, CancellationToken.None)); } [Fact] public async Task GetTaskWorktree_ReturnsBranchAndBaseCommit() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, wt) = await SeedWorktreeAsync(); var sut = BuildSut(CreateQueue()); var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); Assert.Equal(wt.BranchName, info.Branch); Assert.Equal(wt.BaseCommit, info.BaseCommit); Assert.Equal(0, info.Ahead); Assert.False(info.IsDirty); } [Fact] public async Task GetTaskWorktree_BeforeMerge_MergeCommitIsNull() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(); var sut = BuildSut(CreateQueue()); var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); Assert.Null(info.MergeCommit); } [Fact] public async Task GetTaskWorktree_AfterMerge_ExposesMergeCommit() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); Assert.Equal(expected, info.MergeCommit); } // ── GetTaskDiff ──────────────────────────────────────────────────────────── [Fact] public async Task GetTaskDiff_NoWorktree_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.GetTaskDiff(task.Id, false, null, CancellationToken.None)); } [Fact] public async Task GetTaskDiff_AfterCommit_ListsChangedFile() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None); Assert.Contains("added.txt", diff.Files); Assert.False(diff.Truncated); } [Fact] public async Task GetTaskDiff_WithPathsFilter_ReturnsOnlyRequestedFile() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, false, ["added.txt"], CancellationToken.None); Assert.Equal(new[] { "added.txt" }, diff.Files); Assert.DoesNotContain("other.txt", diff.Content); } [Fact] public async Task GetTaskDiff_StatMode_WithPathsFilter_ReturnsOnlyRequestedFile() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, true, ["other.txt"], CancellationToken.None); Assert.Equal(new[] { "other.txt" }, diff.Files); Assert.DoesNotContain("added.txt", diff.Content); } [Fact] public async Task GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); // No WorktreeEntity for this task -- it's a worktree-less list-handler host task // (Mission Control's "Let Claude handle it"): the handler merged its own commit // straight into the list's working dir after HandlerBaseCommit was stamped. File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); using (var ctx = _db.CreateContext()) { var t = await ctx.Tasks.FindAsync(task.Id); t!.HandlerBaseCommit = repo.BaseCommit; t.HandlerHeadCommit = headCommit; await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None); Assert.Contains("handled.txt", diff.Files); Assert.False(diff.Truncated); } [Fact] public async Task GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); using (var ctx = _db.CreateContext()) { var t = await ctx.Tasks.FindAsync(task.Id); t!.HandlerBaseCommit = repo.BaseCommit; t.HandlerHeadCommit = headCommit; await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, true, null, CancellationToken.None); Assert.Contains("handled.txt", diff.Content); } // get_task_diff has no natural i/n (it's one git process, not a loop over items), so it // reports on a timer like merge_task's verify gate. Shrinking the interval to zero forces a // report on every poll of the still-running git process instead of waiting out a real // interval -- Task.Delay(TimeSpan.Zero) resolves synchronously, so the diff process (a real // subprocess, never already complete when polled) always loses the race at least once. [Fact] public async Task GetTaskDiff_ReportsProgressWhileDiffing() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var original = ExternalMcpService.ProgressReportInterval; ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; try { var (task, list, wt) = await SeedWorktreeAsync(); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var sut = BuildSut(CreateQueue()); var progress = new SyncProgress(); var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None, progress); Assert.Contains("added.txt", diff.Files); Assert.NotEmpty(progress.Reports); Assert.All(progress.Reports, r => Assert.Contains("computing diff", r.Message)); } finally { ExternalMcpService.ProgressReportInterval = original; } } // ── MergeTask ────────────────────────────────────────────────────────────── [Fact] public async Task MergeTask_NotDone_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None)); Assert.Contains("Done", ex.Message); } [Fact] public async Task MergeTask_DryRun_DoesNotMerge() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None); Assert.False(result.Merged); Assert.Null(result.MergeCommit); } // ── ListWorktrees ────────────────────────────────────────────────────────── [Fact] public async Task ListWorktrees_Empty_ReturnsEmpty() { var sut = BuildSut(CreateQueue()); var rows = await sut.ListWorktrees(CancellationToken.None); Assert.Empty(rows); } [Fact] public async Task ListWorktrees_ReturnsCreatedWorktree() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(); var sut = BuildSut(CreateQueue()); var rows = await sut.ListWorktrees(CancellationToken.None); Assert.Contains(rows, r => r.TaskId == task.Id); } // list_worktrees fans out one git status + one rev-parse per tracked worktree under // Task.WhenAll -- no natural i/n since rows finish out of order, so it reports on the same // elapsed-time cadence as CleanupTaskWorktree above. [Fact] public async Task ListWorktrees_ReportsProgressWhileSurveying() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var original = ExternalMcpService.ProgressReportInterval; ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; try { await SeedWorktreeAsync(); var sut = BuildSut(CreateQueue()); var progress = new SyncProgress(); var rows = await sut.ListWorktrees(CancellationToken.None, progress); Assert.NotEmpty(rows); Assert.NotEmpty(progress.Reports); } finally { ExternalMcpService.ProgressReportInterval = original; } } // ── CleanupTaskWorktree ──────────────────────────────────────────────────── [Fact] public async Task CleanupTaskWorktree_NotFound_Throws() { var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.CleanupTaskWorktree("does-not-exist", false, CancellationToken.None)); } [Fact] public async Task CleanupTaskWorktree_Running_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Running); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None)); Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task CleanupTaskWorktree_CleanWorktree_Removes() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); var sut = BuildSut(CreateQueue()); var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None); Assert.True(result.Removed); Assert.False(Directory.Exists(wt.WorktreePath)); } // cleanup_task_worktree has no natural i/n either (dirty check + one removal, not a loop), so // it reports on the same timer/zero-interval technique as GetTaskDiff above. [Fact] public async Task CleanupTaskWorktree_ReportsProgressWhileRemoving() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var original = ExternalMcpService.ProgressReportInterval; ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; try { var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); var sut = BuildSut(CreateQueue()); var progress = new SyncProgress(); var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None, progress); Assert.True(result.Removed); Assert.False(Directory.Exists(wt.WorktreePath)); Assert.NotEmpty(progress.Reports); } finally { ExternalMcpService.ProgressReportInterval = original; } } // ── GetTaskConfig ───────────────────────────────────────────────────────── private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster, _db.CreateFactory()); [Fact] public async Task GetTaskConfig_NotFound_Throws() { var sut = BuildConfigSut(); await Assert.ThrowsAsync( () => sut.GetTaskConfig("does-not-exist", CancellationToken.None)); } [Fact] public async Task GetTaskConfig_NoOverrides_ReturnsNull() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildConfigSut(); var result = await sut.GetTaskConfig(task.Id, CancellationToken.None); Assert.False(result.Found); Assert.Null(result.Config); } [Fact] public async Task GetTaskConfig_WithOverrides_ReturnsValues() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); await _tasks.UpdateAgentSettingsAsync(task.Id, "claude-sonnet-4-6", "be concise", null, 10, ct: CancellationToken.None); var sut = BuildConfigSut(); var result = await sut.GetTaskConfig(task.Id, CancellationToken.None); Assert.True(result.Found); Assert.NotNull(result.Config); Assert.Equal("claude-sonnet-4-6", result.Config!.Model); Assert.Equal("be concise", result.Config.SystemPrompt); Assert.Null(result.Config.AgentPath); Assert.Equal(10, result.Config.MaxTurns); } // ── ListTasks status filter ─────────────────────────────────────────────── [Fact] public async Task ListTasks_FilterByWaitingForReview_ReturnsMatchingTasks() { var listId = await SeedListAsync(); await SeedTaskAsync(listId, "wfr", TaskStatus.WaitingForReview); await SeedTaskAsync(listId, "idle", TaskStatus.Idle); var sut = NewService(); var result = await sut.ListTasks(listId, null, "WaitingForReview", cancellationToken: CancellationToken.None); Assert.NotNull(result.Tasks); Assert.Single(result.Tasks!); Assert.Equal("WaitingForReview", result.Tasks![0].Status); } [Fact] public async Task ListTasks_FilterByWaitingForChildren_ReturnsMatchingTasks() { var listId = await SeedListAsync(); await SeedTaskAsync(listId, "wfc", TaskStatus.WaitingForChildren); await SeedTaskAsync(listId, "done", TaskStatus.Done); var sut = NewService(); var result = await sut.ListTasks(listId, null, "WaitingForChildren", cancellationToken: CancellationToken.None); Assert.NotNull(result.Tasks); Assert.Single(result.Tasks!); Assert.Equal("WaitingForChildren", result.Tasks![0].Status); } [Fact] public async Task ListTasks_Default_ReturnsLeanReferences_NoDescriptionOrTasksFull() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, "with desc"); task.Description = "a long description that should not come back by default"; await _tasks.UpdateAsync(task); var sut = NewService(); var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None); Assert.False(result.IncludeDescription); Assert.NotNull(result.Tasks); Assert.Null(result.TasksFull); Assert.Single(result.Tasks!); Assert.Equal(task.Id, result.Tasks![0].Id); Assert.Equal(task.Number, result.Tasks![0].Number); } [Fact] public async Task ListTasks_IncludeDescriptionTrue_ReturnsFullTasks() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, "with desc"); task.Description = "the full description"; await _tasks.UpdateAsync(task); var sut = NewService(); var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None); Assert.True(result.IncludeDescription); Assert.Null(result.Tasks); Assert.NotNull(result.TasksFull); Assert.Single(result.TasksFull!); Assert.Equal("the full description", result.TasksFull![0].Description); Assert.Equal(task.Number, result.TasksFull![0].Number); } // ── MergeTask allowWaitingForReview ─────────────────────────────────────── [Fact] public async Task MergeTask_WaitingForReview_WithoutFlag_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None)); Assert.Contains("Done", ex.Message); } [Fact] public async Task MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, cancellationToken: CancellationToken.None); Assert.False(result.Merged); Assert.Null(result.MergeCommit); } [Fact] public async Task MergeTask_WaitingForReview_WithFlag_MarksTaskDone() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, cancellationToken: CancellationToken.None); Assert.True(result.Merged); var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Done, reloaded!.Status); } // ── leaveConflictsInTree ────────────────────────────────────────────────── private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedConflictingWorktreeAsync( TaskStatus status = TaskStatus.WaitingForReview) { var (task, list, wt) = await SeedWorktreeAsync(status); File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# from worktree\n"); GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit"); File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# from main\n"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit"); return (task, list, wt); } // Three edits spread far enough apart in a 20-line file that git's 3-way merge keeps them as three // separate, non-adjacent hunks instead of folding them into one. private static readonly int[] MultiHunkEditLines = [3, 9, 15]; private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedMultiHunkConflictingWorktreeAsync() { var repo = new GitRepoFixture(); _repos.Add(repo); var baseLines = Enumerable.Range(1, 20).Select(n => $"line{n}").ToArray(); File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), string.Join('\n', baseLines) + "\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "multi-line base"); var listId = Guid.NewGuid().ToString(); var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }; await _lists.AddAsync(list); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); var wt = await mgr.CreateAsync(task, list, CancellationToken.None); _worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath)); WriteMultiHunkVariant(wt.WorktreePath, baseLines, "WT"); GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit"); WriteMultiHunkVariant(list.WorkingDir!, baseLines, "MAIN"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit"); return (task, list, wt); } private static void WriteMultiHunkVariant(string dir, string[] baseLines, string tag) { var lines = (string[])baseLines.Clone(); foreach (var lineNum in MultiHunkEditLines) lines[lineNum - 1] = $"{tag}-{lineNum}"; File.WriteAllText(Path.Combine(dir, "README.md"), string.Join('\n', lines) + "\n"); } [Fact] public async Task MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None); Assert.False(result.Merged); Assert.True(result.ConflictsInTree); Assert.Equal(list.WorkingDir, result.RepoPath); Assert.Contains("README.md", result.Conflicts); Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } [Fact] public async Task MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, leaveConflictsInTree: false, CancellationToken.None); Assert.False(result.Merged); Assert.False(result.ConflictsInTree); Assert.Null(result.RepoPath); Assert.Contains("README.md", result.Conflicts); Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); } [Fact] public async Task ReviewTask_Approve_LeaveConflictsInTree_LeavesMarkersAndReportsRepoPath() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var result = await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None); Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus); Assert.Equal(list.WorkingDir, result.RepoPath); Assert.Contains("continue_merge", result.MergeMessage); Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); Assert.Equal("WaitingForReview", result.Task.Status); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } [Fact] public async Task ReviewTask_Approve_ConflictWithoutLeaveFlag_KeepsOldBehaviour() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus); Assert.Null(result.RepoPath); Assert.Contains("ClaudeDo UI", result.MergeMessage); Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); Assert.Equal("WaitingForReview", result.Task.Status); } // ── continue_merge / abort_merge ────────────────────────────────────────── [Fact] public async Task ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None); // Resolve the conflict on disk and stage it. File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); var result = await sut.ContinueMerge(task.Id, CancellationToken.None); Assert.True(result.Merged); Assert.Equal("Done", result.TaskStatus); Assert.Empty(result.Conflicts); Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); } // Regression coverage for the same class of incident as ReviewTask_ApproveWithSlowVerifyCommand // above, but for the resume path: continue_merge re-runs the verify gate too, and until this // fix it never forwarded its progress token into RunVerifyGateAsync at all. [Fact] public async Task ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var original = TaskMergeService.ProgressReportInterval; TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50); try { var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var plainSut = BuildSut(CreateQueue()); await plainSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None); File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok"), Delay = TimeSpan.FromMilliseconds(300), }; var sut = BuildSut(CreateQueue(), fakeVerify); var reports = new List(); var progress = new Progress(reports.Add); var result = await sut.ContinueMerge(task.Id, CancellationToken.None, progress); Assert.True(result.Merged); Assert.Equal("Done", result.TaskStatus); await Task.Delay(200); Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running")); } finally { TaskMergeService.ProgressReportInterval = original; } } [Fact] public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None); // Markers never resolved — continue must refuse and list the files. var result = await sut.ContinueMerge(task.Id, CancellationToken.None); Assert.False(result.Merged); Assert.Contains("README.md", result.Conflicts); Assert.Equal(list.WorkingDir, result.RepoPath); Assert.Equal("WaitingForReview", result.TaskStatus); Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } [Fact] public async Task AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None); var dto = await sut.AbortMerge(task.Id, CancellationToken.None); Assert.Equal("WaitingForReview", dto.Status); Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); } [Fact] public async Task ContinueMerge_NoMergeInProgress_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.ContinueMerge(task.Id, CancellationToken.None)); Assert.Contains("mid-merge", ex.Message); } [Fact] public async Task AbortMerge_NoMergeInProgress_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.AbortMerge(task.Id, CancellationToken.None)); Assert.Contains("mid-merge", ex.Message); } // ── get_merge_conflicts / resolve_conflict_hunk ───────────────────────────── [Fact] public async Task GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None); var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None); Assert.Equal(1, result.RemainingHunks); var file = Assert.Single(result.Files); Assert.Equal("README.md", file.Path); Assert.False(file.IsBinary); var hunk = Assert.Single(file.Hunks); Assert.Equal(0, hunk.Index); Assert.True(hunk.StartLine >= 1); Assert.Equal("# from main", hunk.Ours.TrimEnd('\r', '\n')); Assert.Equal("# from worktree", hunk.Theirs.TrimEnd('\r', '\n')); Assert.NotEmpty(result.ConflictStyleNote); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } [Fact] public async Task GetMergeConflicts_NoMergeInProgress_ReturnsEmpty() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None); Assert.Equal(0, result.RemainingHunks); Assert.Empty(result.Files); } [Fact] public async Task ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync(); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); var sut = BuildSut(CreateQueue()); var merge = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None); Assert.True(merge.ConflictsInTree); var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None); Assert.Equal(3, before.RemainingHunks); Assert.Equal(3, Assert.Single(before.Files).Hunks.Count); // Resolve hunk 0 three times in a row: each resolution removes a hunk from the front, so the // next still-open hunk always slides into index 0. var r1 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None); Assert.Equal(2, r1.RemainingHunks); var r2 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "theirs", CancellationToken.None); Assert.Equal(1, r2.RemainingHunks); var r3 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "CUSTOM-15\n", CancellationToken.None); Assert.Equal(0, r3.RemainingHunks); var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None); Assert.Equal(0, finalConflicts.RemainingHunks); var continueResult = await sut.ContinueMerge(task.Id, CancellationToken.None); Assert.True(continueResult.Merged); Assert.Equal("Done", continueResult.TaskStatus); var mergedText = File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")); Assert.Contains("MAIN-3", mergedText); // hunk 1 resolved "ours" (the target branch) Assert.Contains("WT-9", mergedText); // hunk 2 resolved "theirs" (the incoming branch) Assert.Contains("CUSTOM-15", mergedText); // hunk 3 resolved to literal text Assert.DoesNotContain("<<<<<<<", mergedText); } [Fact] public async Task ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync(); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); var sut = BuildSut(CreateQueue()); await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None); // Resolve only one of the three hunks. await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None); // continue_merge must still refuse: writing one hunk must not have staged the file as resolved. var result = await sut.ContinueMerge(task.Id, CancellationToken.None); Assert.False(result.Merged); Assert.Contains("README.md", result.Conflicts); Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } [Fact] public async Task ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, CancellationToken.None); await Assert.ThrowsAsync( () => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None)); await Assert.ThrowsAsync( () => sut.ResolveConflictHunk(task.Id, "README.md", 5, "ours", CancellationToken.None)); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } // ── RevertMerge ──────────────────────────────────────────────────────────── [Fact] public async Task RevertMerge_NotMerged_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.RevertMerge(task.Id, "main", CancellationToken.None)); Assert.Contains("expected Merged", ex.Message); } [Fact] public async Task RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); Assert.True(result.Reverted); Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit)); Assert.Empty(result.Conflicts); Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); } [Fact] public async Task RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n"); GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line"); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); // A later commit on main edits the exact line the merge introduced, so the revert conflicts. File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A"); GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line"); var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); Assert.False(result.Reverted); Assert.Contains("README.md", result.Conflicts); Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!)); Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim()); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Done, reloaded!.Status); } [Fact] public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); // main edits README.md so the child's edit conflicts. File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit"); var listId = Guid.NewGuid().ToString(); var parentId = Guid.NewGuid().ToString(); var childId = Guid.NewGuid().ToString(); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed }); ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed }); var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, wtPath)); var branch = $"claudedo/{childId[..8]}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, "README.md"), "# child change\n"); GitRepoFixture.RunGit(wtPath, "add", "README.md"); GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit"); ctx.Worktrees.Add(new WorktreeEntity { TaskId = childId, Path = wtPath, BranchName = branch, BaseCommit = repo.BaseCommit, HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus); Assert.Equal(repo.RepoDir, review.RepoPath); Assert.Contains("continue_merge", review.MergeMessage); // Resolve the conflict on disk. File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n"); var result = await sut.ContinueMerge(parentId, CancellationToken.None); Assert.True(result.Merged); Assert.Equal("Done", result.TaskStatus); Assert.Empty(result.Conflicts); Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir)); using var verify = _db.CreateContext(); Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State); } // ── PreviewMerge / PreviewMergeSet ──────────────────────────────────────── private async Task CreateActiveWorktreeAsync( GitRepoFixture repo, string taskId, string fileName, string content) { var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, wtPath)); var branch = $"claudedo/{taskId[..8]}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, fileName), content); GitRepoFixture.RunGit(wtPath, "add", fileName); GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}"); using var ctx = _db.CreateContext(); ctx.Worktrees.Add(new WorktreeEntity { TaskId = taskId, Path = wtPath, BranchName = branch, BaseCommit = repo.BaseCommit, HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); return wtPath; } [Fact] public async Task PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var git = new GitService(); var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Empty(result.ConflictFiles); Assert.Equal(1, result.ChangedFileCount); Assert.Equal(0, result.Behind); Assert.False(result.IsEmpty); Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); } [Fact] public async Task PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } // Worktree created but nothing committed beyond base -- e.g. a child that reported // CLAUDEDO_BLOCKED before writing any code. var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Equal(0, result.ChangedFileCount); Assert.True(result.IsEmpty); } [Fact] public async Task PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); // Worktree-less list-handler host task whose handler range never actually committed // anything: HandlerBaseCommit == HandlerHeadCommit, not "no worktree -> unknown". using (var ctx = _db.CreateContext()) { var t = await ctx.Tasks.FindAsync(task.Id); t!.HandlerBaseCommit = repo.BaseCommit; t.HandlerHeadCommit = repo.BaseCommit; await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Equal(0, result.ChangedFileCount); Assert.True(result.IsEmpty); } [Fact] public async Task PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); using (var ctx = _db.CreateContext()) { var t = await ctx.Tasks.FindAsync(task.Id); t!.HandlerBaseCommit = repo.BaseCommit; t.HandlerHeadCommit = headCommit; await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Equal(1, result.ChangedFileCount); Assert.False(result.IsEmpty); } [Fact] public async Task PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var git = new GitService(); var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); Assert.Equal(TaskMergeService.PreviewConflict, result.Status); Assert.Contains("README.md", result.ConflictFiles); Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); Assert.False(await git.IsMidMergeAsync(list.WorkingDir!)); Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); } [Fact] public async Task PreviewMerge_NoWorktree_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.PreviewMerge(task.Id, null, CancellationToken.None)); } [Fact] public async Task PreviewMerge_MissingWorkingDir_ThrowsClearError() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); using (var ctx = _db.CreateContext()) { var row = await ctx.Lists.FindAsync(list.Id); row!.WorkingDir = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}"); await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.PreviewMerge(task.Id, null, CancellationToken.None)); Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); var git = new GitService(); var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; var sut = BuildSut(CreateQueue(), fakeVerify); var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Equal(0, result.VerifyExitCode); Assert.Equal("dotnet build", fakeVerify.CapturedCommand); Assert.NotEqual(list.WorkingDir, fakeVerify.CapturedWorkingDir); Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); } [Fact] public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n"); // The target branch itself moved on and touched the same file after the fork point. File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt"); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None); Assert.Equal(1, result.Behind); Assert.NotNull(result.StaleFiles); Assert.Contains("shared.txt", result.StaleFiles!); } [Fact] public async Task PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n"); // Target moved on (behind > 0) but touched an entirely different file -- a stale // branch that still collides with nothing. File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else"); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None); Assert.Equal(1, result.Behind); Assert.NotNull(result.StaleFiles); Assert.Empty(result.StaleFiles!); } [Fact] public async Task PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); using (var ctx = _db.CreateContext()) { var t = await ctx.Tasks.FindAsync(task.Id); t!.HandlerBaseCommit = repo.BaseCommit; t.HandlerHeadCommit = headCommit; await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None); Assert.NotNull(result.StaleFiles); Assert.Empty(result.StaleFiles!); } [Fact] public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, taskA.Id, "shared.txt", "a change\n"); await CreateActiveWorktreeAsync(repo, taskB.Id, "shared.txt", "b change\n"); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None); Assert.Equal(2, result.Tasks.Count); Assert.All(result.Tasks, t => Assert.Null(t.Error)); Assert.All(result.Tasks, t => Assert.Equal(TaskMergeService.PreviewClean, t.Status)); var overlap = Assert.Single(result.Overlaps); Assert.Equal("shared.txt", overlap.File); Assert.Equal( new[] { taskA.Id, taskB.Id }.OrderBy(x => x), overlap.TaskIds.OrderBy(x => x)); // Equal file sets overlap but neither is a PROPER subset of the other. Assert.Empty(result.Subsets); } [Fact] public async Task PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var subsetTask = await SeedTaskAsync(listId, "subset", TaskStatus.WaitingForReview); var supersetTask = await SeedTaskAsync(listId, "superset", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, subsetTask.Id, "shared.txt", "shared change\n"); var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, wtPath)); var branch = $"claudedo/{supersetTask.Id[..8]}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, "shared.txt"), "shared change too\n"); File.WriteAllText(Path.Combine(wtPath, "extra.txt"), "extra\n"); GitRepoFixture.RunGit(wtPath, "add", "-A"); GitRepoFixture.RunGit(wtPath, "commit", "-m", "edit shared+extra"); using (var ctx = _db.CreateContext()) { ctx.Worktrees.Add(new WorktreeEntity { TaskId = supersetTask.Id, Path = wtPath, BranchName = branch, BaseCommit = repo.BaseCommit, HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([subsetTask.Id, supersetTask.Id], "main", cancellationToken: CancellationToken.None); var relation = Assert.Single(result.Subsets); Assert.Equal(subsetTask.Id, relation.TaskId); Assert.Equal(supersetTask.Id, relation.SupersetTaskId); } [Fact] public async Task PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None); Assert.Single(result.Tasks); Assert.Empty(result.Overlaps); } [Fact] public async Task PreviewMergeSet_ReportsProgressPerTask() { var listId = await SeedListAsync(); var t1 = await SeedTaskAsync(listId, "a"); var t2 = await SeedTaskAsync(listId, "b"); var sut = BuildSut(CreateQueue()); var progress = new SyncProgress(); // Neither task has a worktree -- each entry fails individually (PreviewUnavailable), but // the loop still reports progress per task since a per-task InvalidOperationException // doesn't abort the batch. await sut.PreviewMergeSet( [t1.Id, t2.Id], "main", cancellationToken: CancellationToken.None, progress: progress); Assert.Equal(2, progress.Reports.Count); Assert.Equal("Previewing merges (1/2)", progress.Reports[0].Message); Assert.Equal("Previewing merges (2/2)", progress.Reports[1].Message); Assert.Equal(2, progress.Reports[1].Total); } [Fact] public async Task PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var listId2 = await SeedListAsync("no-worktree-list"); var noWorktreeTask = await SeedTaskAsync(listId2, "b", TaskStatus.Idle); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, cancellationToken: CancellationToken.None); Assert.Equal(2, result.Tasks.Count); var ok = result.Tasks.Single(t => t.TaskId == task.Id); Assert.Null(ok.Error); Assert.Equal(TaskMergeService.PreviewClean, ok.Status); var failed = result.Tasks.Single(t => t.TaskId == noWorktreeTask.Id); Assert.NotNull(failed.Error); Assert.Equal(TaskMergeService.PreviewUnavailable, failed.Status); Assert.Empty(result.Overlaps); } [Fact] public async Task PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var emptyTask = await SeedTaskAsync(listId, "empty", TaskStatus.WaitingForReview); var fullTask = await SeedTaskAsync(listId, "full", TaskStatus.WaitingForReview); var emptyBranch = $"claudedo/{emptyTask.Id[..8]}"; var emptyWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, emptyWtPath)); GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", emptyBranch, emptyWtPath, repo.BaseCommit); using (var ctx = _db.CreateContext()) { ctx.Worktrees.Add(new WorktreeEntity { TaskId = emptyTask.Id, Path = emptyWtPath, BranchName = emptyBranch, BaseCommit = repo.BaseCommit, HeadCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n"); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", cancellationToken: CancellationToken.None); var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id); var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id); Assert.Null(emptyEntry.Error); Assert.True(emptyEntry.IsEmpty); Assert.Null(fullEntry.Error); Assert.False(fullEntry.IsEmpty); } [Fact] public async Task PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" }); var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n"); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") }; var sut = BuildSut(CreateQueue(), fakeVerify); var result = await sut.PreviewMergeSet([task.Id], "main", cancellationToken: CancellationToken.None); Assert.Null(result.Tasks.Single().VerifyExitCode); Assert.Null(fakeVerify.CapturedCommand); } [Fact] public async Task PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" }); var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n"); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; var sut = BuildSut(CreateQueue(), fakeVerify); var result = await sut.PreviewMergeSet([task.Id], "main", runVerify: true, cancellationToken: CancellationToken.None); Assert.Equal(0, result.Tasks.Single().VerifyExitCode); Assert.Equal("dotnet build", fakeVerify.CapturedCommand); } [Fact] public async Task AddTask_ReturnsAllocatedTaskNumber() { var listId = await SeedListAsync(); var sut = NewService(); var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None); var loaded = await _tasks.GetByIdAsync(dto.Task.Id); Assert.True(dto.Task.Number > 0); Assert.Equal(loaded!.Number, dto.Task.Number); } // ── AddTask model override ──────────────────────────────────────────────── [Fact] public async Task AddTask_NoModel_LeavesModelNull() { var listId = await SeedListAsync(); var sut = NewService(); var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None); var loaded = await _tasks.GetByIdAsync(dto.Task.Id); Assert.Null(loaded!.Model); } [Fact] public async Task AddTask_PersistsNormalizedModel() { var listId = await SeedListAsync(); var sut = NewService(); var dto = await sut.AddTask(listId, "t", model: "HAIKU", cancellationToken: CancellationToken.None); var loaded = await _tasks.GetByIdAsync(dto.Task.Id); Assert.Equal("haiku", loaded!.Model); } [Fact] public async Task AddTask_RejectsUnknownModel() { var listId = await SeedListAsync(); var sut = NewService(); await Assert.ThrowsAsync( () => sut.AddTask(listId, "t", model: "gpt4", cancellationToken: CancellationToken.None)); } [Fact] public async Task AddTask_WithDependsOnTaskId_PersistsLinkAndReportsBlocked() { var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); var sut = NewService(); var dto = await sut.AddTask( listId, "t", dependsOnTaskId: predecessor.Id, queueImmediately: true, cancellationToken: CancellationToken.None); Assert.Equal(predecessor.Id, dto.Task.DependsOnTaskId); Assert.True(dto.Task.Blocked); Assert.Contains(predecessor.Id, dto.Task.BlockedReason); var loaded = await _tasks.GetByIdAsync(dto.Task.Id); Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId); } [Fact] public async Task AddTask_WithSelfReferencingDependsOnTaskId_Throws() { // Can't reference its own not-yet-known id, so this exercises the not-found path -- // an unknown dependsOnTaskId is rejected the same way a self-reference would be. var listId = await SeedListAsync(); var sut = NewService(); await Assert.ThrowsAsync( () => sut.AddTask(listId, "t", dependsOnTaskId: "does-not-exist", cancellationToken: CancellationToken.None)); } // ── AddTask possible-duplicate check ──────────────────────────────────────── [Fact] public async Task AddTask_SimilarOpenTitleInSameList_IsReportedAsPossibleDuplicate() { // The exact pair that motivated this check (bf4cd901 vs f3718cd8, see task write-up). var listId = await SeedListAsync(); var existing = await SeedTaskAsync( listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.WaitingForReview); var sut = NewService(); var result = await sut.AddTask( listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None); var dup = Assert.Single(result.PossibleDuplicates); Assert.Equal(existing.Id, dup.TaskId); Assert.Equal(existing.Title, dup.Title); Assert.Equal("WaitingForReview", dup.Status); } [Fact] public async Task AddTask_StillCreatesTheTask_EvenWhenAPossibleDuplicateIsFound() { var listId = await SeedListAsync(); await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle); var sut = NewService(); var result = await sut.AddTask( listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None); Assert.NotEmpty(result.PossibleDuplicates); Assert.NotNull(await _tasks.GetByIdAsync(result.Task.Id)); } [Fact] public async Task AddTask_SimilarTitle_ButExistingTaskIsTerminal_IsNotReported() { var listId = await SeedListAsync(); await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Done); var sut = NewService(); var result = await sut.AddTask( listId, "Settings: MaxTurnsCeiling editierbar machen", cancellationToken: CancellationToken.None); Assert.Empty(result.PossibleDuplicates); } [Fact] public async Task AddTask_ClearlyDifferentTitle_IsNotReported() { var listId = await SeedListAsync(); await SeedTaskAsync(listId, "Refactor GitService worktree cleanup", TaskStatus.Idle); var sut = NewService(); var result = await sut.AddTask( listId, "Add dark mode toggle to the settings page", cancellationToken: CancellationToken.None); Assert.Empty(result.PossibleDuplicates); } [Fact] public async Task AddTask_TitlesSharingOnlyOneWordPlusTheGermanStopwordFuer_AreNotReportedAsDuplicates() { // Regression for the "fur" (never-transliterated) stopword typo: NormalizeTitleWords // transliterates "für" -> "fuer" before the stopword check, so a stopword list containing // "fur" can never filter it. Before the fix these two titles shared "report" and "fuer" // (2 words, 50% of the shorter title) and cleared the duplicate threshold undeservedly; // with "fuer" correctly filtered as filler only "report" remains shared (1 word). var listId = await SeedListAsync(); await SeedTaskAsync(listId, "Neuer Report für Bestand", TaskStatus.Idle); var sut = NewService(); var result = await sut.AddTask( listId, "Anderer Report für Kunden", cancellationToken: CancellationToken.None); Assert.Empty(result.PossibleDuplicates); } [Fact] public async Task AddTask_MoreThanThreeMatches_CapsPossibleDuplicatesAtThree() { var listId = await SeedListAsync(); for (var i = 0; i < 5; i++) await SeedTaskAsync(listId, $"Fix flaky queue picker test number {i}", TaskStatus.Idle); var sut = NewService(); var result = await sut.AddTask( listId, "Fix flaky queue picker test number extra", cancellationToken: CancellationToken.None); Assert.Equal(3, result.PossibleDuplicates.Count); } // ── ContinueTask validation ─────────────────────────────────────────────── [Fact] public async Task ContinueTask_EmptyPrompt_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = NewService(); await Assert.ThrowsAsync( () => sut.ContinueTask(task.Id, " ", CancellationToken.None)); } }