From 4ef01274f94c6898e6f8e6e61a0e69bec96c2205 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 20:27:39 +0200 Subject: [PATCH] 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. --- src/ClaudeDo.Data/PromptFiles.cs | 22 +++- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + .../Services/Interfaces/IWorkerClient.cs | 8 ++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 10 ++ .../ViewModels/MissionControlViewModel.cs | 33 ++++++ .../External/HandoffMcpTools.cs | 44 ++++++++ src/ClaudeDo.Worker/Hub/HubBroadcaster.cs | 5 + src/ClaudeDo.Worker/Hub/WorkerHub.cs | 10 ++ src/ClaudeDo.Worker/Program.cs | 2 + .../Runner/InteractiveLaunchSpecService.cs | 73 +++++++++++++ .../IInteractiveLaunchSpecService.cs | 8 ++ tests/ClaudeDo.Data.Tests/PromptFilesTests.cs | 45 ++++++++ tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 5 + .../External/HandoffMcpToolsTests.cs | 96 ++++++++++++++++ .../Hub/MergeHelperTaskHubTests.cs | 34 ++++++ .../InteractiveLaunchSpecServiceTests.cs | 103 ++++++++++++++++++ .../UiVm/TasksIslandViewModelPlanningTests.cs | 4 + 18 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 src/ClaudeDo.Worker/External/HandoffMcpTools.cs create mode 100644 tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs diff --git a/src/ClaudeDo.Data/PromptFiles.cs b/src/ClaudeDo.Data/PromptFiles.cs index 46bb7f55..617048b7 100644 --- a/src/ClaudeDo.Data/PromptFiles.cs +++ b/src/ClaudeDo.Data/PromptFiles.cs @@ -4,7 +4,7 @@ using System.Text.Json; namespace ClaudeDo.Data; -public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial } +public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial, MergeHelperHandoff } /// /// How a prompt kind's on-disk override (if any) relates to the bundled default. @@ -40,6 +40,7 @@ public static class PromptFiles PromptKind.Refine => "refine.md", PromptKind.MergeHelper => "merge-helper-system.md", PromptKind.MergeHelperInitial => "merge-helper-initial.md", + PromptKind.MergeHelperHandoff => "merge-helper-handoff.md", _ => throw new ArgumentOutOfRangeException(nameof(kind)) }; @@ -212,6 +213,7 @@ public static class PromptFiles PromptKind.Refine => RefineDefault, PromptKind.MergeHelper => MergeHelperDefault, PromptKind.MergeHelperInitial => MergeHelperInitialDefault, + PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault, _ => "" }; @@ -425,6 +427,9 @@ public static class PromptFiles If a task visibly bundles several independent features, or has a blocker that is not resolved by anything in its own description, do not force it into one description. Propose splitting it to the user; if they agree, create the pieces with add_task/add_subtask and only move the pieces the user confirmed into "surviving tasks" for the phases below. Split only what the task already asks for — the "do not invent requirements" rule still applies. + ## Handoff + Once every surviving task is enhanced, call handoff_list_handler with this session's task id and the surviving task ids, in the order you intend to run them. That opens a fresh session to carry out phases 3–5 with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop — do not continue into phase 3 yourself. + ## Phase 3 — Run Do NOT use run_task_now for a batch — there is a single override slot and the second call fails with "override slot busy". @@ -485,6 +490,21 @@ public static class PromptFiles When every task is handled, print the summary. """; + private const string MergeHelperHandoffDefault = """ + # List handler handoff + + Scope: {scope} + Repo: {repo} + + A prior session already read, deduped and enhanced this list's tasks. Pick up at phase 3 + for the tasks below — their descriptions are already sharpened, so don't redo phases 0–2. + + {tasks} + + Start with phase 3 (run), continuing through review/merge and the summary as your + instructions describe. + """; + private const string WeeklyReportDefault = """ You are generating a concise weekly standup report for a software developer, covering {start} to {end}. diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 2504b9f3..ec41ee7e 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -291,6 +291,7 @@ "conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}", "conptyStarting": "Sitzung wird gestartet…", "mergeHelperTitle": "Merge-Helfer", + "mergeHelperHandoffTitleSuffix": " (Übergabe)", "mergeHelperTaskTitle": "Listen-Handler: {0}", "mergeHelperTaskDescriptionHeader": "Von diesem Lauf bearbeitete Tasks:", "submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 3c9a44e1..983fb8b4 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -291,6 +291,7 @@ "conptyLaunchFailed": "Couldn't open interactive session: {0}", "conptyStarting": "Starting session…", "mergeHelperTitle": "Merge Helper", + "mergeHelperHandoffTitleSuffix": " (Handoff)", "mergeHelperTaskTitle": "List handler: {0}", "mergeHelperTaskDescriptionHeader": "Tasks handled by this run:", "submitForReviewFailed": "Couldn't submit for review: {0}", diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 27e88646..c28b4065 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -25,6 +25,10 @@ public interface IWorkerClient : INotifyPropertyChanged /// A pending question was answered, timed out, or the run ended: (taskId, questionId). event Action? TaskQuestionResolvedEvent; + /// A running list-handler session called handoff_list_handler at the end of Phase 2: + /// (handlerTaskId, survivingTaskIds). The UI opens a second ConPTY tile for the same task. + event Action>? HandoffRequestedEvent; + event Action? PrepStartedEvent; event Action? PrepLineEvent; event Action? PrepFinishedEvent; @@ -90,6 +94,10 @@ public interface IWorkerClient : INotifyPropertyChanged /// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id. Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default); + /// Launch spec for the fresh ConPTY session a merge-helper run hands off to once Phase 2 + /// is done -- reuses the SAME handler task id (no new task created). + Task GetMergeHelperHandoffLaunchSpecAsync( + string taskId, IReadOnlyList survivingTaskIds, CancellationToken ct = default); /// Starts a planning session and returns the launch spec for an embedded ConPTY /// planning terminal (replaces StartPlanningSessionAsync's external wt window). Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index c8ebe109..ab3e7393 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? TaskUpdatedEvent; public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; + public event Action>? HandoffRequestedEvent; public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? ListUpdatedEvent; @@ -150,6 +151,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId)); }); + _hub.On>("HandoffRequested", (taskId, survivingTaskIds) => + { + Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds)); + }); + _hub.On("WorktreeUpdated", taskId => { Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId)); @@ -531,6 +537,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) => await _hub.InvokeAsync("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct); + public async Task GetMergeHelperHandoffLaunchSpecAsync( + string taskId, IReadOnlyList survivingTaskIds, CancellationToken ct = default) + => await _hub.InvokeAsync("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, ct); + public async Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("GetPlanningStartLaunchSpec", taskId, ct); diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 79d72ddb..25895c02 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -20,6 +20,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable private readonly Action _onTaskFinished; private readonly Action _onTaskUpdated; private readonly Action _onConnectionRestored; + private readonly Action> _onHandoffRequested; // Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the // review/merge/status machinery. @@ -77,6 +78,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _onConnectionRestored = () => { _ = RefreshQueueAsync(); }; _worker.ConnectionRestoredEvent += _onConnectionRestored; + _onHandoffRequested = (taskId, survivingTaskIds) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds); }; + _worker.HandoffRequestedEvent += _onHandoffRequested; + _ = RefreshQueueAsync(); } @@ -243,6 +247,34 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable () => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId)))); } + // List-handler handoff: the running session called handoff_list_handler at the end of Phase 2. + // Opens a SECOND ConPTY tile for the SAME handler task id to carry out Phases 3-5 — deliberately + // NOT deduped by TaskId like OpenMergeHelperConPtySessionAsync above, since the original tile is + // meant to stay open (Mika closes it by hand once he has seen its last message). No new task is + // created here; see InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync. + public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(string taskId, IReadOnlyList survivingTaskIds) + { + if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return; + + var baseTitle = Loc.T("missionControl.mergeHelperTitle"); + var title = baseTitle + Loc.T("missionControl.mergeHelperHandoffTitleSuffix"); + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var task = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId); + if (task is not null) + { + var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == task.ListId); + if (list?.Name is { Length: > 0 } name) + title = $"{baseTitle} — {name}{Loc.T("missionControl.mergeHelperHandoffTitleSuffix")}"; + } + } + catch { /* best-effort title lookup */ } + + AddConPtyPane(new ConPtyPaneViewModel(taskId, title, + () => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds)))); + } + // Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec, // so the tile is on screen (spinner running) while the worker is still preparing the worktree. private void AddConPtyPane(ConPtyPaneViewModel pane) @@ -327,6 +359,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.TaskFinishedEvent -= _onTaskFinished; _worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.ConnectionRestoredEvent -= _onConnectionRestored; + _worker.HandoffRequestedEvent -= _onHandoffRequested; ConPtySessions.CollectionChanged -= OnConPtySessionsChanged; Panes.CollectionChanged -= OnPanesChanged; foreach (var c in ConPtySessions.ToList()) diff --git a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs new file mode 100644 index 00000000..6866d7c4 --- /dev/null +++ b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs @@ -0,0 +1,44 @@ +using System.ComponentModel; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int SurvivingCount); + +[McpServerToolType] +public sealed class HandoffMcpTools +{ + private readonly TaskRepository _tasks; + private readonly HubBroadcaster _broadcaster; + + public HandoffMcpTools(TaskRepository tasks, HubBroadcaster broadcaster) + { + _tasks = tasks; + _broadcaster = broadcaster; + } + + [McpServerTool, Description( + "End of Phase 2 for the list handler (\"Let Claude handle it\"): hand this run off to a fresh " + + "ConPTY session that carries out Phases 3-5, without dragging along this session's dedupe/rewrite " + + "context. taskId is this session's own handler task id; survivingTaskIds are the tasks that made " + + "it past dedupe, in the order to run them. Reuses the SAME handler task -- no new task is created, " + + "and HandlerBaseCommit is untouched. The current tile stays open; end your own turn after calling this.")] + public async Task HandoffListHandler( + string taskId, IReadOnlyList survivingTaskIds, CancellationToken cancellationToken) + { + if (survivingTaskIds.Count == 0) + throw new InvalidOperationException("survivingTaskIds must contain at least one task id."); + + _ = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + + foreach (var id in survivingTaskIds) + _ = await _tasks.GetByIdAsync(id, cancellationToken) + ?? throw new InvalidOperationException($"Task {id} not found."); + + await _broadcaster.HandoffRequested(taskId, survivingTaskIds); + return new HandoffListHandlerResult(true, taskId, survivingTaskIds.Count); + } +} diff --git a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs index 49464696..919a1465 100644 --- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs @@ -32,6 +32,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster public Task TaskQuestionResolved(string taskId, string questionId) => _hub.Clients.All.SendAsync("TaskQuestionResolved", taskId, questionId); + // A running list-handler session called handoff_list_handler at the end of Phase 2 -- the UI + // opens a second ConPTY tile for the same handler task id to carry out Phases 3-5. + public Task HandoffRequested(string taskId, IReadOnlyList survivingTaskIds) => + _hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds); + public Task ListUpdated(string listId) => _hub.Clients.All.SendAsync("ListUpdated", listId); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index ea58169d..29edcb35 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -751,6 +751,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub return taskId; }); + // Builds the launch spec for the fresh ConPTY session a merge-helper run hands off to once + // Phase 2 (enhance) is done -- reuses the SAME handler task id (no new task created), only a + // new session dir + handoff kickoff naming the surviving tasks. + public Task GetMergeHelperHandoffLaunchSpec(string taskId, string[] survivingTaskIds) => HubGuard(() => + { + if (_interactiveLaunchSpec is null) + throw new InvalidOperationException("Interactive launch spec service is not configured."); + return _interactiveLaunchSpec.BuildForMergeHelperHandoffAsync(taskId, survivingTaskIds, Context.ConnectionAborted); + }); + // Starts a planning session (worktree + prompt files + token, task -> Planning) and returns // the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for // StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 8e91011d..78fd5baf 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -300,6 +300,7 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); @@ -315,6 +316,7 @@ if (cfg.ExternalMcpPort > 0) .WithTools() .WithTools() .WithTools() + .WithTools() .WithTools() .WithTools() .WithTools(); diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index a0648a24..3bf2c92a 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -253,6 +253,79 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService return new LaunchSpec(repoDir, resolvedClaude, args, env); } + // Builds the LaunchSpec for the fresh session a merge-helper run hands off to once Phase 2 + // (enhance) is done -- SAME handler task id as the run that called handoff_list_handler, so + // HandlerBaseCommit/HandlerHeadCommit and the review range stay untouched; this never creates + // a task. Reuses the merge-helper system prompt unchanged (the phase 3-5 instructions already + // live there) and only writes a fresh handoff kickoff file, in a NEW session dir -- the old + // ConPTY tile keeps running against its own session-dir files untouched. + public async Task BuildForMergeHelperHandoffAsync( + string taskId, IReadOnlyList survivingTaskIds, CancellationToken ct) + { + if (survivingTaskIds.Count == 0) + throw new InvalidOperationException("No surviving tasks to hand off."); + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var taskRepo = new TaskRepository(ctx); + var listRepo = new ListRepository(ctx); + + var handlerTask = await taskRepo.GetByIdAsync(taskId, ct) + ?? throw new KeyNotFoundException($"Task not found: {taskId}"); + var list = await listRepo.GetByIdAsync(handlerTask.ListId, ct) + ?? throw new KeyNotFoundException($"List not found: {handlerTask.ListId}"); + + var repoDir = list.WorkingDir; + if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir)) + throw new InvalidOperationException($"list '{list.Name}' has no existing working directory"); + + var briefLines = new List(); + foreach (var id in survivingTaskIds) + { + var task = await taskRepo.GetByIdAsync(id, ct) + ?? throw new KeyNotFoundException($"Task not found: {id}"); + briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})"); + } + + var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString()); + Directory.CreateDirectory(sessionDir); + + var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md"); + await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelper), ct); + + var briefPath = Path.Combine(sessionDir, "handoff.md"); + await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff, + new Dictionary + { + ["scope"] = $"List: {list.Name}", + ["repo"] = repoDir, + ["tasks"] = string.Join("\n", briefLines), + }), ct); + + var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) + ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); + + var listConfig = await listRepo.GetConfigAsync(handlerTask.ListId, ct); + var settings = await new AppSettingsRepository(ctx).GetAsync(ct); + + var args = new List + { + "--effort", EffortFor(settings, listConfig?.Model), + "--permission-mode", "auto", + "--allowedTools", MergeHelperAllowedTools, + "--add-dir", sessionDir, repoDir, + "--append-system-prompt-file", systemPromptPath, + $"Read the file {briefPath} first. It lists the surviving tasks and their status. " + + "After reading it, begin at phase 3 as your instructions describe.", + }; + + var env = new Dictionary + { + ["MCP_TOOL_TIMEOUT"] = "200000", + }; + + return new LaunchSpec(repoDir, resolvedClaude, args, env); + } + // Renders one task as a brief list item. A description can itself be arbitrary Markdown // (headings, lists, fenced code) — those must not merge into the brief's own task list, so // the description is placed in a fenced code block indented to the list item's continuation diff --git a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs index 6cf619ec..f19cd2e1 100644 --- a/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs @@ -42,4 +42,12 @@ public interface IInteractiveLaunchSpecService /// is empty or the list has no existing working directory. Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct); + + /// Builds a LaunchSpec for the fresh ConPTY session a merge-helper run hands off to + /// once Phase 2 (enhance) is done -- reuses the SAME handler task id (no new task, HandlerBaseCommit + /// untouched) and the unmodified merge-helper system prompt, writing only a fresh handoff kickoff + /// naming the surviving tasks. Throws KeyNotFoundException if the task/list doesn't exist; + /// InvalidOperationException if survivingTaskIds is empty or the list has no working directory. + Task BuildForMergeHelperHandoffAsync( + string taskId, IReadOnlyList survivingTaskIds, CancellationToken ct); } diff --git a/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs b/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs index 0e973a7c..6d3592a5 100644 --- a/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs +++ b/tests/ClaudeDo.Data.Tests/PromptFilesTests.cs @@ -49,6 +49,7 @@ public class PromptFilesTests { Assert.EndsWith("merge-helper-system.md", PromptFiles.PathFor(PromptKind.MergeHelper)); Assert.EndsWith("merge-helper-initial.md", PromptFiles.PathFor(PromptKind.MergeHelperInitial)); + Assert.EndsWith("merge-helper-handoff.md", PromptFiles.PathFor(PromptKind.MergeHelperHandoff)); } [Fact] @@ -151,4 +152,48 @@ public class PromptFilesTests Assert.DoesNotContain("{scope}", outp); Assert.DoesNotContain("{tasks}", outp); } + + [Fact] + public void DefaultFor_merge_helper_handoff_has_scope_repo_and_tasks_tokens() + { + var d = PromptFiles.DefaultFor(PromptKind.MergeHelperHandoff); + Assert.False(string.IsNullOrWhiteSpace(d)); + Assert.Contains("{scope}", d); + Assert.Contains("{repo}", d); + Assert.Contains("{tasks}", d); + } + + [Fact] + public void DefaultFor_merge_helper_handoff_points_at_phase_3() + { + var d = PromptFiles.DefaultFor(PromptKind.MergeHelperHandoff); + Assert.Contains("phase 3", d, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RenderTemplate_merge_helper_handoff_substitutes_scope_repo_and_tasks() + { + var outp = PromptFiles.RenderTemplate( + PromptFiles.DefaultFor(PromptKind.MergeHelperHandoff), + new Dictionary + { + ["scope"] = "List: Bugs", + ["repo"] = "C:\\repo", + ["tasks"] = "- [WaitingForReview] T1 (id: abc)", + }); + Assert.Contains("Scope: List: Bugs", outp); + Assert.Contains("Repo: C:\\repo", outp); + Assert.Contains("- [WaitingForReview] T1 (id: abc)", outp); + Assert.DoesNotContain("{scope}", outp); + Assert.DoesNotContain("{repo}", outp); + Assert.DoesNotContain("{tasks}", outp); + } + + [Fact] + public void DefaultFor_merge_helper_tells_the_session_to_hand_off_after_phase_2() + { + var d = PromptFiles.DefaultFor(PromptKind.MergeHelper); + Assert.Contains("handoff_list_handler", d); + Assert.Contains("do not continue into phase 3 yourself", d, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 552fb043..18365b68 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -25,6 +25,7 @@ public abstract class StubWorkerClient : IWorkerClient public event Action? WorkerLogReceivedEvent; public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; + public event Action>? HandoffRequestedEvent; public event Action? PrepStartedEvent; public event Action? PrepLineEvent; public event Action? PrepFinishedEvent; @@ -51,6 +52,7 @@ public abstract class StubWorkerClient : IWorkerClient public void RaiseConnectionRestored() => ConnectionRestoredEvent?.Invoke(); public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question); public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId); + public void RaiseHandoffRequested(string taskId, IReadOnlyList survivingTaskIds) => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds); public void RaisePrepStarted() => PrepStartedEvent?.Invoke(); public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line); @@ -105,6 +107,9 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) => Task.FromResult(Guid.NewGuid().ToString()); + public virtual Task GetMergeHelperHandoffLaunchSpecAsync( + string taskId, IReadOnlyList survivingTaskIds, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public virtual Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public virtual Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default) diff --git a/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs new file mode 100644 index 00000000..a22ae794 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs @@ -0,0 +1,96 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class HandoffMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly CapturingHubContext _hubContext = new(); + + public HandoffMcpToolsTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private HandoffMcpTools BuildSut() => new(_tasks, new HubBroadcaster(_hubContext)); + + private async Task SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle, string title = "t") + { + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, + Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", + }; + await _tasks.AddAsync(task); + return task; + } + + private async Task SeedListAsync() + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + return listId; + } + + [Fact] + public async Task HandoffListHandler_ValidIds_BroadcastsAndReturnsCount() + { + var listId = await SeedListAsync(); + var handlerTask = await SeedTaskAsync(listId, title: "List handler: Alpha"); + var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); + + var sut = BuildSut(); + var result = await sut.HandoffListHandler(handlerTask.Id, new[] { survivor.Id }, CancellationToken.None); + + Assert.True(result.Requested); + Assert.Equal(handlerTask.Id, result.TaskId); + Assert.Equal(1, result.SurvivingCount); + + var call = Assert.Single(_hubContext.Proxy.Calls); + Assert.Equal("HandoffRequested", call.Method); + Assert.Equal(handlerTask.Id, call.Args[0]); + } + + [Fact] + public async Task HandoffListHandler_EmptySurvivingIds_Throws() + { + var listId = await SeedListAsync(); + var handlerTask = await SeedTaskAsync(listId); + + var sut = BuildSut(); + await Assert.ThrowsAsync(() => + sut.HandoffListHandler(handlerTask.Id, Array.Empty(), CancellationToken.None)); + } + + [Fact] + public async Task HandoffListHandler_UnknownHandlerTask_Throws() + { + var sut = BuildSut(); + await Assert.ThrowsAsync(() => + sut.HandoffListHandler("missing", new[] { "x" }, CancellationToken.None)); + } + + [Fact] + public async Task HandoffListHandler_UnknownSurvivingTask_Throws() + { + var listId = await SeedListAsync(); + var handlerTask = await SeedTaskAsync(listId); + + var sut = BuildSut(); + await Assert.ThrowsAsync(() => + sut.HandoffListHandler(handlerTask.Id, new[] { "missing" }, CancellationToken.None)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs index ab9101d3..ca412641 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs @@ -26,6 +26,7 @@ public sealed class MergeHelperTaskHubTests : IDisposable private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly List _repos = new(); + private readonly List _mergeHelperSessionDirs = new(); private readonly RecordingClientProxy _proxy = new(); private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); @@ -42,6 +43,8 @@ public sealed class MergeHelperTaskHubTests : IDisposable 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 @@ -136,6 +139,37 @@ public sealed class MergeHelperTaskHubTests : IDisposable () => 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 }); + + 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", 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( + () => hub.GetMergeHelperHandoffLaunchSpec("no-such-task", new[] { "x" })); + } + // ── SubmitTaskForReview (worktree-less branch) ── [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index f375c21e..af745b5a 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -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( + () => svc.BuildForMergeHelperHandoffAsync(handlerTaskId, Array.Empty(), CancellationToken.None)); + } + + [Fact] + public async Task BuildForMergeHelperHandoffAsync_UnknownHandlerTask_ThrowsKeyNotFound() + { + await Assert.ThrowsAsync( + () => 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( + () => 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( + () => 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 CountTasksAsync() + { + using var ctx = _db.CreateContext(); + return await ctx.Tasks.CountAsync(); + } + [Fact] public void BuildPlanningStart_MapsPlanningArgsAndEnv() { diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 8a5e063a..3538e11b 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -36,6 +36,7 @@ sealed class FakeWorkerClient : IWorkerClient public event Action? WorkerLogReceivedEvent; public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; + public event Action>? HandoffRequestedEvent; public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId); public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId); public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line); @@ -77,6 +78,9 @@ sealed class FakeWorkerClient : IWorkerClient public Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) => Task.FromResult(Guid.NewGuid().ToString()); + public Task GetMergeHelperHandoffLaunchSpecAsync( + string taskId, IReadOnlyList survivingTaskIds, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty(), new Dictionary())); public int PlanningStartSpecCalls { get; private set; }