diff --git a/bridged/src/main/java/dev/ltms/bridged/inject/CompletionResolver.java b/bridged/src/main/java/dev/ltms/bridged/inject/CompletionResolver.java index a444704..3f8dc2f 100644 --- a/bridged/src/main/java/dev/ltms/bridged/inject/CompletionResolver.java +++ b/bridged/src/main/java/dev/ltms/bridged/inject/CompletionResolver.java @@ -89,7 +89,9 @@ public final class CompletionResolver implements TurnListener { reason = ""; } if (reason.isBlank()) { - reason = "worker turn ended in an unrecoverable state (herdr status stuck at unknown)"; + // No screen to scrape — either the worker is stuck (CB-109) or gone (CB-110). + reason = "worker did not reply; its turn ended in an unrecoverable state " + + "(worker unreachable or stuck)"; } if (rendezvous.resolveFailure(target, reason)) { log.debug("failed send to {} via turn-stall fallback", target); diff --git a/bridged/src/main/java/dev/ltms/bridged/inject/Injector.java b/bridged/src/main/java/dev/ltms/bridged/inject/Injector.java index ba91500..1ee8964 100644 --- a/bridged/src/main/java/dev/ltms/bridged/inject/Injector.java +++ b/bridged/src/main/java/dev/ltms/bridged/inject/Injector.java @@ -240,20 +240,30 @@ public final class Injector { } /** - * Forget a target whose worker is gone, failing every still-queued message so awaiting - * callers unblock instead of hanging forever. Futures are completed after the monitor is - * released. + * Forget a target whose worker is gone, failing every still-queued message so awaiting callers + * unblock instead of hanging forever. If a message had already been delivered but its + * turn was not yet resolved (CB-110 — the worker vanished mid-turn, e.g. its pane crashed), fire + * {@link TurnListener#onTurnFailed} for it: a delivered message is no longer in the queue, so + * failing queued waiters alone would leave that send's rendezvous hanging until the async + * timeout. Futures and listeners are completed after the monitor is released. */ public void drop(String target, Throwable cause) { Target t = targets.remove(target); if (t == null) return; List pending; + boolean hadDeliveredTurn; synchronized (t) { pending = new ArrayList<>(t.queue); t.queue.clear(); + hadDeliveredTurn = t.awaitingCompletion; + t.awaitingCompletion = false; + t.awaitingPickup = false; } for (Pending p : pending) { p.delivered().completeExceptionally(cause); } + if (hadDeliveredTurn) { + turnListener.onTurnFailed(target); + } } } diff --git a/bridged/src/test/java/dev/ltms/bridged/inject/InjectorTest.java b/bridged/src/test/java/dev/ltms/bridged/inject/InjectorTest.java index ff69286..6dad04d 100644 --- a/bridged/src/test/java/dev/ltms/bridged/inject/InjectorTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/inject/InjectorTest.java @@ -239,6 +239,20 @@ class InjectorTest { assertTrue(f.isCompletedExceptionally(), "queued waiters unblock when the worker vanishes"); } + @Test + void dropFailsTheTurnOfADeliveredMessageWhenTheWorkerVanishes() { + // CB-110: the message was delivered (no longer queued), so failing queued waiters alone would + // leave its send hanging. A vanished worker must fail that in-flight turn too. + Captor cap = new Captor(); + Injector inj = new Injector(new AgentControl(herdr), cap); + inj.enqueue(T, "task"); + inj.onStatus(T, AgentStatus.IDLE); // deliver + inj.onStatus(T, AgentStatus.WORKING); // turn running + + inj.drop(T, new HerdrException("worker gone", "pane_not_found", null)); + assertEquals(List.of(T), cap.failed, "a worker that vanishes mid-turn fails its in-flight send"); + } + @Test void pollerDeliversToAnIdleWorker() throws Exception { // End-to-end through the poller: idle worker → message delivered without manual onStatus. diff --git a/bridged/src/test/java/dev/ltms/bridged/msg/MessageServiceTest.java b/bridged/src/test/java/dev/ltms/bridged/msg/MessageServiceTest.java index 7382c8d..320f7b2 100644 --- a/bridged/src/test/java/dev/ltms/bridged/msg/MessageServiceTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/msg/MessageServiceTest.java @@ -3,6 +3,7 @@ package dev.ltms.bridged.msg; import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.AgentStatus; import dev.ltms.bridged.herdr.FakeHerdr; +import dev.ltms.bridged.herdr.HerdrException; import dev.ltms.bridged.inject.CompletionResolver; import dev.ltms.bridged.inject.Injector; import org.junit.jupiter.api.Test; @@ -88,4 +89,20 @@ class MessageServiceTest { assertFalse(reply.completed(), "a wedge is terminal but not a successful completion"); assertTrue(reply.text().contains("ENOTFOUND"), "the error screen is carried as the failure reason"); } + + @Test + void aWorkerThatVanishesMidTurnResolvesTheSendAsFailed() throws Exception { + CompletableFuture send = sendAsync(); + awaitWaiting(); + + injector.onStatus(T, AgentStatus.IDLE); // deliver + injector.onStatus(T, AgentStatus.WORKING); // worker starts the turn + // The worker's pane crashes — the poller sees a *_not_found and drops it (CB-110). + injector.drop(T, new HerdrException("worker gone", "pane_not_found", null)); + + MessageService.Reply reply = send.get(5, TimeUnit.SECONDS); + assertEquals(MessageService.Outcome.WORKER_FAILED, reply.outcome(), + "a delivered send whose worker vanishes fails instead of hanging to the timeout"); + assertFalse(reply.completed()); + } }