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
@@ -1,4 +1,5 @@
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.MissionControl;
using Xunit;
@@ -150,5 +151,62 @@ public class ConPtyPaneViewModelTests
Assert.True(pane.RetryCommand.CanExecute(null));
}
// ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ────────────────────
[Fact]
public void SessionDiedAfterStartup_TaskPane_RaisesWithTaskIdAndExitCode()
{
using var pane = NewTaskPane(NeverCompletes, taskId: "handler-1");
(string TaskId, int ExitCode)? reported = null;
pane.SessionDiedAfterStartup += (id, code) => reported = (id, code);
DieAfterRunning(pane, exitCode: 1);
Assert.Equal(("handler-1", 1), reported);
}
[Fact]
public void SessionDiedAfterStartup_AdHocPane_NeverRaises()
{
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
var raised = false;
pane.SessionDiedAfterStartup += (_, _) => raised = true;
DieAfterRunning(pane, exitCode: 1);
Assert.False(raised);
}
// Retry swaps in a fresh terminal -- the death of the RETRIED session must still be reported.
[Fact]
public void SessionDiedAfterStartup_StillWiredAfterRetry()
{
var callCount = 0;
using var pane = NewTaskPane(
() => callCount++ == 0 ? Failing() : NeverCompletes(), taskId: "handler-1");
pane.Start();
pane.RetryCommand.Execute(null);
var raised = false;
pane.SessionDiedAfterStartup += (_, _) => raised = true;
DieAfterRunning(pane, exitCode: 1);
Assert.True(raised);
}
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
// Drives the terminal past its startup grace window, then kills it: IsRunning stamps the launch
// time off the real clock, so a sleep-free "ran for a while" needs the exit routed through a
// terminal whose clock has already moved on.
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);
}
}
@@ -49,4 +49,54 @@ public class InteractiveTerminalViewModelTests
Assert.True(terminal.HasExited);
Assert.Null(terminal.StartError);
}
// ── ExitedAfterStartup (a session that ran, then broke -- used to be a silent dead tile;
// the worker turns it into a usage-limit auto-restart for list handlers) ───────────────────
[Fact]
public void ProcessExitsNonzero_AfterRunningAWhile_RaisesExitedAfterStartup()
{
using var terminal = NewTerminalExitingAfter(TimeSpan.FromMinutes(90));
int? reported = null;
terminal.ExitedAfterStartup += code => reported = code;
terminal.OnSessionProcessExited(null, 1);
Assert.Equal(1, reported);
}
[Fact]
public void ProcessExitsNonzero_WithinGraceWindow_DoesNotRaiseExitedAfterStartup()
{
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
terminal.IsRunning = true;
var raised = false;
terminal.ExitedAfterStartup += _ => raised = true;
terminal.OnSessionProcessExited(null, 1);
Assert.False(raised);
Assert.NotNull(terminal.StartError); // still the startup-failure banner
}
[Fact]
public void ProcessExitsZero_AfterRunningAWhile_DoesNotRaiseExitedAfterStartup()
{
using var terminal = NewTerminalExitingAfter(TimeSpan.FromMinutes(90));
var raised = false;
terminal.ExitedAfterStartup += _ => raised = true;
terminal.OnSessionProcessExited(null, 0);
Assert.False(raised);
}
private static InteractiveTerminalViewModel NewTerminalExitingAfter(TimeSpan uptime)
{
var callCount = 0;
var terminal = new InteractiveTerminalViewModel(
() => callCount++ == 0 ? LaunchedAt : LaunchedAt + uptime);
terminal.IsRunning = true; // records LaunchedAt via the first clock call
return terminal;
}
}