Queuing (update_task_status, batch_update_task_status) and run_task_now now surface a non-blocking baseDirty warning (separate modified/untracked counts) when the list's working dir has uncommitted changes at enqueue time, since a new worktree forks from the commit tip and silently misses them. BaseDirtyChecker caches per working dir for a few seconds so a batch queue over many tasks in one list only shells out to git once. The UI surfaces the same warning via the footer error strip on queue actions.
317 lines
12 KiB
C#
317 lines
12 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.External;
|
|
using ClaudeDo.Worker.Git;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using ClaudeDo.Worker.Usage;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.External;
|
|
|
|
public sealed class BatchMcpToolsTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly List<GitRepoFixture> _repos = new();
|
|
|
|
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
|
|
|
public BatchMcpToolsTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
_broadcaster = new HubBroadcaster(new CapturingHubContext());
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var r in _repos) r.Dispose();
|
|
_ctx.Dispose();
|
|
_db.Dispose();
|
|
}
|
|
|
|
private async Task<string> SeedListAsync(string? workingDir = null)
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow, WorkingDir = workingDir });
|
|
return id;
|
|
}
|
|
|
|
private async Task<TaskEntity> SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Idle)
|
|
{
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = title,
|
|
Status = status,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
private BatchMcpTools BuildSut()
|
|
{
|
|
var git = new GitService();
|
|
var factory = _db.CreateFactory();
|
|
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
|
var planningMerge = new PlanningMergeOrchestrator(
|
|
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
|
var svc = new ExternalMcpService(
|
|
_tasks, _lists, CreateQueue(), _broadcaster,
|
|
state,
|
|
git, factory, maintenance, merge, planningMerge,
|
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
|
return new BatchMcpTools(svc);
|
|
}
|
|
|
|
private QueueService CreateQueue()
|
|
{
|
|
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_batch_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(tempDir);
|
|
var cfg = new WorkerConfig
|
|
{
|
|
SandboxRoot = Path.Combine(tempDir, "sandbox"),
|
|
LogRoot = Path.Combine(tempDir, "logs"),
|
|
QueueBackstopIntervalMs = 50,
|
|
};
|
|
var dbFactory = _db.CreateFactory();
|
|
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
|
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
|
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg,
|
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
|
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,
|
|
new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state, runCancels,
|
|
new FakeUsageGate(), new UsageState(), broadcaster);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchAddTasks_CreatesAll_AndReportsOkPerItem()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchAddTasks(listId, new[]
|
|
{
|
|
new BatchAddTaskInput("a"),
|
|
new BatchAddTaskInput("b"),
|
|
new BatchAddTaskInput("c"),
|
|
}, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(3, results.Count);
|
|
Assert.All(results, r => Assert.True(r.Ok));
|
|
Assert.Equal(new[] { 0, 1, 2 }, results.Select(r => r.Index).ToArray());
|
|
var inList = await _tasks.GetByListIdAsync(listId);
|
|
Assert.Equal(3, inList.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchAddTasks_FailingItem_DoesNotAbortTheRest()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchAddTasks(listId, new[]
|
|
{
|
|
new BatchAddTaskInput("ok-1"),
|
|
new BatchAddTaskInput(" "), // blank title → AddTask throws
|
|
new BatchAddTaskInput("ok-2"),
|
|
}, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.True(results[0].Ok);
|
|
Assert.False(results[1].Ok);
|
|
Assert.NotNull(results[1].Error);
|
|
Assert.True(results[2].Ok);
|
|
var inList = await _tasks.GetByListIdAsync(listId);
|
|
Assert.Equal(2, inList.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchAddTasks_SimilarTitleInSameList_ReportsPossibleDuplicate()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
await SeedTaskAsync(listId, "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchAddTasks(listId, new[]
|
|
{
|
|
new BatchAddTaskInput("Settings: MaxTurnsCeiling editierbar machen"),
|
|
}, cancellationToken: CancellationToken.None);
|
|
|
|
var result = Assert.Single(results);
|
|
Assert.True(result.Ok);
|
|
Assert.NotNull(result.PossibleDuplicates);
|
|
Assert.Single(result.PossibleDuplicates!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_MissingId_IsFoundFalseNotError()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(new[] { task.Id, "nope" }, cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id);
|
|
var missing = results.Single(r => r.Id == "nope");
|
|
Assert.True(found.Found);
|
|
Assert.NotNull(found.Task);
|
|
Assert.Null(found.TaskFull);
|
|
Assert.False(missing.Found);
|
|
Assert.Null(missing.Task);
|
|
Assert.Null(missing.TaskFull);
|
|
Assert.Null(missing.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_Default_ReturnsLeanTask_NoTaskFull()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.Description = "a description that should not come back by default";
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(new[] { task.Id }, cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id);
|
|
Assert.True(found.Found);
|
|
Assert.NotNull(found.Task);
|
|
Assert.Equal(task.Id, found.Task!.Id);
|
|
Assert.Null(found.TaskFull);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchGetTasks_IncludeDescriptionTrue_ReturnsTaskFull()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId);
|
|
task.Description = "the full description";
|
|
await _tasks.UpdateAsync(task);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchGetTasks(new[] { task.Id }, includeDescription: true, cancellationToken: CancellationToken.None);
|
|
|
|
var found = results.Single(r => r.Id == task.Id);
|
|
Assert.True(found.Found);
|
|
Assert.Null(found.Task);
|
|
Assert.NotNull(found.TaskFull);
|
|
Assert.Equal("the full description", found.TaskFull!.Description);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchDeleteTasks_RunningTask_ReportedNotOk_OthersDeleted()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var deletable = await SeedTaskAsync(listId, "del", TaskStatus.Idle);
|
|
var running = await SeedTaskAsync(listId, "run", TaskStatus.Running);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchDeleteTasks(new[] { deletable.Id, running.Id }, CancellationToken.None);
|
|
|
|
Assert.True(results.Single(r => r.TaskId == deletable.Id).Ok);
|
|
Assert.False(results.Single(r => r.TaskId == running.Id).Ok);
|
|
Assert.Null(await _tasks.GetByIdAsync(deletable.Id));
|
|
Assert.NotNull(await _tasks.GetByIdAsync(running.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchUpdateTaskStatus_QueuesIdleTasks()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
|
|
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchUpdateTaskStatus(new[] { t1.Id, t2.Id }, "Queued", CancellationToken.None);
|
|
|
|
Assert.All(results, r => Assert.True(r.Ok));
|
|
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t1.Id))!.Status);
|
|
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchUpdateTaskStatus_DirtyBaseRepo_ReportsWarningPerItem_OneListOneRepo()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = new GitRepoFixture();
|
|
_repos.Add(repo);
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "scratch.txt"), "new");
|
|
|
|
var listId = await SeedListAsync(repo.RepoDir);
|
|
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
|
|
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
|
|
// Two tasks queued from the same list -- BaseDirtyChecker's TTL cache means only one
|
|
// `git status` actually runs underneath, but both items still see the warning.
|
|
var results = await sut.BatchUpdateTaskStatus(new[] { t1.Id, t2.Id }, "Queued", CancellationToken.None);
|
|
|
|
Assert.All(results, r => Assert.True(r.Ok));
|
|
Assert.All(results, r =>
|
|
{
|
|
Assert.NotNull(r.BaseDirty);
|
|
Assert.Equal(1, r.BaseDirty!.UntrackedCount);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchUpdateTaskStatus_Done_MixedWorktreeState_ReportsPerItemAndDoesNotAbort()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var noWorktree = await SeedTaskAsync(listId, "no-wt", TaskStatus.Idle);
|
|
var missing = "does-not-exist";
|
|
var sut = BuildSut();
|
|
|
|
var results = await sut.BatchUpdateTaskStatus(
|
|
new[] { noWorktree.Id, missing }, "Done", CancellationToken.None);
|
|
|
|
Assert.True(results.Single(r => r.TaskId == noWorktree.Id).Ok);
|
|
Assert.False(results.Single(r => r.TaskId == missing).Ok);
|
|
Assert.Equal(TaskStatus.Done, (await _tasks.GetByIdAsync(noWorktree.Id))!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchTools_RejectEmptyBatch()
|
|
{
|
|
var sut = BuildSut();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.BatchGetTasks(Array.Empty<string>(), cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchTools_RejectOversizedBatch()
|
|
{
|
|
var sut = BuildSut();
|
|
var ids = Enumerable.Range(0, 101).Select(i => i.ToString()).ToArray();
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.BatchGetTasks(ids, cancellationToken: CancellationToken.None));
|
|
Assert.Contains("max", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|