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.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user