using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Runner; /// Verifies InteractiveLaunchSpecService's guards mirror WorkerHub.ResumeTaskInTerminal /// (Running/Queued rejected, missing/invalid worktree rejected) and that it reuses the /// autonomous-run worktree prep: session-skills seeding via ISessionSkillSeeder, and /// produces --resume for a resumable task vs. a fresh-start spec for a never-run task. /// Never spawns the real claude CLI: ClaudeBin points at a stub file on disk. public sealed class InteractiveLaunchSpecServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly string _tempDir; private readonly string _worktreeDir; private readonly string _claudeStubPath; private readonly FakeSessionSkillSeeder _seeder = new(); private readonly FakeSessionSkillRegistry _registry = new(); private readonly List _gitFixtures = new(); private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public InteractiveLaunchSpecServiceTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"cd_ilss_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); _worktreeDir = Path.Combine(_tempDir, "worktree"); Directory.CreateDirectory(_worktreeDir); _claudeStubPath = Path.Combine(_tempDir, "claude.exe"); File.WriteAllText(_claudeStubPath, "stub"); } private GitRepoFixture CreateRepo() { var f = new GitRepoFixture(); _gitFixtures.Add(f); return f; } public void Dispose() { _db.Dispose(); foreach (var f in _gitFixtures) f.Dispose(); try { Directory.Delete(_tempDir, true); } catch { /* best effort */ } } private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry { public List Installed { get; } = new(); public Task> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException(); public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException(); public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException(); public Task> ListAsync(CancellationToken ct) => Task.FromResult>(Installed); } private InteractiveLaunchSpecService BuildService() => new(_db.CreateFactory(), _seeder, _registry, new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger.Instance), new WorkerConfig { ClaudeBin = _claudeStubPath }); private async Task SeedListAsync(string? workingDir = null) { var listId = Guid.NewGuid().ToString(); using var ctx = _db.CreateContext(); await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = workingDir ?? _tempDir, CreatedAt = DateTime.UtcNow, }); return listId; } private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null) { using var ctx = _db.CreateContext(); await new TaskRepository(ctx).AddAsync(new TaskEntity { Id = taskId, ListId = listId, Title = "T", Status = status, CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson, }); } private async Task SeedWorktreeAsync(string taskId, WorktreeState state, string? path = null) { using var ctx = _db.CreateContext(); ctx.Worktrees.Add(new WorktreeEntity { TaskId = taskId, Path = path ?? _worktreeDir, BranchName = "claudedo/x", BaseCommit = "abc123", State = state, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } private async Task SeedRunAsync(string taskId, string? sessionId) { using var ctx = _db.CreateContext(); await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity { Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false, Prompt = "p", SessionId = sessionId, StartedAt = DateTime.UtcNow.AddMinutes(-5), FinishedAt = DateTime.UtcNow.AddMinutes(-1), ExitCode = 0, ResultMarkdown = "ok", }); } [Fact] public async Task BuildForTaskAsync_TaskNotFound_ThrowsKeyNotFound() { var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(Guid.NewGuid().ToString(), CancellationToken.None)); } [Theory] [InlineData(TaskStatus.Running)] [InlineData(TaskStatus.Queued)] public async Task BuildForTaskAsync_TaskRunningOrQueued_ThrowsInvalidOperation(TaskStatus status) { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, status); await SeedWorktreeAsync(taskId, WorktreeState.Active); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } [Fact] public async Task BuildForTaskAsync_NoWorktreeRow_NotAGitRepo_ThrowsInvalidOperation() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } [Fact] public async Task BuildForTaskAsync_NoWorktreeRow_NoWorkingDirConfigured_ThrowsInvalidOperation() { var listId = await SeedListAsync(workingDir: null); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } [Fact] public async Task BuildForTaskAsync_FreshTask_NoWorktreeButGitRepoConfigured_CreatesWorktreeOnDemand() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = CreateRepo(); var listId = await SeedListAsync(workingDir: repo.RepoDir); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); // No worktree row seeded -- task has never run. var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); using var readCtx = _db.CreateContext(); var wtRow = await new WorktreeRepository(readCtx).GetByTaskIdAsync(taskId); Assert.NotNull(wtRow); Assert.Equal(WorktreeState.Active, wtRow!.State); Assert.True(Directory.Exists(wtRow.Path)); Assert.Equal(wtRow.Path, spec.Cwd); Assert.Empty(spec.Args); Assert.Equal(_claudeStubPath, spec.Exe); } [Theory] [InlineData(WorktreeState.Merged)] [InlineData(WorktreeState.Discarded)] public async Task BuildForTaskAsync_WorktreeNotActiveOrKept_ThrowsInvalidOperation(WorktreeState state) { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, state); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } [Fact] public async Task BuildForTaskAsync_WorktreeDirectoryMissing_ThrowsInvalidOperation() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active, path: Path.Combine(_tempDir, "does-not-exist")); var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForTaskAsync(taskId, CancellationToken.None)); } [Fact] public async Task BuildForTaskAsync_ResumableTask_ProducesResumeArgsAndCwd() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active); await SeedRunAsync(taskId, "sess-123"); var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Equal(_worktreeDir, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); Assert.Equal(new[] { "--resume", "sess-123" }, spec.Args); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); } [Fact] public async Task BuildForTaskAsync_KeptWorktree_IsAllowed() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Kept); await SeedRunAsync(taskId, "sess-kept"); var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Equal(new[] { "--resume", "sess-kept" }, spec.Args); } [Fact] public async Task BuildForTaskAsync_NeverRunTask_ProducesFreshStartSpec_NoResumeArg() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active); // No TaskRunEntity at all -- never run. var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Empty(spec.Args); Assert.Equal(_worktreeDir, spec.Cwd); } [Fact] public async Task BuildForTaskAsync_RunWithoutSessionId_ProducesFreshStartSpec() { var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle); await SeedWorktreeAsync(taskId, WorktreeState.Active); await SeedRunAsync(taskId, sessionId: null); var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); Assert.Empty(spec.Args); } [Fact] public async Task BuildForTaskAsync_SeedsSessionSkills_FilteredToInstalled() { _registry.Installed.Add(new SessionSkillEntity { Name = "installed-skill", SourceUrl = "https://example.com/x.git", PinnedRef = "abc", Subpath = "skills/installed-skill", Description = "d", AddedAt = DateTimeOffset.UtcNow, }); var listId = await SeedListAsync(); var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId, listId, TaskStatus.Idle, sessionSkillsJson: "[\"installed-skill\",\"missing-skill\"]"); await SeedWorktreeAsync(taskId, WorktreeState.Active); var svc = BuildService(); await svc.BuildForTaskAsync(taskId, CancellationToken.None); var call = Assert.Single(_seeder.Calls); Assert.Equal(_worktreeDir, call.WorkingDir); Assert.True(call.IsWorktree); Assert.Equal(new[] { "installed-skill" }, call.SkillNames); } [Fact] public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec() { var svc = BuildService(); var spec = await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None); Assert.Equal(_tempDir, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); Assert.Empty(spec.Args); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); Assert.Empty(_seeder.Calls); } [Fact] public async Task BuildForDirectoryAsync_NonExistentDirectory_ThrowsInvalidOperation() { var svc = BuildService(); await Assert.ThrowsAsync( () => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None)); } }