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
@@ -44,6 +44,7 @@ public sealed class InteractiveLaunchSpecService
private readonly GitService _git;
private readonly string _claudePath;
private readonly HandoffRoundTracker _handoffRounds;
private readonly HandlerRoundState _roundState;
public InteractiveLaunchSpecService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
@@ -52,7 +53,8 @@ public sealed class InteractiveLaunchSpecService
WorktreeManager wtManager,
GitService git,
WorkerConfig cfg,
HandoffRoundTracker handoffRounds)
HandoffRoundTracker handoffRounds,
HandlerRoundState roundState)
{
_dbFactory = dbFactory;
_skillSeeder = skillSeeder;
@@ -61,6 +63,7 @@ public sealed class InteractiveLaunchSpecService
_git = git;
_claudePath = cfg.ClaudeBin;
_handoffRounds = handoffRounds;
_roundState = roundState;
}
/// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
@@ -342,9 +345,20 @@ public sealed class InteractiveLaunchSpecService
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
// The opening round. Recorded so anything watching a handler run knows which round is live
// (the deferred-handoff follow-up needs that), but marked "triage" rather than a
// MergeHelperPhase value on purpose: there is no handoff spec for this phase, so
// HandlerRestartCoordinator will not reopen it.
_roundState.Save(new HandlerRound(handlerTaskId, TriagePhase, taskIds, DateTime.UtcNow));
return new LaunchSpec(repoDir, resolvedClaude, args, env);
}
/// <summary>The <see cref="HandlerRound.Phase"/> marker for the initial (dedupe/enhance) handler
/// session. Deliberately not a <see cref="MergeHelperPhase"/> value — that set is exactly the
/// phases <see cref="BuildForMergeHelperHandoffAsync"/> can rebuild.</summary>
public const string TriagePhase = "triage";
// Builds the LaunchSpec for the fresh session a merge-helper run hands off to for the given
// phase -- SAME handler task id as the run that called handoff_list_handler, so
// HandlerBaseCommit/HandlerHeadCommit and the review range stay untouched; this never creates
@@ -449,6 +463,11 @@ public sealed class InteractiveLaunchSpecService
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
// Persist the round that is about to start. Phase + task set only exist in the transient
// HandoffRequested broadcast otherwise, so without this a dead tile cannot be reopened at
// the same phase with the same set (see HandlerRestartCoordinator).
_roundState.Save(new HandlerRound(taskId, nextPhase, survivingTaskIds, DateTime.UtcNow));
return new LaunchSpec(repoDir, resolvedClaude, args, env);
}