From ed4bbc1c561230b8c9023a941743b1f031e7e3b9 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 05:37:30 +0200 Subject: [PATCH] CB-573: add pure health classification model --- .../dev/ltms/bridged/health/FleetHealth.java | 35 +++++++++++++++++++ .../ltms/bridged/health/HealthDecision.java | 4 +++ .../dev/ltms/bridged/health/HealthPrior.java | 6 ++++ .../ltms/bridged/health/HealthSnapshot.java | 11 ++++++ .../dev/ltms/bridged/health/HealthState.java | 8 +++++ .../dev/ltms/bridged/health/PaneBudget.java | 26 ++++++++++++++ .../ltms/bridged/health/FleetHealthTest.java | 32 +++++++++++++++++ .../ltms/bridged/health/PaneBudgetTest.java | 14 ++++++++ 8 files changed, 136 insertions(+) create mode 100644 bridged/src/main/java/dev/ltms/bridged/health/FleetHealth.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/health/HealthDecision.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/health/HealthPrior.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/health/HealthSnapshot.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/health/HealthState.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/health/PaneBudget.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/health/FleetHealthTest.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/health/PaneBudgetTest.java diff --git a/bridged/src/main/java/dev/ltms/bridged/health/FleetHealth.java b/bridged/src/main/java/dev/ltms/bridged/health/FleetHealth.java new file mode 100644 index 0000000..875d346 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/health/FleetHealth.java @@ -0,0 +1,35 @@ +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. */ +public final class FleetHealth { + private FleetHealth() { } + + public static HealthDecision decide(HealthSnapshot s, HealthPrior prior, long nowNanos) { + if (s.controlLinkDown()) return result(HealthState.CONTROL_LINK_DOWN, false); + if (s.targetNotFound()) return result(HealthState.GONE, false); + if (s.sessionState() == MemberSession.State.SPAWNING && !s.present() && s.readinessGraceElapsed()) { + return result(HealthState.NEVER_READY, false); + } + if (s.orphanedDelegation()) return result(HealthState.DELEGATION_ORPHANED, false); + boolean disagreement = s.sessionState() == MemberSession.State.BUSY && s.acceptedDelivery() + && (s.liveStatus() == AgentStatus.IDLE || s.liveStatus() == AgentStatus.DONE); + if (disagreement && prior.busyButDone()) return result(HealthState.TURN_BOUNDARY_LOST, true); + if (s.stalled()) return result(HealthState.STALL_SUSPECTED, disagreement); + if (s.replyStranded()) return result(HealthState.REPLY_STRANDED, disagreement); + if (s.queuedDelivery() || s.inboxMessage()) return result(HealthState.WORK_PENDING, disagreement); + if (s.sessionState() == MemberSession.State.SPAWNING) return result(HealthState.STARTING, disagreement); + 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); + return result(HealthState.IDLE, disagreement); + } + + private static HealthDecision result(HealthState state, boolean disagreement) { + return new HealthDecision(state, new HealthPrior(disagreement)); + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/health/HealthDecision.java b/bridged/src/main/java/dev/ltms/bridged/health/HealthDecision.java new file mode 100644 index 0000000..fc10ed0 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/health/HealthDecision.java @@ -0,0 +1,4 @@ +package dev.ltms.bridged.health; + +/** Classification plus the private fact that the next pure decision needs. */ +public record HealthDecision(HealthState state, HealthPrior prior) { } diff --git a/bridged/src/main/java/dev/ltms/bridged/health/HealthPrior.java b/bridged/src/main/java/dev/ltms/bridged/health/HealthPrior.java new file mode 100644 index 0000000..6c0816e --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/health/HealthPrior.java @@ -0,0 +1,6 @@ +package dev.ltms.bridged.health; + +/** Private cross-tick observation. It is deliberately not a reported health value. */ +public record HealthPrior(boolean busyButDone) { + public static final HealthPrior NONE = new HealthPrior(false); +} diff --git a/bridged/src/main/java/dev/ltms/bridged/health/HealthSnapshot.java b/bridged/src/main/java/dev/ltms/bridged/health/HealthSnapshot.java new file mode 100644 index 0000000..0089c28 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/health/HealthSnapshot.java @@ -0,0 +1,11 @@ +package dev.ltms.bridged.health; + +import dev.ltms.bridged.herdr.AgentStatus; +import dev.ltms.bridged.session.MemberSession; + +/** Read-only facts from one fleet collection tick. */ +public record HealthSnapshot(MemberSession.State sessionState, AgentStatus liveStatus, + boolean acceptedDelivery, boolean queuedDelivery, boolean inboxMessage, + boolean present, boolean targetNotFound, boolean controlLinkDown, + boolean readinessGraceElapsed, boolean orphanedDelegation, + boolean replyStranded, boolean stalled) { } diff --git a/bridged/src/main/java/dev/ltms/bridged/health/HealthState.java b/bridged/src/main/java/dev/ltms/bridged/health/HealthState.java new file mode 100644 index 0000000..5a9c0bd --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/health/HealthState.java @@ -0,0 +1,8 @@ +package dev.ltms.bridged.health; + +/** Health classifications reported for a member. */ +public enum HealthState { + STARTING, IDLE, WORKING, WORK_PENDING, BLOCKED_AMBIGUOUS, + NEVER_READY, GONE, TURN_BOUNDARY_LOST, ERROR_ON_SCREEN, STALL_SUSPECTED, + MUTE, REPLY_STRANDED, DELEGATION_ORPHANED, CONTROL_LINK_DOWN +} diff --git a/bridged/src/main/java/dev/ltms/bridged/health/PaneBudget.java b/bridged/src/main/java/dev/ltms/bridged/health/PaneBudget.java new file mode 100644 index 0000000..315c76a --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/health/PaneBudget.java @@ -0,0 +1,26 @@ +package dev.ltms.bridged.health; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Fixed pane-probe limits. Pane content is never retained here. */ +public final class PaneBudget { + public static final long COOLDOWN_NANOS = 60_000_000_000L; + public static final int MAX_PER_TICK = 2; + private final Map lastProbe = new HashMap<>(); + private int cursor; + + public List choose(List candidates, long nowNanos, long configuredCooldownNanos) { + long cooldown = Math.max(COOLDOWN_NANOS, configuredCooldownNanos); + List out = new ArrayList<>(); + for (int n = 0; n < candidates.size() && out.size() < MAX_PER_TICK; n++) { + String target = candidates.get((cursor + n) % candidates.size()); + Long last = lastProbe.get(target); + if (last == null || nowNanos - last >= cooldown) { out.add(target); lastProbe.put(target, nowNanos); } + } + if (!candidates.isEmpty()) cursor = (cursor + 1) % candidates.size(); + return List.copyOf(out); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/health/FleetHealthTest.java b/bridged/src/test/java/dev/ltms/bridged/health/FleetHealthTest.java new file mode 100644 index 0000000..22c7d29 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/health/FleetHealthTest.java @@ -0,0 +1,32 @@ +package dev.ltms.bridged.health; + +import dev.ltms.bridged.herdr.AgentStatus; +import dev.ltms.bridged.session.MemberSession; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FleetHealthTest { + @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.TURN_BOUNDARY_LOST, FleetHealth.decide(s, first.prior(), 2).state()); + } + + @Test void blockedDoesNotGuessPromptKind() { + assertEquals(HealthState.BLOCKED_AMBIGUOUS, FleetHealth.decide( + snapshot(MemberSession.State.BUSY, AgentStatus.BLOCKED, true), HealthPrior.NONE, 1).state()); + } + + @Test void controlLinkOutranksMemberFault() { + HealthSnapshot s = new HealthSnapshot(MemberSession.State.BUSY, AgentStatus.DONE, true, false, + false, true, true, true, false, true, true, true); + assertEquals(HealthState.CONTROL_LINK_DOWN, FleetHealth.decide(s, HealthPrior.NONE, 1).state()); + } + + private static HealthSnapshot snapshot(MemberSession.State state, AgentStatus live, boolean accepted) { + return new HealthSnapshot(state, live, accepted, false, false, true, false, false, + false, false, false, false); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/health/PaneBudgetTest.java b/bridged/src/test/java/dev/ltms/bridged/health/PaneBudgetTest.java new file mode 100644 index 0000000..162ec20 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/health/PaneBudgetTest.java @@ -0,0 +1,14 @@ +package dev.ltms.bridged.health; + +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PaneBudgetTest { + @Test void fixedLimitsIgnoreWeakerConfig() { + PaneBudget budget = new PaneBudget(); + List targets = List.of("a", "b", "c"); + assertEquals(List.of("a", "b"), budget.choose(targets, 0, 0)); + assertEquals(List.of("c"), budget.choose(targets, 1, 0)); + } +}