Queuing (update_task_status, batch_update_task_status) and run_task_now now surface a non-blocking baseDirty warning (separate modified/untracked counts) when the list's working dir has uncommitted changes at enqueue time, since a new worktree forks from the commit tip and silently misses them. BaseDirtyChecker caches per working dir for a few seconds so a batch queue over many tasks in one list only shells out to git once. The UI surfaces the same warning via the footer error strip on queue actions.
1866 lines
79 KiB
C#
1866 lines
79 KiB
C#
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.Usage;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
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<string> excludedConnectionIds) => Proxy;
|
|
public IClientProxy Client(string connectionId) => Proxy;
|
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
|
public IClientProxy Group(string groupName) => Proxy;
|
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
|
public IClientProxy User(string userId) => Proxy;
|
|
public IClientProxy Users(IReadOnlyList<string> 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<WorkerHub>
|
|
{
|
|
public ExternalRecordingHubClients RecordingClients { get; } = new();
|
|
public IHubClients Clients => RecordingClients;
|
|
public IGroupManager Groups => throw new NotImplementedException();
|
|
}
|
|
|
|
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<GitRepoFixture> _repos = new();
|
|
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new();
|
|
|
|
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<WorktreeManager>.Instance);
|
|
var wt = await mgr.CreateAsync(task, list, CancellationToken.None);
|
|
_worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath));
|
|
return (task, list, wt);
|
|
}
|
|
|
|
private async Task<string> SeedListAsync(string name = "L")
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow });
|
|
return id;
|
|
}
|
|
|
|
private async Task<TaskEntity> 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",
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
private ExternalMcpService BuildSut(QueueService queue)
|
|
{
|
|
var git = new GitService();
|
|
var factory = _db.CreateFactory();
|
|
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
|
var planningMerge = new PlanningMergeOrchestrator(
|
|
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
|
return new ExternalMcpService(
|
|
_tasks, _lists, queue, _broadcaster,
|
|
state,
|
|
git, factory, maintenance, merge, planningMerge,
|
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.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<WorktreeManager>.Instance);
|
|
var argsBuilder = new ClaudeArgsBuilder();
|
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
|
|
NullLogger<TaskRunner>.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<RunCancellationRegistry>.Instance);
|
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
|
|
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
|
|
new FakeUsageGate(), new UsageState(), broadcaster);
|
|
}
|
|
|
|
[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, 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, CancellationToken.None);
|
|
|
|
Assert.Equal(task.Id, dto.Id);
|
|
Assert.Equal(listId, dto.ListId);
|
|
Assert.Equal("new title", dto.Title);
|
|
}
|
|
|
|
[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 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<InvalidOperationException>(() =>
|
|
sut.UpdateTask(task.Id, "x", null, null, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_NotFound_Throws()
|
|
{
|
|
var queue = CreateQueue();
|
|
var sut = BuildSut(queue);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.UpdateTask("does-not-exist", "x", null, null, 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<InvalidOperationException>(() =>
|
|
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<InvalidOperationException>(() =>
|
|
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);
|
|
}
|
|
|
|
[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 });
|
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 });
|
|
|
|
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);
|
|
}
|
|
|
|
[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 });
|
|
ctx.Tasks.Add(new TaskEntity { Id = normalChildId, ListId = listId, Title = "normal child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 });
|
|
ctx.Tasks.Add(new TaskEntity { Id = blockedChildId, ListId = listId, Title = "blocked child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2, RoadblockCount = 1 });
|
|
|
|
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<InvalidOperationException>(() =>
|
|
sut.DeleteTask(task.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTask_NotFound_Throws()
|
|
{
|
|
var queue = CreateQueue();
|
|
var sut = BuildSut(queue);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
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<InvalidOperationException>(
|
|
() => 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<InvalidOperationException>(
|
|
() => 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);
|
|
}
|
|
|
|
private ExternalMcpService NewService() => BuildSut(CreateQueue());
|
|
|
|
private async Task<string> 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",
|
|
};
|
|
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",
|
|
};
|
|
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",
|
|
};
|
|
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",
|
|
};
|
|
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",
|
|
};
|
|
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",
|
|
};
|
|
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<InvalidOperationException>(
|
|
() => 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);
|
|
}
|
|
|
|
// ── GetTaskWorktree ────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetTaskWorktree_NoWorktree_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.GetTaskWorktree(task.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskWorktree_ReturnsBranchAndBaseCommit()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, wt) = await SeedWorktreeAsync();
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(wt.BranchName, info.Branch);
|
|
Assert.Equal(wt.BaseCommit, info.BaseCommit);
|
|
Assert.Equal(0, info.Ahead);
|
|
Assert.False(info.IsDirty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskWorktree_BeforeMerge_MergeCommitIsNull()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync();
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);
|
|
|
|
Assert.Null(info.MergeCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskWorktree_AfterMerge_ExposesMergeCommit()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file");
|
|
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
|
|
|
|
var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None);
|
|
|
|
var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();
|
|
Assert.Equal(expected, info.MergeCommit);
|
|
}
|
|
|
|
// ── GetTaskDiff ────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetTaskDiff_NoWorktree_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.GetTaskDiff(task.Id, false, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskDiff_AfterCommit_ListsChangedFile()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync();
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
|
|
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var diff = await sut.GetTaskDiff(task.Id, false, CancellationToken.None);
|
|
|
|
Assert.Contains("added.txt", diff.Files);
|
|
Assert.False(diff.Truncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
// No WorktreeEntity for this task -- it's a worktree-less list-handler host task
|
|
// (Mission Control's "Let Claude handle it"): the handler merged its own commit
|
|
// straight into the list's working dir after HandlerBaseCommit was stamped.
|
|
|
|
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 headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var t = await ctx.Tasks.FindAsync(task.Id);
|
|
t!.HandlerBaseCommit = repo.BaseCommit;
|
|
t.HandlerHeadCommit = headCommit;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var diff = await sut.GetTaskDiff(task.Id, false, CancellationToken.None);
|
|
|
|
Assert.Contains("handled.txt", diff.Files);
|
|
Assert.False(diff.Truncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
|
|
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 headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var t = await ctx.Tasks.FindAsync(task.Id);
|
|
t!.HandlerBaseCommit = repo.BaseCommit;
|
|
t.HandlerHeadCommit = headCommit;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var diff = await sut.GetTaskDiff(task.Id, true, CancellationToken.None);
|
|
|
|
Assert.Contains("handled.txt", diff.Content);
|
|
}
|
|
|
|
// ── MergeTask ──────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task MergeTask_NotDone_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
|
|
Assert.Contains("Done", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_DryRun_DoesNotMerge()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.Null(result.MergeCommit);
|
|
}
|
|
|
|
// ── ListWorktrees ──────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ListWorktrees_Empty_ReturnsEmpty()
|
|
{
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var rows = await sut.ListWorktrees(CancellationToken.None);
|
|
|
|
Assert.Empty(rows);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListWorktrees_ReturnsCreatedWorktree()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync();
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var rows = await sut.ListWorktrees(CancellationToken.None);
|
|
|
|
Assert.Contains(rows, r => r.TaskId == task.Id);
|
|
}
|
|
|
|
// ── CleanupTaskWorktree ────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task CleanupTaskWorktree_NotFound_Throws()
|
|
{
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.CleanupTaskWorktree("does-not-exist", false, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CleanupTaskWorktree_Running_Throws()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Running);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None));
|
|
Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CleanupTaskWorktree_CleanWorktree_Removes()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None);
|
|
|
|
Assert.True(result.Removed);
|
|
Assert.False(Directory.Exists(wt.WorktreePath));
|
|
}
|
|
|
|
// ── GetTaskConfig ─────────────────────────────────────────────────────────
|
|
|
|
private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster, _db.CreateFactory());
|
|
|
|
[Fact]
|
|
public async Task GetTaskConfig_NotFound_Throws()
|
|
{
|
|
var sut = BuildConfigSut();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => 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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// ── MergeTask allowWaitingForReview ───────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task MergeTask_WaitingForReview_WithoutFlag_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
|
|
Assert.Contains("Done", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.Null(result.MergeCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_WaitingForReview_WithFlag_MarksTaskDone()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
|
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(result.Merged);
|
|
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, reloaded!.Status);
|
|
}
|
|
|
|
// ── 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 MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
|
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.True(result.ConflictsInTree);
|
|
Assert.Equal(list.WorkingDir, result.RepoPath);
|
|
Assert.Contains("README.md", result.Conflicts);
|
|
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
|
allowWaitingForReview: true, leaveConflictsInTree: false, CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.False(result.ConflictsInTree);
|
|
Assert.Null(result.RepoPath);
|
|
Assert.Contains("README.md", result.Conflicts);
|
|
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
}
|
|
|
|
[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.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);
|
|
}
|
|
|
|
// ── continue_merge / abort_merge ──────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
// Resolve the conflict on disk and stage it.
|
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
|
|
|
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
|
|
|
Assert.True(result.Merged);
|
|
Assert.Equal("Done", result.TaskStatus);
|
|
Assert.Empty(result.Conflicts);
|
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
// Markers never resolved — continue must refuse and list the files.
|
|
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.Contains("README.md", result.Conflicts);
|
|
Assert.Equal(list.WorkingDir, result.RepoPath);
|
|
Assert.Equal("WaitingForReview", result.TaskStatus);
|
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
var dto = await sut.AbortMerge(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("WaitingForReview", dto.Status);
|
|
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
|
|
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMerge_NoMergeInProgress_Throws()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.ContinueMerge(task.Id, CancellationToken.None));
|
|
Assert.Contains("mid-merge", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AbortMerge_NoMergeInProgress_Throws()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.AbortMerge(task.Id, CancellationToken.None));
|
|
Assert.Contains("mid-merge", ex.Message);
|
|
}
|
|
|
|
// ── RevertMerge ────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task RevertMerge_NotMerged_Throws()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.RevertMerge(task.Id, "main", CancellationToken.None));
|
|
Assert.Contains("expected Merged", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file");
|
|
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
|
|
Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt")));
|
|
|
|
var result = await sut.RevertMerge(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.True(result.Reverted);
|
|
Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit));
|
|
Assert.Empty(result.Conflicts);
|
|
Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt")));
|
|
|
|
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line");
|
|
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None);
|
|
|
|
// A later commit on main edits the exact line the merge introduced, so the revert conflicts.
|
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line");
|
|
var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim();
|
|
|
|
var result = await sut.RevertMerge(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.False(result.Reverted);
|
|
Assert.Contains("README.md", result.Conflicts);
|
|
Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!));
|
|
Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim());
|
|
|
|
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Done, reloaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
// main edits README.md so the child's edit conflicts.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
|
|
|
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 });
|
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 });
|
|
|
|
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, "README.md"), "# child change\n");
|
|
GitRepoFixture.RunGit(wtPath, "add", "README.md");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit");
|
|
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 review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
|
|
Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus);
|
|
Assert.Equal(repo.RepoDir, review.RepoPath);
|
|
Assert.Contains("continue_merge", review.MergeMessage);
|
|
|
|
// Resolve the conflict on disk.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
|
|
|
|
var result = await sut.ContinueMerge(parentId, CancellationToken.None);
|
|
|
|
Assert.True(result.Merged);
|
|
Assert.Equal("Done", result.TaskStatus);
|
|
Assert.Empty(result.Conflicts);
|
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
using var verify = _db.CreateContext();
|
|
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
|
|
}
|
|
|
|
// ── PreviewMerge / PreviewMergeSet ────────────────────────────────────────
|
|
|
|
private async Task<string> CreateActiveWorktreeAsync(
|
|
GitRepoFixture repo, string taskId, string fileName, string content)
|
|
{
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
|
var branch = $"claudedo/{taskId[..8]}";
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, fileName), content);
|
|
GitRepoFixture.RunGit(wtPath, "add", fileName);
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}");
|
|
|
|
using var ctx = _db.CreateContext();
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = taskId, Path = wtPath, BranchName = branch,
|
|
BaseCommit = repo.BaseCommit,
|
|
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
return wtPath;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
|
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
|
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
|
|
|
var git = new GitService();
|
|
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Empty(result.ConflictFiles);
|
|
Assert.Equal(1, result.ChangedFileCount);
|
|
Assert.Equal(0, result.Behind);
|
|
Assert.False(result.IsEmpty);
|
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
|
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
// Worktree created but nothing committed beyond base -- e.g. a child that reported
|
|
// CLAUDEDO_BLOCKED before writing any code.
|
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Equal(0, result.ChangedFileCount);
|
|
Assert.True(result.IsEmpty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
// Worktree-less list-handler host task whose handler range never actually committed
|
|
// anything: HandlerBaseCommit == HandlerHeadCommit, not "no worktree -> unknown".
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var t = await ctx.Tasks.FindAsync(task.Id);
|
|
t!.HandlerBaseCommit = repo.BaseCommit;
|
|
t.HandlerHeadCommit = repo.BaseCommit;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Equal(0, result.ChangedFileCount);
|
|
Assert.True(result.IsEmpty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
|
|
|
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 headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var t = await ctx.Tasks.FindAsync(task.Id);
|
|
t!.HandlerBaseCommit = repo.BaseCommit;
|
|
t.HandlerHeadCommit = headCommit;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Equal(1, result.ChangedFileCount);
|
|
Assert.False(result.IsEmpty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var git = new GitService();
|
|
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewConflict, result.Status);
|
|
Assert.Contains("README.md", result.ConflictFiles);
|
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
|
Assert.False(await git.IsMidMergeAsync(list.WorkingDir!));
|
|
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_NoWorktree_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_MissingWorkingDir_ThrowsClearError()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
var row = await ctx.Lists.FindAsync(list.Id);
|
|
row!.WorkingDir = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
|
|
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
|
|
|
|
await CreateActiveWorktreeAsync(repo, taskA.Id, "shared.txt", "a change\n");
|
|
await CreateActiveWorktreeAsync(repo, taskB.Id, "shared.txt", "b change\n");
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", CancellationToken.None);
|
|
|
|
Assert.Equal(2, result.Tasks.Count);
|
|
Assert.All(result.Tasks, t => Assert.Null(t.Error));
|
|
Assert.All(result.Tasks, t => Assert.Equal(TaskMergeService.PreviewClean, t.Status));
|
|
var overlap = Assert.Single(result.Overlaps);
|
|
Assert.Equal("shared.txt", overlap.File);
|
|
Assert.Equal(
|
|
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
|
|
overlap.TaskIds.OrderBy(x => x));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMergeSet([task.Id], target, CancellationToken.None);
|
|
|
|
Assert.Single(result.Tasks);
|
|
Assert.Empty(result.Overlaps);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var listId2 = await SeedListAsync("no-worktree-list");
|
|
var noWorktreeTask = await SeedTaskAsync(listId2, "b", TaskStatus.Idle);
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, CancellationToken.None);
|
|
|
|
Assert.Equal(2, result.Tasks.Count);
|
|
var ok = result.Tasks.Single(t => t.TaskId == task.Id);
|
|
Assert.Null(ok.Error);
|
|
Assert.Equal(TaskMergeService.PreviewClean, ok.Status);
|
|
var failed = result.Tasks.Single(t => t.TaskId == noWorktreeTask.Id);
|
|
Assert.NotNull(failed.Error);
|
|
Assert.Equal(TaskMergeService.PreviewUnavailable, failed.Status);
|
|
Assert.Empty(result.Overlaps);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
|
var emptyTask = await SeedTaskAsync(listId, "empty", TaskStatus.WaitingForReview);
|
|
var fullTask = await SeedTaskAsync(listId, "full", TaskStatus.WaitingForReview);
|
|
|
|
var emptyBranch = $"claudedo/{emptyTask.Id[..8]}";
|
|
var emptyWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, emptyWtPath));
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", emptyBranch, emptyWtPath, repo.BaseCommit);
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = emptyTask.Id, Path = emptyWtPath, BranchName = emptyBranch,
|
|
BaseCommit = repo.BaseCommit,
|
|
HeadCommit = repo.BaseCommit,
|
|
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n");
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", CancellationToken.None);
|
|
|
|
var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id);
|
|
var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id);
|
|
Assert.Null(emptyEntry.Error);
|
|
Assert.True(emptyEntry.IsEmpty);
|
|
Assert.Null(fullEntry.Error);
|
|
Assert.False(fullEntry.IsEmpty);
|
|
}
|
|
|
|
// ── 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<ArgumentException>(
|
|
() => sut.AddTask(listId, "t", model: "gpt4", 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<InvalidOperationException>(
|
|
() => sut.ContinueTask(task.Id, " ", CancellationToken.None));
|
|
}
|
|
}
|