diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java
index 5ee4dcd..52d8e15 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java
@@ -34,7 +34,16 @@ import java.util.function.Supplier;
* {@code tabLabel} — is read the same live way, through the same supplier
* ({@code () -> config.get().fleet()}). But {@code fleet:} as a whole is NOT in this
* class: {@code fleet.leaders} inside the same key is frozen, which is exactly what
- * makes {@code fleet:} split rather than hot — see below.
+ * makes {@code fleet:} split rather than hot — see below. {@code models:} (fleetd #422) joined
+ * this class whole: {@link FleetConfig#validateModels()} re-runs fully against the fresh
+ * config on every {@link #reload()} (via {@link FleetConfig#validateAll()}), refusing a bad
+ * edit outright rather than caching a stale copy anywhere, and the on/off half added by
+ * fleetd #422 is read live both by {@code CompositePeerLauncher}'s spawn gate
+ * ({@code enforceModelEnabled} and its candidate filter) and by {@code fleet_profiles}/
+ * {@code GET /profiles} (via {@code PeerLauncher.disabledModels()}). Nothing about
+ * {@code models:} is baked into an object built at startup, so — unlike the deferred keys
+ * below — there is no frozen half left to report; it moved here from deferred rather than
+ * joining split.
*
Deferred — accepted into the new snapshot, but the wiring built at startup
* keeps the old value until a restart: {@code lifecycle:}, {@code leadHeartbeat:},
* {@code idleSleepGuard:} ({@code Fleetd.java} reads it once, at startup, to decide whether
@@ -43,12 +52,6 @@ import java.util.function.Supplier;
* running daemon keeps whatever this was at startup regardless of a later edit),
* {@code spawnReadyTimeoutMs} / {@code spawnReadyPollMs}, {@code quarantineCooldownSeconds}
* (CB-578 stage B — baked once into the {@code BackendQuarantine} built at startup),
- * {@code models:} (fleetd ticket "central allow-list of usable models" — {@link
- * FleetConfig#validateModels()} re-runs against the fresh config in {@link #reload()}
- * (via {@link FleetConfig#validateAll()}), so a
- * models.allow: edit that would refuse to boot still refuses the reload; a change that
- * passes has nothing built at startup to rebuild, so it is reported deferred rather than
- * silently accepted with no report at all),
* {@code guard:}, {@code worktreeRoot:}, {@code worktreeGroup:} and {@code memberSkills:}
* (all three of the latter baked once into the {@code GitWorktrees} built at
* {@code Fleetd.java:251} and never rebuilt — fleetd #323 instance 2 found
@@ -141,16 +144,18 @@ import java.util.function.Supplier;
*
*
* The denominator, measured on 2026-09-04 (fleetd #330; recounted for fleetd #333);
- * recounted again for fleetd #362, again after {@code idleSleepGuard:} was added, and again after
- * {@code models:} was added.
- * {@code FleetConfig} has 25 top-level record components: 5 cold, 14 deferred, 3 split, 3
- * hot-excluded. Three of them are named nowhere in this file, and the reason is the same for all
- * three: {@code placement}, {@code memberCredentials} and {@code memberLoginShell} are
- * hot and correctly absent — all three are read live off {@code config.get()}
+ * recounted again for fleetd #362, again after {@code idleSleepGuard:} was added, again after
+ * {@code models:} was added as deferred, and again for fleetd #422, which moved {@code models:}
+ * from deferred to hot-excluded once its on/off half was read live everywhere.
+ * {@code FleetConfig} has 25 top-level record components: 5 cold, 13 deferred, 3 split, 4
+ * hot-excluded. Four of them are named nowhere in this file, and the reason is the same for all
+ * four: {@code placement}, {@code memberCredentials}, {@code memberLoginShell} and {@code models}
+ * are hot and correctly absent — all four are read live off {@code config.get()}
* (placement through the {@code CompositePeerLauncher} supplier the Hot bullet names;
* {@code memberCredentials}/{@code memberLoginShell} at spawn time, {@code Fleetd.java:198, 205, 729}
- * and {@code HerdrPeerLauncher#configuredMemberLoginShell}), so a reload takes effect on the next
- * spawn with no entry needed here.
+ * and {@code HerdrPeerLauncher#configuredMemberLoginShell}; {@code models} the same way, through the
+ * Hot bullet's {@code models:} paragraph), so a reload takes effect on the next spawn (or, for
+ * {@code models}, the next reported status) with no entry needed here.
* {@code health} and {@code coordinator} used to be a third kind — undecided, not
* hot — until fleetd #330 added the split class above and gave them a home. A
* reload touching either used to report a bare "config reloaded", which under-claimed; now it names
@@ -226,7 +231,7 @@ public final class ConfigRef implements Supplier {
static final Set DEFERRED_KEYS = Set.of(
"guard", "worktreeRoot", "worktreeGroup", "memberSkills", "primary", "configReload",
"leadHeartbeat", "lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs",
- "quarantineCooldownSeconds", "profiles", "idleSleepGuard", "models");
+ "quarantineCooldownSeconds", "profiles", "idleSleepGuard");
private final Path path;
private final AtomicReference current;
@@ -446,15 +451,6 @@ public final class ConfigRef implements Supplier {
if (!Objects.equals(old.idleSleepGuard(), fresh.idleSleepGuard())) {
changed.add("idleSleepGuard");
}
- // fleetd ticket "central allow-list of usable models": validateModels() runs again in
- // reload() above (via validateAll()), so a bad edit is already refused as cold-adjacent
- // (the whole reload is refused via the catch block, never partially applied). A GOOD edit
- // to the allow-list
- // itself has nothing built at startup to rebuild — it only ever mattered to the validation
- // call that already ran — so report it deferred rather than silently swallowing the change.
- if (!Objects.equals(old.models(), fresh.models())) {
- changed.add("models");
- }
if (!Objects.equals(old.spawnReadyTimeoutMs(), fresh.spawnReadyTimeoutMs())
|| !Objects.equals(old.spawnReadyPollMs(), fresh.spawnReadyPollMs())) {
changed.add("spawnReady*");
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 9ec3452..68660de 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java
@@ -133,8 +133,10 @@ import java.util.regex.PatternSyntaxException;
* nothing and every existing config keeps working exactly as it does today.
* When non-empty, a profile whose {@code model:} is not one of {@link
* Models#ids()} fails config load, naming both the model and the profile —
- * see {@link #validateModels()}. This block only decides what may be
- * CONFIGURED; nothing here enforces it at spawn time. See {@link Models}.
+ * see {@link #validateModels()}. This block decides what may be CONFIGURED;
+ * fleetd #422 added the separate on/off question — whether a configured model
+ * may be spawned onto RIGHT NOW ({@link Models.ModelEntry#enabled}) — enforced
+ * live at spawn by {@code CompositePeerLauncher}, not here. See {@link Models}.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record FleetConfig(
@@ -1362,9 +1364,14 @@ public record FleetConfig(
* the set of permitted models — only editing {@code models.allow:} itself can. This is the
* invariant the ticket asked for: the two blocks are validated in one direction only.
*
- * Out of scope here, deliberately: nothing in this block is read at spawn time —
- * enforcing it against a live spawn, an on/off runtime switch, and any interaction with {@code
- * BackendQuarantine} are separate units. This block is config-load validation only.
+ *
Spawn-time enforcement (fleetd #422, units 2+3) lives outside this record —
+ * {@code CompositePeerLauncher.enforceModelEnabled} and its candidate-set filter read this
+ * block LIVE (through the same kind of supplier {@code weight}/{@code maxLoad} already use),
+ * so the on/off state below is hot: no restart needed. This block itself still only decides
+ * what may be CONFIGURED (membership in {@link #allow}); {@link ModelEntry#enabled} decides
+ * whether a member of that list is currently spawnable. The two questions are deliberately
+ * separate — see {@link ModelEntry}'s javadoc for why turning a model off must never mean
+ * removing it from {@link #allow}.
*
* @param allow the permitted models, each its own {@link ModelEntry} rather than a bare
* string — see that record's javadoc for why. {@code null}/empty ⇒ the block is
@@ -1378,24 +1385,56 @@ public record FleetConfig(
}
/**
- * One permitted model, named as a record rather than a bare string on purpose: a later unit
- * needs to hang an on/off state and a load-limit state off each entry, and a bare {@code
- * List} cannot grow those fields without changing the YAML shape underneath every
- * operator who already wrote one. {@link #model()} is intentionally a single flat,
- * opaque-string namespace — a bare Claude id ({@code claude-sonnet-5}) and an opencode
- * provider-prefixed id ({@code openai/gpt-5.6-terra}) both fit it unchanged, because
- * {@link FleetConfig#validateModels()} only ever compares a profile's {@code model:} value
- * against this string for exact equality; it never parses a provider prefix or branches on
- * a profile's {@code kind:}.
+ * One permitted model, named as a record rather than a bare string on purpose: this ticket
+ * (fleetd #422) is the "later unit" the original comment here predicted — it hangs an on/off
+ * state ({@link #enabled}) off each entry, and a bare {@code List} could not have
+ * grown that field without changing the YAML shape underneath every operator who already
+ * wrote one. {@link #model()} is intentionally a single flat, opaque-string namespace — a
+ * bare Claude id ({@code claude-sonnet-5}) and an opencode provider-prefixed id
+ * ({@code openai/gpt-5.6-terra}) both fit it unchanged, because {@link
+ * FleetConfig#validateModels()} only ever compares a profile's {@code model:} value against
+ * this string for exact equality; it never parses a provider prefix or branches on a
+ * profile's {@code kind:}.
*
- * @param model the model id exactly as a {@code profiles:} entry's {@code model:} field
- * would name it
+ * @param model the model id exactly as a {@code profiles:} entry's {@code model:} field
+ * would name it
+ * @param enabled {@code false} turns spawning onto this model off; {@code null} (the field
+ * omitted — every config written before fleetd #422 is this shape) or
+ * {@code true} leaves it on. Turning a model off must NEVER remove it from
+ * {@link Models#allow} — {@link FleetConfig#validateModels()} checks
+ * membership only, never the on/off state, so an off entry stays a
+ * valid thing for a {@code profiles:} entry to name; only
+ * {@code CompositePeerLauncher}'s spawn-time gate reads {@link #enabled}.
+ * Collapsing the two — turning a model off by deleting its {@code allow:}
+ * entry — would make {@link FleetConfig#validateModels()} refuse the whole
+ * config reload the moment a still-configured profile names it, which is
+ * exactly the restart-to-flip-a-switch problem this field exists to avoid.
+ * A model id can be named by more than one {@code profiles:} entry (e.g.
+ * {@code deepseek-v4-flash} backs both {@code local} and {@code
+ * local-direct} in the live config) — turning it off disables every profile
+ * that names it, on purpose: the model is what a subscription's rate limit
+ * actually constrains, not any one profile alias for it.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
- public record ModelEntry(String model) {
+ public record ModelEntry(String model, Boolean enabled) {
public ModelEntry {
model = (model == null || model.isBlank()) ? null : model.trim();
}
+
+ /**
+ * Back-compat form before {@link #enabled} was added (fleetd #422) — the model is
+ * unconditionally on, exactly as every {@code ModelEntry} behaved before this field
+ * existed. Keeps pre-#422 call sites (and any YAML that omits {@code enabled:})
+ * compiling and behaving identically.
+ */
+ public ModelEntry(String model) {
+ this(model, null);
+ }
+
+ /** {@code true} unless {@link #enabled} is explicitly {@code false} — absent means on. */
+ public boolean isEnabled() {
+ return !Boolean.FALSE.equals(enabled);
+ }
}
/** {@link #allow}'s model ids, as a set for membership checks. Blank/null entries are dropped. */
@@ -1408,6 +1447,23 @@ public record FleetConfig(
}
return Collections.unmodifiableSet(ids);
}
+
+ /**
+ * Model ids currently turned off (fleetd #422: {@link ModelEntry#isEnabled()} {@code
+ * false}). Read live by {@code CompositePeerLauncher}'s spawn gate and by {@code
+ * fleet_profiles}/{@code GET /profiles} — both must read this same accessor off the same
+ * live config so the two surfaces cannot disagree about which model is off (the fleetd
+ * #404 lesson: a status field must read the source the behaviour reads).
+ */
+ public Set offIds() {
+ Set off = new java.util.LinkedHashSet<>();
+ for (ModelEntry e : allow) {
+ if (e != null && e.model() != null && !e.isEnabled()) {
+ off.add(e.model());
+ }
+ }
+ return Collections.unmodifiableSet(off);
+ }
}
/**
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 69943ac..792305a 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java
@@ -37,6 +37,7 @@ import io.modelcontextprotocol.spec.McpSchema;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServlet;
+import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -1179,6 +1180,14 @@ public final class FleetMcp {
if (!coolingOff.isEmpty()) {
result.put("coolingOff", coolingOff);
}
+ // fleetd #422: read the exact same accessor CompositePeerLauncher's spawn gate reads
+ // (PeerLauncher.disabledModels(), which for the composite is models0().offIds()) — never a
+ // separately-derived answer, so this status can never overstate or understate what the gate
+ // actually enforces (the fleetd #404 lesson).
+ Set modelsOff = workers.disabledModels();
+ if (!modelsOff.isEmpty()) {
+ result.put("modelsOff", new ArrayList<>(modelsOff));
+ }
return result;
}
diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java
index f4eced5..49c64fe 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java
@@ -90,6 +90,19 @@ public final class CompositePeerLauncher implements PeerLauncher {
private final Supplier