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
@@ -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();
}