Files
ClaudeDo/src/ClaudeDo.Worker/External/HandoffMcpTools.cs
T
mika kuns 5be972564a feat(ui): mission-control pane title shows usage limit reset time
When a list-handler handoff is deferred waiting for the 5h usage limit to
reset, the Mission Control pane title is augmented with ⏳ waiting for
limit reset (HH:mm) so the wait is visible at a glance. Extends
HubBroadcaster.HandoffRequested with an optional deferredUntil DateTime
parameter, wired through SignalR to MissionControlViewModel, which formats
it into the pane title via Loc.T(missionControl.waitingForLimitReset).
2026-09-10 13:18:09 +02:00

127 lines
7.0 KiB
C#

using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
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.
// DeferredUntil set means the next session was NOT opened yet: the 5h usage window is exhausted and
// the handoff is parked until it resets (HandlerUsageGuard).
public sealed record HandoffListHandlerResult(
bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase, bool Coerced = false,
DateTimeOffset? DeferredUntil = null);
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;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HandlerUsageGuard _usageGuard;
public HandoffMcpTools(
TaskRepository tasks,
HubBroadcaster broadcaster,
HandoffRoundTracker rounds,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
HandlerUsageGuard usageGuard)
{
_tasks = tasks;
_broadcaster = broadcaster;
_rounds = rounds;
_dbFactory = dbFactory;
_usageGuard = usageGuard;
}
[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 a configurable number of handoffs (the ListHandlerMaxHandoffs setting, " +
"4 by default, 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. If the 5h usage window is " +
"already exhausted the next session is NOT opened right away -- the handoff is parked until " +
"the window resets and the result carries deferredUntil; say so in your summary. The current " +
"tile stays open; you must end your own turn immediately after calling this either way.")]
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.");
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(cancellationToken)).ListHandlerMaxHandoffs;
var round = _rounds.Register(taskId, nextPhase, maxHandoffs);
if (round.Rejected)
throw new InvalidOperationException(round.Error);
// The round is registered either way (the budget is spent by asking, not by the tile
// opening); only the broadcast waits when the 5h window is exhausted.
var deferredUntil = await _usageGuard.TryDeferHandoffAsync(
taskId, survivingTaskIds, round.EffectivePhase, cancellationToken);
if (deferredUntil is null)
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
else
await _broadcaster.HandlerHandoffDeferred(taskId, deferredUntil.Value);
return new HandoffListHandlerResult(
true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced, deferredUntil);
}
[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.");
// Same setting HandoffListHandler enforces against — read here too, so a raised cap shows up
// in the reported budget instead of the tool confidently understating it.
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(cancellationToken)).ListHandlerMaxHandoffs;
var status = _rounds.GetStatus(taskId, maxHandoffs);
return new HandoffStatusResult(taskId, task.Number, status.Round, status.Remaining, status.Final);
}
}