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.
127 lines
5.2 KiB
C#
127 lines
5.2 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
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 ClaudeDo.Worker.Config;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.External;
|
|
|
|
public sealed class AddSubtaskToolTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
|
|
public AddSubtaskToolTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
}
|
|
|
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
|
|
|
private async Task<string> SeedListAsync()
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
return id;
|
|
}
|
|
|
|
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle)
|
|
{
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "t",
|
|
Status = status,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
private ExternalMcpService BuildSut()
|
|
{
|
|
var cfg = new WorkerConfig
|
|
{
|
|
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
|
|
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
|
|
QueueBackstopIntervalMs = 50,
|
|
};
|
|
var dbFactory = _db.CreateFactory();
|
|
var hubCtx = new CapturingHubContext();
|
|
var broadcaster = new HubBroadcaster(hubCtx);
|
|
var git = new ClaudeDo.Data.Git.GitService();
|
|
var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
|
var fake = new FakeClaudeProcess();
|
|
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);
|
|
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
|
|
new FakeUsageGate(), new UsageState(), broadcaster);
|
|
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
|
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
|
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
|
var planningMerge = new PlanningMergeOrchestrator(
|
|
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
|
return new ExternalMcpService(
|
|
_tasks, _lists, queue, broadcaster,
|
|
state,
|
|
git, dbFactory, maintenance, merge, planningMerge,
|
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddSubtask_appends_row_with_next_order()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, TaskStatus.Idle);
|
|
var sut = BuildSut();
|
|
|
|
await sut.AddSubtask(task.Id, "First step", null, CancellationToken.None);
|
|
await sut.AddSubtask(task.Id, "Second step", null, CancellationToken.None);
|
|
|
|
await using var verifyCtx = _db.CreateContext();
|
|
var subtasks = await new SubtaskRepository(verifyCtx).GetByTaskIdAsync(task.Id);
|
|
|
|
Assert.Equal(2, subtasks.Count);
|
|
Assert.Equal("First step", subtasks[0].Title);
|
|
Assert.Equal("Second step", subtasks[1].Title);
|
|
Assert.Equal(0, subtasks[0].OrderNum);
|
|
Assert.Equal(1, subtasks[1].OrderNum);
|
|
Assert.False(subtasks[0].Completed);
|
|
Assert.False(subtasks[1].Completed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddSubtask_refuses_running_task()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var task = await SeedTaskAsync(listId, TaskStatus.Running);
|
|
var sut = BuildSut();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => sut.AddSubtask(task.Id, "Should fail", null, CancellationToken.None));
|
|
}
|
|
}
|