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
@@ -96,6 +96,13 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
}
}
/// <summary>Raised when the process died non-zero *after* clearing <see cref="StartupGraceWindow"/> —
/// i.e. a session that ran, then broke. That used to be a silent dead tile with no signal at all;
/// the worker turns it into a usage-limit auto-restart for list-handler sessions
/// (<c>HandlerRestartCoordinator</c>). A startup death keeps using <see cref="StartError"/> instead:
/// it never reached a state worth resuming.</summary>
public event Action<int>? ExitedAfterStartup;
/// <summary>Internal (not private) so tests can drive it directly without a real ConPTY spawn.</summary>
internal void OnSessionProcessExited(object? sender, int exitCode)
{
@@ -103,11 +110,16 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
HasExited = true;
ExitCode = exitCode;
if (exitCode == 0) return;
if (_startedAtUtc is not { } startedAt) return; // never reached "running" — nothing ran to resume
// Died at startup: route through the same banner as a launch-time failure instead of
// leaving a dead terminal with no Retry affordance (ConPtyPaneViewModel.CanRetry
// requires StartError).
if (exitCode != 0 && _startedAtUtc is { } startedAt && _utcNow() - startedAt < StartupGraceWindow)
if (_utcNow() - startedAt < StartupGraceWindow)
StartError = $"Session exited immediately (code {exitCode}).";
else
ExitedAfterStartup?.Invoke(exitCode);
}
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
@@ -36,6 +36,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// commits the worktree and moves the task to WaitingForReview.</summary>
public event Action<string>? SubmitForReviewRequested;
/// <summary>Raised with <c>(taskId, exitCode)</c> when a task-based session that had been running
/// dies non-zero. The host reports it to the worker, which decides whether this was a list
/// handler killed by the usage limit and worth reopening. Ad-hoc panes never raise it — there is
/// no task to reopen.</summary>
public event Action<string, int>? SessionDiedAfterStartup;
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
@@ -49,7 +55,34 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
TaskId = taskId;
_displayTitle = displayTitle;
_descriptorFactory = descriptorFactory;
Terminal.PropertyChanged += OnTerminalPropertyChanged;
Subscribe(Terminal);
}
// Terminal is swapped wholesale on Retry, so the handlers follow the property rather than being
// wired once at construction. (The initial value comes from the field initializer, which never
// runs through the setter -- hence the explicit Subscribe in the constructor.)
partial void OnTerminalChanging(InteractiveTerminalViewModel? oldValue, InteractiveTerminalViewModel newValue)
{
if (oldValue is not null) Unsubscribe(oldValue);
}
partial void OnTerminalChanged(InteractiveTerminalViewModel value) => Subscribe(value);
private void Subscribe(InteractiveTerminalViewModel terminal)
{
terminal.PropertyChanged += OnTerminalPropertyChanged;
terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup;
}
private void Unsubscribe(InteractiveTerminalViewModel terminal)
{
terminal.PropertyChanged -= OnTerminalPropertyChanged;
terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup;
}
private void OnTerminalExitedAfterStartup(int exitCode)
{
if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode);
}
private readonly Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> _descriptorFactory;
@@ -111,16 +144,15 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
[RelayCommand(CanExecute = nameof(CanRetry))]
private void Retry()
{
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
Terminal.Dispose();
Terminal = new InteractiveTerminalViewModel();
Terminal.PropertyChanged += OnTerminalPropertyChanged;
var dead = Terminal;
Terminal = new InteractiveTerminalViewModel(); // OnTerminalChanging/Changed move the handlers
dead.Dispose();
Start();
}
public void Dispose()
{
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
Unsubscribe(Terminal);
Terminal.Kill();
Terminal.Dispose();
}
@@ -413,10 +413,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
pane.SessionDiedAfterStartup += OnPaneSessionDied;
ConPtySessions.Add(pane);
pane.Start();
}
// A ConPTY session that had been running died. The UI process owns the terminal, so this is the
// only place the exit code exists -- report it and let the worker decide whether it was a list
// handler killed by the usage limit and worth reopening after the reset
// (HandlerRestartCoordinator). Every other pane is just a no-op there, so no filtering here.
private async void OnPaneSessionDied(string taskId, int exitCode)
{
try
{
await _worker.ReportInteractiveSessionExitedAsync(taskId, exitCode);
}
catch (Exception ex)
{
// A worker that is down cannot restart anything anyway; the dead tile is already visible.
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
}
}
private static async System.Threading.Tasks.Task<TerminalLaunchDescriptor> DescribeAsync(
Func<System.Threading.Tasks.Task<LaunchSpec>> fetch)
{
@@ -457,6 +475,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.ErrorReported -= OnConPtyPaneError;
pane.CloseRequested -= CloseConPtySession;
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
ConPtySessions.Remove(pane);
pane.Dispose();
}
@@ -507,6 +526,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
c.ErrorReported -= OnConPtyPaneError;
c.CloseRequested -= CloseConPtySession;
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
c.SessionDiedAfterStartup -= OnPaneSessionDied;
c.Dispose();
}
ConPtySessions.Clear();