From c26f69540246ab1dd397aa03d14d99a97d18f10e Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 10 Sep 2026 08:34:33 +0700 Subject: [PATCH] fleetd #395: warn when exhaustedPattern detection is silently off exhaustedPattern is opt-in per profile: unset means a usage-limit refusal on that profile is never classified BACKEND_EXHAUSTED and never quarantines its credential, with nothing telling the operator. Add a startup WARN naming every unarmed profile (a louder, separate WARN for a subscription: true profile, since that is the operator's own metered plan). Surface the same fact per profile in fleet_profiles as exhaustionDetectionArmed, so an operator can tell "healthy" from "can never be caught" without reading fleetd.yaml. --- .../src/main/java/dev/ltms/fleet/Fleetd.java | 58 ++++++- .../java/dev/ltms/fleet/mcp/FleetMcp.java | 40 ++++- .../fleet/ExhaustedPatternGapReportTest.java | 150 ++++++++++++++++++ .../mcp/FleetProfilesArmedFieldTest.java | 62 ++++++++ 4 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 fleetd/src/test/java/dev/ltms/fleet/ExhaustedPatternGapReportTest.java create mode 100644 fleetd/src/test/java/dev/ltms/fleet/mcp/FleetProfilesArmedFieldTest.java diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index b7e0829..cc9191d 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -134,6 +134,10 @@ public final class Fleetd { // secret is reported above, so upgrading past this commit never silently drops CB-592's // protection. reportMemberCredentialsGap(cfg); + // fleetd #395: an unset exhaustedPattern is a silent opt-out of usage-limit detection for + // that profile — say so loudly, the same way the two reports above do, rather than let an + // operator discover it only when a limit goes undetected. + reportExhaustedPatternGap(cfg); // CB-559: `cfg` stays the startup snapshot — every validation and every piece of one-time // wiring below reads it, and must, because those decisions cannot be unmade. `config` is the // live reference the hot paths read per use. Which keys can actually move is ConfigRef's @@ -664,7 +668,13 @@ public final class Fleetd { FleetMcp.QuarantineSource quarantineSource = new FleetMcp.QuarantineSource(profile -> { var configured = config.get().profiles().get(profile); return configured == null ? null : configured.effectiveCredentialId(); - }, quarantine); + }, 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.OutageSource outageSource = new FleetMcp.OutageSource(profile -> { var configured = config.get().profiles().get(profile); return configured == null ? null : configured.effectiveCredentialId(); @@ -1323,6 +1333,52 @@ public final class Fleetd { + "fleetd.yaml — see fleetd.example.yaml — and restart."); } + /** + * fleetd #395: {@code exhaustedPattern} (see {@link FleetConfig.Profile#exhaustedPattern}) is + * deliberately opt-in — {@code null}/blank means a backend refusal on that profile is never + * classified as {@code BACKEND_EXHAUSTED}, so its credential is never quarantined. That is a + * legitimate choice (guessing the vendor's wording would be worse), but an operator who never + * opted a profile in should not discover the gap only when a usage limit silently goes + * undetected. Warn once at startup, naming every unarmed profile, exactly like {@link + * #reportMemberCredentialsGap} — never refuse to start over it. + * + *

A {@code subscription: true} profile that is unarmed gets a SECOND, louder WARN of its + * own: it bills the operator's metered Claude plan, the case where an undetected usage limit + * costs the most. + * + *

