From 2330b426c675ee2ec570ac4f0dbe2f089e1938eb Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 14:43:44 +0200 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat(claude-do):=20Expose=20HandoffRou?= =?UTF-8?q?ndTracker=20budget=20via=20an=20MCP=20tool=20(e.g.=20get=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../External/HandoffMcpTools.cs | 22 +++++++ .../External/HandoffRoundTracker.cs | 14 +++++ .../External/ExternalMcpToolSchemaTests.cs | 2 +- .../External/HandoffMcpToolsTests.cs | 60 +++++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs index 434a7fe7..aaf9ad3a 100644 --- a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs +++ b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs @@ -11,6 +11,8 @@ namespace ClaudeDo.Worker.External; // 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 { @@ -66,4 +68,24 @@ public sealed class HandoffMcpTools 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); + } } diff --git a/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs b/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs index 56516242..4d6b6721 100644 --- a/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs +++ b/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs @@ -26,6 +26,20 @@ public sealed class HandoffRoundTracker public sealed record Result(bool Rejected, bool Coerced, string EffectivePhase, string? Error); + // Read-only budget lookup for a handler task that hasn't (yet) called HandoffListHandler -- + // Round/Remaining/Final all report their untouched defaults instead of throwing, since "no + // handoff yet" is a normal state, not an error. + public sealed record Status(int Round, int Remaining, bool Final); + + public Status GetStatus(string taskId) + { + if (!_states.TryGetValue(taskId, out var state)) + return new Status(0, MaxHandoffs, false); + + lock (state) + return new Status(state.Count, Math.Max(0, MaxHandoffs - state.Count), state.Sealed); + } + public Result Register(string taskId, string nextPhase) { var state = _states.GetOrAdd(taskId, _ => new TaskState()); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs index 9f6ba4ee..c35ac144 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs @@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests "list_runs", "get_run", "get_task_log", "list_agents", "reset_failed_task", "submit_task_for_review", - "handoff_list_handler", + "handoff_list_handler", "get_handoff_status", "get_app_settings", "wait_for_task_change", "get_queue_state", diff --git a/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs index f7f6450d..c7847300 100644 --- a/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs @@ -220,4 +220,64 @@ public sealed class HandoffMcpToolsTests : IDisposable Assert.False(resultB.Coerced); Assert.Equal("wait", resultB.NextPhase); } + + [Fact] + public async Task GetHandoffStatus_NeverHandedOff_ReportsZeroRoundFullBudget() + { + var listId = await SeedListAsync(); + var handlerTask = await SeedTaskAsync(listId); + + var sut = BuildSut(); + var status = await sut.GetHandoffStatus(handlerTask.Id, CancellationToken.None); + + Assert.Equal(handlerTask.Id, status.TaskId); + Assert.Equal(handlerTask.Number, status.Number); + Assert.Equal(0, status.Round); + Assert.Equal(HandoffRoundTracker.MaxHandoffs, status.Remaining); + Assert.False(status.Final); + } + + [Fact] + public async Task GetHandoffStatus_AfterHandoffs_ReflectsTrackerState() + { + var listId = await SeedListAsync(); + var handlerTask = await SeedTaskAsync(listId); + var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); + var tracker = new HandoffRoundTracker(); + var survivors = new[] { survivor.Id }; + + await BuildSut(tracker).HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None); + await BuildSut(tracker).HandoffListHandler(handlerTask.Id, survivors, "merge", CancellationToken.None); + + var status = await BuildSut(tracker).GetHandoffStatus(handlerTask.Id, CancellationToken.None); + + Assert.Equal(2, status.Round); + Assert.Equal(HandoffRoundTracker.MaxHandoffs - 2, status.Remaining); + Assert.False(status.Final); + } + + [Fact] + public async Task GetHandoffStatus_AfterMergeFinal_ReportsFinal() + { + var listId = await SeedListAsync(); + var handlerTask = await SeedTaskAsync(listId); + var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); + var tracker = new HandoffRoundTracker(); + var survivors = new[] { survivor.Id }; + + await BuildSut(tracker).HandoffListHandler(handlerTask.Id, survivors, "merge_final", CancellationToken.None); + + var status = await BuildSut(tracker).GetHandoffStatus(handlerTask.Id, CancellationToken.None); + + Assert.True(status.Final); + Assert.Equal(1, status.Round); + } + + [Fact] + public async Task GetHandoffStatus_UnknownTask_Throws() + { + var sut = BuildSut(); + await Assert.ThrowsAsync(() => + sut.GetHandoffStatus("missing", CancellationToken.None)); + } }