diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index cc9191d..120c7b0 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -665,16 +665,8 @@ public final class Fleetd { // built a second time — two independently-constructed sources reading the SAME BackendQuarantine // / BackendOutagePolicy would still be able to drift (e.g. a future edit to the credentialIdFor // closure in only one of the two places), exactly the shape #284 was. - FleetMcp.QuarantineSource quarantineSource = new FleetMcp.QuarantineSource(profile -> { - var configured = config.get().profiles().get(profile); - return configured == null ? null : configured.effectiveCredentialId(); - }, quarantine, profile -> { - // fleetd #395: read live off the current config, like credentialIdFor above — an - // exhaustedPattern edit takes effect on the next fleet_profiles/fleet_list call, no - // restart needed, same as the credential-id lookup it sits beside. - var configured = config.get().profiles().get(profile); - return configured != null && configured.hasExhaustedPattern(); - }); + FleetMcp.QuarantineSource quarantineSource = quarantineSource(config, quarantine, + exhaustedPatternsByProfile); FleetMcp.OutageSource outageSource = new FleetMcp.OutageSource(profile -> { var configured = config.get().profiles().get(profile); return configured == null ? null : configured.effectiveCredentialId(); @@ -808,6 +800,19 @@ public final class Fleetd { return target -> presence.isPresent(target) || leads.get().containsKey(target); } + /** + * fleetd #404: production source for quarantine reporting. Credential IDs are hot, but + * exhausted patterns are compiled once at startup for {@link CompletionResolver}, so the armed + * field must use that same compiled map until restart. + */ + static FleetMcp.QuarantineSource quarantineSource(ConfigRef config, BackendQuarantine quarantine, + Map startupExhaustedPatterns) { + return new FleetMcp.QuarantineSource(profile -> { + var configured = config.get().profiles().get(profile); + return configured == null ? null : configured.effectiveCredentialId(); + }, quarantine, profile -> startupExhaustedPatterns.containsKey(profile)); + } + /** * fleetd #248: package-private factory for the member worktree/branch lookup {@link * CompletionResolver} uses to name a fallback report's worktree and branch (fleetd#241). diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdExhaustionDetectionArmedWiringTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdExhaustionDetectionArmedWiringTest.java new file mode 100644 index 0000000..e1ddb29 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdExhaustionDetectionArmedWiringTest.java @@ -0,0 +1,94 @@ +package dev.ltms.fleet; + +import dev.ltms.fleet.config.ConfigRef; +import dev.ltms.fleet.config.FleetConfig; +import dev.ltms.fleet.mcp.FleetMcp; +import dev.ltms.fleet.placement.BackendQuarantine; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.regex.Pattern; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #404: {@code exhaustionDetectionArmed} must describe the startup pattern map, not the + * reloaded config snapshot. + * + *

This test needs a reload. At startup the two snapshots agree, so a test of only a newly + * started daemon would not detect a live {@code config.get()} lookup in the report field. + */ +class FleetdExhaustionDetectionArmedWiringTest { + + private static final String NO_PATTERN = """ + bind: + host: 127.0.0.1 + port: 8765 + herdrSocket: ~/.config/herdr/herdr.sock + profiles: + terra: + baseUrl: http://gx00.gw:8000 + model: terra + guard: + offSubscriptionHosts: + - gx00.gw + """; + + private static final String WITH_PATTERN = """ + bind: + host: 127.0.0.1 + port: 8765 + herdrSocket: ~/.config/herdr/herdr.sock + profiles: + terra: + baseUrl: http://gx00.gw:8000 + model: terra + exhaustedPattern: "usage limit" + guard: + offSubscriptionHosts: + - gx00.gw + """; + + @Test + @DisplayName("reloading an exhaustedPattern does not arm the startup detection source") + void reloadedPatternDoesNotChangeTheArmedFieldUntilRestart(@TempDir Path dir) throws Exception { + Path file = dir.resolve("fleetd.yaml"); + Files.writeString(file, NO_PATTERN); + ConfigRef config = new ConfigRef(file, FleetConfig.load(file)); + + Files.writeString(file, WITH_PATTERN); + assertTrue(config.reload().applied()); + assertTrue(config.get().profiles().get("terra").hasExhaustedPattern()); + + FleetMcp.QuarantineSource source = Fleetd.quarantineSource(config, BackendQuarantine.none(), + Map.of()); + assertFalse(source.exhaustedPatternArmed().apply("terra"), + "exhaustionDetectionArmed must use the startup pattern map, not config.get()"); + } + + @Test + @DisplayName("a profile in the startup pattern map is reported as armed") + void aProfileInTheStartupMapIsArmed(@TempDir Path dir) throws Exception { + // fleetd #404, second direction. The test above only ever passes an EMPTY startup map, so + // it cannot tell a correct lookup from one that is permanently off. Measured: replacing the + // armed lambda with `profile -> false` left the whole suite green at 1475 tests. That + // mutation would make #395's visibility feature dead — an operator fixing a detection gap + // would be told the gap is still open after fixing it, forever. Both directions are needed: + // this test is the only thing that fails when the field stops reporting armed at all. + Path file = dir.resolve("fleetd.yaml"); + Files.writeString(file, WITH_PATTERN); + ConfigRef config = new ConfigRef(file, FleetConfig.load(file)); + + FleetMcp.QuarantineSource source = Fleetd.quarantineSource(config, BackendQuarantine.none(), + Map.of("terra", Pattern.compile("usage limit"))); + + assertTrue(source.exhaustedPatternArmed().apply("terra"), + "a profile whose pattern was compiled at startup must report armed"); + assertFalse(source.exhaustedPatternArmed().apply("sonnet"), + "a profile absent from the startup map must not report armed"); + } +}