diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml index 79a0d0a..da3b057 100644 --- a/fleetd/fleetd.example.yaml +++ b/fleetd/fleetd.example.yaml @@ -306,6 +306,15 @@ profiles: # GOTCHA 2 — `maxLoad` is the ONLY throttle you have here. There is no metering, no budget # and no refusal on cost; the cap on live members is the single thing standing between a # fan-out and your monthly limit. Set it deliberately and keep it small. + # + # GOTCHA 3 (fleetd #176) — `maxLoad` counts members, never the lead itself. The lead is a live + # `claude` session on this SAME account (a lead is never moved off-subscription, whatever its + # own profile says), so it already holds one seat before any member spawns. If a lead's + # `fleet.leaders..profile` names THIS profile (or one sharing its `credentialId`), + # `fleet_list`'s `free` for this profile subtracts that lead's live seat(s) automatically — see + # `profile:` under THE FLEET below. If no lead entry names this profile, fleetd has no way to + # know it shares this account, and `free` will overstate what a fresh `fleet_spawn` actually + # gets by exactly the seats the lead is quietly holding. # gitTokenEnv: GITEA_TOKEN # opt-in: let this profile's workers open their own PR (CB-302) # gitHostEnv: GITEA_HOST # defaults to GITEA_HOST; injected only with gitTokenEnv # exhaustedPattern: "usage limit has been reached" # opt-in: classify a usage-limit refusal (CB-578) @@ -503,6 +512,17 @@ fleet: # recognised: give it a `profile:` and the daemon launches the shortfall when fewer than # `instances` are live. Omit `profile:` and it is recognise-only, as before. # + # `profile:` has a SECOND job as of fleetd #176, even for a recognise-only lead you never want + # auto-launched: it is also how fleetd learns which account this lead's own session shares. A + # `subscription: true` profile bills the operator's Claude account, and the lead itself is always + # a live `claude` session on that same account — `maxLoad` never counted that seat. If a lead + # entry here names a profile (or one sharing its `credentialId`) that matches a worker profile, + # `fleet_list`'s `free` for that worker profile subtracts the lead's live seat(s) automatically. + # Setting `profile:` on an already-running, recognise-only lead is safe — the daemon only launches + # the SHORTFALL below `instances`, so naming a profile here does not, by itself, start anything. + # Omit it and fleetd has no way to derive the sharing — there is no other reliable signal on the + # daemon's side — so that lead's seat goes uncounted, exactly as before this ticket. + # # `tab:` (CB-579) is REQUIRED and is the only field identity depends on — the exact label of the # tab hosting the lead, matched case-insensitively. Label the tab yourself and put that same # string here, and the pane is recognised on the next rescan. Reopen the tab later, or the session diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 8d4cae3..ffb8153 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -640,7 +640,8 @@ public final class Fleetd { new FleetMcp.OutageSource(profile -> { var configured = config.get().profiles().get(profile); return configured == null ? null : configured.effectiveCredentialId(); - }, outagePolicy)); + }, outagePolicy), + new FleetMcp.LeadSeatSource(leadSeatLookup(() -> config.get().profiles(), leaders, leads))); // CB-637: the receive half. Only constructed when a lead mailbox actually opened — with no // coordinator (or an unreachable one) there is nothing to deliver, so no scheduler is @@ -765,6 +766,68 @@ public final class Fleetd { .orElse(null); } + /** + * fleetd #176: per-profile factory for {@link FleetMcp.LeadSeatSource} — how many seats a + * profile's own live LEAD session(s) hold on the same Claude subscription. + * + *

