using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; 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.State; using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Usage; using ClaudeDo.Worker.Worktrees; using ClaudeDo.Data.Git; using ClaudeDo.Worker.Config; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.External; public sealed class LifecycleMcpToolsTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly List _repos = new(); private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public LifecycleMcpToolsTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); } public void Dispose() { foreach (var r in _repos) r.Dispose(); _ctx.Dispose(); _db.Dispose(); } private LifecycleMcpTools BuildSut() { var cfg = new WorkerConfig { SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"), LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"), }; var git = new GitService(); var dbFactory = _db.CreateFactory(); var broadcaster = new HubBroadcaster(new CapturingHubContext()); var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(dbFactory).State; var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger.Instance); var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger.Instance); var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator(dbFactory, git, NullLogger.Instance); var planningMerge = new PlanningMergeOrchestrator( dbFactory, merge, aggregator, broadcaster, git, state, NullLogger.Instance); var svc = new ExternalMcpService( _tasks, _lists, CreateQueue(dbFactory, wtManager, state, broadcaster), broadcaster, state, git, dbFactory, maintenance, merge, planningMerge, new BaseDirtyChecker(git, NullLogger.Instance)); var reviewSubmission = new InteractiveReviewSubmissionService(dbFactory, git, state, wtManager); return new LifecycleMcpTools(_tasks, reset, svc, reviewSubmission); } private QueueService CreateQueue( IDbContextFactory dbFactory, WorktreeManager wtManager, ITaskStateService state, HubBroadcaster broadcaster) { var cfg = new WorkerConfig { SandboxRoot = Path.Combine(Path.GetTempPath(), $"cdq_{Guid.NewGuid():N}"), LogRoot = Path.Combine(Path.GetTempPath(), $"cdql_{Guid.NewGuid():N}"), QueueBackstopIntervalMs = 50, }; var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); var runCancels = new RunCancellationRegistry(NullLogger.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state, runCancels, new FakeUsageGate(), new UsageState(), broadcaster); } private async Task SeedTaskAsync( TaskStatus status, string? workingDir = null, string? handlerBaseCommit = null) { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = workingDir, CreatedAt = DateTime.UtcNow }); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = status, HandlerBaseCommit = handlerBaseCommit, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); return task; } private GitRepoFixture CreateRepo() { var f = new GitRepoFixture(); _repos.Add(f); return f; } [Fact] public async Task ResetFailedTask_OnFailed_ResetsToIdle() { var task = await SeedTaskAsync(TaskStatus.Failed); var sut = BuildSut(); var result = await sut.ResetFailedTask(task.Id, CancellationToken.None); Assert.True(result.Reset); Assert.Equal(task.Id, result.TaskId); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Idle, loaded!.Status); } [Fact] public async Task ResetFailedTask_OnNonFailed_Throws() { var task = await SeedTaskAsync(TaskStatus.Done); var sut = BuildSut(); await Assert.ThrowsAsync(() => sut.ResetFailedTask(task.Id, CancellationToken.None)); } [Fact] public async Task ResetFailedTask_NotFound_Throws() { var sut = BuildSut(); await Assert.ThrowsAsync(() => sut.ResetFailedTask("missing", CancellationToken.None)); } // ── SubmitTaskForReview ── [Fact] public async Task SubmitTaskForReview_WorktreeLessHandlerTask_StampsHeadCommit_TransitionsToWaitingForReview() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = CreateRepo(); var task = await SeedTaskAsync(TaskStatus.Idle, workingDir: repo.RepoDir, handlerBaseCommit: repo.BaseCommit); File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); var expectedHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); var sut = BuildSut(); var dto = await sut.SubmitTaskForReview(task.Id, CancellationToken.None); Assert.Equal("WaitingForReview", dto.Status); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); Assert.Equal(expectedHead, reloaded.HandlerHeadCommit); } [Fact] public async Task SubmitTaskForReview_NoWorktreeAndNoHandlerBaseCommit_Throws() { var task = await SeedTaskAsync(TaskStatus.Idle, workingDir: Path.GetTempPath()); var sut = BuildSut(); await Assert.ThrowsAsync(() => sut.SubmitTaskForReview(task.Id, CancellationToken.None)); } [Fact] public async Task SubmitTaskForReview_RunningTask_Throws() { var task = await SeedTaskAsync(TaskStatus.Running, workingDir: Path.GetTempPath(), handlerBaseCommit: "abc123"); var sut = BuildSut(); await Assert.ThrowsAsync(() => sut.SubmitTaskForReview(task.Id, CancellationToken.None)); } [Fact] public async Task SubmitTaskForReview_AlreadyDone_Throws_HandlerHeadCommitUntouched() { var task = await SeedTaskAsync(TaskStatus.Done, workingDir: Path.GetTempPath(), handlerBaseCommit: "abc123"); var sut = BuildSut(); var ex = await Assert.ThrowsAsync(() => sut.SubmitTaskForReview(task.Id, CancellationToken.None)); Assert.Contains("Idle or Failed", ex.Message); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Null(reloaded!.HandlerHeadCommit); Assert.Equal(TaskStatus.Done, reloaded.Status); } [Fact] public async Task SubmitTaskForReview_NotFound_Throws() { var sut = BuildSut(); await Assert.ThrowsAsync(() => sut.SubmitTaskForReview("missing", CancellationToken.None)); } }