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
@@ -31,17 +31,18 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
private readonly List<TimeSpan> _waited = new();
private static readonly DateTimeOffset DueAt = ResetsAt + HandlerUsageGuard.ResetGrace;
private HandlerRestartCoordinator BuildCoordinator(UsageState usage)
{
var coordinator = new HandlerRestartCoordinator(
_db.CreateFactory(), usage, _rounds, new HubBroadcaster(_hub),
private HandlerRestartCoordinator BuildCoordinator(UsageState usage) =>
new(_db.CreateFactory(), usage, _rounds, new HubBroadcaster(_hub),
NullLogger<HandlerRestartCoordinator>.Instance);
coordinator.NowUtc = () => ResetsAt.AddHours(-3);
coordinator.Delay = (d, _) => { _waited.Add(d); return Task.CompletedTask; };
return coordinator;
}
/// The coordinator only parks the restart; this is what actually fires it. `now` decides whether
/// the parked handoff is already due, so a 5h wait costs a test nothing.
private HandlerUsageGuard BuildGuard(UsageState usage, DateTimeOffset now) =>
new(_db.CreateFactory(), usage, _rounds, new HandoffRoundTracker(), new HubBroadcaster(_hub),
NullLogger<HandlerUsageGuard>.Instance)
{ NowUtc = () => now };
/// The one signal a ConPTY death gives the worker: the 5h window pinned at the limit.
private static UsageState AtUsageLimit(double percent = 100) =>
@@ -91,16 +92,45 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a", "b" }, DateTime.UtcNow));
var coordinator = BuildCoordinator(AtUsageLimit());
Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1));
Assert.True(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
// Parked, not relaunched straight into the same exhausted window.
Assert.Empty(HandoffCalls);
Assert.Equal(DueAt, _rounds.Get(taskId)!.DeferredHandoffAt);
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
var call = Assert.Single(HandoffCalls);
Assert.Equal(taskId, call.Args[0]);
Assert.Equal(new[] { "a", "b" }, (IReadOnlyList<string>)call.Args[1]!);
Assert.Equal(MergeHelperPhase.Merge, call.Args[2]);
// Waited out the window instead of relaunching straight into the same limit.
Assert.Equal(new[] { TimeSpan.FromHours(3) }, _waited);
}
// The #272 review finding: the restart budget used to be spent when the wait STARTED, so a
// worker that died during the hours-long wait left the round marked "restart used" without one
// ever having happened -- and the session stayed dead forever.
[Fact]
public async Task ParkedRestart_SurvivesAWorkerRestart_AndOnlySpendsTheBudgetOnceItFires()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
Assert.True(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
// Worker restart: a fresh state object re-reads the same file, nothing else carries over.
var afterRestart = new HandlerRoundState(Path.Combine(_tempDir, "handler-rounds.json"));
Assert.False(afterRestart.Get(taskId)!.AutoRestartUsed);
Assert.Equal(DueAt, afterRestart.Get(taskId)!.DeferredHandoffAt);
// Still parked while the window is open...
await BuildGuard(AtUsageLimit(), DueAt.AddMinutes(-5)).TickAsync(default);
Assert.Empty(HandoffCalls);
// ...and sent as soon as it is due, which is also when the budget is spent.
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
Assert.Single(HandoffCalls);
Assert.True(_rounds.Get(taskId)!.AutoRestartUsed);
Assert.Null(_rounds.Get(taskId)!.DeferredHandoffAt);
}
// ── acceptance 2: at most one auto-restart per round ───────────────────────────────────────
@@ -112,9 +142,25 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
var coordinator = BuildCoordinator(AtUsageLimit());
Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1));
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
Assert.Single(HandoffCalls);
}
// A second death while the first restart is still parked must not overwrite or duplicate it.
[Fact]
public async Task SecondDeathWhileTheRestartIsStillParked_ChangesNothing()
{
var taskId = await SeedHandlerTaskAsync();
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
var coordinator = BuildCoordinator(AtUsageLimit());
Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1));
Assert.False(await coordinator.OnSessionExitedAsync(taskId, 1));
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
Assert.Single(HandoffCalls);
}
@@ -128,6 +174,7 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
var coordinator = BuildCoordinator(AtUsageLimit());
Assert.True(await coordinator.OnSessionExitedAsync(taskId, 1));
await BuildGuard(AtUsageLimit(), DueAt).TickAsync(default);
// The relaunched session's launch spec writes the round again, same phase.
_rounds.Save(new HandlerRound(taskId, MergeHelperPhase.Merge, new[] { "a" }, DateTime.UtcNow));
@@ -176,11 +223,14 @@ public sealed class HandlerRestartCoordinatorTests : IDisposable
tracker.Register(taskId, MergeHelperPhase.Merge, 4); // the run's real handoff into this phase
using var ctx = _db.CreateContext();
var usage = AtUsageLimit();
var tools = new HandoffMcpTools(
new TaskRepository(ctx), new HubBroadcaster(_hub), tracker, _db.CreateFactory());
new TaskRepository(ctx), new HubBroadcaster(_hub), tracker, _db.CreateFactory(),
BuildGuard(usage, ResetsAt));
var before = await tools.GetHandoffStatus(taskId);
Assert.True(await BuildCoordinator(AtUsageLimit()).OnSessionExitedAsync(taskId, 1));
Assert.True(await BuildCoordinator(usage).OnSessionExitedAsync(taskId, 1));
await BuildGuard(usage, DueAt).TickAsync(default);
var after = await tools.GetHandoffStatus(taskId);
Assert.Equal(before.Round, after.Round);