feat(worker): Handler-Session vor dem Usage-Limit warnen und Handoff bis zum Reset zurueckhalten

Eine PowerUse-Handler-Session soll am 5h-Limit nicht mehr mitten im Merge
sterben, sondern vorher selbst sauber uebergeben:

- HandlerUsageGuard (neu) warnt die laufende Session genau einmal pro Runde,
  sobald FiveHour.Utilization den Schwellwert PowerUseHandoffPct erreicht.
  Die Warnung wird per HandlerUsageWarning-Broadcast an Mission Control
  geschickt und dort in das Pane der Handler-TaskId getippt.
- handoff_list_handler broadcastet den Handoff dann nicht sofort, sondern parkt
  ihn bis ResetsAt + 60 s; das Ergebnis traegt deferredUntil. Ohne ResetsAt
  wird wie bisher sofort gesendet.
- Der geparkte Handoff liegt im HandlerRoundState (handler-rounds.json) und
  wird vom Guard-Tick ausgeloest, also auch nach einem Worker-Neustart wieder
  eingeplant bzw. sofort gesendet, wenn er ueberfaellig ist.
- Review-Befund aus #272: HandlerRestartCoordinator wartet nicht mehr selbst
  bis ResetsAt, sondern parkt den Neustart im selben Slot. AutoRestartUsed wird
  erst gesetzt, wenn der Broadcast wirklich rausging - ein Worker-Absturz
  waehrend der Wartezeit laesst die Runde nicht mehr dauerhaft tot zurueck.
  Zurueckgehaltener Handoff und Auto-Neustart teilen sich einen Slot, also
  oeffnet nie beides dieselbe Runde. UsageLimitProofPct entfaellt zugunsten von
  PowerUseHandoffPct.
- PtyTerminalSession.SendLineAsync schreibt in die laufende Session. Gegen eine
  echte claude-Session verifiziert: Text und Enter muessen zwei getrennte
  Writes sein, die Enter-Bytes kommen von Terminal.GenerateKeyInput.
This commit is contained in:
mika kuns
2026-09-02 21:35:29 +02:00
parent c3a418f077
commit fe8186514f
19 changed files with 851 additions and 83 deletions
+22 -6
View File
@@ -11,7 +11,11 @@ namespace ClaudeDo.Worker.External;
// Coerced=true means nextPhase asked for a non-final phase but the server-side round cap
// (HandoffRoundTracker) forced it to its "_final" counterpart instead -- NextPhase already
// reflects the effective (post-coercion) value in that case.
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase, bool Coerced = false);
// DeferredUntil set means the next session was NOT opened yet: the 5h usage window is exhausted and
// the handoff is parked until it resets (HandlerUsageGuard).
public sealed record HandoffListHandlerResult(
bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase, bool Coerced = false,
DateTimeOffset? DeferredUntil = null);
public sealed record HandoffStatusResult(string TaskId, int Number, int Round, int Remaining, bool Final);
@@ -22,17 +26,20 @@ public sealed class HandoffMcpTools
private readonly HubBroadcaster _broadcaster;
private readonly HandoffRoundTracker _rounds;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HandlerUsageGuard _usageGuard;
public HandoffMcpTools(
TaskRepository tasks,
HubBroadcaster broadcaster,
HandoffRoundTracker rounds,
IDbContextFactory<ClaudeDoDbContext> dbFactory)
IDbContextFactory<ClaudeDoDbContext> dbFactory,
HandlerUsageGuard usageGuard)
{
_tasks = tasks;
_broadcaster = broadcaster;
_rounds = rounds;
_dbFactory = dbFactory;
_usageGuard = usageGuard;
}
[McpServerTool, Description(
@@ -47,8 +54,10 @@ public sealed class HandoffMcpTools
"4 by default, i.e. two " +
"full wait/merge cycles) for a given handler task: once reached, a \"wait\"/\"merge\" request is " +
"silently coerced to its \"_final\" counterpart instead (check the result's coerced field), and " +
"any handoff at all after a merge_final round for this task throws. The current tile stays " +
"open; you must end your own turn immediately after calling this.")]
"any handoff at all after a merge_final round for this task throws. If the 5h usage window is " +
"already exhausted the next session is NOT opened right away -- the handoff is parked until " +
"the window resets and the result carries deferredUntil; say so in your summary. The current " +
"tile stays open; you must end your own turn immediately after calling this either way.")]
public async Task<HandoffListHandlerResult> HandoffListHandler(
[Description("This session's own handler task id.")] string taskId,
[Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList<string> survivingTaskIds,
@@ -77,8 +86,15 @@ public sealed class HandoffMcpTools
if (round.Rejected)
throw new InvalidOperationException(round.Error);
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced);
// The round is registered either way (the budget is spent by asking, not by the tile
// opening); only the broadcast waits when the 5h window is exhausted.
var deferredUntil = await _usageGuard.TryDeferHandoffAsync(
taskId, survivingTaskIds, round.EffectivePhase, cancellationToken);
if (deferredUntil is null)
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
return new HandoffListHandlerResult(
true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced, deferredUntil);
}
[McpServerTool, Description(