fleetd #176: subtract the lead's own subscription seat from free
maxLoad counted panes, never subscription seats: a subscription:true profile's lead is itself a live claude session on that same account, so free overstated capacity by the lead's own seat (measured free:1 with a real ceiling of 0, and free:3 on an idle fleet with a real ceiling of 2). Add FleetMcp.LeadSeatSource (same shape as QuarantineSource/ OutageSource) and Fleetd.leadSeatLookup, which derives the seat count from fleet.leaders.<name>.profile matched against the target profile by effectiveCredentialId() - no hardcoded "-1", and no new config key: profile: already exists for this exact "which account does this lead share" question. maxLoad itself is left untouched; only free (and a new, additive-only leadSeats field) changes. Exhaustion quarantine (cause 2 in the ticket) already forced free to 0 via the same BackendQuarantine capacityView already reads - confirmed by reading the exhaustionSink wiring, no code change needed there.
This commit is contained in:
@@ -306,6 +306,15 @@ profiles:
|
|||||||
# GOTCHA 2 — `maxLoad` is the ONLY throttle you have here. There is no metering, no budget
|
# 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
|
# 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.
|
# 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.<name>.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)
|
# 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
|
# 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)
|
# 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
|
# 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.
|
# `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:` (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
|
# 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
|
# string here, and the pane is recognised on the next rescan. Reopen the tab later, or the session
|
||||||
|
|||||||
@@ -640,7 +640,8 @@ public final class Fleetd {
|
|||||||
new FleetMcp.OutageSource(profile -> {
|
new FleetMcp.OutageSource(profile -> {
|
||||||
var configured = config.get().profiles().get(profile);
|
var configured = config.get().profiles().get(profile);
|
||||||
return configured == null ? null : configured.effectiveCredentialId();
|
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
|
// 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
|
// coordinator (or an unreachable one) there is nothing to deliver, so no scheduler is
|
||||||
@@ -765,6 +766,68 @@ public final class Fleetd {
|
|||||||
.orElse(null);
|
.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.
|
||||||
|
*
|
||||||
|
* <p>{@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.
|
||||||
|
*
|
||||||
|
* <p><b>The derivation, and why this route was chosen over a new config key.</b> The link is
|
||||||
|
* {@code fleet.leaders.<name>.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.
|
||||||
|
*
|
||||||
|
* <p>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<String, Integer> leadSeatLookup(Supplier<Map<String, FleetConfig.Profile>> profiles,
|
||||||
|
Map<String, FleetConfig.Leader> leaders, Supplier<Map<String, String>> 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
|
* 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
|
* pattern lookup {@link CompletionResolver} classifies a pane scrape against. Closes over the
|
||||||
|
|||||||
@@ -946,7 +946,15 @@ public record FleetConfig(
|
|||||||
* survives restarts of the agent inside it — so identity is now the tab label alone.
|
* 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
|
* @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.
|
||||||
|
* <p>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;
|
* @param tab the exact tab label hosting this lead, matched case-insensitively;
|
||||||
* the only field identity depends on. Required — a lead with no
|
* the only field identity depends on. Required — a lead with no
|
||||||
* {@code tab} can never be discovered, launched or not
|
* {@code tab} can never be discovered, launched or not
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ public final class FleetMcp {
|
|||||||
private final QuarantineSource quarantine;
|
private final QuarantineSource quarantine;
|
||||||
/** fleetd #201 Unit 5: SEPARATE from {@link #quarantine} — see {@link OutageSource}'s doc. */
|
/** fleetd #201 Unit 5: SEPARATE from {@link #quarantine} — see {@link OutageSource}'s doc. */
|
||||||
private final OutageSource outage;
|
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. */
|
/** CB-637: this daemon's lead-to-lead channel; {@code null} when no coordinator is configured. */
|
||||||
private final LeadChannel leadChannel;
|
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.
|
||||||
|
*
|
||||||
|
* <p>{@code maxLoad} counts only <em>members</em>, 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.<name>
|
||||||
|
* .profile} and each profile's {@code effectiveCredentialId()}, never a hardcoded constant.
|
||||||
|
*/
|
||||||
|
public record LeadSeatSource(Function<String, Integer> 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.
|
* @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
|
* 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,
|
CallerResolver callers, Metrics metrics, CapacitySource capacity, HealthCoverageSource healthCoverage,
|
||||||
QuarantineSource quarantine) {
|
QuarantineSource quarantine) {
|
||||||
this(messages, workers, sessions, identity, presence, primaryRegistry, callers, metrics, capacity,
|
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,
|
CallerResolver callers, Metrics metrics, CapacitySource capacity, HealthCoverageSource healthCoverage,
|
||||||
QuarantineSource quarantine, LeadChannel leadChannel) {
|
QuarantineSource quarantine, LeadChannel leadChannel) {
|
||||||
this(messages, workers, sessions, identity, presence, primaryRegistry, callers, metrics, capacity,
|
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}
|
* 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
|
* @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).
|
* 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,
|
ConnectionIdentity identity, MemberPresence presence, PrimaryRegistry primaryRegistry,
|
||||||
CallerResolver callers, Metrics metrics, CapacitySource capacity, HealthCoverageSource healthCoverage,
|
CallerResolver callers, Metrics metrics, CapacitySource capacity, HealthCoverageSource healthCoverage,
|
||||||
QuarantineSource quarantine, LeadChannel leadChannel, OutageSource outage) {
|
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.leadChannel = leadChannel;
|
||||||
this.capacity = capacity;
|
this.capacity = capacity;
|
||||||
this.quarantine = Objects.requireNonNull(quarantine, "quarantine");
|
this.quarantine = Objects.requireNonNull(quarantine, "quarantine");
|
||||||
this.outage = Objects.requireNonNull(outage, "outage");
|
this.outage = Objects.requireNonNull(outage, "outage");
|
||||||
|
this.leadSeats = Objects.requireNonNull(leadSeats, "leadSeats");
|
||||||
this.healthCoverage = healthCoverage;
|
this.healthCoverage = healthCoverage;
|
||||||
McpJsonMapper json = new JacksonMcpJsonMapperSupplier().get();
|
McpJsonMapper json = new JacksonMcpJsonMapperSupplier().get();
|
||||||
this.transport = HttpServletStreamableServerTransportProvider.builder()
|
this.transport = HttpServletStreamableServerTransportProvider.builder()
|
||||||
@@ -310,7 +348,7 @@ public final class FleetMcp {
|
|||||||
McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null);
|
McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null);
|
||||||
if (denied != null) return denied;
|
if (denied != null) return denied;
|
||||||
return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine, outage,
|
return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine, outage,
|
||||||
callers == null ? Map.of() : callers.leads(),
|
leadSeats, callers == null ? Map.of() : callers.leads(),
|
||||||
callerTerminal(exchange),
|
callerTerminal(exchange),
|
||||||
leadChannel == null ? null : leadChannel.selfCoordId());
|
leadChannel == null ? null : leadChannel.selfCoordId());
|
||||||
};
|
};
|
||||||
@@ -994,7 +1032,8 @@ public final class FleetMcp {
|
|||||||
CapacitySource capacity, HealthCoverageSource healthCoverage,
|
CapacitySource capacity, HealthCoverageSource healthCoverage,
|
||||||
QuarantineSource quarantine, OutageSource outage,
|
QuarantineSource quarantine, OutageSource outage,
|
||||||
Map<String, String> leads, String selfTerm) {
|
Map<String, String> 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<String, String> leads, String selfTerm,
|
QuarantineSource quarantine, Map<String, String> leads, String selfTerm,
|
||||||
String selfCoordId) {
|
String selfCoordId) {
|
||||||
return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine, OutageSource.none(),
|
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}). */
|
/** 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,
|
CapacitySource capacity, HealthCoverageSource healthCoverage,
|
||||||
QuarantineSource quarantine, OutageSource outage,
|
QuarantineSource quarantine, OutageSource outage,
|
||||||
Map<String, String> leads, String selfTerm, String selfCoordId) {
|
Map<String, String> 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<String, String> leads, String selfTerm,
|
||||||
|
String selfCoordId) {
|
||||||
try {
|
try {
|
||||||
Map<String, Agent> live = workers.list().stream()
|
Map<String, Agent> live = workers.list().stream()
|
||||||
.map(Agent.class::cast)
|
.map(Agent.class::cast)
|
||||||
@@ -1045,7 +1094,7 @@ public final class FleetMcp {
|
|||||||
}
|
}
|
||||||
if (capacity.available()) result.put("capacity", profiles.stream()
|
if (capacity.available()) result.put("capacity", profiles.stream()
|
||||||
.map(profile -> capacityView(profile, capacity.liveCount(), capacity.maxLoad(), roster, messages,
|
.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));
|
return text(json(result));
|
||||||
} catch (HerdrException e) {
|
} catch (HerdrException e) {
|
||||||
return error("herdr error listing the fleet: " + e.getMessage());
|
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} —
|
* {@code credentialId}/{@code coolingOffForSeconds}, but never {@code quarantinedForSeconds} —
|
||||||
* that key is added only when exhaustion quarantine is ALSO active for this profile, since the
|
* 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.
|
* two checks are independent and either, both, or neither can be true.
|
||||||
|
*
|
||||||
|
* <p>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<String, Object> capacityView(String profile, Function<String, Integer> liveCount,
|
private static Map<String, Object> capacityView(String profile, Function<String, Integer> liveCount,
|
||||||
Function<String, Integer> maxLoad, List<MemberSession> roster,
|
Function<String, Integer> maxLoad, List<MemberSession> roster,
|
||||||
MessageService messages, long nowNanos, QuarantineSource quarantine,
|
MessageService messages, long nowNanos, QuarantineSource quarantine,
|
||||||
OutageSource outage) {
|
OutageSource outage, LeadSeatSource leadSeats) {
|
||||||
Integer cap = maxLoad.apply(profile);
|
Integer cap = maxLoad.apply(profile);
|
||||||
int live = liveCount.apply(profile);
|
int live = liveCount.apply(profile);
|
||||||
|
int leadSeatCount = leadSeats.seatsFor().apply(profile);
|
||||||
int reclaimable = (int) roster.stream().filter(s -> profile.equals(s.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 -> (s.state() == MemberSession.State.READY || s.state() == MemberSession.State.DONE))
|
||||||
.filter(s -> messages == null || (!messages.hasAcceptedDelivery(s.terminalId()) && !messages.hasInboxMessage(s.terminalId())))
|
.filter(s -> messages == null || (!messages.hasAcceptedDelivery(s.terminalId()) && !messages.hasInboxMessage(s.terminalId())))
|
||||||
.count();
|
.count();
|
||||||
Map<String, Object> row = new LinkedHashMap<>();
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("profile", profile); row.put("maxLoad", cap); row.put("live", live);
|
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);
|
String credentialId = quarantine.credentialIdFor().apply(profile);
|
||||||
if (credentialId != null) {
|
if (credentialId != null) {
|
||||||
quarantine.quarantine().remainingSeconds(credentialId).ifPresent(remaining -> {
|
quarantine.quarantine().remainingSeconds(credentialId).ifPresent(remaining -> {
|
||||||
|
|||||||
@@ -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<String, FleetConfig.Profile> profiles = Map.of("sonnet", subscriptionProfile("sonnet", null));
|
||||||
|
Map<String, FleetConfig.Leader> leaders = Map.of("primary", leadOnProfile("sonnet"));
|
||||||
|
Function<String, Integer> 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<String, FleetConfig.Profile> profiles = Map.of("sonnet", subscriptionProfile("sonnet", null));
|
||||||
|
Map<String, FleetConfig.Leader> leaders = Map.of("primary", leadOnProfile("sonnet"));
|
||||||
|
Function<String, Integer> 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<String, FleetConfig.Profile> profiles = Map.of("sonnet", subscriptionProfile("sonnet", null));
|
||||||
|
FleetConfig.Leader recogniseOnly = new FleetConfig.Leader(null, "lead: primary", 1, "lead:", 10,
|
||||||
|
"claude", "claude-sonnet-5");
|
||||||
|
Map<String, FleetConfig.Leader> leaders = Map.of("primary", recogniseOnly);
|
||||||
|
Function<String, Integer> 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<String, FleetConfig.Profile> profiles = Map.of(
|
||||||
|
"terra", offSubscriptionProfile("terra", "shared-openai"),
|
||||||
|
"sonnet", subscriptionProfile("sonnet", "shared-openai"));
|
||||||
|
Map<String, FleetConfig.Leader> leaders = Map.of("primary", leadOnProfile("sonnet"));
|
||||||
|
Function<String, Integer> 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<String, FleetConfig.Profile> profiles = Map.of(
|
||||||
|
"sonnet", subscriptionProfile("sonnet", "claude-account-a"),
|
||||||
|
"opus", subscriptionProfile("opus", "claude-account-b"));
|
||||||
|
Map<String, FleetConfig.Leader> leaders = Map.of("primary", leadOnProfile("opus"));
|
||||||
|
Function<String, Integer> 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<String, FleetConfig.Profile> profiles = Map.of("sonnet", subscriptionProfile("sonnet", null));
|
||||||
|
Map<String, FleetConfig.Leader> leaders = Map.of("primary", leadOnProfile("sonnet"));
|
||||||
|
Function<String, Integer> 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<String, Integer> 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<String, FleetConfig.Profile> profiles = Map.of("sonnet", subscriptionProfile("sonnet", null));
|
||||||
|
Map<String, FleetConfig.Leader> leaders = Map.of("primary", leadOnProfile("sonnet"));
|
||||||
|
java.util.Map<String, String> live = new java.util.HashMap<>();
|
||||||
|
Function<String, Integer> lookup = Fleetd.leadSeatLookup(() -> profiles, leaders, () -> live);
|
||||||
|
|
||||||
|
assertEquals(0, lookup.apply("sonnet"));
|
||||||
|
live.put("term_primary", "primary");
|
||||||
|
assertEquals(1, lookup.apply("sonnet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>{@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.
|
||||||
|
*
|
||||||
|
* <p><b>This test checks source text, not runtime behaviour.</b> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -767,6 +767,68 @@ class FleetMcpTest {
|
|||||||
assertFalse(out.contains("quarantinedForSeconds"), out);
|
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
|
@Test
|
||||||
void listReportsLeadsAndFlagsTheCallersOwnRow() {
|
void listReportsLeadsAndFlagsTheCallersOwnRow() {
|
||||||
FakeHerdr h = new FakeHerdr();
|
FakeHerdr h = new FakeHerdr();
|
||||||
|
|||||||
Reference in New Issue
Block a user