using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Xunit; namespace ClaudeDo.Worker.Tests.Runner; /// /// A run that reports success (exit 0, non-null result text) but whose only tool calls were /// permission-denied — e.g. the claude-cli haiku/"auto" downgrade to interactive "default" — must /// not land as a normal WaitingForReview with an empty diff. See PermissionModeResolver. /// public sealed class PermissionDenialFailureTests : IDisposable { private readonly List _repos = new(); private readonly List _dbs = new(); private readonly List<(string repoDir, string wtPath)> _cleanups = new(); [Fact] public async Task Success_withPermissionDenials_andNoChanges_endsFailed() { if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); var db = new DbFixture(); _dbs.Add(db); var dbFactory = db.CreateFactory(); var tempDir = Path.Combine(Path.GetTempPath(), $"cd_permdenial_{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var cfg = new WorkerConfig { SandboxRoot = tempDir, LogRoot = tempDir, WorktreeRootStrategy = "sibling" }; using (var ctx = db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "Edit a file", CommitType = "chore", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } // The Edit tool was denied; no file changed. Mirrors the real "result" event's shape. var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "I need permission to edit the file.", PermissionDenials = new[] { "Edit" }, })); var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, new ClaudeArgsBuilder(), cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); try { using (var ctx = db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", CancellationToken.None); using var verify = db.CreateContext(); var task = (await new TaskRepository(verify).GetByIdAsync("t1"))!; Assert.Equal(TaskStatus.Failed, task.Status); Assert.Contains("permission", task.Result, StringComparison.OrdinalIgnoreCase); } finally { using var ctx = db.CreateContext(); var wtRow = await new WorktreeRepository(ctx).GetByTaskIdAsync("t1"); if (wtRow is not null) _cleanups.Add((repo.RepoDir, wtRow.Path)); try { Directory.Delete(tempDir, true); } catch { } } } [Fact] public async Task Success_withPermissionDenials_butSomeChangesCommitted_stillWaitsForReview() { if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); var db = new DbFixture(); _dbs.Add(db); var dbFactory = db.CreateFactory(); var tempDir = Path.Combine(Path.GetTempPath(), $"cd_permdenial_{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var cfg = new WorkerConfig { SandboxRoot = tempDir, LogRoot = tempDir, WorktreeRootStrategy = "sibling" }; using (var ctx = db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "Edit some files", CommitType = "chore", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } // One edit succeeded (a file was actually written), another was denied. var fake = new FakeClaudeProcess((_, workingDirectory, _, _, _) => { File.WriteAllText(Path.Combine(workingDirectory, "changed.txt"), "partial success"); return Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "Made one change; one edit was denied.", PermissionDenials = new[] { "Write" }, }); }); var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, new ClaudeArgsBuilder(), cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); try { using (var ctx = db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", CancellationToken.None); using var verify = db.CreateContext(); var task = (await new TaskRepository(verify).GetByIdAsync("t1"))!; Assert.Equal(TaskStatus.WaitingForReview, task.Status); } finally { using var ctx = db.CreateContext(); var wtRow = await new WorktreeRepository(ctx).GetByTaskIdAsync("t1"); if (wtRow is not null) _cleanups.Add((repo.RepoDir, wtRow.Path)); try { Directory.Delete(tempDir, true); } catch { } } } [Fact] public async Task Success_withNonWriteDenial_andNoChanges_stillWaitsForReview() { if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); var db = new DbFixture(); _dbs.Add(db); var dbFactory = db.CreateFactory(); var tempDir = Path.Combine(Path.GetTempPath(), $"cd_permdenial_{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var cfg = new WorkerConfig { SandboxRoot = tempDir, LogRoot = tempDir, WorktreeRootStrategy = "sibling" }; using (var ctx = db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "Investigate something", CommitType = "chore", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } // A research task that legitimately changes nothing and had one non-write tool denied. // "no commit + some denial" must NOT be read as "all edits were blocked". var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "Investigated; no changes needed. One command was not permitted.", PermissionDenials = new[] { "Bash" }, })); var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, new ClaudeArgsBuilder(), cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); try { using (var ctx = db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", CancellationToken.None); using var verify = db.CreateContext(); var task = (await new TaskRepository(verify).GetByIdAsync("t1"))!; Assert.Equal(TaskStatus.WaitingForReview, task.Status); } finally { using var ctx = db.CreateContext(); var wtRow = await new WorktreeRepository(ctx).GetByTaskIdAsync("t1"); if (wtRow is not null) _cleanups.Add((repo.RepoDir, wtRow.Path)); try { Directory.Delete(tempDir, true); } catch { } } } public void Dispose() { foreach (var (repoDir, wtPath) in _cleanups) try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { } foreach (var r in _repos) r.Dispose(); foreach (var d in _dbs) d.Dispose(); } }