Package-private so a test can capture the real log via a {@link + * ch.qos.logback.core.read.ListAppender}, the same pattern {@link + * #reportMemberCredentialsGap}'s own test uses. + */ + static void reportExhaustedPatternGap(FleetConfig cfg) { + List unarmedSubscription = new ArrayList<>(); + List unarmedOther = new ArrayList<>(); + cfg.profiles().forEach((name, profile) -> { + if (!profile.hasExhaustedPattern()) { + (profile.isSubscription() ? unarmedSubscription : unarmedOther).add(name); + } + }); + if (unarmedSubscription.isEmpty() && unarmedOther.isEmpty()) { + log.info("exhaustedPattern: every configured profile has usage-limit detection armed"); + return; + } + List allUnarmed = new ArrayList<>(unarmedSubscription); + allUnarmed.addAll(unarmedOther); + allUnarmed = allUnarmed.stream().sorted().toList(); + log.warn("exhaustedPattern: profile(s) {} have no exhaustedPattern configured — a " + + "usage-limit refusal on any of them is never detected and never " + + "quarantines its credential. Set exhaustedPattern (see " + + "fleetd.example.yaml) to arm detection for a profile.", + allUnarmed); + if (!unarmedSubscription.isEmpty()) { + List sortedSubscription = unarmedSubscription.stream().sorted().toList(); + log.warn("exhaustedPattern: subscription profile(s) {} run on the operator's metered " + + "Claude plan and have NO usage-limit detection armed — this is the " + + "case where a missed usage limit costs the most.", + sortedSubscription); + } + } + /** * Poll herdr's {@code ping} until it answers or {@link #HERDR_WAIT_SECONDS} elapses (CB-504). * diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index 84a28ca..53125b8 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -119,9 +119,28 @@ public final class FleetMcp { /** * CB-578 stage B quarantine facts used by {@code fleet_profiles}: a profile → credential id * lookup, plus the shared {@link BackendQuarantine} to read remaining cooldowns off. + * + * @param exhaustedPatternArmed fleetd #395: profile → whether that profile's {@code + * exhaustedPattern} is configured (see {@code + * FleetConfig.Profile#hasExhaustedPattern}), i.e. whether a backend refusal + * on it can EVER be classified {@code BACKEND_EXHAUSTED} and quarantine its + * credential. Bundled here, not a separate Source, because it answers the + * exact question {@code fleet_profiles}'s quarantine facts already answer + * for a QUARANTINED profile — "can this profile's usage limit ever be + * caught?" — just for every profile, not only one currently caught. */ - public record QuarantineSource(Function credentialIdFor, BackendQuarantine quarantine) { - /** Inert source — no profile is ever reported quarantined. Explicit stand-in, not a default. */ + public record QuarantineSource(Function credentialIdFor, BackendQuarantine quarantine, + Function exhaustedPatternArmed) { + /** + * Backward-compatible 2-arg form, before fleetd #395 added {@code exhaustedPatternArmed} — + * reports every profile unarmed. Keeps every pre-existing call site (production and test) + * compiling and behaving identically for the quarantine facts they actually asked for. + */ + public QuarantineSource(Function credentialIdFor, BackendQuarantine quarantine) { + this(credentialIdFor, quarantine, _ -> false); + } + + /** Inert source — no profile is ever reported quarantined or armed. Explicit stand-in, not a default. */ public static QuarantineSource none() { return new QuarantineSource(_ -> null, BackendQuarantine.none()); } } @@ -1109,6 +1128,14 @@ public final class FleetMcp { * not the other, and the two then disagree about a live outage. That is exactly what fleetd * #284 was, where one rule computed in two places was widened in only one and a single response * contradicted itself. Shared inputs do not make duplicated computation safe. + * + *

fleetd #395: also reports {@code exhaustionDetectionArmed}, one boolean per configured + * profile — {@code true} when that profile's {@code exhaustedPattern} is set, {@code false} + * when it is not, so an operator can tell "this profile is healthy" from "nothing can ever + * quarantine this profile" without reading {@code fleetd.yaml}. Unlike {@code quarantined}/ + * {@code coolingOff}, this map always names every profile: an unarmed profile never enters a + * transient state to be absent from, so silence here would read as "healthy" rather than "not + * being watched at all". */ public static Map profilesView(PeerLauncher workers, QuarantineSource quarantine, OutageSource outage) { Map result = new LinkedHashMap<>(); @@ -1116,7 +1143,9 @@ public final class FleetMcp { result.put("default", workers.defaultProfile() == null ? "" : workers.defaultProfile()); Map quarantined = new LinkedHashMap<>(); Map coolingOff = new LinkedHashMap<>(); + Map exhaustionDetectionArmed = new LinkedHashMap<>(); for (String profile : workers.profiles()) { + exhaustionDetectionArmed.put(profile, quarantine.exhaustedPatternArmed().apply(profile)); String credentialId = quarantine.credentialIdFor().apply(profile); if (credentialId != null) { quarantine.quarantine().remainingSeconds(credentialId).ifPresent(remaining -> { @@ -1136,6 +1165,7 @@ public final class FleetMcp { }); } } + result.put("exhaustionDetectionArmed", exhaustionDetectionArmed); if (!quarantined.isEmpty()) { result.put("quarantined", quarantined); } @@ -1663,7 +1693,11 @@ public final class FleetMcp { "List the configured worker profiles (backends) and which one fleet_spawn uses by " + "default. A 'quarantined' map is present when a backend-exhausted refusal put " + "a profile's credential on cooldown — fleet_spawn onto it is refused until " - + "quarantinedForSeconds elapses; a profile sharing that credential is listed too.", + + "quarantinedForSeconds elapses; a profile sharing that credential is listed too. " + + "'exhaustionDetectionArmed' reports, per profile, whether a usage-limit refusal " + + "on it can EVER be classified and quarantined (its exhaustedPattern is " + + "configured) — false means that profile's credential can never be quarantined " + + "by this mechanism, however many usage-limit refusals it sees.", objectSchema(Map.of(), List.of())); } diff --git a/fleetd/src/test/java/dev/ltms/fleet/ExhaustedPatternGapReportTest.java b/fleetd/src/test/java/dev/ltms/fleet/ExhaustedPatternGapReportTest.java new file mode 100644 index 0000000..bdc4fb4 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/ExhaustedPatternGapReportTest.java @@ -0,0 +1,150 @@ +package dev.ltms.fleet; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import dev.ltms.fleet.config.FleetConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #395: {@code exhaustedPattern} (see {@link FleetConfig.Profile#exhaustedPattern}) is + * deliberately opt-in — an unset one leaves usage-limit detection silently OFF for that profile, + * and nothing quarantines its credential. {@link Fleetd#reportExhaustedPatternGap} must say so at + * startup, naming every unarmed profile, and must never fire when every profile is armed. Mirrors + * {@link MemberCredentialsGapReportTest}'s pattern, capturing the real log via a + * {@link ListAppender}. + * + *

The 8-profile shape in {@link #theLiveEightProfileShapeWarnsExactlyTheSixUnarmedProfiles} is + * the live {@code fleetd.yaml} shape measured 2026-09-10 (fleetd #395's own ticket): 6 of 8 + * profiles unarmed, 2 of those 6 ({@code opus}, {@code sonnet}) running on the operator's Claude + * subscription. {@code fleetd.yaml} itself is gitignored and unavailable to this test, so the + * shape is reproduced as a throwaway config in a {@code @TempDir} rather than read off disk. + */ +class ExhaustedPatternGapReportTest { + + private static FleetConfig load(Path dir, String yaml) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, yaml); + return FleetConfig.load(f); + } + + private static ListAppender attach() { + Logger logger = (Logger) LoggerFactory.getLogger(Fleetd.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + return appender; + } + + private static void detach(ListAppender appender) { + ((Logger) LoggerFactory.getLogger(Fleetd.class)).detachAppender(appender); + } + + /** Every profile name mentioned by a WARN-level log line, across every WARN this call produced. */ + private static List warnMessages(ListAppender appender) { + return appender.list.stream() + .filter(e -> e.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + @Test + void theLiveEightProfileShapeWarnsExactlyTheSixUnarmedProfiles(@TempDir Path dir) throws Exception { + // Reproduces the live shape measured 2026-09-10: 8 profiles, 2 armed (sol, terra), 6 + // unarmed (local, local-direct, gx, opus, sonnet, xf) — 2 of the unarmed 6 (opus, sonnet) + // are subscription: true. + FleetConfig cfg = load(dir, """ + profiles: + local: + baseUrl: http://gx00.gw:8000 + local-direct: + baseUrl: http://gx01.gw:8000 + gx: + kind: opencode + baseUrl: https://llm.ltms.dev/v1 + opus: + subscription: true + model: claude-opus-5 + sonnet: + subscription: true + model: claude-sonnet-5 + sol: + baseUrl: https://llm.ltms.dev/v1 + exhaustedPattern: "The usage limit has been reached" + terra: + baseUrl: https://llm.ltms.dev/v1 + exhaustedPattern: "The usage limit has been reached" + xf: + baseUrl: https://llm.ltms.dev/v1 + """); + + ListAppender appender = attach(); + try { + Fleetd.reportExhaustedPatternGap(cfg); + } finally { + detach(appender); + } + + List warns = warnMessages(appender); + assertFalse(warns.isEmpty(), "6 of 8 profiles are unarmed — at least one WARN must fire"); + + // Exactly one WARN aggregates every unarmed profile, naming all 6 and none of the 2 armed. + String aggregate = warns.stream() + .filter(m -> m.contains("no exhaustedPattern configured")) + .findFirst() + .orElseThrow(() -> new AssertionError("expected an aggregate unarmed-profiles WARN: " + warns)); + for (String unarmed : List.of("local", "local-direct", "gx", "opus", "sonnet", "xf")) { + assertTrue(aggregate.contains(unarmed), "aggregate WARN must name '" + unarmed + "': " + aggregate); + } + for (String armed : List.of("sol", "terra")) { + assertFalse(aggregate.contains(armed), "aggregate WARN must NOT name armed profile '" + armed + "': " + aggregate); + } + + // A second, louder WARN calls out the subscription profiles specifically. + String subscriptionWarn = warns.stream() + .filter(m -> m.contains("metered Claude plan")) + .findFirst() + .orElseThrow(() -> new AssertionError("expected a subscription-specific WARN: " + warns)); + assertTrue(subscriptionWarn.contains("opus"), subscriptionWarn); + assertTrue(subscriptionWarn.contains("sonnet"), subscriptionWarn); + assertFalse(subscriptionWarn.contains("local-direct"), + "the subscription WARN must not name a non-subscription profile: " + subscriptionWarn); + } + + @Test + void everyProfileArmedProducesNoWarningAtAll(@TempDir Path dir) throws Exception { + FleetConfig cfg = load(dir, """ + profiles: + sol: + baseUrl: https://llm.ltms.dev/v1 + exhaustedPattern: "The usage limit has been reached" + terra: + baseUrl: https://llm.ltms.dev/v1 + exhaustedPattern: "The usage limit has been reached" + opus: + subscription: true + model: claude-opus-5 + exhaustedPattern: "5-hour limit reached" + """); + + ListAppender appender = attach(); + try { + Fleetd.reportExhaustedPatternGap(cfg); + } finally { + detach(appender); + } + + assertTrue(warnMessages(appender).isEmpty(), + "every profile is armed — a checker that warns anyway always fires: " + warnMessages(appender)); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetProfilesArmedFieldTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetProfilesArmedFieldTest.java new file mode 100644 index 0000000..043251f --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetProfilesArmedFieldTest.java @@ -0,0 +1,62 @@ +package dev.ltms.fleet.mcp; + +import dev.ltms.fleet.config.FleetConfig; +import dev.ltms.fleet.guard.SubscriptionGuard; +import dev.ltms.fleet.herdr.AgentControl; +import dev.ltms.fleet.herdr.FakeHerdr; +import dev.ltms.fleet.herdr.WorkspaceControl; +import dev.ltms.fleet.member.ClaudeCodeLauncher; +import dev.ltms.fleet.peer.PeerLauncher; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #395: {@code fleet_profiles} must let an operator tell "this profile's usage-limit + * detection is armed" from "nothing can ever quarantine this profile" — see {@link + * FleetMcp.QuarantineSource#exhaustedPatternArmed()} and {@link + * FleetMcp#profilesView(PeerLauncher, FleetMcp.QuarantineSource, FleetMcp.OutageSource)}. + */ +class FleetProfilesArmedFieldTest { + + private static PeerLauncher twoProfileLauncher(FakeHerdr h) { + FleetConfig.Profile armed = new FleetConfig.Profile( + "armed-profile", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null, + "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null); + FleetConfig.Profile unarmed = new FleetConfig.Profile( + "unarmed-profile", "http://gx01.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null, + "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null); + Map profiles = new LinkedHashMap<>(); + profiles.put(armed.profile(), armed); + profiles.put(unarmed.profile(), unarmed); + return new ClaudeCodeLauncher(new AgentControl(h), new WorkspaceControl(h), + new SubscriptionGuard(Set.of("gx00.gw", "gx01.gw")), profiles, armed.profile(), _ -> "tok"); + } + + private static String textOf(McpSchema.CallToolResult r) { + return ((McpSchema.TextContent) r.content().getFirst()).text(); + } + + @Test + void armedProfileReportsArmedAndUnarmedReportsUnarmed() { + FakeHerdr h = new FakeHerdr(); + PeerLauncher workers = twoProfileLauncher(h); + FleetMcp.QuarantineSource source = new FleetMcp.QuarantineSource( + _ -> null, dev.ltms.fleet.placement.BackendQuarantine.none(), + profile -> "armed-profile".equals(profile)); + + McpSchema.CallToolResult res = FleetMcp.profiles(workers, source); + assertNotEquals(Boolean.TRUE, res.isError()); + String out = textOf(res); + + assertTrue(out.contains("\"exhaustionDetectionArmed\""), out); + assertTrue(out.contains("\"armed-profile\":true"), out); + assertTrue(out.contains("\"unarmed-profile\":false"), out); + } +}