Merge branch 'main' into feat/poweruse-list-handler

Kollision mit #264 (Handoff-Budget im Brief sichtbar machen): beide Seiten haben
HandoffRoundTracker, HandoffMcpTools und InteractiveLaunchSpecService angefasst.

Der Konflikt war inhaltlich, nicht nur textuell. #264 liest den Cap an zwei
Stellen aus der Konstante (GetStatus und das roundInfo-Rendering), waehrend
dieser Branch ihn zum Parameter aus app_settings gemacht hat. Ein reiner
Text-Merge haette kompiliert und trotzdem gelogen: Register haette den
konfigurierten Cap durchgesetzt, waehrend der Brief dem Modell weiter "2 Runden
uebrig" gemeldet haette, obwohl es 39 hat.

Aufgeloest so, dass der Cap eine einzige Quelle hat:
- GetStatus(taskId, maxHandoffs) nimmt den Cap als Parameter, mit demselben
  Clamp wie Register. Die Rechnerei liegt damit an einer Stelle.
- GetHandoffStatus liest das Setting und gibt es weiter.
- Das roundInfo-Rendering benutzt GetStatus statt eigener Arithmetik.

Zwei Tests nageln genau diesen Drift fest, damit er beim naechsten Merge nicht
unbemerkt zurueckkommt: einer auf GetHandoffStatus, einer auf den Brief.

MaxHandoffs -> DefaultMaxHandoffs in #264s vier Test-Referenzen; die laufen alle
mit Default-Settings, die Semantik bleibt.
This commit is contained in:
Mika Kuns
2026-09-02 15:48:43 +02:00
41 changed files with 2323 additions and 225 deletions
+35 -3
View File
@@ -5,8 +5,8 @@ namespace ClaudeDo.Worker.External;
// Bounds the list-handler wait<->merge handoff chain server-side, so a model that ignores the
// cooperative "this is the FINAL round" line in handoff.md (InteractiveLaunchSpecService's
// finalNote) cannot loop wait->merge->wait->merge forever, each round spawning a fresh ConPTY
// session. Keyed by handler task id -- a "Let Claude handle it" run always creates a fresh
// roundInfo, read back via GetState) cannot loop wait->merge->wait->merge forever, each round
// spawning a fresh ConPTY session. Keyed by handler task id -- a "Let Claude handle it" run always creates a fresh
// handler task (InteractiveLaunchSpecService.CreateMergeHelperTaskAsync), so the counter is
// per-run by construction: no reset logic needed, and a later run on the same list starts at 0
// on its own. In-memory only, same tradeoff as TaskRunTokenRegistry -- a worker restart mid-run
@@ -28,6 +28,37 @@ public sealed class HandoffRoundTracker
public sealed record Result(bool Rejected, bool Coerced, string EffectivePhase, string? Error);
/// <summary>Read-only snapshot for a handler task -- does NOT register a round. Used to report
/// the current round/remaining budget (e.g. in the handoff brief) without mutating state that
/// only Register should advance. Count is 0 for a task that hasn't handed off yet.</summary>
public (int Count, bool Sealed) GetState(string taskId)
{
if (!_states.TryGetValue(taskId, out var state))
return (0, false);
lock (state)
return (state.Count, state.Sealed);
}
// Read-only budget lookup for a handler task that hasn't (yet) called HandoffListHandler --
// Round/Remaining/Final all report their untouched defaults instead of throwing, since "no
// handoff yet" is a normal state, not an error.
public sealed record Status(int Round, int Remaining, bool Final);
/// <param name="maxHandoffs">The live cap (AppSettingsEntity.ListHandlerMaxHandoffs), passed in
/// rather than read off a constant so the REPORTED budget can never drift from the cap Register
/// actually enforces. A brief telling the model "2 rounds left" while the chain really allows 40
/// is worse than no budget line at all. Clamped like Register's.</param>
public Status GetStatus(string taskId, int maxHandoffs = DefaultMaxHandoffs)
{
var cap = Math.Max(1, maxHandoffs);
if (!_states.TryGetValue(taskId, out var state))
return new Status(0, cap, false);
lock (state)
return new Status(state.Count, Math.Max(0, cap - state.Count), state.Sealed);
}
/// <param name="maxHandoffs">Cap for this chain, normally AppSettingsEntity.ListHandlerMaxHandoffs.
/// Values below 1 are treated as 1: the cap exists to bound the loop, so "no cap" is not an
/// option a caller gets to pick by passing 0.</param>
@@ -39,7 +70,8 @@ public sealed class HandoffRoundTracker
{
if (state.Sealed)
return new Result(true, false, nextPhase,
$"Task {taskId} already completed its merge_final round -- no further handoff is allowed.");
$"Task {taskId} already completed its merge_final round -- no further handoff is allowed. " +
"Start a new handler run (\"Let Claude handle it\") on this list instead.");
state.Count++;
var isFinal = nextPhase is MergeHelperPhase.WaitFinal or MergeHelperPhase.MergeFinal;