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:
+18
-5
@@ -6,18 +6,23 @@ using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase);
|
||||
// Coerced=true means nextPhase asked for a non-final phase but the server-side round cap
|
||||
// (HandoffRoundTracker) forced it to its "_final" counterpart instead -- NextPhase already
|
||||
// reflects the effective (post-coercion) value in that case.
|
||||
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase, bool Coerced = false);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class HandoffMcpTools
|
||||
{
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly HandoffRoundTracker _rounds;
|
||||
|
||||
public HandoffMcpTools(TaskRepository tasks, HubBroadcaster broadcaster)
|
||||
public HandoffMcpTools(TaskRepository tasks, HubBroadcaster broadcaster, HandoffRoundTracker rounds)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_broadcaster = broadcaster;
|
||||
_rounds = rounds;
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -27,7 +32,11 @@ public sealed class HandoffMcpTools
|
||||
"untouched. nextPhase picks the next session's role: \"wait\" (queue + wait; the normal handoff " +
|
||||
"after triage) or \"merge\" (review + merge; the normal handoff after wait) -- \"wait_final\"/" +
|
||||
"\"merge_final\" are only for a merge session that started reruns and must eventually stop the " +
|
||||
"chain instead of restarting again. An unrecognized value is rejected. The current tile stays " +
|
||||
"chain instead of restarting again. An unrecognized value is rejected. This handoff chain is " +
|
||||
"capped server-side at " + nameof(HandoffRoundTracker.MaxHandoffs) + " (four handoffs, i.e. two " +
|
||||
"full wait/merge cycles) for a given handler task: once reached, a \"wait\"/\"merge\" request is " +
|
||||
"silently coerced to its \"_final\" counterpart instead (check the result's coerced field), and " +
|
||||
"any handoff at all after a merge_final round for this task throws. The current tile stays " +
|
||||
"open; you must end your own turn immediately after calling this.")]
|
||||
public async Task<HandoffListHandlerResult> HandoffListHandler(
|
||||
[Description("This session's own handler task id.")] string taskId,
|
||||
@@ -50,7 +59,11 @@ public sealed class HandoffMcpTools
|
||||
_ = await _tasks.GetByIdAsync(id, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {id} not found.");
|
||||
|
||||
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, nextPhase);
|
||||
return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, nextPhase);
|
||||
var round = _rounds.Register(taskId, nextPhase);
|
||||
if (round.Rejected)
|
||||
throw new InvalidOperationException(round.Error);
|
||||
|
||||
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
|
||||
return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user