{@code maxLoad} counts only members; the lead itself is a live {@code claude} session that + * is never moved off-subscription ({@code LeadLauncher} strips {@code ANTHROPIC_BASE_URL}/ + * {@code AUTH_TOKEN} from a lead's env whatever its profile says), so a {@code subscription: + * true} profile's real ceiling is lower than its configured {@code maxLoad} by exactly the + * number of lead seats sharing that same account. + * + *

The derivation, and why this route was chosen over a new config key. The link is + * {@code fleet.leaders..profile} — the field the operator already sets to name which + * {@code profiles:} entry a lead runs on (CB-557; see {@code fleetd.example.yaml}) — matched + * against the profile passed in here via {@link FleetConfig.Profile#effectiveCredentialId()}, + * the same grouping key {@link dev.ltms.fleet.placement.BackendQuarantine} already uses to say + * two profiles share one account. Nothing new is added to the config schema: this reuses a field + * that already exists and already means "the profile this lead's own session runs on". A lead + * entry that names no {@code profile:} (recognise-only, CB-558) says nothing about which account + * it shares, and there is no other reliable signal on the daemon's side to derive that from — so + * such a lead contributes no seats, exactly as before this ticket. Making that lead's seat count + * requires the operator to add one line (`profile: sonnet` under its {@code fleet.leaders} entry) + * — a config statement, not a code change, and the smallest one available given the field + * already exists for a closely related purpose. + * + *

Only counts leads {@code liveLeadTerminals} currently reports — CB-531's live tab scan (or + * the legacy {@code primary.terminal} pin) — never every configured lead: an entry whose + * {@code instances} nobody has actually started is not really competing for a seat, and must not + * shrink capacity for one that was never live. + * + * @param profiles the live profile map, normally {@code () -> config.get().profiles()} + * in {@code main} — hot, like every other {@code maxLoad}/ + * {@code credentialId} read {@link FleetMcp.CapacitySource} already does + * @param leaders {@code fleet.leaders}, read once at startup like the rest of that + * block ({@code fleetd.example.yaml} notes it is not hot) — passed as a + * plain map, never re-read from {@code config.get()} + * @param liveLeadTerminals terminal_id → lead name for every CURRENTLY recognised lead, normally + * the same supplier {@link dev.ltms.fleet.auth.CallerResolver#leads()} + * and {@code LeadCoordLoop} already consult + */ + static Function leadSeatLookup(Supplier> profiles, + Map leaders, Supplier> liveLeadTerminals) { + return profileName -> { + FleetConfig.Profile target = profiles.get().get(profileName); + if (target == null || !target.isSubscription()) { + return 0; + } + String targetCredential = target.effectiveCredentialId(); + int seats = 0; + for (String leadName : liveLeadTerminals.get().values()) { + FleetConfig.Leader lead = leaders.get(leadName); + if (lead == null || lead.profile() == null || lead.profile().isBlank()) { + continue; + } + FleetConfig.Profile leadProfile = profiles.get().get(lead.profile()); + if (leadProfile != null && targetCredential.equals(leadProfile.effectiveCredentialId())) { + seats++; + } + } + return seats; + }; + } + /** * fleetd #248 / fleetd#201 Unit 5: package-private factory for the per-target backend-error * pattern lookup {@link CompletionResolver} classifies a pane scrape against. Closes over the diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java index 81300ac..e507886 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -946,7 +946,15 @@ public record FleetConfig( * survives restarts of the agent inside it — so identity is now the tab label alone. * * @param profile the {@code profiles:} entry to launch this lead on when one must - * be created; {@code null} ⇒ recognise-only, never create + * be created; {@code null} ⇒ recognise-only, never create. + *

fleetd #176: also the field {@code Fleetd.leadSeatLookup} reads + * to learn which account this lead's own live session shares — set it + * (safely, even on an already-running recognise-only lead: naming a + * profile here never starts anything beyond {@code instances}) so a + * {@code subscription: true} worker profile sharing its + * {@code effectiveCredentialId()} has this lead's seat subtracted from + * {@code fleet_list}'s {@code free}. {@code null} here also means this + * lead's seat cannot be derived and is not counted. * @param tab the exact tab label hosting this lead, matched case-insensitively; * the only field identity depends on. Required — a lead with no * {@code tab} can never be discovered, launched or not 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 f395ad4..e55ee4b 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -96,6 +96,8 @@ public final class FleetMcp { private final QuarantineSource quarantine; /** fleetd #201 Unit 5: SEPARATE from {@link #quarantine} — see {@link OutageSource}'s doc. */ private final OutageSource outage; + /** fleetd #176: SEPARATE from both of the above — see {@link LeadSeatSource}'s doc. */ + private final LeadSeatSource leadSeats; /** CB-637: this daemon's lead-to-lead channel; {@code null} when no coordinator is configured. */ private final LeadChannel leadChannel; @@ -135,6 +137,24 @@ public final class FleetMcp { } } + /** + * fleetd #176: the seats a profile's own live LEAD session(s) hold on the same Claude + * subscription — the third reason (alongside {@link QuarantineSource} and {@link OutageSource}) + * {@code free} can overstate what a fresh {@code fleet_spawn} would actually get. + * + *

{@code maxLoad} counts only members, never the lead itself. But a + * {@code subscription: true} profile bills the operator's own Claude account, and the lead is + * always a live {@code claude} session on that same account (it is never moved off-subscription + * — see {@code LeadLauncher}). So a fan-out that fills every member slot still leaves the lead's + * own seat unaccounted for, and the daemon reports a slot that was never really free. See + * {@code Fleetd.leadSeatLookup} for how the count is derived — from {@code fleet.leaders. + * .profile} and each profile's {@code effectiveCredentialId()}, never a hardcoded constant. + */ + public record LeadSeatSource(Function seatsFor) { + /** Inert source — no profile is ever reported as sharing a seat with a lead. */ + public static LeadSeatSource none() { return new LeadSeatSource(_ -> 0); } + } + /** * @param callers resolves each call's {@link Principal}; {@code null} disables authorization. * This surface needs its own enforcement: {@code /mcp} is a raw servlet on @@ -149,7 +169,7 @@ public final class FleetMcp { CallerResolver callers, Metrics metrics, CapacitySource capacity, HealthCoverageSource healthCoverage, QuarantineSource quarantine) { this(messages, workers, sessions, identity, presence, primaryRegistry, callers, metrics, capacity, - healthCoverage, quarantine, null, OutageSource.none()); + healthCoverage, quarantine, null, OutageSource.none(), LeadSeatSource.none()); } /** @@ -163,12 +183,12 @@ public final class FleetMcp { CallerResolver callers, Metrics metrics, CapacitySource capacity, HealthCoverageSource healthCoverage, QuarantineSource quarantine, LeadChannel leadChannel) { this(messages, workers, sessions, identity, presence, primaryRegistry, callers, metrics, capacity, - healthCoverage, quarantine, leadChannel, OutageSource.none()); + healthCoverage, quarantine, leadChannel, OutageSource.none(), LeadSeatSource.none()); } /** * As above, with fleetd #201 Unit 5 cool-off facts for {@code fleet_list}/{@code fleet_profiles} - * (see {@link OutageSource}). This is what {@code Fleetd.main} actually wires up. + * (see {@link OutageSource}). * * @param outage required — pass {@link OutageSource#none()} for a caller that does not want the * feature, never a defaulting overload (the same rule {@code quarantine} follows). @@ -177,10 +197,28 @@ public final class FleetMcp { ConnectionIdentity identity, MemberPresence presence, PrimaryRegistry primaryRegistry, CallerResolver callers, Metrics metrics, CapacitySource capacity, HealthCoverageSource healthCoverage, QuarantineSource quarantine, LeadChannel leadChannel, OutageSource outage) { + this(messages, workers, sessions, identity, presence, primaryRegistry, callers, metrics, capacity, + healthCoverage, quarantine, leadChannel, outage, LeadSeatSource.none()); + } + + /** + * As above, with fleetd #176 lead-seat facts (see {@link LeadSeatSource}). This is what + * {@code Fleetd.main} actually wires up. + * + * @param leadSeats required — pass {@link LeadSeatSource#none()} for a caller that does not want + * the feature, never a defaulting overload (the same rule {@code quarantine} and + * {@code outage} follow). + */ + public FleetMcp(MessageService messages, PeerLauncher workers, SessionManager sessions, + ConnectionIdentity identity, MemberPresence presence, PrimaryRegistry primaryRegistry, + CallerResolver callers, Metrics metrics, CapacitySource capacity, HealthCoverageSource healthCoverage, + QuarantineSource quarantine, LeadChannel leadChannel, OutageSource outage, + LeadSeatSource leadSeats) { this.leadChannel = leadChannel; this.capacity = capacity; this.quarantine = Objects.requireNonNull(quarantine, "quarantine"); this.outage = Objects.requireNonNull(outage, "outage"); + this.leadSeats = Objects.requireNonNull(leadSeats, "leadSeats"); this.healthCoverage = healthCoverage; McpJsonMapper json = new JacksonMcpJsonMapperSupplier().get(); this.transport = HttpServletStreamableServerTransportProvider.builder() @@ -310,7 +348,7 @@ public final class FleetMcp { McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null); if (denied != null) return denied; return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine, outage, - callers == null ? Map.of() : callers.leads(), + leadSeats, callers == null ? Map.of() : callers.leads(), callerTerminal(exchange), leadChannel == null ? null : leadChannel.selfCoordId()); }; @@ -994,7 +1032,8 @@ public final class FleetMcp { CapacitySource capacity, HealthCoverageSource healthCoverage, QuarantineSource quarantine, OutageSource outage, Map leads, String selfTerm) { - return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine, outage, leads, selfTerm, null); + return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine, outage, + LeadSeatSource.none(), leads, selfTerm, null); } /** @@ -1011,7 +1050,7 @@ public final class FleetMcp { QuarantineSource quarantine, Map leads, String selfTerm, String selfCoordId) { return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine, OutageSource.none(), - leads, selfTerm, selfCoordId); + LeadSeatSource.none(), leads, selfTerm, selfCoordId); } /** As above, plus fleetd #201 Unit 5 cool-off facts (see {@link OutageSource}). */ @@ -1019,6 +1058,16 @@ public final class FleetMcp { CapacitySource capacity, HealthCoverageSource healthCoverage, QuarantineSource quarantine, OutageSource outage, Map leads, String selfTerm, String selfCoordId) { + return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine, outage, + LeadSeatSource.none(), leads, selfTerm, selfCoordId); + } + + /** As above, plus fleetd #176 lead-seat facts (see {@link LeadSeatSource}). */ + static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, MessageService messages, + CapacitySource capacity, HealthCoverageSource healthCoverage, + QuarantineSource quarantine, OutageSource outage, + LeadSeatSource leadSeats, Map leads, String selfTerm, + String selfCoordId) { try { Map live = workers.list().stream() .map(Agent.class::cast) @@ -1045,7 +1094,7 @@ public final class FleetMcp { } if (capacity.available()) result.put("capacity", profiles.stream() .map(profile -> capacityView(profile, capacity.liveCount(), capacity.maxLoad(), roster, messages, - capacity.clock().getAsLong(), quarantine, outage)).toList()); + capacity.clock().getAsLong(), quarantine, outage, leadSeats)).toList()); return text(json(result)); } catch (HerdrException e) { return error("herdr error listing the fleet: " + e.getMessage()); @@ -1084,20 +1133,33 @@ public final class FleetMcp { * {@code credentialId}/{@code coolingOffForSeconds}, but never {@code quarantinedForSeconds} — * that key is added only when exhaustion quarantine is ALSO active for this profile, since the * two checks are independent and either, both, or neither can be true. + * + *

fleetd #176: {@code maxLoad} counts panes, not subscription seats — it never counted the + * lead's own seat on a {@code subscription: true} profile's account. {@link LeadSeatSource} + * reports that count (0 for a non-subscription profile, or when no live lead shares its + * credential), and it is subtracted from {@code free} the same way {@code live} already is — + * {@code maxLoad} itself is left untouched, so the row still reports the configured cap. The + * {@code leadSeats} key is added only when the count is positive, for the same + * byte-identical-when-unused reason as the quarantine/cool-off keys above. */ private static Map capacityView(String profile, Function liveCount, Function maxLoad, List roster, MessageService messages, long nowNanos, QuarantineSource quarantine, - OutageSource outage) { + OutageSource outage, LeadSeatSource leadSeats) { Integer cap = maxLoad.apply(profile); int live = liveCount.apply(profile); + int leadSeatCount = leadSeats.seatsFor().apply(profile); int reclaimable = (int) roster.stream().filter(s -> profile.equals(s.profile())) .filter(s -> (s.state() == MemberSession.State.READY || s.state() == MemberSession.State.DONE)) .filter(s -> messages == null || (!messages.hasAcceptedDelivery(s.terminalId()) && !messages.hasInboxMessage(s.terminalId()))) .count(); Map row = new LinkedHashMap<>(); row.put("profile", profile); row.put("maxLoad", cap); row.put("live", live); - row.put("free", cap == null ? null : Math.max(0, cap - live)); row.put("reclaimable", reclaimable); + row.put("free", cap == null ? null : Math.max(0, cap - live - leadSeatCount)); + row.put("reclaimable", reclaimable); + if (leadSeatCount > 0) { + row.put("leadSeats", leadSeatCount); + } String credentialId = quarantine.credentialIdFor().apply(profile); if (credentialId != null) { quarantine.quarantine().remainingSeconds(credentialId).ifPresent(remaining -> { diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatLookupTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatLookupTest.java new file mode 100644 index 0000000..b1b4219 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatLookupTest.java @@ -0,0 +1,129 @@ +package dev.ltms.fleet; + +import dev.ltms.fleet.config.FleetConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * fleetd #176: {@link Fleetd#leadSeatLookup} is the factory {@code Fleetd.main} wires into {@code + * FleetMcp.LeadSeatSource} so {@code fleet_list}'s {@code free} can subtract the seat(s) a + * {@code subscription: true} profile's own live LEAD session holds on that same account — + * {@code maxLoad} never counted the lead, only members. {@code FleetdLeadSeatWiringTest} proves + * {@code main} still passes this factory's result in; this class proves the factory's own matching + * logic: subscription-only, credential-matched, and counting only CURRENTLY LIVE leads. + */ +class FleetdLeadSeatLookupTest { + + private static FleetConfig.Profile subscriptionProfile(String name, String credentialId) { + return new FleetConfig.Profile(name, null, "claude-sonnet-5", null, null, null, + "tab", "fleet", "w #{n}", null, null, null, null, null, null, null, + null, 3, true, null, credentialId, null); + } + + private static FleetConfig.Profile offSubscriptionProfile(String name, String credentialId) { + return new FleetConfig.Profile(name, "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", + null, "tab", "fleet", "w #{n}", null, null, null, null, null, null, null, + null, 2, false, null, credentialId, null); + } + + private static FleetConfig.Leader leadOnProfile(String profile) { + return new FleetConfig.Leader(profile, "lead: primary", 1, "lead:", 10, "claude", "claude-sonnet-5"); + } + + @Test + @DisplayName("a live lead sharing the target profile's credential counts as one seat") + void liveLeadSharingCredentialCountsAsOneSeat() { + Map profiles = Map.of("sonnet", subscriptionProfile("sonnet", null)); + Map leaders = Map.of("primary", leadOnProfile("sonnet")); + Function lookup = Fleetd.leadSeatLookup(() -> profiles, leaders, + () -> Map.of("term_primary", "primary")); + + assertEquals(1, lookup.apply("sonnet")); + } + + @Test + @DisplayName("no live lead names this profile ⇒ zero seats, exactly as before this ticket") + void noLiveLeadOnTheProfileCountsAsZero() { + Map profiles = Map.of("sonnet", subscriptionProfile("sonnet", null)); + Map leaders = Map.of("primary", leadOnProfile("sonnet")); + Function lookup = Fleetd.leadSeatLookup(() -> profiles, leaders, Map::of); + + assertEquals(0, lookup.apply("sonnet")); + } + + @Test + @DisplayName("a lead entry with no `profile:` (recognise-only) contributes no seats — cannot be derived") + void recogniseOnlyLeadWithNoProfileContributesNothing() { + Map profiles = Map.of("sonnet", subscriptionProfile("sonnet", null)); + FleetConfig.Leader recogniseOnly = new FleetConfig.Leader(null, "lead: primary", 1, "lead:", 10, + "claude", "claude-sonnet-5"); + Map leaders = Map.of("primary", recogniseOnly); + Function lookup = Fleetd.leadSeatLookup(() -> profiles, leaders, + () -> Map.of("term_primary", "primary")); + + assertEquals(0, lookup.apply("sonnet")); + } + + @Test + @DisplayName("a non-subscription profile never has a lead seat subtracted, whatever the credential match") + void nonSubscriptionProfileIsNeverAdjusted() { + Map profiles = Map.of( + "terra", offSubscriptionProfile("terra", "shared-openai"), + "sonnet", subscriptionProfile("sonnet", "shared-openai")); + Map leaders = Map.of("primary", leadOnProfile("sonnet")); + Function lookup = Fleetd.leadSeatLookup(() -> profiles, leaders, + () -> Map.of("term_primary", "primary")); + + assertEquals(0, lookup.apply("terra"), "terra is not subscription:true, so it must never be adjusted"); + } + + @Test + @DisplayName("credential mismatch ⇒ no seat counted, even though both profiles are subscription:true") + void differentCredentialIsNotCounted() { + Map profiles = Map.of( + "sonnet", subscriptionProfile("sonnet", "claude-account-a"), + "opus", subscriptionProfile("opus", "claude-account-b")); + Map leaders = Map.of("primary", leadOnProfile("opus")); + Function lookup = Fleetd.leadSeatLookup(() -> profiles, leaders, + () -> Map.of("term_primary", "primary")); + + assertEquals(0, lookup.apply("sonnet"), "different accounts must never be conflated into one seat count"); + } + + @Test + @DisplayName("two live instances of the same lead count as two seats") + void twoLiveInstancesOfTheSameLeadCountAsTwoSeats() { + Map profiles = Map.of("sonnet", subscriptionProfile("sonnet", null)); + Map leaders = Map.of("primary", leadOnProfile("sonnet")); + Function lookup = Fleetd.leadSeatLookup(() -> profiles, leaders, + () -> Map.of("term_a", "primary", "term_b", "primary")); + + assertEquals(2, lookup.apply("sonnet")); + } + + @Test + @DisplayName("an unconfigured target profile resolves to zero, not a thrown exception") + void unconfiguredTargetProfileIsZero() { + Function lookup = Fleetd.leadSeatLookup(Map::of, Map.of(), Map::of); + + assertEquals(0, lookup.apply("ghost")); + } + + @Test + @DisplayName("live leads are read through the supplier on every call, not snapshotted") + void liveLeadsAreReadThroughOnEveryCall() { + Map profiles = Map.of("sonnet", subscriptionProfile("sonnet", null)); + Map leaders = Map.of("primary", leadOnProfile("sonnet")); + java.util.Map live = new java.util.HashMap<>(); + Function lookup = Fleetd.leadSeatLookup(() -> profiles, leaders, () -> live); + + assertEquals(0, lookup.apply("sonnet")); + live.put("term_primary", "primary"); + assertEquals(1, lookup.apply("sonnet")); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatWiringTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatWiringTest.java new file mode 100644 index 0000000..29ecdc3 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatWiringTest.java @@ -0,0 +1,43 @@ +package dev.ltms.fleet; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #176: {@code Fleetd.main} builds its {@code FleetMcp} from a 14-argument constructor whose + * last argument is a {@code FleetMcp.LeadSeatSource} wrapping {@link Fleetd#leadSeatLookup}. That + * argument is exactly the kind of wiring fleetd #248 warned about: dropping it (or swapping it for + * the inert {@code FleetMcp.LeadSeatSource.none()}) compiles with 0 errors and leaves every test + * that builds its own {@code FleetMcp}/{@code CapacitySource} directly — every test that predates + * this ticket — green, because none of them go through {@code main} at all. + * + *

{@link FleetdLeadSeatLookupTest} proves the factory's own matching logic; this class is the + * plain source-text assertion that proves {@code main} still passes its result in, mirroring + * {@code FleetdCompletionResolverWiringTest}'s approach for the same class of gap. + * + *

This test checks source text, not runtime behaviour. It never constructs a + * {@code FleetMcp} and never runs {@code main}. + */ +class FleetdLeadSeatWiringTest { + + private static String fleetdSource() throws Exception { + return Files.readString(Path.of("src/main/java/dev/ltms/fleet/Fleetd.java")); + } + + @Test + @DisplayName("[SOURCE TEXT] FleetMcp's construction call still passes a LeadSeatSource built from leadSeatLookup(...)") + void fleetMcpConstructionStillWiresLeadSeatLookup() throws Exception { + String source = fleetdSource(); + assertTrue(source.contains("new FleetMcp.LeadSeatSource(leadSeatLookup(() -> config.get().profiles(), " + + "leaders, leads))"), + "FleetMcp's construction call must still pass a LeadSeatSource built from " + + "Fleetd.leadSeatLookup(...). Dropping it or swapping in " + + "FleetMcp.LeadSeatSource.none() (fleetd #176's would-be silent regression, the same " + + "shape as fleetd #248's measured mutations) compiles with 0 errors and leaves every " + + "existing behavioural test green — this source check is what must go red instead."); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java index 600e3e7..d3c3973 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java @@ -767,6 +767,68 @@ class FleetMcpTest { assertFalse(out.contains("quarantinedForSeconds"), out); } + /** + * fleetd #176: this is the exact shape measured on the Mac fleet — {@code maxLoad:3, live:2}, + * where one of the "free" three is really the lead's own seat on the same subscription. The old + * formula ({@code max(0, cap - live)}) reported {@code free:1}; the real ceiling is {@code 0} + * (two members plus the lead's own seat already fill all three). + */ + @Test + void leadSeatSubtractsFromFreeTheSameWayLiveDoes() { + FakeHerdr h = new FakeHerdr(); + SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); + FleetMcp.LeadSeatSource leadSeats = new FleetMcp.LeadSeatSource( + profile -> "sonnet".equals(profile) ? 1 : 0); + + String out = textOf(FleetMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), + sessions, null, new FleetMcp.CapacitySource(profile -> 2, profile -> 3, + () -> Set.of("sonnet"), () -> 0), new FleetMcp.HealthCoverageSource(() -> "off"), + FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), leadSeats, Map.of(), "", null)); + + assertTrue(out.contains("\"maxLoad\":3"), "maxLoad itself must be left untouched: " + out); + assertTrue(out.contains("\"live\":2"), out); + assertTrue(out.contains("\"free\":0"), "2 live + 1 lead seat fills all 3: " + out); + assertTrue(out.contains("\"leadSeats\":1"), out); + } + + /** + * fleetd #176: the OTHER measurement in the issue — a completely idle fleet still overstates + * {@code free} by the lead's own seat. {@code maxLoad:3, live:0} must report {@code free:2}, the + * real fan-out ceiling, not {@code 3}. + */ + @Test + void leadSeatLowersFreeOnAnOtherwiseIdleSubscriptionProfile() { + FakeHerdr h = new FakeHerdr(); + SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); + FleetMcp.LeadSeatSource leadSeats = new FleetMcp.LeadSeatSource( + profile -> "sonnet".equals(profile) ? 1 : 0); + + String out = textOf(FleetMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), + sessions, null, new FleetMcp.CapacitySource(profile -> 0, profile -> 3, + () -> Set.of("sonnet"), () -> 0), new FleetMcp.HealthCoverageSource(() -> "off"), + FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), leadSeats, Map.of(), "", null)); + + assertTrue(out.contains("\"live\":0"), out); + assertTrue(out.contains("\"free\":2"), "an idle fleet's real ceiling is 3 minus the lead's own seat: " + out); + assertTrue(out.contains("\"leadSeats\":1"), out); + } + + /** A profile with no lead seats reported must be byte-identical to before this ticket. */ + @Test + void zeroLeadSeatsOmitsTheKeyAndLeavesFreeUnchanged() { + FakeHerdr h = new FakeHerdr(); + SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); + + String out = textOf(FleetMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), + sessions, null, new FleetMcp.CapacitySource(profile -> 0, profile -> 2, + () -> Set.of("terra"), () -> 0), new FleetMcp.HealthCoverageSource(() -> "off"), + FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(), + Map.of(), "", null)); + + assertTrue(out.contains("\"free\":2"), out); + assertFalse(out.contains("leadSeats"), "no lead shares this profile's credential: " + out); + } + @Test void listReportsLeadsAndFlagsTheCallersOwnRow() { FakeHerdr h = new FakeHerdr();