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:
mika kuns
2026-08-11 16:52:49 +02:00
parent 79b35801ae
commit 39b24c2f74
4 changed files with 156 additions and 6 deletions
@@ -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);
}
}