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 _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); } } }