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
@@ -3,8 +3,11 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Usage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
@@ -16,18 +19,35 @@ public sealed class HandoffMcpToolsTests : IDisposable
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly CapturingHubContext _hubContext = new();
private readonly string _tempDir;
public HandoffMcpToolsTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_handoffmcp_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
try { Directory.Delete(_tempDir, true); } catch { }
}
private HandoffMcpTools BuildSut(HandoffRoundTracker? tracker = null) =>
new(_tasks, new HubBroadcaster(_hubContext), tracker ?? new HandoffRoundTracker(), _db.CreateFactory());
// No usage snapshot -> HandlerUsageGuard never withholds, which is the behaviour every test
// below assumes. The withholding path has its own tests in HandlerUsageGuardTests.
private HandoffMcpTools BuildSut(HandoffRoundTracker? tracker = null)
{
var rounds = tracker ?? new HandoffRoundTracker();
var guard = new HandlerUsageGuard(
_db.CreateFactory(), new UsageState(),
new HandlerRoundState(Path.Combine(_tempDir, "handler-rounds.json")), rounds,
new HubBroadcaster(_hubContext), NullLogger<HandlerUsageGuard>.Instance);
return new HandoffMcpTools(_tasks, new HubBroadcaster(_hubContext), rounds, _db.CreateFactory(), guard);
}
/// The cap the SUT reads comes from app_settings, so a test that wants a different cap has to
/// write it there. GetAsync is AsNoTracking, hence the separate tracked query.