Files
ClaudeDo/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs
T
mika kuns 39b24c2f74 fix(worker): cap the list-handler wait/merge handoff chain server-side
HandoffMcpTools.HandoffListHandler only validated the phase name and
broadcast it -- nothing bounded how many times a ConPTY session could
hand off wait<->merge, so a model that skimmed past the prompt's
"final round" line could loop indefinitely. Add HandoffRoundTracker,
an in-memory per-handler-task counter (a list-handler run always
creates a fresh handler task, so no reset logic is needed): past 4
handoffs (two full wait/merge cycles) a non-final nextPhase is
coerced to its "_final" counterpart, and any handoff after a
merge_final round for that task is rejected outright.
2026-08-11 16:52:49 +02:00

55 lines
2.2 KiB
C#

using System.Collections.Concurrent;
using ClaudeDo.Worker.Runner;
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
// 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
// resets the count, which is acceptable since the chain restarts fresh anyway.
public sealed class HandoffRoundTracker
{
// Two full wait->merge cycles: wait, merge, wait, merge.
public const int MaxHandoffs = 4;
private sealed class TaskState
{
public int Count;
public bool Sealed;
}
private readonly ConcurrentDictionary<string, TaskState> _states = new();
public sealed record Result(bool Rejected, bool Coerced, string EffectivePhase, string? Error);
public Result Register(string taskId, string nextPhase)
{
var state = _states.GetOrAdd(taskId, _ => new TaskState());
lock (state)
{
if (state.Sealed)
return new Result(true, false, nextPhase,
$"Task {taskId} already completed its merge_final round -- no further handoff is allowed.");
state.Count++;
var isFinal = nextPhase is MergeHelperPhase.WaitFinal or MergeHelperPhase.MergeFinal;
var effective = nextPhase;
if (state.Count > MaxHandoffs && !isFinal)
{
effective = nextPhase == MergeHelperPhase.Wait ? MergeHelperPhase.WaitFinal : MergeHelperPhase.MergeFinal;
isFinal = true;
}
if (isFinal && effective == MergeHelperPhase.MergeFinal)
state.Sealed = true;
return new Result(false, !string.Equals(effective, nextPhase, StringComparison.Ordinal), effective, null);
}
}
}