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.
55 lines
2.2 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|