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
92 lines
5.1 KiB
C#
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);
|
|
}
|
|
}
|