diff --git a/bridged/src/main/java/dev/ltms/bridged/health/FleetHealth.java b/bridged/src/main/java/dev/ltms/bridged/health/FleetHealth.java index 875d346..5c8ca23 100644 --- a/bridged/src/main/java/dev/ltms/bridged/health/FleetHealth.java +++ b/bridged/src/main/java/dev/ltms/bridged/health/FleetHealth.java @@ -3,7 +3,11 @@ package dev.ltms.bridged.health; import dev.ltms.bridged.herdr.AgentStatus; import dev.ltms.bridged.session.MemberSession; -/** Pure classifier. Collection and repair are deliberately outside this package. */ +/** + * Pure classifier. Collection and repair are deliberately outside this package. + * {@link HealthState#ERROR_ON_SCREEN} is not decided yet because it needs a bounded pane detection + * read and an adapter-specific fatal signature; status facts alone must not guess it. + */ public final class FleetHealth { private FleetHealth() { } @@ -24,8 +28,9 @@ public final class FleetHealth { if (s.acceptedDelivery() && s.liveStatus() == AgentStatus.BLOCKED) { return result(HealthState.BLOCKED_AMBIGUOUS, disagreement); } - if (s.sessionState() == MemberSession.State.BUSY && s.liveStatus() == AgentStatus.WORKING - && s.acceptedDelivery()) return result(HealthState.WORKING, false); + // An accepted delivery remains bridge work even when herdr is late, unknown, or has already + // reported DONE once. It cannot be IDLE until the delegation has resolved. + if (s.acceptedDelivery()) return result(HealthState.WORKING, disagreement); return result(HealthState.IDLE, disagreement); } diff --git a/bridged/src/main/java/dev/ltms/bridged/health/MuteCounter.java b/bridged/src/main/java/dev/ltms/bridged/health/MuteCounter.java new file mode 100644 index 0000000..72f2c76 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/health/MuteCounter.java @@ -0,0 +1,25 @@ +package dev.ltms.bridged.health; + +import dev.ltms.bridged.msg.Rendezvous; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Counts turns that ended via the completion fallback instead of {@code bridge_reply}. + * MUTE is an observation by target and profile, not a classifier state and never suppresses faults. + */ +public final class MuteCounter { + private final Map byTarget = new ConcurrentHashMap<>(); + private final Map byProfile = new ConcurrentHashMap<>(); + + /** Record only fallback completion; a structured reply does not make a member mute. */ + public void observe(String target, String profile, Rendezvous.Kind kind) { + if (kind != Rendezvous.Kind.COMPLETION) return; + byTarget.merge(target, 1, Integer::sum); + byProfile.merge(profile, 1, Integer::sum); + } + + public int forTarget(String target) { return byTarget.getOrDefault(target, 0); } + public int forProfile(String profile) { return byProfile.getOrDefault(profile, 0); } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/health/FleetHealthTest.java b/bridged/src/test/java/dev/ltms/bridged/health/FleetHealthTest.java index 22c7d29..6cc2e55 100644 --- a/bridged/src/test/java/dev/ltms/bridged/health/FleetHealthTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/health/FleetHealthTest.java @@ -2,18 +2,42 @@ package dev.ltms.bridged.health; import dev.ltms.bridged.herdr.AgentStatus; import dev.ltms.bridged.session.MemberSession; +import dev.ltms.bridged.msg.Rendezvous; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class FleetHealthTest { + @Test void muteCountsOnlyCompletionFallbacks() { + MuteCounter mute = new MuteCounter(); + mute.observe("target", "terra", Rendezvous.Kind.REPLY); + mute.observe("target", "terra", Rendezvous.Kind.COMPLETION); + assertEquals(1, mute.forTarget("target")); + assertEquals(1, mute.forProfile("terra")); + } @Test void turnBoundaryNeedsTwoSnapshots() { HealthSnapshot s = snapshot(MemberSession.State.BUSY, AgentStatus.DONE, true); HealthDecision first = FleetHealth.decide(s, HealthPrior.NONE, 1); - assertEquals(HealthState.IDLE, first.state()); + assertEquals(HealthState.WORKING, first.state()); + assertEquals(new HealthPrior(true), first.prior()); assertEquals(HealthState.TURN_BOUNDARY_LOST, FleetHealth.decide(s, first.prior(), 2).state()); } + @Test void unknownLiveStatusWithAcceptedDeliveryIsNotIdle() { + assertEquals(HealthState.WORKING, FleetHealth.decide( + snapshot(MemberSession.State.BUSY, AgentStatus.UNKNOWN, true), HealthPrior.NONE, 1).state()); + } + + @Test void acceptedDeliveryNeverReportsIdle() { + for (MemberSession.State session : MemberSession.State.values()) { + for (AgentStatus live : AgentStatus.values()) { + HealthSnapshot s = snapshot(session, live, true); + assertEquals(false, FleetHealth.decide(s, HealthPrior.NONE, 1).state() == HealthState.IDLE, + () -> "accepted delivery returned IDLE for " + session + "/" + live); + } + } + } + @Test void blockedDoesNotGuessPromptKind() { assertEquals(HealthState.BLOCKED_AMBIGUOUS, FleetHealth.decide( snapshot(MemberSession.State.BUSY, AgentStatus.BLOCKED, true), HealthPrior.NONE, 1).state());