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:
mika kuns
2026-08-05 09:16:34 +02:00
parent 63d8b5c28d
commit c07c1f70a8
28 changed files with 1590 additions and 43 deletions
@@ -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()
{