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).
127 lines
7.0 KiB
C#
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);
|
|
}
|
|
}
|