feat(worker): surface the handoff round budget in the merge-helper handoff brief

A handler session only learned the chain was capped when handoff_list_handler
threw at the end of its work, stranding open tasks. The round count already
lived in HandoffRoundTracker the whole time -- it just never reached the
brief. InteractiveLaunchSpecService now reads HandoffRoundTracker.GetState
(read-only, doesn't advance the round) and renders round N of MaxHandoffs
plus the remaining count into the handoff brief, replacing the old one-line
finalNote. The rejection error also names the next action (start a new
handler run) instead of just failing.
This commit is contained in:
Mika Kuns
2026-09-02 14:26:56 +02:00
parent 20685827fb
commit 81a5fc5d33
7 changed files with 110 additions and 18 deletions
+15 -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
@@ -26,6 +26,17 @@ 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);
}
public Result Register(string taskId, string nextPhase)
{
var state = _states.GetOrAdd(taskId, _ => new TaskState());
@@ -33,7 +44,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;