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:
+22
-6
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user