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
@@ -30,6 +30,11 @@ public interface IWorkerClient : INotifyPropertyChanged
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
/// <summary>The 5h usage window is running out under a running list-handler session:
/// (handlerTaskId, message). Mission Control types the message into that task's ConPTY pane so
/// the session can hand off cleanly; no pane for the task means nothing happens.</summary>
event Action<string, string>? HandlerUsageWarningEvent;
/// <summary>An agent added a note through the MCP tool — an open notes editor reloads.</summary>
event Action? NotesUpdatedEvent;
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Iciclecreek.Terminal;
@@ -93,6 +94,56 @@ public sealed class PtyTerminalSession : IDisposable
ProcessExited?.Invoke(this, e.ExitCode);
}
/// <summary>
/// Types <paramref name="text"/> into the running session and submits it, exactly as if the user
/// had typed it — used to warn a list-handler session before the usage limit
/// (<c>HandlerUsageWarning</c>). Verified against a real `claude` TUI, including while it was
/// mid-turn (the message is queued and acted on when the turn ends).
///
/// Three non-obvious details, all established by that spike:
/// • <see cref="TerminalControl"/> exposes no public write path at all — its only public paste is
/// <c>TerminalView.PasteAsync()</c>, which takes no argument and reads the clipboard. The
/// private <c>SendToPtyAsync</c> on the inner view is the write seam, hence the reflection.
/// • The text and the Enter must be TWO writes. A single <c>text + "\r"</c> chunk lands in
/// claude's composer but is never submitted — a chunk carrying an embedded CR reads as pasted
/// text, not as a keypress.
/// • The Enter bytes come from the emulator (<c>GenerateKeyInput</c>) rather than a literal
/// "\r", because claude turns Win32 input mode on and the emulator owns that encoding.
///
/// Best-effort by design: a package upgrade that renames the private member costs a warning that
/// never arrives, never a crashed Mission Control.
/// </summary>
public async Task<bool> SendLineAsync(string text)
{
if (_control is not { } control || !IsRunning || string.IsNullOrEmpty(text)) return false;
try
{
await SendRawAsync(control, text);
await Task.Delay(SubmitDelay);
await SendRawAsync(control, control.Terminal.GenerateKeyInput(XTerm.Input.Key.Enter, XTerm.Input.KeyModifiers.None));
return true;
}
catch (Exception)
{
return false;
}
}
// Long enough that the two writes can't be read as one chunk by the child's stdin reader.
private static readonly TimeSpan SubmitDelay = TimeSpan.FromMilliseconds(150);
private static readonly FieldInfo? s_terminalViewField =
typeof(TerminalControl).GetField("_terminalView", BindingFlags.Instance | BindingFlags.NonPublic);
private static readonly MethodInfo? s_sendToPty =
typeof(TerminalView).GetMethod("SendToPtyAsync", BindingFlags.Instance | BindingFlags.NonPublic);
private static Task SendRawAsync(TerminalControl control, string data)
{
if (s_terminalViewField?.GetValue(control) is not TerminalView view || s_sendToPty is null)
throw new InvalidOperationException("Iciclecreek.Avalonia.Terminal no longer exposes a PTY write seam.");
return (Task)s_sendToPty.Invoke(view, new object[] { data, CancellationToken.None })!;
}
public void Kill()
{
try { _control?.Kill(); }
+6
View File
@@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, string>? HandlerUsageWarningEvent;
public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? ListUpdatedEvent;
@@ -159,6 +160,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
});
_hub.On<string, string>("HandlerUsageWarning", (taskId, message) =>
{
Dispatcher.UIThread.Post(() => HandlerUsageWarningEvent?.Invoke(taskId, message));
});
_hub.On<string>("WorktreeUpdated", taskId =>
{
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
@@ -131,6 +131,11 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
StartError = message;
}
/// <summary>Types a line into the live session and submits it — see
/// <see cref="PtyTerminalSession.SendLineAsync"/>. False when there is no running process to
/// type into (or the write seam is gone).</summary>
public Task<bool> SendLineAsync(string text) => _session.SendLineAsync(text);
public void Kill() => _session.Kill();
public void Dispose()
@@ -123,6 +123,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
}
}
/// <summary>Types a message into this pane's live session and submits it (the worker's
/// pre-usage-limit warning). False when the session isn't running.</summary>
public System.Threading.Tasks.Task<bool> SendLineAsync(string text) => Terminal.SendLineAsync(text);
[RelayCommand]
private void Close() => CloseRequested?.Invoke(this);
@@ -21,6 +21,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private readonly Action<string> _onTaskUpdated;
private readonly Action _onConnectionRestored;
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
private readonly Action<string, string> _onHandlerUsageWarning;
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
// review/merge/status machinery.
@@ -98,6 +99,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
_worker.HandoffRequestedEvent += _onHandoffRequested;
_onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); };
_worker.HandlerUsageWarningEvent += _onHandlerUsageWarning;
_ = RefreshQueueAsync();
_ = RefreshSessionTargetsAsync();
}
@@ -406,6 +410,19 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
}
// The 5h usage window is about to run out under this handler session: type the worker's warning
// into its pane so the session can hand off cleanly instead of dying mid-merge. The UI process
// owns the terminal, so this is the only place that can reach it at all. No pane (the tile was
// closed, or this is a second app instance) = nothing to warn, and that is not an error.
internal async System.Threading.Tasks.Task WarnHandlerSessionAsync(string taskId, string message)
{
if (string.IsNullOrEmpty(taskId) || string.IsNullOrEmpty(message)) return;
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane) return;
if (!await pane.SendLineAsync(message))
ErrorReported?.Invoke(Loc.T("missionControl.usageWarningNotDelivered"));
}
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
// so the tile is on screen (spinner running) while the worker is still preparing the worktree.
private void AddConPtyPane(ConPtyPaneViewModel pane)
@@ -519,6 +536,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.TaskUpdatedEvent -= _onTaskUpdated;
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
_worker.HandoffRequestedEvent -= _onHandoffRequested;
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
Panes.CollectionChanged -= OnPanesChanged;
foreach (var c in ConPtySessions.ToList())