Merge branch 'claudedo/0a185f8e823d42fc9a5b97c5eb91c616'
This commit is contained in:
+22
@@ -11,6 +11,8 @@ namespace ClaudeDo.Worker.External;
|
|||||||
// reflects the effective (post-coercion) value in that case.
|
// 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 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]
|
[McpServerToolType]
|
||||||
public sealed class HandoffMcpTools
|
public sealed class HandoffMcpTools
|
||||||
{
|
{
|
||||||
@@ -66,4 +68,24 @@ public sealed class HandoffMcpTools
|
|||||||
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
|
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
|
||||||
return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ public sealed class HandoffRoundTracker
|
|||||||
return (state.Count, state.Sealed);
|
return (state.Count, state.Sealed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
public Result Register(string taskId, string nextPhase)
|
||||||
{
|
{
|
||||||
var state = _states.GetOrAdd(taskId, _ => new TaskState());
|
var state = _states.GetOrAdd(taskId, _ => new TaskState());
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests
|
|||||||
"list_runs", "get_run", "get_task_log",
|
"list_runs", "get_run", "get_task_log",
|
||||||
"list_agents",
|
"list_agents",
|
||||||
"reset_failed_task", "submit_task_for_review",
|
"reset_failed_task", "submit_task_for_review",
|
||||||
"handoff_list_handler",
|
"handoff_list_handler", "get_handoff_status",
|
||||||
"get_app_settings",
|
"get_app_settings",
|
||||||
"wait_for_task_change",
|
"wait_for_task_change",
|
||||||
"get_queue_state",
|
"get_queue_state",
|
||||||
|
|||||||
@@ -277,4 +277,64 @@ public sealed class HandoffMcpToolsTests : IDisposable
|
|||||||
Assert.False(resultB.Coerced);
|
Assert.False(resultB.Coerced);
|
||||||
Assert.Equal("wait", resultB.NextPhase);
|
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<InvalidOperationException>(() =>
|
||||||
|
sut.GetHandoffStatus("missing", CancellationToken.None));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user