fix(worker): cap the list-handler wait/merge handoff chain server-side
HandoffMcpTools.HandoffListHandler only validated the phase name and broadcast it -- nothing bounded how many times a ConPTY session could hand off wait<->merge, so a model that skimmed past the prompt's "final round" line could loop indefinitely. Add HandoffRoundTracker, an in-memory per-handler-task counter (a list-handler run always creates a fresh handler task, so no reset logic is needed): past 4 handoffs (two full wait/merge cycles) a non-final nextPhase is coerced to its "_final" counterpart, and any handoff after a merge_final round for that task is rejected outright.
This commit is contained in:
@@ -25,7 +25,8 @@ public sealed class HandoffMcpToolsTests : IDisposable
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private HandoffMcpTools BuildSut() => new(_tasks, new HubBroadcaster(_hubContext));
|
||||
private HandoffMcpTools BuildSut(HandoffRoundTracker? tracker = null) =>
|
||||
new(_tasks, new HubBroadcaster(_hubContext), tracker ?? new HandoffRoundTracker());
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle, string title = "t")
|
||||
{
|
||||
@@ -138,4 +139,85 @@ public sealed class HandoffMcpToolsTests : IDisposable
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.HandoffListHandler(handlerTask.Id, new[] { "missing" }, cancellationToken: CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffListHandler_NormalWaitMergeWaitFinalMergeFinalSequence_PassesUntouched()
|
||||
{
|
||||
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 };
|
||||
|
||||
foreach (var phase in new[] { "wait", "merge", "wait_final", "merge_final" })
|
||||
{
|
||||
var sut = BuildSut(tracker);
|
||||
var result = await sut.HandoffListHandler(handlerTask.Id, survivors, phase, CancellationToken.None);
|
||||
Assert.False(result.Coerced);
|
||||
Assert.Equal(phase, result.NextPhase);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffListHandler_PastCap_CoercesNonFinalPhaseToItsFinalCounterpart()
|
||||
{
|
||||
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 };
|
||||
|
||||
foreach (var phase in new[] { "wait", "merge", "wait", "merge" })
|
||||
{
|
||||
var sut = BuildSut(tracker);
|
||||
var result = await sut.HandoffListHandler(handlerTask.Id, survivors, phase, CancellationToken.None);
|
||||
Assert.False(result.Coerced);
|
||||
}
|
||||
|
||||
var fifthSut = BuildSut(tracker);
|
||||
var fifth = await fifthSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None);
|
||||
|
||||
Assert.True(fifth.Coerced);
|
||||
Assert.Equal("wait_final", fifth.NextPhase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffListHandler_AfterMergeFinal_AnyFurtherHandoffThrows()
|
||||
{
|
||||
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 };
|
||||
|
||||
var firstSut = BuildSut(tracker);
|
||||
await firstSut.HandoffListHandler(handlerTask.Id, survivors, "merge_final", CancellationToken.None);
|
||||
|
||||
var secondSut = BuildSut(tracker);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
secondSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffListHandler_TwoHandlerTasks_DoNotShareACounter()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var handlerA = await SeedTaskAsync(listId, title: "Handler A");
|
||||
var handlerB = await SeedTaskAsync(listId, title: "Handler B");
|
||||
var survivorA = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor A");
|
||||
var survivorB = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor B");
|
||||
var tracker = new HandoffRoundTracker();
|
||||
|
||||
foreach (var phase in new[] { "wait", "merge", "wait", "merge" })
|
||||
{
|
||||
var sut = BuildSut(tracker);
|
||||
await sut.HandoffListHandler(handlerA.Id, new[] { survivorA.Id }, phase, CancellationToken.None);
|
||||
}
|
||||
|
||||
var sutB = BuildSut(tracker);
|
||||
var resultB = await sutB.HandoffListHandler(handlerB.Id, new[] { survivorB.Id }, "wait", CancellationToken.None);
|
||||
|
||||
Assert.False(resultB.Coerced);
|
||||
Assert.Equal("wait", resultB.NextPhase);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user