From 7bcca3647e8c08ccdf4d9b396fa4319e2668a502 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 13:28:33 +0200 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFrefactor(claude-do):=20refactor(worker?= =?UTF-8?q?):=20move=20the=20merge=20and=20worktree=20MCP=20tools=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/ClaudeDo.Worker/External/ExternalMcpService.cs` ist **1897 Zeilen** (nachgezählt 2026-09-02; die Datei ist seit dem Anlegen dieses Tasks von 1696 weiter gewachsen): ~30 DTO-Records oben plus ~25 Tool-Methoden — während **13** Geschwisterdateien `*McpTools.cs` im selben Ordner das Topic-Split-Muster schon tragen (`ListMcpTools`, `ConfigMcpTools`, `BatchMcpTools`, `NotesMcpTools`, `QueueStateMc ClaudeDo-Task: 1862e160-ffde-4a50-aa03-bb7251f56088 --- src/ClaudeDo.Worker/External/BatchMcpTools.cs | 9 +- src/ClaudeDo.Worker/External/MergeMcpTools.cs | 1 + .../External/WorktreeMcpTools.cs | 3 + .../External/BatchMcpToolsTests.cs | 4 +- ...merge_bodies.txt => MergeMcpToolsTests.cs} | 306 +++- ...ee_bodies.txt => WorktreeMcpToolsTests.cs} | 183 +- .../External/_extract.py | 136 -- .../External/_finalize_remainder.py | 54 - .../External/_remainder.cs | 1544 ----------------- 9 files changed, 479 insertions(+), 1761 deletions(-) rename tests/ClaudeDo.Worker.Tests/External/{_merge_bodies.txt => MergeMcpToolsTests.cs} (76%) rename tests/ClaudeDo.Worker.Tests/External/{_worktree_bodies.txt => WorktreeMcpToolsTests.cs} (61%) delete mode 100644 tests/ClaudeDo.Worker.Tests/External/_extract.py delete mode 100644 tests/ClaudeDo.Worker.Tests/External/_finalize_remainder.py delete mode 100644 tests/ClaudeDo.Worker.Tests/External/_remainder.cs diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs index aff487f8..5bb0c16c 100644 --- a/src/ClaudeDo.Worker/External/BatchMcpTools.cs +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -85,8 +85,13 @@ public sealed class BatchMcpTools }; private readonly ExternalMcpService _svc; + private readonly WorktreeMcpTools _worktree; - public BatchMcpTools(ExternalMcpService svc) => _svc = svc; + public BatchMcpTools(ExternalMcpService svc, WorktreeMcpTools worktree) + { + _svc = svc; + _worktree = worktree; + } [McpServerTool, Description( "Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " + @@ -365,7 +370,7 @@ public sealed class BatchMcpTools var id = taskIds[i]; try { - var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken); + var r = await _worktree.CleanupTaskWorktree(id, force, cancellationToken); results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null, r.Number)); } catch (OperationCanceledException) { throw; } diff --git a/src/ClaudeDo.Worker/External/MergeMcpTools.cs b/src/ClaudeDo.Worker/External/MergeMcpTools.cs index 11231ed9..1c9a1951 100644 --- a/src/ClaudeDo.Worker/External/MergeMcpTools.cs +++ b/src/ClaudeDo.Worker/External/MergeMcpTools.cs @@ -9,6 +9,7 @@ using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Planning; +using Microsoft.EntityFrameworkCore; using ModelContextProtocol; using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; diff --git a/src/ClaudeDo.Worker/External/WorktreeMcpTools.cs b/src/ClaudeDo.Worker/External/WorktreeMcpTools.cs index 32b475d3..a44726a1 100644 --- a/src/ClaudeDo.Worker/External/WorktreeMcpTools.cs +++ b/src/ClaudeDo.Worker/External/WorktreeMcpTools.cs @@ -1,10 +1,13 @@ using System.ComponentModel; using System.Diagnostics; using ClaudeDo.Data; +using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Git; +using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Worktrees; +using Microsoft.EntityFrameworkCore; using ModelContextProtocol; using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index cedcc68b..74fced35 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -89,7 +89,9 @@ public sealed class BatchMcpToolsTests : IDisposable state, git, factory, merge, planningMerge, new BaseDirtyChecker(git, NullLogger.Instance)); - return new BatchMcpTools(svc); + var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance); + var worktree = new WorktreeMcpTools(_tasks, git, factory, maintenance); + return new BatchMcpTools(svc, worktree); } private QueueService CreateQueue() diff --git a/tests/ClaudeDo.Worker.Tests/External/_merge_bodies.txt b/tests/ClaudeDo.Worker.Tests/External/MergeMcpToolsTests.cs similarity index 76% rename from tests/ClaudeDo.Worker.Tests/External/_merge_bodies.txt rename to tests/ClaudeDo.Worker.Tests/External/MergeMcpToolsTests.cs index 53ef1d10..41e27ef7 100644 --- a/tests/ClaudeDo.Worker.Tests/External/_merge_bodies.txt +++ b/tests/ClaudeDo.Worker.Tests/External/MergeMcpToolsTests.cs @@ -1,3 +1,277 @@ +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.Extensions.Logging.Abstractions; +using ModelContextProtocol; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.External; + +// 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 MergeMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + 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 MergeMcpToolsTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _broadcaster = new HubBroadcaster(new CapturingHubContext()); + } + + 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 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"); + } + + 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; + } + + // queue is accepted (and ignored) so every extracted test body can keep calling + // BuildSut(CreateQueue()) unchanged -- MergeMcpTools itself has no QueueService dependency. + private MergeMcpTools BuildSut(QueueService queue) => BuildSut(queue, new VerifyCommandRunner()); + + private MergeMcpTools BuildSut(QueueService queue, IVerifyCommandRunner verify) + { + var git = new GitService(); + var factory = _db.CreateFactory(); + 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 MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge); + } + + // ReviewTask (approve) still lives on ExternalMcpService. Several tests here approve a task + // first, then act on the resulting in-progress merge via MergeMcpTools -- for a plain (childless) + // task that's safe with two independently-built instances (TaskMergeService/GitService are + // git-disk- and DB-backed, not instance-stateful). Only the parent/children unit-merge path + // (PlanningMergeOrchestrator's in-memory active-merge tracking) needs the two SUTs to share the + // same underlying objects -- see BuildSharedSuts below. + private ExternalMcpService BuildReviewSut(QueueService queue) => BuildReviewSut(queue, new VerifyCommandRunner()); + + private ExternalMcpService BuildReviewSut(QueueService queue, IVerifyCommandRunner verify) + { + var git = new GitService(); + var factory = _db.CreateFactory(); + 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, merge, planningMerge, + new BaseDirtyChecker(git, NullLogger.Instance)); + } + + // Parent/children unit merges route through PlanningMergeOrchestrator's in-memory _states + // dictionary (ContinueMerge/AbortMerge check HasActiveMerge on it) -- a review that starts the + // unit merge and a later continue/abort must observe the SAME orchestrator instance, so this + // shares git/factory/state/merge/planningMerge between the two SUTs instead of building fresh. + private (ExternalMcpService ReviewSut, MergeMcpTools MergeSut) BuildSharedSuts(QueueService queue) + { + var git = new GitService(); + var factory = _db.CreateFactory(); + var state = TaskStateServiceBuilder.Build(factory).State; + var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); + var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); + var planningMerge = new PlanningMergeOrchestrator( + factory, merge, aggregator, _broadcaster, git, state, NullLogger.Instance); + var reviewSut = new ExternalMcpService( + _tasks, _lists, queue, _broadcaster, state, git, factory, merge, planningMerge, + new BaseDirtyChecker(git, NullLogger.Instance)); + var mergeSut = new MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge); + return (reviewSut, mergeSut); + } + + private QueueService CreateQueue() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_merge_{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); + var usageState = new UsageState(); + return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state, runCancels, + new FakeUsageGate(), usageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); + } + [Fact] public async Task MergeTask_NotDone_Throws() { @@ -121,14 +395,15 @@ 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, + var reviewSut = BuildReviewSut(CreateQueue()); + await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: 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 sut = BuildSut(CreateQueue()); var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); Assert.True(result.Merged); @@ -147,7 +422,7 @@ try { var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); - var plainSut = BuildSut(CreateQueue()); + var plainSut = BuildReviewSut(CreateQueue()); await plainSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); @@ -186,11 +461,12 @@ 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, + var reviewSut = BuildReviewSut(CreateQueue()); + await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); // Markers never resolved — continue must refuse and list the files. + var sut = BuildSut(CreateQueue()); var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); Assert.False(result.Merged); @@ -208,10 +484,11 @@ 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, + var reviewSut = BuildReviewSut(CreateQueue()); + await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + var sut = BuildSut(CreateQueue()); var dto = await sut.AbortMerge(task.Id, CancellationToken.None); Assert.Equal("WaitingForReview", dto.Status); @@ -251,9 +528,10 @@ 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: CancellationToken.None); + var reviewSut = BuildReviewSut(CreateQueue()); + await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + var sut = BuildSut(CreateQueue()); var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None); Assert.Equal(1, result.RemainingHunks); @@ -356,9 +634,10 @@ 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: CancellationToken.None); + var reviewSut = BuildReviewSut(CreateQueue()); + await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); + var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None)); await Assert.ThrowsAsync( @@ -479,8 +758,8 @@ await ctx.SaveChangesAsync(); } - var sut = BuildSut(CreateQueue()); - var review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None); + var (reviewSut, sut) = BuildSharedSuts(CreateQueue()); + var review = await reviewSut.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); @@ -994,3 +1273,4 @@ Assert.Equal(0, result.Tasks.Single().VerifyExitCode); Assert.Equal("dotnet build", fakeVerify.CapturedCommand); } +} diff --git a/tests/ClaudeDo.Worker.Tests/External/_worktree_bodies.txt b/tests/ClaudeDo.Worker.Tests/External/WorktreeMcpToolsTests.cs similarity index 61% rename from tests/ClaudeDo.Worker.Tests/External/_worktree_bodies.txt rename to tests/ClaudeDo.Worker.Tests/External/WorktreeMcpToolsTests.cs index 6d6319cd..ebc26d51 100644 --- a/tests/ClaudeDo.Worker.Tests/External/_worktree_bodies.txt +++ b/tests/ClaudeDo.Worker.Tests/External/WorktreeMcpToolsTests.cs @@ -1,3 +1,162 @@ +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.Extensions.Logging.Abstractions; +using ModelContextProtocol; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.External; + +// 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 WorktreeMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + 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 WorktreeMcpToolsTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _broadcaster = new HubBroadcaster(new CapturingHubContext()); + } + + 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; + } + + // queue is accepted (and ignored) so every extracted test body can keep calling + // BuildSut(CreateQueue()) unchanged -- WorktreeMcpTools itself has no QueueService dependency. + private WorktreeMcpTools BuildSut(QueueService queue) + { + var git = new GitService(); + var factory = _db.CreateFactory(); + var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance); + return new WorktreeMcpTools(_tasks, git, factory, maintenance); + } + + // GetTaskWorktree_AfterMerge_ExposesMergeCommit needs a real merge first -- MergeTask lives on + // MergeMcpTools now, not here. A fresh instance is fine (TaskMergeService/GitService are + // git-disk- and DB-backed, no shared in-memory state to worry about for a plain merge). + private MergeMcpTools BuildMergeSut() + { + var git = new GitService(); + var factory = _db.CreateFactory(); + var state = TaskStateServiceBuilder.Build(factory).State; + var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); + var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); + var planningMerge = new PlanningMergeOrchestrator( + factory, merge, aggregator, _broadcaster, git, state, NullLogger.Instance); + return new MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge); + } + + private QueueService CreateQueue() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_worktree_{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); + var usageState = new UsageState(); + return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state, runCancels, + new FakeUsageGate(), usageState, broadcaster, + new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); + } + [Fact] public async Task GetTaskWorktree_NoWorktree_Throws() { @@ -49,9 +208,10 @@ 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 mergeSut = BuildMergeSut(); + await mergeSut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); + var sut = BuildSut(CreateQueue()); var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); @@ -197,8 +357,8 @@ { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - var original = ExternalMcpService.ProgressReportInterval; - ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; + var original = WorktreeMcpTools.ProgressReportInterval; + WorktreeMcpTools.ProgressReportInterval = TimeSpan.Zero; try { var (task, list, wt) = await SeedWorktreeAsync(); @@ -218,7 +378,7 @@ } finally { - ExternalMcpService.ProgressReportInterval = original; + WorktreeMcpTools.ProgressReportInterval = original; } } @@ -250,8 +410,8 @@ { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - var original = ExternalMcpService.ProgressReportInterval; - ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; + var original = WorktreeMcpTools.ProgressReportInterval; + WorktreeMcpTools.ProgressReportInterval = TimeSpan.Zero; try { await SeedWorktreeAsync(); @@ -265,7 +425,7 @@ } finally { - ExternalMcpService.ProgressReportInterval = original; + WorktreeMcpTools.ProgressReportInterval = original; } } @@ -310,8 +470,8 @@ { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } - var original = ExternalMcpService.ProgressReportInterval; - ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; + var original = WorktreeMcpTools.ProgressReportInterval; + WorktreeMcpTools.ProgressReportInterval = TimeSpan.Zero; try { var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); @@ -326,6 +486,7 @@ } finally { - ExternalMcpService.ProgressReportInterval = original; + WorktreeMcpTools.ProgressReportInterval = original; } } +} diff --git a/tests/ClaudeDo.Worker.Tests/External/_extract.py b/tests/ClaudeDo.Worker.Tests/External/_extract.py deleted file mode 100644 index 56a2d681..00000000 --- a/tests/ClaudeDo.Worker.Tests/External/_extract.py +++ /dev/null @@ -1,136 +0,0 @@ -import re, sys, json - -path = r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\ExternalMcpServiceTests.cs" -with open(path, encoding='utf-8') as f: - lines = f.readlines() - -# Find all [Fact] blocks: (start_idx, end_idx, name) where start_idx is the [Fact] line (0-based) -# and end_idx is the line with the closing brace (inclusive), 0-based. -blocks = {} -i = 0 -n = len(lines) -while i < n: - if lines[i].strip() == '[Fact]': - fact_start = i - # next line should be method signature - sig_line = lines[i+1] - m = re.search(r'public async Task (\w+)\(', sig_line) - assert m, f"no method name at line {i+2}: {sig_line}" - name = m.group(1) - # find opening brace - it's on the line after signature (possibly signature spans multiple lines) - j = i + 1 - # advance until we find a line that is just '{' (with indentation) - while lines[j].strip() != '{': - j += 1 - brace_line = j - depth = 0 - k = brace_line - while True: - depth += lines[k].count('{') - lines[k].count('}') - if depth == 0: - break - k += 1 - end_idx = k - blocks[name] = (fact_start, end_idx) - i = end_idx + 1 - else: - i += 1 - -print(f"Found {len(blocks)} [Fact] blocks", file=sys.stderr) - -merge_names = """MergeTask_NotDone_Throws -MergeTask_DryRun_DoesNotMerge -MergeTask_WaitingForReview_WithoutFlag_Throws -MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds -MergeTask_WaitingForReview_WithFlag_MarksTaskDone -MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge -MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly -ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone -ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout -ContinueMerge_MarkersStillPresent_ReportsConflicts -AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview -ContinueMerge_NoMergeInProgress_Throws -AbortMerge_NoMergeInProgress_Throws -GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base -GetMergeConflicts_NoMergeInProgress_ReturnsEmpty -ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount -ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge -ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws -RevertMerge_NotMerged_Throws -RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview -RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly -ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes -PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo -PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue -PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue -PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse -PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo -PreviewMerge_NoWorktree_Throws -PreviewMerge_MissingWorkingDir_ThrowsClearError -PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget -PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles -PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles -PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles -PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap -PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset -PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap -PreviewMergeSet_ReportsProgressPerTask -PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers -PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry -PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured -PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask""".split() - -worktree_names = """GetTaskWorktree_NoWorktree_Throws -GetTaskWorktree_ReturnsBranchAndBaseCommit -GetTaskWorktree_BeforeMerge_MergeCommitIsNull -GetTaskWorktree_AfterMerge_ExposesMergeCommit -GetTaskDiff_NoWorktree_Throws -GetTaskDiff_AfterCommit_ListsChangedFile -GetTaskDiff_WithPathsFilter_ReturnsOnlyRequestedFile -GetTaskDiff_StatMode_WithPathsFilter_ReturnsOnlyRequestedFile -GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir -GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange -GetTaskDiff_ReportsProgressWhileDiffing -ListWorktrees_Empty_ReturnsEmpty -ListWorktrees_ReturnsCreatedWorktree -ListWorktrees_ReportsProgressWhileSurveying -CleanupTaskWorktree_NotFound_Throws -CleanupTaskWorktree_Running_Throws -CleanupTaskWorktree_CleanWorktree_Removes -CleanupTaskWorktree_ReportsProgressWhileRemoving""".split() - -missing_merge = [n for n in merge_names if n not in blocks] -missing_worktree = [n for n in worktree_names if n not in blocks] -print("missing merge:", missing_merge, file=sys.stderr) -print("missing worktree:", missing_worktree, file=sys.stderr) -overlap = set(merge_names) & set(worktree_names) -print("overlap:", overlap, file=sys.stderr) - -def extract(names): - out = [] - for name in names: - s, e = blocks[name] - out.append(''.join(lines[s:e+1])) - return out - -with open(r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_merge_bodies.txt", 'w', encoding='utf-8') as f: - f.write('\n'.join(extract(merge_names))) - -with open(r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_worktree_bodies.txt", 'w', encoding='utf-8') as f: - f.write('\n'.join(extract(worktree_names))) - -# Build set of all line ranges to remove (sorted), then write remainder file for inspection -all_names = merge_names + worktree_names -ranges = sorted([blocks[n] for n in all_names]) -# merge overlapping/adjacent not needed; just mark lines -remove_lines = set() -for s, e in ranges: - for x in range(s, e+1): - remove_lines.add(x) - -remainder = [lines[i] for i in range(n) if i not in remove_lines] -with open(r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_remainder.cs", 'w', encoding='utf-8') as f: - f.writelines(remainder) - -print(f"Total lines removed: {len(remove_lines)}", file=sys.stderr) -print(f"Remainder line count: {len(remainder)}", file=sys.stderr) diff --git a/tests/ClaudeDo.Worker.Tests/External/_finalize_remainder.py b/tests/ClaudeDo.Worker.Tests/External/_finalize_remainder.py deleted file mode 100644 index 7519fc72..00000000 --- a/tests/ClaudeDo.Worker.Tests/External/_finalize_remainder.py +++ /dev/null @@ -1,54 +0,0 @@ -import re - -path = r"C:\Private\.claudedo-worktrees\claude-do\1862e160-ffde-4a50-aa03-bb7251f56088\tests\ClaudeDo.Worker.Tests\External\_remainder.cs" -with open(path, encoding='utf-8') as f: - lines = f.readlines() - -def idx(marker, start=0): - for i in range(start, len(lines)): - if marker in lines[i]: - return i - raise AssertionError(f"marker not found: {marker!r}") - -# Each deletion described as (start_marker, end_marker_exclusive) -- delete from the line -# containing start_marker up to (but not including) the line containing end_marker. -deletions = [ - ("// ── GetTaskWorktree ──", "// ── GetTaskConfig ──"), - ("// ── MergeTask allowWaitingForReview ──", "// ── leaveConflictsInTree ──"), - ("// Three edits spread far enough apart", "[Fact]"), # multi-hunk helpers, up to next [Fact] - ("// ── continue_merge / abort_merge ──", "// ── PreviewMerge / PreviewMergeSet ──"), - ("private async Task CreateActiveWorktreeAsync(", "[Fact]"), -] - -# Apply deletions from bottom to top so earlier indices stay valid. -spans = [] -for start_marker, end_marker in deletions: - s = idx(start_marker) - e = idx(end_marker, s + 1) - spans.append((s, e)) - -spans.sort(reverse=True) -for s, e in spans: - del lines[s:e] - -text = ''.join(lines) - -# Drop the now-unused `maintenance` local + ctor arg in BuildSut(QueueService, IVerifyCommandRunner). -before = text -text = text.replace( - " var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance);\n", - "", 1) -assert text != before, "maintenance local not removed" -before = text -text = text.replace( - " git, factory, maintenance, merge, planningMerge,\n", - " git, factory, merge, planningMerge,\n", 1) -assert text != before, "maintenance ctor arg not removed" - -# Collapse any remaining runs of 2+ blank lines down to a single blank line (cosmetic only). -text = re.sub(r'\n{3,}', '\n\n', text) - -with open(path, 'w', encoding='utf-8') as f: - f.write(text) - -print("final line count:", len(text.splitlines())) diff --git a/tests/ClaudeDo.Worker.Tests/External/_remainder.cs b/tests/ClaudeDo.Worker.Tests/External/_remainder.cs deleted file mode 100644 index 481bd52e..00000000 --- a/tests/ClaudeDo.Worker.Tests/External/_remainder.cs +++ /dev/null @@ -1,1544 +0,0 @@ -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 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, 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); - var usageState = new UsageState(); - return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state, runCancels, - new FakeUsageGate(), usageState, broadcaster, - new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); - } - - [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, 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, null, CancellationToken.None); - - Assert.Equal(task.Id, dto.Id); - Assert.Equal(listId, dto.ListId); - Assert.Equal("new title", dto.Title); - } - - [Fact] - public async Task UpdateTask_SetsIsManualTrue() - { - var listId = await SeedListAsync(); - var task = await SeedTaskAsync(listId); - var sut = BuildSut(CreateQueue()); - - var dto = await sut.UpdateTask(task.Id, isManual: true, cancellationToken: CancellationToken.None); - - Assert.True(dto.IsManual); - var loaded = await _tasks.GetByIdAsync(task.Id); - Assert.True(loaded!.IsManual); - } - - [Fact] - public async Task UpdateTask_SetsIsManualFalse_ClearsExistingFlag() - { - var listId = await SeedListAsync(); - var task = await SeedTaskAsync(listId); - task.IsManual = true; - await _tasks.UpdateAsync(task, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - - var dto = await sut.UpdateTask(task.Id, isManual: false, cancellationToken: CancellationToken.None); - - Assert.False(dto.IsManual); - var loaded = await _tasks.GetByIdAsync(task.Id); - Assert.False(loaded!.IsManual); - } - - [Fact] - public async Task UpdateTask_IsManualNull_LeavesUnchanged() - { - var listId = await SeedListAsync(); - var task = await SeedTaskAsync(listId); - task.IsManual = true; - await _tasks.UpdateAsync(task, CancellationToken.None); - var sut = BuildSut(CreateQueue()); - - var dto = await sut.UpdateTask(task.Id, title: "new title", cancellationToken: CancellationToken.None); - - Assert.True(dto.IsManual); - } - - [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, 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, 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_WithChildren_ThrowsUnderstandableMessage() - { - var listId = await SeedListAsync(); - var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren); - await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id); - var sut = BuildSut(CreateQueue()); - - var ex = await Assert.ThrowsAsync(() => - sut.DeleteTask(parent.Id, CancellationToken.None)); - - Assert.Contains("child task", ex.Message, StringComparison.OrdinalIgnoreCase); - Assert.NotNull(await _tasks.GetByIdAsync(parent.Id)); - } - - [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); - } - - [Fact] - public async Task UpdateTaskStatus_Idle_OnRunningTask_Throws() - { - var listId = await SeedListAsync(); - var task = await SeedTaskAsync(listId, status: TaskStatus.Running); - var sut = BuildSut(CreateQueue()); - - await Assert.ThrowsAsync( - () => sut.UpdateTaskStatus(task.Id, "Idle", CancellationToken.None)); - - var loaded = await _tasks.GetByIdAsync(task.Id); - Assert.Equal(TaskStatus.Running, loaded!.Status); - } - - [Fact] - public async Task UpdateTaskStatus_Done_OnRunningTask_Throws() - { - var listId = await SeedListAsync(); - var task = await SeedTaskAsync(listId, status: TaskStatus.Running); - var sut = BuildSut(CreateQueue()); - - var ex = await Assert.ThrowsAsync( - () => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None)); - - Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase); - var loaded = await _tasks.GetByIdAsync(task.Id); - Assert.Equal(TaskStatus.Running, 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); - } - - // ── 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); - } - - // ── 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); - } - - [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: 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); - } - - [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)); - } -}