diff --git a/fleetd/src/test/java/dev/ltms/fleet/inject/InjectorTest.java b/fleetd/src/test/java/dev/ltms/fleet/inject/InjectorTest.java index e55e06e..4efaf91 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/inject/InjectorTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/inject/InjectorTest.java @@ -1310,4 +1310,53 @@ class InjectorTest { + "is the CB-116 cross-turn stale reply fleetd #553 fixed, and this redesign " + "must not reopen it"); } + + @Test + void aRuntimeExceptionFromOnTurnCompleteStillLeavesTheNextDeliveryRegisteredOnTheRecoveryPath() { + // fleetd #556 rework (PR #566, comment 17009): there are TWO registrar.register call sites + // in the delivery method — the ordinary path inside `if (sent != null)`, and the fleetd + // #553 `finally` backstop, reached only when an earlier block (here, onTurnComplete for the + // PREVIOUS turn) throws before the ordinary path ever runs. The existing #553 regression + // test for this exact scenario + // (aRuntimeExceptionFromOnTurnCompleteStillCompletesTheNextDelivery, above) asserts only + // that "second"'s DELIVERED FUTURE completes — never that "second" is REGISTERED with + // CompletionResolver — so a redesign that dropped registrar.register() from the backstop + // passed every existing test while reopening this ticket's own defect on the one path + // fleetd #553 exists for. This test closes that gap: on the recovery path, the backstop is + // the ONLY thing that registers "second", so its waiter must still be resolvable afterward. + RuntimeException boom = new RuntimeException("fleetd #556 rework: boom from onTurnComplete"); + TurnListener throwing = new TurnListener() { + @Override + public void onTurnComplete(String target) { + throw boom; + } + }; + Rendezvous rendezvous = new Rendezvous(); + CompletionResolver completion = new CompletionResolver(new AgentControl(herdr), rendezvous, + ExhaustedPatternLookup.none(), ExhaustionSink.none()); + Injector inj = new Injector(new AgentControl(herdr), throwing, _ -> true, _ -> { + }, completion::register); + + inj.enqueue(T, "first", TestTurnTokens.inert(T)); + CompletableFuture secondWaiter = rendezvous.open(T); + inj.enqueue(T, "second", new TurnToken(T, secondWaiter)); + + inj.onStatus(T, AgentStatus.IDLE); // delivers "first" (inert token — nothing to register) + inj.onStatus(T, AgentStatus.WORKING); // picked up + + RuntimeException thrown = assertThrows(RuntimeException.class, + // "first" completes (onTurnComplete throws) before "second"'s ordinary delivery + // path ever runs; only the finally backstop is left to register "second". + () -> inj.onStatus(T, AgentStatus.IDLE), + "the throwable from onTurnComplete must still escape onStatus"); + assertSame(boom, thrown); + + CompletionResolver.InFlight inFlight = completion.inFlight(T); + assertNotNull(inFlight, + "fleetd #556: \"second\" must be registered by the fleetd #553 finally backstop " + + "even though onTurnComplete threw for \"first\"'s completion before the " + + "ordinary registration path ever ran for \"second\""); + assertSame(secondWaiter, inFlight.waiter(), + "the registered entry must carry \"second\"'s own waiter, not some other value"); + } }