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; } // The dependency set has no defined order, and the ids are random GUIDs -- sort both sides. private static List Sorted(params string[] ids) => ids.OrderBy(x => x).ToList(); private static List Sorted(IEnumerable ids) => ids.OrderBy(x => x).ToList(); 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: 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: 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 UpdateTask_SetsExpectsNoDiffTrue() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); var dto = await sut.UpdateTask(task.Id, expectsNoDiff: true, cancellationToken: CancellationToken.None); Assert.True(dto.ExpectsNoDiff); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.True(loaded!.ExpectsNoDiff); } [Fact] public async Task UpdateTask_SetsExpectsNoDiffFalse_ClearsExistingFlag() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); task.ExpectsNoDiff = true; await _tasks.UpdateAsync(task, CancellationToken.None); var sut = BuildSut(CreateQueue()); var dto = await sut.UpdateTask(task.Id, expectsNoDiff: false, cancellationToken: CancellationToken.None); Assert.False(dto.ExpectsNoDiff); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.False(loaded!.ExpectsNoDiff); } [Fact] public async Task UpdateTask_ExpectsNoDiffNull_LeavesUnchanged() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); task.ExpectsNoDiff = 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.ExpectsNoDiff); } [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); await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id); 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); await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id); 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); await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id); 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); await _ctx.SeedDependsOnAsync(blocked.Id, predecessor.Id); 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); await _ctx.SeedDependsOnAsync(blocked.Id, predecessor.Id); 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: 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: 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.DependsOnTaskIds); Assert.True(dto.Blocked); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal([predecessor.Id], loaded!.DependsOnTaskIds); } [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.Empty(dto.DependsOnTaskIds!); 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 UpdateTask_WithDependsOnTaskIds_SetsSeveralPredecessors() { var listId = await SeedListAsync(); var a = await SeedTaskAsync(listId, "a", TaskStatus.Idle); var b = await SeedTaskAsync(listId, "b", TaskStatus.Idle); var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); var sut = BuildSut(CreateQueue()); var dto = await sut.UpdateTask( task.Id, dependsOnTaskIds: [a.Id, b.Id], cancellationToken: CancellationToken.None); Assert.Equal(Sorted(a.Id, b.Id), Sorted(dto.DependsOnTaskIds!)); Assert.True(dto.Blocked); // Naming only the first predecessor would send a caller off to unblock the wrong task. Assert.Contains(a.Id, dto.BlockedReason); Assert.Contains(b.Id, dto.BlockedReason); } [Fact] public async Task UpdateTask_WithEmptyDependsOnTaskIds_ClearsTheWholeSet() { var listId = await SeedListAsync(); var a = await SeedTaskAsync(listId, "a", TaskStatus.Idle); var b = await SeedTaskAsync(listId, "b", TaskStatus.Idle); var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); var sut = BuildSut(CreateQueue()); await sut.UpdateTask(task.Id, dependsOnTaskIds: [a.Id, b.Id], cancellationToken: CancellationToken.None); var dto = await sut.UpdateTask(task.Id, dependsOnTaskIds: [], cancellationToken: CancellationToken.None); Assert.Empty(dto.DependsOnTaskIds!); Assert.False(dto.Blocked); } [Fact] public async Task UpdateTask_LegacyScalarDependsOnTaskId_StillReplacesTheWholeSet() { // Back-compat: existing callers and prompts only know the scalar form. It has to keep // behaving as "this is the one predecessor", not append to whatever is already there. var listId = await SeedListAsync(); var a = await SeedTaskAsync(listId, "a", TaskStatus.Idle); var b = await SeedTaskAsync(listId, "b", TaskStatus.Idle); var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); var sut = BuildSut(CreateQueue()); await sut.UpdateTask(task.Id, dependsOnTaskIds: [a.Id, b.Id], cancellationToken: CancellationToken.None); var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None); Assert.Equal([a.Id], dto.DependsOnTaskIds); } [Fact] public async Task UpdateTask_WithCyclicDependsOnTaskIds_Throws() { var listId = await SeedListAsync(); var a = await SeedTaskAsync(listId, "a", TaskStatus.Idle); var b = await SeedTaskAsync(listId, "b", TaskStatus.Idle); var c = await SeedTaskAsync(listId, "c", TaskStatus.Idle); var innocent = await SeedTaskAsync(listId, "innocent", TaskStatus.Idle); var sut = BuildSut(CreateQueue()); await sut.UpdateTask(b.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None); await sut.UpdateTask(c.Id, dependsOnTaskId: b.Id, cancellationToken: CancellationToken.None); // a -> {innocent, c} closes a -> c -> b -> a over three edges. await Assert.ThrowsAsync(() => sut.UpdateTask(a.Id, dependsOnTaskIds: [innocent.Id, c.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); } [Fact] public async Task AddTask_DefaultExpectsNoDiff_IsFalse() { var listId = await SeedListAsync(); var sut = NewService(); var dto = await sut.AddTask(listId, "t", cancellationToken: CancellationToken.None); Assert.False(dto.Task.ExpectsNoDiff); var loaded = await _tasks.GetByIdAsync(dto.Task.Id); Assert.False(loaded!.ExpectsNoDiff); } [Fact] public async Task AddTask_ExpectsNoDiffTrue_PersistsFlag() { var listId = await SeedListAsync(); var sut = NewService(); var dto = await sut.AddTask(listId, "t", expectsNoDiff: true, cancellationToken: CancellationToken.None); Assert.True(dto.Task.ExpectsNoDiff); var loaded = await _tasks.GetByIdAsync(dto.Task.Id); Assert.True(loaded!.ExpectsNoDiff); } // ── 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.DependsOnTaskIds); 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!.DependsOnTaskIds); } [Fact] public async Task AddTask_WithDependsOnTaskIds_PersistsEveryLink() { var listId = await SeedListAsync(); var a = await SeedTaskAsync(listId, "a", TaskStatus.Idle); var b = await SeedTaskAsync(listId, "b", TaskStatus.Done); var sut = NewService(); var dto = await sut.AddTask( listId, "t", dependsOnTaskIds: [a.Id, b.Id], queueImmediately: true, cancellationToken: CancellationToken.None); Assert.Equal(Sorted(a.Id, b.Id), Sorted(dto.Task.DependsOnTaskIds!)); Assert.True(dto.Task.Blocked); var loaded = await _tasks.GetByIdAsync(dto.Task.Id); Assert.Equal(Sorted(a.Id, b.Id), Sorted(loaded!.DependsOnTaskIds)); } [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)); } }