using System.ComponentModel; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; // 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); public sealed record HandoffStatusResult(string TaskId, int Number, int Round, int Remaining, bool Final); [McpServerToolType] public sealed class HandoffMcpTools { private readonly TaskRepository _tasks; private readonly HubBroadcaster _broadcaster; private readonly HandoffRoundTracker _rounds; public HandoffMcpTools(TaskRepository tasks, HubBroadcaster broadcaster, HandoffRoundTracker rounds) { _tasks = tasks; _broadcaster = broadcaster; _rounds = rounds; } [McpServerTool, Description( "Call at the end of a list-handler (\"Let Claude handle it\") phase to hand this run off to a " + "fresh ConPTY session that carries out the next phase, without dragging along this session's " + "own context. Reuses the SAME handler task -- no new task is created, and HandlerBaseCommit is " + "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. This handoff chain is " + "capped server-side at 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 HandoffListHandler( [Description("This session's own handler task id.")] string taskId, [Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList survivingTaskIds, [Description("Next session's phase: wait | merge | wait_final | merge_final.")] string nextPhase = "wait", CancellationToken cancellationToken = default) { MergeHelperPhase.Validate(nextPhase); if (survivingTaskIds.Count == 0) throw new InvalidOperationException("survivingTaskIds must contain at least one task id."); taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); survivingTaskIds = await TaskIdResolver.ResolveManyAsync(_tasks, survivingTaskIds, cancellationToken); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); foreach (var id in survivingTaskIds) _ = await _tasks.GetByIdAsync(id, cancellationToken) ?? throw new InvalidOperationException($"Task {id} not found."); 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); } [McpServerTool, Description( "Read-only lookup of a handler task's handoff-round budget (round already used, rounds " + "remaining before handoff_list_handler starts coercing to a _final phase, and whether the " + "chain is already sealed by a completed merge_final round) -- for checking the budget mid-" + "session or from outside a handoff, instead of only seeing it once at the start of a round. " + "A task that never called handoff_list_handler reports round 0, its full remaining budget, " + "and final=false. In-memory only, same as the tracker itself: a worker restart resets it." + McpToolDocs.TaskNumberHint)] public async Task GetHandoffStatus( [Description("The handler task's own id.")] string taskId, CancellationToken cancellationToken = default) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); var status = _rounds.GetStatus(taskId); return new HandoffStatusResult(taskId, task.Number, status.Round, status.Remaining, status.Final); } }