feat(mission-control): give the list handler its own review task
"Let Claude handle it" now creates one ClaudeDo task per run to host the ConPTY session (Idle/IsManual, never queued) instead of an untracked ad-hoc tile, so the run has a real title, diff, and review outcome. Since the handler merges its own changes straight into the list's working dir, the task never gets a WorktreeEntity; its review range lives as new HandlerBaseCommit/HandlerHeadCommit columns on TaskEntity instead, reusing the existing commit-range diff machinery and keeping it out of the worktrees overview entirely.
This commit is contained in:
@@ -656,6 +656,70 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.False(diff.Truncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
// No WorktreeEntity for this task -- it's a worktree-less list-handler host task
|
||||
// (Mission Control's "Let Claude handle it"): the handler merged its own commit
|
||||
// straight into the list's working dir after HandlerBaseCommit was stamped.
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
||||
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
var t = await ctx.Tasks.FindAsync(task.Id);
|
||||
t!.HandlerBaseCommit = repo.BaseCommit;
|
||||
t.HandlerHeadCommit = headCommit;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var diff = await sut.GetTaskDiff(task.Id, false, CancellationToken.None);
|
||||
|
||||
Assert.Contains("handled.txt", diff.Files);
|
||||
Assert.False(diff.Truncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
||||
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
var t = await ctx.Tasks.FindAsync(task.Id);
|
||||
t!.HandlerBaseCommit = repo.BaseCommit;
|
||||
t.HandlerHeadCommit = headCommit;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var diff = await sut.GetTaskDiff(task.Id, true, CancellationToken.None);
|
||||
|
||||
Assert.Contains("handled.txt", diff.Content);
|
||||
}
|
||||
|
||||
// ── MergeTask ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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.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 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();
|
||||
}
|
||||
|
||||
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 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.Online.OnlineTokenStore(),
|
||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!,
|
||||
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git);
|
||||
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"));
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
}
|
||||
}
|
||||
|
||||
// RecordingClientProxy / FakeHubCallerClients / FakeHubCallerContext are defined once for the
|
||||
// whole ClaudeDo.Worker.Tests.Hub namespace in PlanningHubTests.cs; reused here as-is.
|
||||
@@ -70,6 +70,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
private InteractiveLaunchSpecService BuildService() =>
|
||||
new(_db.CreateFactory(), _seeder, _registry,
|
||||
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
|
||||
new GitService(),
|
||||
new WorkerConfig { ClaudeBin = _claudeStubPath });
|
||||
|
||||
private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
|
||||
@@ -477,6 +478,65 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
Assert.Contains(t2, brief);
|
||||
}
|
||||
|
||||
// ── CreateMergeHelperTaskAsync ──
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTaskAsync_EmptyTaskIds_ThrowsInvalidOperation()
|
||||
{
|
||||
var listId = await SeedListAsync(workingDir: _tempDir);
|
||||
var svc = BuildService();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => svc.CreateMergeHelperTaskAsync(Array.Empty<string>(), listId, "title", "header", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTaskAsync_UnknownList_ThrowsKeyNotFound()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
var svc = BuildService();
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(
|
||||
() => svc.CreateMergeHelperTaskAsync(new[] { taskId }, "no-such-list", "title", "header", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTaskAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
|
||||
{
|
||||
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
|
||||
|
||||
var svc = BuildService();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => svc.CreateMergeHelperTaskAsync(new[] { taskId }, listId, "title", "header", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
|
||||
var t1 = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||
|
||||
var svc = BuildService();
|
||||
var newTaskId = await svc.CreateMergeHelperTaskAsync(
|
||||
new[] { t1 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);
|
||||
|
||||
using var readCtx = _db.CreateContext();
|
||||
var created = await new TaskRepository(readCtx).GetByIdAsync(newTaskId);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("List handler: Alpha", created!.Title);
|
||||
Assert.Equal(TaskStatus.Idle, created.Status);
|
||||
Assert.True(created.IsManual);
|
||||
Assert.Equal(repo.BaseCommit, created.HandlerBaseCommit);
|
||||
Assert.Null(created.HandlerHeadCommit);
|
||||
Assert.Contains("Tasks handled by this run:", created.Description);
|
||||
Assert.Contains("First task", created.Description);
|
||||
Assert.Contains(t1, created.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPlanningStart_MapsPlanningArgsAndEnv()
|
||||
{
|
||||
|
||||
@@ -77,6 +77,9 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||
=> Task.FromResult(Guid.NewGuid().ToString());
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public int PlanningStartSpecCalls { get; private set; }
|
||||
|
||||
Reference in New Issue
Block a user