continue_merge and the parent/children unit-merge drain (PlanningMergeOrchestrator) re-run the post-merge verify gate but never forwarded their IProgress token into it, so a slow verify command on either path went silent past Claude Code's 300s MCP idle-abort even though D1-D3 already fixed this for merge_task/review_task's childless path. list_worktrees also gets elapsed-time progress: many tracked worktrees means many concurrent git subprocess spawns. Worker CLAUDE.md's existing progress rule now points at ProgressReporter as the one implementation instead of a fresh polling loop.
2884 lines
126 KiB
C#
2884 lines
126 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.Tests.Services;
|
|
using ClaudeDo.Worker.Usage;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ModelContextProtocol;
|
|
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();
|
|
}
|
|
|
|
// A synchronously-collecting IProgress<T>: Progress<T> marshals through the SynchronizationContext
|
|
// captured at construction, which is unreliable to assert on immediately in a test.
|
|
file sealed class SyncProgress<T> : IProgress<T>
|
|
{
|
|
public readonly List<T> Reports = new();
|
|
public void Report(T value) => Reports.Add(value);
|
|
}
|
|
|
|
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 int _numberSeed;
|
|
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
private ExternalMcpService BuildSut(QueueService queue) => BuildSut(queue, new VerifyCommandRunner());
|
|
|
|
private ExternalMcpService BuildSut(QueueService queue, IVerifyCommandRunner verify)
|
|
{
|
|
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, verify, 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, 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, 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_ReturnsTaskNumber()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(task.Number, dto.Number);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_ByHashNumber_ResolvesToTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask($"#{task.Number}", CancellationToken.None);
|
|
|
|
Assert.Equal(task.Id, dto.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_ByBareNumber_ResolvesToTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Number.ToString(), CancellationToken.None);
|
|
|
|
Assert.Equal(task.Id, dto.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_UnknownNumber_ThrowsWithNumberInMessage()
|
|
{
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.GetTask("#999999", CancellationToken.None));
|
|
|
|
Assert.Contains("999999", ex.Message);
|
|
}
|
|
|
|
[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 GetTask_Failed_ReturnsClassifiedFailureReason()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
|
|
task.FailureReason = "max_turns";
|
|
task.FailureTurnsUsed = 55;
|
|
task.FailureMaxTurns = 60;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("max_turns", dto.FailureReason);
|
|
Assert.Equal(55, dto.FailureTurnsUsed);
|
|
Assert.Equal(60, dto.FailureMaxTurns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_FailedWithoutStoredReason_ReturnsUnknownNotError()
|
|
{
|
|
// A Failed task written before this field existed has FailureReason=null in the DB —
|
|
// the MCP surface must still hand back a defined value, not null or a throw.
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("unknown", dto.FailureReason);
|
|
Assert.Null(dto.FailureTurnsUsed);
|
|
Assert.Null(dto.FailureMaxTurns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_NotFailed_FailureReasonIsNull()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.Null(dto.FailureReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskRefAsync_Failed_ReturnsClassifiedFailureReason()
|
|
{
|
|
// GetTaskRefAsync backs batch_get_tasks' default (lean) path — the same field must be
|
|
// present there, not just on the full get_task DTO.
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Failed);
|
|
task.FailureReason = "error";
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTaskRefAsync(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("error", dto.FailureReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_QueuedWithUnmetDependsOn_ReportsBlockedTrueAndReason()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
|
task.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.True(dto.Blocked);
|
|
Assert.Contains(predecessor.Id, dto.BlockedReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_QueuedWithDoneDependsOn_ReportsBlockedFalse()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Done);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
|
task.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.False(dto.Blocked);
|
|
Assert.Null(dto.BlockedReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTask_IdleWithDependsOnTaskId_ReportsBlockedFalse_NotYetQueued()
|
|
{
|
|
// Blocking is only meaningful once the picker would actually be gating the task --
|
|
// an Idle task hasn't been queued at all, so DependsOnTaskId doesn't apply yet.
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
task.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.False(dto.Blocked);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListTasks_ReportsBlockedPerTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
|
var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
|
blocked.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(blocked, CancellationToken.None);
|
|
var unblocked = await SeedTaskAsync(listId, "unblocked", TaskStatus.Queued);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.ListTasks(listId, cancellationToken: CancellationToken.None);
|
|
|
|
var blockedRef = result.Tasks!.Single(t => t.Id == blocked.Id);
|
|
var unblockedRef = result.Tasks!.Single(t => t.Id == unblocked.Id);
|
|
Assert.True(blockedRef.Blocked);
|
|
Assert.Contains(predecessor.Id, blockedRef.BlockedReason);
|
|
Assert.False(unblockedRef.Blocked);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListTasks_IncludeDescriptionTrue_ReportsBlockedOnFullDto()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
|
var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
|
blocked.DependsOnTaskId = predecessor.Id;
|
|
await _tasks.UpdateAsync(blocked, CancellationToken.None);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None);
|
|
|
|
var dto = result.TasksFull!.Single(t => t.Id == blocked.Id);
|
|
Assert.True(dto.Blocked);
|
|
}
|
|
|
|
[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, 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, null, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_WithDependsOnTaskId_SetsLinkAndReportsBlocked()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(predecessor.Id, dto.DependsOnTaskId);
|
|
Assert.True(dto.Blocked);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_WithEmptyStringDependsOnTaskId_ClearsExistingLink()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None);
|
|
|
|
var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: "", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Null(dto.DependsOnTaskId);
|
|
Assert.False(dto.Blocked);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTask_WithCyclicDependsOnTaskId_Throws()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var a = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var b = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.UpdateTask(b.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.UpdateTask(a.Id, dependsOnTaskId: b.Id, cancellationToken: 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);
|
|
}
|
|
|
|
// Regression coverage for the incident this fixes: an approve whose post-merge verify gate
|
|
// runs long enough gets no MCP traffic at all until RunVerifyGateAsync returns, so Claude
|
|
// Code's ~300s idle-silence abort kills the call -- the merge had already landed and
|
|
// committed, but the task never reached Done and every dependsOn successor stayed blocked
|
|
// until someone noticed and force-set the status by hand. Shrinks TaskMergeService's own
|
|
// progress interval (not TaskWaitMcpTools') to observe a report without waiting 30s real time.
|
|
[Fact]
|
|
public async Task ReviewTask_ApproveWithSlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var original = TaskMergeService.ProgressReportInterval;
|
|
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
|
|
try
|
|
{
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
|
|
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);
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner
|
|
{
|
|
Result = new VerifyCommandResult(0, false, "ok"),
|
|
Delay = TimeSpan.FromMilliseconds(300),
|
|
};
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var reports = new List<ProgressNotificationValue>();
|
|
var progress = new Progress<ProgressNotificationValue>(reports.Add);
|
|
|
|
var result = await sut.ReviewTask(
|
|
task.Id, "approve", null, null, cancellationToken: CancellationToken.None, progress: progress);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
Assert.Equal("Done", result.Task.Status);
|
|
// Progress<T> marshals via the SynchronizationContext captured at construction; give
|
|
// any queued callbacks a beat to run before asserting on `reports`.
|
|
await Task.Delay(200);
|
|
Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
|
|
}
|
|
finally
|
|
{
|
|
TaskMergeService.ProgressReportInterval = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReviewTask_ApproveWithVerifyCommand_NoProgressToken_DoesNotThrow()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
|
|
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);
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
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);
|
|
}
|
|
|
|
[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, Number = ++_numberSeed });
|
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
|
|
|
|
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);
|
|
}
|
|
|
|
// Regression coverage for the parent/children unit-merge path specifically: PlanningMergeOrchestrator
|
|
// used to drain every child's merge (and verify gate) without ever forwarding review_task's own
|
|
// progress token, so a multi-child approve with a slow verify command reported only the single
|
|
// "review_task started" tick and then went silent for as long as every child's gate took combined.
|
|
[Fact]
|
|
public async Task ReviewTask_Approve_ParentWithChildren_SlowVerifyCommand_ReportsProgress()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var original = TaskMergeService.ProgressReportInterval;
|
|
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
|
|
try
|
|
{
|
|
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, Number = ++_numberSeed });
|
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
|
|
|
|
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 new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner
|
|
{
|
|
Result = new VerifyCommandResult(0, false, "ok"),
|
|
Delay = TimeSpan.FromMilliseconds(300),
|
|
};
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var reports = new List<ProgressNotificationValue>();
|
|
var progress = new Progress<ProgressNotificationValue>(reports.Add);
|
|
|
|
var result = await sut.ReviewTask(
|
|
parentId, "approve", null, "main", cancellationToken: CancellationToken.None, progress: progress);
|
|
|
|
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
|
|
Assert.Equal("Done", result.Task.Status);
|
|
await Task.Delay(200);
|
|
Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
|
|
}
|
|
finally
|
|
{
|
|
TaskMergeService.ProgressReportInterval = original;
|
|
}
|
|
}
|
|
|
|
[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, Number = ++_numberSeed });
|
|
ctx.Tasks.Add(new TaskEntity { Id = normalChildId, ListId = listId, Title = "normal child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
|
|
ctx.Tasks.Add(new TaskEntity { Id = blockedChildId, ListId = listId, Title = "blocked child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2, RoadblockCount = 1, Number = ++_numberSeed });
|
|
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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",
|
|
Number = ++_numberSeed,
|
|
};
|
|
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, null, 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, null, CancellationToken.None);
|
|
|
|
Assert.Contains("added.txt", diff.Files);
|
|
Assert.False(diff.Truncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskDiff_WithPathsFilter_ReturnsOnlyRequestedFile()
|
|
{
|
|
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");
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other 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, ["added.txt"], CancellationToken.None);
|
|
|
|
Assert.Equal(new[] { "added.txt" }, diff.Files);
|
|
Assert.DoesNotContain("other.txt", diff.Content);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskDiff_StatMode_WithPathsFilter_ReturnsOnlyRequestedFile()
|
|
{
|
|
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");
|
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other 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, true, ["other.txt"], CancellationToken.None);
|
|
|
|
Assert.Equal(new[] { "other.txt" }, diff.Files);
|
|
Assert.DoesNotContain("added.txt", diff.Content);
|
|
}
|
|
|
|
[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, null, 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, null, CancellationToken.None);
|
|
|
|
Assert.Contains("handled.txt", diff.Content);
|
|
}
|
|
|
|
// get_task_diff has no natural i/n (it's one git process, not a loop over items), so it
|
|
// reports on a timer like merge_task's verify gate. Shrinking the interval to zero forces a
|
|
// report on every poll of the still-running git process instead of waiting out a real
|
|
// interval -- Task.Delay(TimeSpan.Zero) resolves synchronously, so the diff process (a real
|
|
// subprocess, never already complete when polled) always loses the race at least once.
|
|
[Fact]
|
|
public async Task GetTaskDiff_ReportsProgressWhileDiffing()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var original = ExternalMcpService.ProgressReportInterval;
|
|
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
|
|
try
|
|
{
|
|
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 progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None, progress);
|
|
|
|
Assert.Contains("added.txt", diff.Files);
|
|
Assert.NotEmpty(progress.Reports);
|
|
Assert.All(progress.Reports, r => Assert.Contains("computing diff", r.Message));
|
|
}
|
|
finally
|
|
{
|
|
ExternalMcpService.ProgressReportInterval = original;
|
|
}
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// list_worktrees fans out one git status + one rev-parse per tracked worktree under
|
|
// Task.WhenAll -- no natural i/n since rows finish out of order, so it reports on the same
|
|
// elapsed-time cadence as CleanupTaskWorktree above.
|
|
[Fact]
|
|
public async Task ListWorktrees_ReportsProgressWhileSurveying()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var original = ExternalMcpService.ProgressReportInterval;
|
|
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
|
|
try
|
|
{
|
|
await SeedWorktreeAsync();
|
|
var sut = BuildSut(CreateQueue());
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
var rows = await sut.ListWorktrees(CancellationToken.None, progress);
|
|
|
|
Assert.NotEmpty(rows);
|
|
Assert.NotEmpty(progress.Reports);
|
|
}
|
|
finally
|
|
{
|
|
ExternalMcpService.ProgressReportInterval = original;
|
|
}
|
|
}
|
|
|
|
// ── 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));
|
|
}
|
|
|
|
// cleanup_task_worktree has no natural i/n either (dirty check + one removal, not a loop), so
|
|
// it reports on the same timer/zero-interval technique as GetTaskDiff above.
|
|
[Fact]
|
|
public async Task CleanupTaskWorktree_ReportsProgressWhileRemoving()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var original = ExternalMcpService.ProgressReportInterval;
|
|
ExternalMcpService.ProgressReportInterval = TimeSpan.Zero;
|
|
try
|
|
{
|
|
var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
|
var sut = BuildSut(CreateQueue());
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None, progress);
|
|
|
|
Assert.True(result.Removed);
|
|
Assert.False(Directory.Exists(wt.WorktreePath));
|
|
Assert.NotEmpty(progress.Reports);
|
|
}
|
|
finally
|
|
{
|
|
ExternalMcpService.ProgressReportInterval = original;
|
|
}
|
|
}
|
|
|
|
// ── 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);
|
|
Assert.Equal(task.Number, result.Tasks![0].Number);
|
|
}
|
|
|
|
[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);
|
|
Assert.Equal(task.Number, result.TasksFull![0].Number);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// Three edits spread far enough apart in a 20-line file that git's 3-way merge keeps them as three
|
|
// separate, non-adjacent hunks instead of folding them into one.
|
|
private static readonly int[] MultiHunkEditLines = [3, 9, 15];
|
|
|
|
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedMultiHunkConflictingWorktreeAsync()
|
|
{
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
|
|
var baseLines = Enumerable.Range(1, 20).Select(n => $"line{n}").ToArray();
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), string.Join('\n', baseLines) + "\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "multi-line base");
|
|
|
|
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: TaskStatus.WaitingForReview);
|
|
|
|
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));
|
|
|
|
WriteMultiHunkVariant(wt.WorktreePath, baseLines, "WT");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md");
|
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit");
|
|
|
|
WriteMultiHunkVariant(list.WorkingDir!, baseLines, "MAIN");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit");
|
|
|
|
return (task, list, wt);
|
|
}
|
|
|
|
private static void WriteMultiHunkVariant(string dir, string[] baseLines, string tag)
|
|
{
|
|
var lines = (string[])baseLines.Clone();
|
|
foreach (var lineNum in MultiHunkEditLines)
|
|
lines[lineNum - 1] = $"{tag}-{lineNum}";
|
|
File.WriteAllText(Path.Combine(dir, "README.md"), string.Join('\n', lines) + "\n");
|
|
}
|
|
|
|
[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!));
|
|
}
|
|
|
|
// Regression coverage for the same class of incident as ReviewTask_ApproveWithSlowVerifyCommand
|
|
// above, but for the resume path: continue_merge re-runs the verify gate too, and until this
|
|
// fix it never forwarded its progress token into RunVerifyGateAsync at all.
|
|
[Fact]
|
|
public async Task ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var original = TaskMergeService.ProgressReportInterval;
|
|
TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50);
|
|
try
|
|
{
|
|
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var plainSut = BuildSut(CreateQueue());
|
|
await plainSut.ReviewTask(task.Id, "approve", null, null,
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner
|
|
{
|
|
Result = new VerifyCommandResult(0, false, "ok"),
|
|
Delay = TimeSpan.FromMilliseconds(300),
|
|
};
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var reports = new List<ProgressNotificationValue>();
|
|
var progress = new Progress<ProgressNotificationValue>(reports.Add);
|
|
|
|
var result = await sut.ContinueMerge(task.Id, CancellationToken.None, progress);
|
|
|
|
Assert.True(result.Merged);
|
|
Assert.Equal("Done", result.TaskStatus);
|
|
await Task.Delay(200);
|
|
Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running"));
|
|
}
|
|
finally
|
|
{
|
|
TaskMergeService.ProgressReportInterval = original;
|
|
}
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// ── get_merge_conflicts / resolve_conflict_hunk ─────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base()
|
|
{
|
|
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 result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(1, result.RemainingHunks);
|
|
var file = Assert.Single(result.Files);
|
|
Assert.Equal("README.md", file.Path);
|
|
Assert.False(file.IsBinary);
|
|
var hunk = Assert.Single(file.Hunks);
|
|
Assert.Equal(0, hunk.Index);
|
|
Assert.True(hunk.StartLine >= 1);
|
|
Assert.Equal("# from main", hunk.Ours.TrimEnd('\r', '\n'));
|
|
Assert.Equal("# from worktree", hunk.Theirs.TrimEnd('\r', '\n'));
|
|
Assert.NotEmpty(result.ConflictStyleNote);
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMergeConflicts_NoMergeInProgress_ReturnsEmpty()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal(0, result.RemainingHunks);
|
|
Assert.Empty(result.Files);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
|
var sut = BuildSut(CreateQueue());
|
|
|
|
var merge = await sut.MergeTask(task.Id, target, true, dryRun: false,
|
|
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
|
|
Assert.True(merge.ConflictsInTree);
|
|
|
|
var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
|
Assert.Equal(3, before.RemainingHunks);
|
|
Assert.Equal(3, Assert.Single(before.Files).Hunks.Count);
|
|
|
|
// Resolve hunk 0 three times in a row: each resolution removes a hunk from the front, so the
|
|
// next still-open hunk always slides into index 0.
|
|
var r1 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
|
Assert.Equal(2, r1.RemainingHunks);
|
|
|
|
var r2 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "theirs", CancellationToken.None);
|
|
Assert.Equal(1, r2.RemainingHunks);
|
|
|
|
var r3 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "CUSTOM-15\n", CancellationToken.None);
|
|
Assert.Equal(0, r3.RemainingHunks);
|
|
|
|
var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None);
|
|
Assert.Equal(0, finalConflicts.RemainingHunks);
|
|
|
|
var continueResult = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
|
Assert.True(continueResult.Merged);
|
|
Assert.Equal("Done", continueResult.TaskStatus);
|
|
|
|
var mergedText = File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"));
|
|
Assert.Contains("MAIN-3", mergedText); // hunk 1 resolved "ours" (the target branch)
|
|
Assert.Contains("WT-9", mergedText); // hunk 2 resolved "theirs" (the incoming branch)
|
|
Assert.Contains("CUSTOM-15", mergedText); // hunk 3 resolved to literal text
|
|
Assert.DoesNotContain("<<<<<<<", mergedText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync();
|
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
|
|
var sut = BuildSut(CreateQueue());
|
|
await sut.MergeTask(task.Id, target, true, dryRun: false,
|
|
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
|
|
|
|
// Resolve only one of the three hunks.
|
|
await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None);
|
|
|
|
// continue_merge must still refuse: writing one hunk must not have staged the file as resolved.
|
|
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
|
|
|
|
Assert.False(result.Merged);
|
|
Assert.Contains("README.md", result.Conflicts);
|
|
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws()
|
|
{
|
|
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);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.ResolveConflictHunk(task.Id, "README.md", 5, "ours", CancellationToken.None));
|
|
|
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
|
|
}
|
|
|
|
// ── 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, Number = ++_numberSeed });
|
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
|
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed });
|
|
|
|
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 PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget()
|
|
{
|
|
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);
|
|
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
|
|
|
|
var git = new GitService();
|
|
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
|
|
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
|
|
|
|
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
|
|
Assert.Equal(0, result.VerifyExitCode);
|
|
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
|
Assert.NotEqual(list.WorkingDir, fakeVerify.CapturedWorkingDir);
|
|
Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None));
|
|
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles()
|
|
{
|
|
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 task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n");
|
|
|
|
// The target branch itself moved on and touched the same file after the fork point.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt");
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
|
|
|
|
Assert.Equal(1, result.Behind);
|
|
Assert.NotNull(result.StaleFiles);
|
|
Assert.Contains("shared.txt", result.StaleFiles!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles()
|
|
{
|
|
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 task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n");
|
|
|
|
// Target moved on (behind > 0) but touched an entirely different file -- a stale
|
|
// branch that still collides with nothing.
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else");
|
|
|
|
var sut = BuildSut(CreateQueue());
|
|
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
|
|
|
|
Assert.Equal(1, result.Behind);
|
|
Assert.NotNull(result.StaleFiles);
|
|
Assert.Empty(result.StaleFiles!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles()
|
|
{
|
|
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.NotNull(result.StaleFiles);
|
|
Assert.Empty(result.StaleFiles!);
|
|
}
|
|
|
|
[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: 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));
|
|
// Equal file sets overlap but neither is a PROPER subset of the other.
|
|
Assert.Empty(result.Subsets);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset()
|
|
{
|
|
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 subsetTask = await SeedTaskAsync(listId, "subset", TaskStatus.WaitingForReview);
|
|
var supersetTask = await SeedTaskAsync(listId, "superset", TaskStatus.WaitingForReview);
|
|
|
|
await CreateActiveWorktreeAsync(repo, subsetTask.Id, "shared.txt", "shared change\n");
|
|
|
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
_worktreeCleanups.Add((repo.RepoDir, wtPath));
|
|
var branch = $"claudedo/{supersetTask.Id[..8]}";
|
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(wtPath, "shared.txt"), "shared change too\n");
|
|
File.WriteAllText(Path.Combine(wtPath, "extra.txt"), "extra\n");
|
|
GitRepoFixture.RunGit(wtPath, "add", "-A");
|
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", "edit shared+extra");
|
|
using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Worktrees.Add(new WorktreeEntity
|
|
{
|
|
TaskId = supersetTask.Id, 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.PreviewMergeSet([subsetTask.Id, supersetTask.Id], "main", cancellationToken: CancellationToken.None);
|
|
|
|
var relation = Assert.Single(result.Subsets);
|
|
Assert.Equal(subsetTask.Id, relation.TaskId);
|
|
Assert.Equal(supersetTask.Id, relation.SupersetTaskId);
|
|
}
|
|
|
|
[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: CancellationToken.None);
|
|
|
|
Assert.Single(result.Tasks);
|
|
Assert.Empty(result.Overlaps);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_ReportsProgressPerTask()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var t1 = await SeedTaskAsync(listId, "a");
|
|
var t2 = await SeedTaskAsync(listId, "b");
|
|
var sut = BuildSut(CreateQueue());
|
|
var progress = new SyncProgress<ProgressNotificationValue>();
|
|
|
|
// Neither task has a worktree -- each entry fails individually (PreviewUnavailable), but
|
|
// the loop still reports progress per task since a per-task InvalidOperationException
|
|
// doesn't abort the batch.
|
|
await sut.PreviewMergeSet(
|
|
[t1.Id, t2.Id], "main", cancellationToken: CancellationToken.None, progress: progress);
|
|
|
|
Assert.Equal(2, progress.Reports.Count);
|
|
Assert.Equal("Previewing merges (1/2)", progress.Reports[0].Message);
|
|
Assert.Equal("Previewing merges (2/2)", progress.Reports[1].Message);
|
|
Assert.Equal(2, progress.Reports[1].Total);
|
|
}
|
|
|
|
[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: 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: 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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured()
|
|
{
|
|
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 });
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
|
|
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var result = await sut.PreviewMergeSet([task.Id], "main", cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Null(result.Tasks.Single().VerifyExitCode);
|
|
Assert.Null(fakeVerify.CapturedCommand);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask()
|
|
{
|
|
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 });
|
|
using (var ctx = _db.CreateContext())
|
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" });
|
|
var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
|
|
await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n");
|
|
|
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
|
|
var sut = BuildSut(CreateQueue(), fakeVerify);
|
|
|
|
var result = await sut.PreviewMergeSet([task.Id], "main", runVerify: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
|
|
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_ReturnsAllocatedTaskNumber()
|
|
{
|
|
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.True(dto.Task.Number > 0);
|
|
Assert.Equal(loaded!.Number, dto.Task.Number);
|
|
}
|
|
|
|
// ── 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));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_WithDependsOnTaskId_PersistsLinkAndReportsBlocked()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
|
var sut = NewService();
|
|
|
|
var dto = await sut.AddTask(
|
|
listId, "t", dependsOnTaskId: predecessor.Id, queueImmediately: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(predecessor.Id, dto.Task.DependsOnTaskId);
|
|
Assert.True(dto.Task.Blocked);
|
|
Assert.Contains(predecessor.Id, dto.Task.BlockedReason);
|
|
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
|
Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddTask_WithSelfReferencingDependsOnTaskId_Throws()
|
|
{
|
|
// Can't reference its own not-yet-known id, so this exercises the not-found path --
|
|
// an unknown dependsOnTaskId is rejected the same way a self-reference would be.
|
|
var listId = await SeedListAsync();
|
|
var sut = NewService();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.AddTask(listId, "t", dependsOnTaskId: "does-not-exist", 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));
|
|
}
|
|
}
|