Files
ClaudeDo/src/ClaudeDo.Worker/External/HandoffMcpTools.cs
T
Mika Kuns 2330b426c6 feat(claude-do): Expose HandoffRoundTracker budget via an MCP tool (e.g. get_
The handoff-budget task (round/remaining/final now surfaced in the handoff.md brief via HandoffRoundTracker.GetStatus, wired through InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync) only reaches the session at the start of a handoff round. There is no way to query the current round/remaining budget for a handler task mid-session or from outside (e.g. the UI, or a script). Consider add

ClaudeDo-Task: 0a185f8e-823d-42fc-9a5b-97c5eb91c616
2026-09-02 14:43:44 +02:00

92 lines
5.1 KiB
C#

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<HandoffListHandlerResult> 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<string> 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<HandoffStatusResult> 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);
}
}