feat(list-handler): hand off to a fresh session after Phase 2

The merge-helper ("Let Claude handle it") system prompt now calls a new
handoff_list_handler MCP tool once every surviving task is enhanced,
instead of continuing into Phases 3-5 in the same session -- avoiding
paying for Phases 0-2's dedupe/rewrite context on every polling round of
the run/review/merge phases.

The tool broadcasts HandoffRequested; Mission Control opens a second
ConPTY tile for the SAME handler task id (no new task, HandlerBaseCommit
untouched) running a fresh handoff brief that starts at Phase 3. The
original tile stays open. Adds PromptKind.MergeHelperHandoff,
InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync, and the
GetMergeHelperHandoffLaunchSpec hub method.
This commit is contained in:
mika kuns
2026-08-05 20:27:39 +02:00
parent bdee731376
commit 4ef01274f9
18 changed files with 503 additions and 1 deletions
@@ -7,6 +7,7 @@ using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -697,6 +698,108 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains(t1, created.Description);
}
// ── BuildForMergeHelperHandoffAsync ──
[Fact]
public async Task BuildForMergeHelperHandoffAsync_EmptySurvivingTaskIds_ThrowsInvalidOperation()
{
var listId = await SeedListAsync(workingDir: _tempDir);
var handlerTaskId = Guid.NewGuid().ToString();
await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle);
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, Array.Empty<string>(), CancellationToken.None));
}
[Fact]
public async Task BuildForMergeHelperHandoffAsync_UnknownHandlerTask_ThrowsKeyNotFound()
{
await Assert.ThrowsAsync<KeyNotFoundException>(
() => BuildService().BuildForMergeHelperHandoffAsync("no-such-task", new[] { "x" }, CancellationToken.None));
}
[Fact]
public async Task BuildForMergeHelperHandoffAsync_UnknownSurvivingTask_ThrowsKeyNotFound()
{
var listId = await SeedListAsync(workingDir: _tempDir);
var handlerTaskId = Guid.NewGuid().ToString();
await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle);
var svc = BuildService();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { "no-such-task" }, CancellationToken.None));
}
[Fact]
public async Task BuildForMergeHelperHandoffAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
{
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
var handlerTaskId = Guid.NewGuid().ToString();
await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle);
var survivor = Guid.NewGuid().ToString();
await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview);
var svc = BuildService();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None));
Assert.Contains("working directory", ex.Message);
}
[Fact]
public async Task BuildForMergeHelperHandoffAsync_ReusesHandlerTaskId_NoNewTaskCreated()
{
var repo = Path.Combine(_tempDir, "repoHandoff");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var handlerTaskId = Guid.NewGuid().ToString();
await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "List handler: Alpha");
var survivor = Guid.NewGuid().ToString();
await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor task");
var beforeCount = await CountTasksAsync();
var svc = BuildService();
var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var afterCount = await CountTasksAsync();
Assert.Equal(beforeCount, afterCount);
Assert.Equal(repo, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
var args = spec.Args.ToList();
var atIdx = args.IndexOf("--allowedTools");
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);
var appendIdx = args.IndexOf("--append-system-prompt-file");
var systemPromptPath = args[appendIdx + 1];
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelper), File.ReadAllText(systemPromptPath));
var kickoff = args[^1];
var handoffPath = Path.Combine(sessionDir, "handoff.md");
Assert.Contains(handoffPath, kickoff);
Assert.DoesNotContain('\n', kickoff);
var handoffBrief = File.ReadAllText(handoffPath);
Assert.Contains("Scope: List: Alpha", handoffBrief);
Assert.Contains($"Repo: {repo}", handoffBrief);
Assert.Contains("Survivor task", handoffBrief);
Assert.Contains(survivor, handoffBrief);
Assert.Contains("phase 3", handoffBrief, StringComparison.OrdinalIgnoreCase);
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
}
private async Task<int> CountTasksAsync()
{
using var ctx = _db.CreateContext();
return await ctx.Tasks.CountAsync();
}
[Fact]
public void BuildPlanningStart_MapsPlanningArgsAndEnv()
{