chore(claude-do): Mission Control: allgemeiner Session-Handoff an eine frische

## Ziel

Jede Mission-Control-Session (Ad-hoc UND Task-Session) soll ihre Arbeit an eine frische Session
uebergeben koennen: "gib das ab an eine neue Session". Die alte Kachel wird geschlossen, an
derselben Position oeffnet eine frische Session im selben cwd, die mit einem Brief startet, den
die abgebende Session selbst geschrieben hat.

Motivation: eine lange Session mit vollem/verrottetem Kontex

ClaudeDo-Task: 8b846deb-ad10-4243-9272-9aea2a01ec93
This commit is contained in:
Mika Kuns
2026-09-28 11:57:37 +02:00
parent e0a1448f44
commit 08d1797ead
16 changed files with 598 additions and 35 deletions
+5 -1
View File
@@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, string>? SessionHandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
public event Action<string, string>? HandlerUsageWarningEvent;
public event Action? NotesUpdatedEvent;
@@ -59,6 +60,7 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
public void RaiseListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds);
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
public void RaiseSessionHandoffRequested(string paneId, string briefPath) => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath);
public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message);
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
@@ -124,8 +126,10 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(cwd, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync(
@@ -53,7 +53,7 @@ public class ConPtyPaneViewModelTests
[Fact]
public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning()
{
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes);
SetRunning(pane);
Assert.False(pane.IsTaskBased);
@@ -192,7 +192,7 @@ public class ConPtyPaneViewModelTests
[Fact]
public void SessionDiedAfterStartup_AdHocPane_NeverRaises()
{
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes);
var raised = false;
pane.SessionDiedAfterStartup += (_, _) => raised = true;
@@ -490,7 +490,7 @@ public class MissionControlViewModelTests : IDisposable
private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient
{
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
=> throw new InvalidOperationException("bad directory");
}
@@ -771,6 +771,81 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(color, vm.ConPtySessions[1].IdentityColorKey);
}
private sealed class RecordingSessionHandoffWorker : StubWorkerClient
{
public string? AdHocPaneId;
public readonly TaskCompletionSource<(string PaneId, string Cwd, string BriefPath, string? TaskId)> HandoffCall = new();
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
{
AdHocPaneId ??= paneId;
return base.GetAdHocLaunchSpecAsync(directory, paneId, ct);
}
public override Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
{
HandoffCall.TrySetResult((paneId, cwd, briefPath, taskId));
return base.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId, ct);
}
}
// handoff_session replaces the tile in place: same slot, same identity colour, same cwd, and the
// outgoing pane is really closed rather than left running beside the new one.
[Fact]
public async Task SessionHandoffRequested_ReplacesPaneInPlace_KeepingIdentityAndCwd()
{
var worker = new RecordingSessionHandoffWorker();
using var vm = BuildVm(worker);
var dir = Path.Combine("C:", "Some", "Path");
await vm.OpenConPtySessionAsync("t1");
await vm.OpenAdHocConPtySessionAsync(dir);
await vm.OpenConPtySessionAsync("t2");
var outgoing = vm.ConPtySessions[1];
Assert.Equal(outgoing.IdentityKey, worker.AdHocPaneId);
var color = outgoing.IdentityColorKey;
var opened = 0;
vm.OpenRequested += () => opened++;
worker.RaiseSessionHandoffRequested(outgoing.IdentityKey, "brief.md");
Assert.Equal(3, vm.ConPtySessions.Count);
var incoming = vm.ConPtySessions[1];
Assert.NotSame(outgoing, incoming);
Assert.Null(incoming.TaskId);
Assert.Equal(outgoing.IdentityKey, incoming.IdentityKey);
Assert.Equal(color, incoming.IdentityColorKey);
Assert.Same(incoming, vm.Panes[1]);
Assert.Equal(1, opened);
Assert.Equal(0, SubscriberCount(outgoing, "SubmitForReviewRequested"));
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal((outgoing.IdentityKey, dir, "brief.md", (string?)null), call);
}
[Fact]
public async Task SessionHandoffRequested_TaskPane_KeepsTaskId()
{
var worker = new RecordingSessionHandoffWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
worker.RaiseSessionHandoffRequested("t1", "brief.md");
Assert.Equal("t1", Assert.Single(vm.ConPtySessions).TaskId);
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("t1", call.TaskId);
}
[Fact]
public void SessionHandoffRequested_UnknownPane_DoesNothing()
{
var worker = new RecordingSessionHandoffWorker();
using var vm = BuildVm(worker);
worker.RaiseSessionHandoffRequested("nope", "brief.md");
Assert.Empty(vm.ConPtySessions);
Assert.False(worker.HandoffCall.Task.IsCompleted);
}
[Fact]
public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
{
@@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests
"list_runs", "get_run", "get_task_log",
"list_agents",
"reset_failed_task", "submit_task_for_review",
"handoff_list_handler", "get_handoff_status", "start_list_handler",
"handoff_list_handler", "get_handoff_status", "start_list_handler", "handoff_session",
"get_app_settings",
"wait_for_task_change",
"get_queue_state",
@@ -0,0 +1,74 @@
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.Http;
namespace ClaudeDo.Worker.Tests.External;
public sealed class SessionHandoffMcpToolsTests : IDisposable
{
private readonly CapturingHubContext _hubContext = new();
private readonly string _paneId = Guid.NewGuid().ToString("n");
public void Dispose()
{
try { Directory.Delete(InteractiveLaunchSpecService.PaneSessionDir(_paneId), true); } catch { }
}
private static HttpContext Request(string? paneId, string? sessionId = null)
{
var http = new DefaultHttpContext();
if (paneId is not null) http.Request.Headers[SessionHandoffMcpTools.PaneHeader] = paneId;
if (sessionId is not null) http.Request.Headers[SessionHandoffMcpTools.SessionHeader] = sessionId;
return http;
}
private SessionHandoffMcpTools BuildSut(HttpContext http) =>
new(new HubBroadcaster(_hubContext), new HttpContextAccessor { HttpContext = http });
[Fact]
public async Task HandoffSession_IdentityFromHeaders_WritesBriefWithOrigin_AndBroadcasts()
{
var result = await BuildSut(Request(_paneId, "sess-1")).HandoffSession(" do the thing ", CancellationToken.None);
Assert.True(result.Requested);
Assert.StartsWith(InteractiveLaunchSpecService.PaneSessionDir(_paneId), result.BriefPath);
Assert.Equal("Handed off from session `sess-1`.\n\ndo the thing\n", File.ReadAllText(result.BriefPath));
var call = Assert.Single(_hubContext.Proxy.Calls);
Assert.Equal("SessionHandoffRequested", call.Method);
Assert.Equal(new object?[] { _paneId, result.BriefPath }, call.Args);
}
[Theory]
[InlineData(null)]
[InlineData("not-a-guid")]
[InlineData(@"..\..\evil")]
public async Task HandoffSession_MissingOrInvalidPaneHeader_ThrowsWithoutBroadcast(string? paneId)
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BuildSut(Request(paneId)).HandoffSession("brief", CancellationToken.None));
Assert.Empty(_hubContext.Proxy.Calls);
}
[Fact]
public async Task HandoffSession_EmptyBrief_Throws()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BuildSut(Request(_paneId)).HandoffSession(" ", CancellationToken.None));
Assert.Empty(_hubContext.Proxy.Calls);
}
// The per-tile entry lists only handoff_session; the plain entry every session has hides it, so
// the tool is never duplicated and never offered where it can't identify the tile.
[Theory]
[InlineData("handoff_session", true, true)]
[InlineData("handoff_session", false, false)]
[InlineData("get_task", true, false)]
[InlineData("get_task", false, true)]
public void IsVisible_SplitsToolsByPaneHeader(string tool, bool paneRequest, bool expected)
{
Assert.Equal(expected, SessionHandoffMcpTools.IsVisible(tool, Request(paneRequest ? _paneId : null)));
}
}
@@ -60,6 +60,8 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
try { Directory.Delete(d, true); } catch { /* best effort */ }
foreach (var id in _seededTaskIds)
try { Directory.Delete(Path.Combine(Paths.AppDataRoot(), "task-sessions", id), true); } catch { /* best effort */ }
foreach (var id in _paneIds)
try { Directory.Delete(InteractiveLaunchSpecService.PaneSessionDir(id), true); } catch { /* best effort */ }
}
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
@@ -576,7 +578,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec()
{
var svc = BuildService();
var spec = await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None);
var spec = await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None);
Assert.Equal(_tempDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
@@ -590,7 +592,134 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
{
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None));
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), null, CancellationToken.None));
}
// ── Mission Control session handoff ──
private readonly List<string> _paneIds = new();
private string NewPaneId()
{
var id = Guid.NewGuid().ToString("n");
_paneIds.Add(id);
return id;
}
/// Asserts the spec leads with the per-tile MCP entry and the handoff hint: the entry's headers
/// name this pane and the session id the spec actually launches/resumes, and the hint names no
/// id at all (the model would guess it).
private static void AssertHandoffHint(LaunchSpec spec, string paneId, string sessionId)
{
Assert.Equal("--mcp-config", spec.Args[0]);
Assert.Equal("--append-system-prompt-file", spec.Args[2]);
using var mcp = System.Text.Json.JsonDocument.Parse(File.ReadAllText(spec.Args[1]));
var server = mcp.RootElement.GetProperty("mcpServers").GetProperty(InteractiveLaunchSpecService.PaneMcpServerName);
Assert.Equal($"http://127.0.0.1:{new WorkerConfig().ExternalMcpPort}/mcp", server.GetProperty("url").GetString());
var headers = server.GetProperty("headers");
Assert.Equal(paneId, headers.GetProperty(SessionHandoffMcpTools.PaneHeader).GetString());
Assert.Equal(sessionId, headers.GetProperty(SessionHandoffMcpTools.SessionHeader).GetString());
var hint = File.ReadAllText(spec.Args[3]);
Assert.Contains("mcp__claudedo_pane__handoff_session", hint);
Assert.DoesNotContain(paneId, hint);
Assert.DoesNotContain(sessionId, hint);
}
[Fact]
public async Task BuildForDirectoryAsync_ExternalMcpDisabled_NoPaneEntry()
{
var svc = new InteractiveLaunchSpecService(_db.CreateFactory(), _seeder, _registry,
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
new GitService(), new WorkerConfig { ClaudeBin = _claudeStubPath, ExternalMcpPort = 0 },
new HandoffRoundTracker(), NewRoundState());
var spec = await svc.BuildForDirectoryAsync(_tempDir, NewPaneId(), CancellationToken.None);
Assert.DoesNotContain("--mcp-config", spec.Args);
}
private static string ArgAfter(LaunchSpec spec, string flag) => spec.Args[spec.Args.ToList().IndexOf(flag) + 1];
[Fact]
public async Task BuildForDirectoryAsync_WithPaneId_PreAssignsSessionIdAndAppendsHint()
{
var paneId = NewPaneId();
var spec = await BuildService().BuildForDirectoryAsync(_tempDir, paneId, CancellationToken.None);
var sessionId = ArgAfter(spec, "--session-id");
Assert.True(Guid.TryParse(sessionId, out _));
AssertHandoffHint(spec, paneId, sessionId);
}
[Fact]
public async Task BuildForTaskAsync_WithPaneId_ResumedSession_HintNamesResumedId()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
var svc = BuildService();
var (_, _, _, firstSessionId) = ParseFreshTaskArgs(await svc.BuildForTaskAsync(taskId, CancellationToken.None));
var paneId = NewPaneId();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None, paneId);
AssertHandoffHint(spec, paneId, firstSessionId);
Assert.Equal(firstSessionId, ArgAfter(spec, "--resume"));
}
private static string WriteBrief(string paneId)
{
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", "h1");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "brief.md");
File.WriteAllText(path, "brief");
return path;
}
[Fact]
public async Task BuildHandoffAsync_AdHoc_KicksOffFromBriefInSameCwd()
{
var paneId = NewPaneId();
var briefPath = WriteBrief(paneId);
var spec = await BuildService().BuildHandoffAsync(paneId, _tempDir + Path.DirectorySeparatorChar, briefPath, null, CancellationToken.None);
// A trailing separator on the cwd must not survive into the spec (ConPTY quoting trap).
Assert.Equal(_tempDir, spec.Cwd);
var sessionId = ArgAfter(spec, "--session-id");
AssertHandoffHint(spec, paneId, sessionId);
Assert.Equal(Path.GetDirectoryName(briefPath), ArgAfter(spec, "--add-dir"));
var kickoff = spec.Args[^1];
Assert.Contains(briefPath, kickoff);
Assert.DoesNotContain('\n', kickoff);
Assert.Equal("--session-id", spec.Args[^3]);
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
}
[Fact]
public async Task BuildHandoffAsync_Task_PersistsNewSessionIdForResume()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
var briefPath = WriteBrief(taskId);
_paneIds.Add(taskId);
var spec = await BuildService().BuildHandoffAsync(taskId, _tempDir, briefPath, taskId, CancellationToken.None);
Assert.Equal(ArgAfter(spec, "--session-id"), await ReadInteractiveSessionIdAsync(taskId));
}
[Fact]
public async Task BuildHandoffAsync_BriefOutsidePaneDir_Throws()
{
var outside = Path.Combine(_tempDir, "brief.md");
File.WriteAllText(outside, "x");
await Assert.ThrowsAsync<InvalidOperationException>(
() => BuildService().BuildHandoffAsync(NewPaneId(), _tempDir, outside, null, CancellationToken.None));
}
// ── Merge helper ──
@@ -1482,7 +1611,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var specs = new List<LaunchSpec>
{
await svc.BuildForTaskAsync(taskId, CancellationToken.None),
await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None),
await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None),
mergeHelperSpec,
handoffSpec,
svc.BuildPlanningStart(planningStartCtx),