Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs
T

269 lines
11 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Hub;
/// Covers the two hub methods a worktree-less "list handler" host task (Mission Control's
/// "Let Claude handle it") relies on: CreateMergeHelperTask (task creation + HandlerBaseCommit
/// stamp) and SubmitTaskForReview's worktree-less branch (HandlerHeadCommit stamp + transition).
/// The handler itself merges the tasks it handles directly into the list's working dir -- no
/// worktree of its own -- so these hub methods are the whole story for its review range.
public sealed class MergeHelperTaskHubTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly List<GitRepoFixture> _repos = new();
private readonly List<string> _mergeHelperSessionDirs = new();
private readonly RecordingClientProxy _proxy = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
public MergeHelperTaskHubTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose()
{
foreach (var r in _repos) r.Dispose();
_ctx.Dispose();
_db.Dispose();
foreach (var d in _mergeHelperSessionDirs)
try { Directory.Delete(d, true); } catch { /* best effort */ }
}
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
{
public Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException();
public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Array.Empty<SessionSkillEntity>());
}
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var git = new GitService();
var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
new WorkerConfig { ClaudeBin = "claude" });
var built = TaskStateServiceBuilder.Build(factory);
var reviewSubmission = new InteractiveReviewSubmissionService(factory, git, built.State, wtManager);
var hub = new WorkerHub(
null!, null!, null!, null!, null!, factory, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!,
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git,
interactiveReviewSubmission: reviewSubmission);
hub.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext();
return hub;
}
private GitRepoFixture CreateRepo()
{
var f = new GitRepoFixture();
_repos.Add(f);
return f;
}
private async Task<string> SeedListAsync(string workingDir, string name = "L")
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = name, WorkingDir = workingDir, CreatedAt = DateTime.UtcNow });
return listId;
}
private async Task<TaskEntity> SeedTaskAsync(
string listId, TaskStatus status = TaskStatus.Idle, string? handlerBaseCommit = null, string title = "T")
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Status = status,
HandlerBaseCommit = handlerBaseCommit,
CreatedAt = DateTime.UtcNow,
};
await _tasks.AddAsync(task);
return task;
}
// ── CreateMergeHelperTask ──
[Fact]
public async Task CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var listId = await SeedListAsync(repo.RepoDir, name: "Alpha");
var t1 = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "First task");
var hub = CreateHub();
var newTaskId = await hub.CreateMergeHelperTask(
new[] { t1.Id }, listId, "List handler: Alpha", "Tasks handled by this run:");
var created = await _tasks.GetByIdAsync(newTaskId);
Assert.NotNull(created);
Assert.Equal(TaskStatus.Idle, created!.Status);
Assert.True(created.IsManual);
Assert.Equal(repo.BaseCommit, created.HandlerBaseCommit);
Assert.Contains("First task", created.Description);
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
}
[Fact]
public async Task CreateMergeHelperTask_UnknownList_Throws()
{
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(
() => hub.CreateMergeHelperTask(new[] { "t1" }, "no-such-list", "title", "header"));
}
// ── GetMergeHelperHandoffLaunchSpec ──
[Fact]
public async Task GetMergeHelperHandoffLaunchSpec_ReusesHandlerTaskId_NoNewTaskCreated()
{
var listId = await SeedListAsync(Path.GetTempPath(), name: "Alpha");
var handlerTask = await SeedTaskAsync(listId, TaskStatus.Idle, title: "List handler: Alpha");
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor task");
var hub = CreateHub();
var spec = await hub.GetMergeHelperHandoffLaunchSpec(handlerTask.Id, new[] { survivor.Id }, "wait");
var args = spec.Args.ToList();
var sessionDir = args[args.IndexOf("--add-dir") + 1];
_mergeHelperSessionDirs.Add(sessionDir);
Assert.Contains("--allowedTools", args);
Assert.Contains("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task", args);
var kickoff = args[^1];
Assert.Contains(Path.Combine(sessionDir, "handoff.md"), kickoff);
}
[Fact]
public async Task GetMergeHelperHandoffLaunchSpec_UnknownHandlerTask_Throws()
{
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(
() => hub.GetMergeHelperHandoffLaunchSpec("no-such-task", new[] { "x" }, "wait"));
}
// ── SubmitTaskForReview (worktree-less branch) ──
[Fact]
public async Task SubmitTaskForReview_WorktreeLessHandlerTask_StampsHeadCommit_TransitionsToWaitingForReview()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var listId = await SeedListAsync(repo.RepoDir);
var task = await SeedTaskAsync(listId, TaskStatus.Idle, handlerBaseCommit: repo.BaseCommit);
// The handler merged a task's worktree into the list's working dir on its own.
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 expectedHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var hub = CreateHub();
await hub.SubmitTaskForReview(task.Id);
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
Assert.Equal(expectedHead, reloaded.HandlerHeadCommit);
}
[Fact]
public async Task SubmitTaskForReview_NoWorktreeAndNoHandlerBaseCommit_Throws()
{
var listId = await SeedListAsync(Path.GetTempPath());
var task = await SeedTaskAsync(listId, TaskStatus.Idle);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
}
[Fact]
public async Task SubmitTaskForReview_RunningTask_Throws_RegardlessOfHandlerState()
{
var listId = await SeedListAsync(Path.GetTempPath());
var task = await SeedTaskAsync(listId, TaskStatus.Running, handlerBaseCommit: "abc123");
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
}
// The status gate (Idle or Failed) must run before either mutation branch below, so a
// Done/Cancelled task can never get its worktree committed or its HandlerHeadCommit stamped
// and then rejected by SubmitInteractiveForReviewAsync, stranding the work.
[Fact]
public async Task SubmitTaskForReview_DoneTask_WorktreePath_ThrowsBeforeCommit_WorktreeUntouched()
{
var listId = await SeedListAsync(Path.GetTempPath());
var task = await SeedTaskAsync(listId, TaskStatus.Done);
var worktree = new WorktreeEntity
{
TaskId = task.Id,
Path = Path.Combine(Path.GetTempPath(), $"cd_no_such_worktree_{Guid.NewGuid():N}"),
BranchName = "claudedo/does-not-matter",
BaseCommit = "abc123",
State = WorktreeState.Active,
CreatedAt = DateTime.UtcNow,
};
await new WorktreeRepository(_ctx).AddAsync(worktree);
var hub = CreateHub();
var ex = await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
Assert.Contains("Idle or Failed", ex.Message);
var reloadedWorktree = await new WorktreeRepository(_ctx).GetByTaskIdAsync(task.Id);
Assert.Null(reloadedWorktree!.HeadCommit);
var reloadedTask = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, reloadedTask!.Status);
}
[Fact]
public async Task SubmitTaskForReview_CancelledTask_HandlerPath_ThrowsBeforeStamp_HandlerHeadCommitUntouched()
{
var listId = await SeedListAsync(Path.GetTempPath());
var task = await SeedTaskAsync(listId, TaskStatus.Cancelled, handlerBaseCommit: "abc123");
var hub = CreateHub();
var ex = await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
Assert.Contains("Idle or Failed", ex.Message);
var reloaded = await _tasks.GetByIdAsync(task.Id);
Assert.Null(reloaded!.HandlerHeadCommit);
Assert.Equal(TaskStatus.Cancelled, reloaded.Status);
}
}
// RecordingClientProxy / FakeHubCallerClients / FakeHubCallerContext are defined once for the
// whole ClaudeDo.Worker.Tests.Hub namespace in PlanningHubTests.cs; reused here as-is.