feat(worker): Handler-Session nach Usage-Limit-Reset automatisch neu starten

Eine List-Handler-ConPTY-Session, die mitten in einer Phase stirbt, war bisher
ein stilles totes Tile: InteractiveTerminalViewModel.OnSessionProcessExited
setzte nur bei einem Tod innerhalb des 5s-Grace-Fensters ein sichtbares Banner.

Neu:
- HandlerRoundState (JSON unter ~/.claudeDo/handler-rounds.json, keine
  EF-Migration): Phase + Task-Set der laufenden Runde, geschrieben von
  BuildForMergeHelperAsync ("triage") und BuildForMergeHelperHandoffAsync.
  Ohne das existieren beide nur im transienten HandoffRequested-Broadcast.
- HandlerRestartCoordinator: entscheidet den Neustart, fail-closed. Beleg fuer
  "war das Usage-Limit" ist FiveHour.Utilization >= 95 zum Todeszeitpunkt --
  eine ConPTY-Session hat kein stdout, TaskRunner.IsUsageLimitMessage hat hier
  nichts zu lesen. Nur bei PowerUse (beide Keys), max ein Neustart pro Runde,
  Warten bis FiveHour.ResetsAt, dann HandoffRequested erneut broadcasten.
  Verbraucht kein Handoff-Budget (HandoffRoundTracker.Register wird nicht
  angefasst). WorkerLog-Zeilen beim Tod und beim Neustart.
- Ui meldet den Exit-Code (nur dort bekannt) via
  WorkerHub.ReportInteractiveSessionExited, ohne selbst zu filtern.

Sibling zu UsageLimitAutoContinueCoordinator, kein Umbau davon: der sieht nur
headless Runs.
This commit is contained in:
mika kuns
2026-09-02 19:17:51 +02:00
parent 20b3b4ce2b
commit 016b9203f8
18 changed files with 843 additions and 12 deletions
@@ -867,6 +867,61 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(0, SubscriberCount(pane, "SubmitForReviewRequested"));
}
// ── usage-limit auto-restart: the exit code has to reach the worker ─────────────────────────
private sealed class RecordingExitWorker : StubWorkerClient
{
public readonly List<(string TaskId, int ExitCode)> Reported = new();
public override Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
{
Reported.Add((taskId, exitCode));
return Task.CompletedTask;
}
}
// A handler tile that ran for a while and then died non-zero used to be a silent dead pane.
// Whether it was the usage limit (and worth reopening) is the worker's call, so the UI's only
// job is reporting the exit -- hence no filtering here, every task pane reports.
[Fact]
public async Task PaneSessionDiesAfterRunning_ReportsExitToWorker()
{
var worker = new RecordingExitWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperHandoffConPtySessionAsync("handler-1", new[] { "t1" }, "merge");
var pane = Assert.Single(vm.ConPtySessions);
DieAfterRunning(pane, exitCode: 1);
Assert.Equal(new[] { ("handler-1", 1) }, worker.Reported);
}
[Fact]
public async Task ClosedPane_DoesNotReportItsSessionExit()
{
var worker = new RecordingExitWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperHandoffConPtySessionAsync("handler-1", new[] { "t1" }, "merge");
var pane = Assert.Single(vm.ConPtySessions);
pane.CloseCommand.Execute(null);
DieAfterRunning(pane, exitCode: 1);
Assert.Empty(worker.Reported);
}
// Drives a pane's terminal past its startup grace window and then kills it. IsRunning stamps the
// launch time off the terminal's own clock, so a sleep-free "ran for 90 minutes" needs a fresh
// terminal with a stubbed clock -- the pane re-wires its handlers on assignment.
private static void DieAfterRunning(ConPtyPaneViewModel pane, int exitCode)
{
var launchedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var callCount = 0;
pane.Terminal = new InteractiveTerminalViewModel(
() => callCount++ == 0 ? launchedAt : launchedAt.AddMinutes(90));
pane.Terminal.IsRunning = true;
pane.Terminal.OnSessionProcessExited(null, exitCode);
}
[Fact]
public void ToggleLayoutCommand_FlipsIsFocusMode()
{