- claude process: run stdout/stderr reads without ct; rely on kill-on-cancel closing the pipes to unblock them — previously ReadLineAsync(ct) could hang, stalling task slots and shutdown - task runner: terminal db writes (task_runs, MarkDone, MarkFailed, SetLogPath) now use CancellationToken.None; RunOnceAsync catches OCE and finalizes the run row so ContinueAsync can resume - task repository: GetNextQueuedAgentTaskAsync is now a single UPDATE ... RETURNING statement — closes TOCTOU window where two loop iterations could dispatch the same queued task - queue service: dispose CancellationTokenSource in slot-completion ContinueWith to stop leaking wait handles - git service: register ct.Kill(processTree), drain reads without ct, always reap via WaitForExitAsync(None) — no more git zombies on cancelled worktree ops - worktree manager: branch name uses full task id (dashes stripped) instead of 8-char prefix, eliminating collision risk Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
187 lines
6.5 KiB
C#
187 lines
6.5 KiB
C#
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Runner;
|
|
|
|
public class WorktreeManagerTests : IDisposable
|
|
{
|
|
private readonly List<GitRepoFixture> _fixtures = new();
|
|
private readonly List<DbFixture> _dbFixtures = new();
|
|
private readonly List<string> _tempDirs = new();
|
|
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
|
|
|
|
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
|
|
|
private GitRepoFixture CreateRepo()
|
|
{
|
|
var f = new GitRepoFixture();
|
|
_fixtures.Add(f);
|
|
return f;
|
|
}
|
|
|
|
private async Task<(WorktreeManager mgr, WorktreeRepository wtRepo)> CreateManagerAsync(
|
|
TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null)
|
|
{
|
|
var db = new DbFixture();
|
|
_dbFixtures.Add(db);
|
|
|
|
// Seed the DB with list and task so FK constraints pass.
|
|
var listRepo = new ListRepository(db.Factory);
|
|
var taskRepo = new TaskRepository(db.Factory);
|
|
await listRepo.AddAsync(list);
|
|
await taskRepo.AddAsync(task);
|
|
|
|
var wtRepo = new WorktreeRepository(db.Factory);
|
|
var cfg = new WorkerConfig
|
|
{
|
|
WorktreeRootStrategy = strategy,
|
|
};
|
|
if (centralRoot is not null)
|
|
cfg.CentralWorktreeRoot = centralRoot;
|
|
|
|
var mgr = new WorktreeManager(
|
|
new GitService(), wtRepo, cfg, NullLogger<WorktreeManager>.Instance);
|
|
return (mgr, wtRepo);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_Succeeds_InGitRepo()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = CreateRepo();
|
|
var (task, list) = MakeEntities(repo.RepoDir);
|
|
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
|
|
|
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
|
|
|
Assert.NotNull(ctx);
|
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
|
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
|
|
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
|
|
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
|
Assert.NotNull(row);
|
|
Assert.Equal(WorktreeState.Active, row!.State);
|
|
Assert.Equal(ctx.BaseCommit, row.BaseCommit);
|
|
Assert.Null(row.HeadCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CommitIfChangedAsync_NoChanges_HeadCommitStaysNull()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = CreateRepo();
|
|
var (task, list) = MakeEntities(repo.RepoDir);
|
|
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
|
|
|
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
|
|
|
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
|
|
|
|
Assert.False(committed);
|
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
|
Assert.Null(row!.HeadCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CommitIfChangedAsync_WithNewFile_HeadCommitSet()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = CreateRepo();
|
|
var (task, list) = MakeEntities(repo.RepoDir);
|
|
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
|
|
|
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
|
|
|
File.WriteAllText(Path.Combine(ctx.WorktreePath, "hello.txt"), "hello world");
|
|
|
|
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
|
|
|
|
Assert.True(committed);
|
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
|
Assert.NotNull(row!.HeadCommit);
|
|
Assert.NotEqual(ctx.BaseCommit, row.HeadCommit);
|
|
Assert.NotNull(row.DiffStat);
|
|
Assert.Contains("hello.txt", row.DiffStat);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_NonGitDir_Throws_NoRow()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var tmpDir = Path.Combine(Path.GetTempPath(), $"claudedo_nogit_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(tmpDir);
|
|
_tempDirs.Add(tmpDir);
|
|
|
|
var (task, list) = MakeEntities(tmpDir);
|
|
|
|
var db = new DbFixture();
|
|
_dbFixtures.Add(db);
|
|
var listRepo = new ListRepository(db.Factory);
|
|
var taskRepo = new TaskRepository(db.Factory);
|
|
await listRepo.AddAsync(list);
|
|
await taskRepo.AddAsync(task);
|
|
|
|
var wtRepo = new WorktreeRepository(db.Factory);
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(
|
|
new GitService(), wtRepo, cfg, NullLogger<WorktreeManager>.Instance);
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => mgr.CreateAsync(task, list, CancellationToken.None));
|
|
Assert.Contains("not a git repository", ex.Message);
|
|
|
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
|
Assert.Null(row);
|
|
}
|
|
|
|
private static (TaskEntity task, ListEntity list) MakeEntities(string workingDir)
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
var taskId = Guid.NewGuid().ToString();
|
|
var task = new TaskEntity
|
|
{
|
|
Id = taskId,
|
|
ListId = listId,
|
|
Title = "test task",
|
|
Description = "a description",
|
|
CommitType = "chore",
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
var list = new ListEntity
|
|
{
|
|
Id = listId,
|
|
Name = "Test List",
|
|
WorkingDir = workingDir,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
return (task, list);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var (repoDir, wtPath) in _worktreeCleanups)
|
|
{
|
|
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
|
|
}
|
|
foreach (var f in _fixtures) f.Dispose();
|
|
foreach (var db in _dbFixtures) db.Dispose();
|
|
foreach (var d in _tempDirs)
|
|
{
|
|
try { Directory.Delete(d, true); } catch { }
|
|
}
|
|
}
|
|
}
|