CB-566: add fleet charter config
CI / contract (pull_request) Successful in 43s
CI / build (pull_request) Successful in 55s

This commit is contained in:
Dai Ha
2026-08-15 04:49:53 +02:00
parent f8522edacd
commit 799668e129
6 changed files with 148 additions and 5 deletions
@@ -96,6 +96,7 @@ public final class Bridged {
// CB-542: a subscription:true profile whose env: reseats ANTHROPIC_BASE_URL/AUTH_TOKEN would
// reach an unguarded endpoint (the launcher skips SubscriptionGuard for it). Refuse at load.
cfg.validateSubscriptionProfiles();
cfg.validateCharters();
// CB-548: every architect slot must name a configured workers: profile — the strong-model
// backend the future spawn lifecycle would read. A stale reference dies here, not later.
cfg.validateMembers();
@@ -532,6 +532,7 @@ public record BridgedConfig(
* @param architects profiles the {@code architect} role may run on
* @param developers profiles the {@code dev} role may run on
* @param reviewers profiles the {@code reviewer} role may run on
* @param charters optional launch-charter text keyed by singular role wire name
* @param tabLabel template for a member tab's label; {@code {role}}, {@code {profile}},
* {@code {model}} and {@code {n}} (a per role+profile counter) are
* substituted. Default {@link #DEFAULT_TAB_LABEL}
@@ -541,6 +542,7 @@ public record BridgedConfig(
Map<String, Slot> architects,
Map<String, Slot> developers,
Map<String, Slot> reviewers,
Map<String, String> charters,
String tabLabel) {
/**
@@ -556,9 +558,16 @@ public record BridgedConfig(
architects = unmodifiableOrEmpty(architects);
developers = unmodifiableOrEmpty(developers);
reviewers = unmodifiableOrEmpty(reviewers);
charters = unmodifiableOrEmpty(charters);
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
}
/** Convenience constructor for code that does not configure launch charters. */
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
Map<String, Slot> developers, Map<String, Slot> reviewers, String tabLabel) {
this(leaders, architects, developers, reviewers, null, tabLabel);
}
/**
* Deliberately not {@code Map.copyOf}: its iteration order is salted per JVM run, which
* would discard YAML definition order. The {@code fixed} placement policy answers with a
@@ -582,6 +591,11 @@ public record BridgedConfig(
};
}
/** The configured launch charter for {@code role}, or {@code null} when it is absent. */
public String charterFor(MemberRole role) {
return role == null ? null : charters.get(role.wireName());
}
/**
* The profile names {@code role} may run on, in definition order, without repeats.
*
@@ -1052,7 +1066,7 @@ public record BridgedConfig(
// fleet IS defaulted, unlike the leadScan: block it replaced, because an empty Fleet is not
// the same as an enabled one: every pool is empty, so no lead is scanned for or created and
// no role has a pool. Constructing it saves every reader a null check for no behaviour change.
Fleet f = (fleet != null) ? fleet : new Fleet(null, null, null, null, null);
Fleet f = (fleet != null) ? fleet : new Fleet(null, null, null, null, null, null);
// leadHeartbeat is left as-is (CB-551): null is "off", and LeadHeartbeat's own compact
// constructor defaults the fields of a block that IS present. Defaulting it here would
// switch the feature on for every config that never mentioned it.
@@ -1190,6 +1204,35 @@ public record BridgedConfig(
}
}
/**
* Reject configured charter entries that would remove a role's contract or never be read.
*
* <p>The map deliberately retains every key from {@code fleet.charters:}. A typed record would
* silently discard an unknown child because {@link Fleet} ignores unknown JSON properties, which
* would make a typo look like an accepted configuration.
*
* @throws IllegalStateException when a charter key is not a role wire name or its value is blank
*/
public void validateCharters() {
if (fleet == null || fleet.charters().isEmpty()) {
return;
}
List<String> valid = java.util.Arrays.stream(MemberRole.values())
.map(MemberRole::wireName)
.toList();
List<String> bad = new ArrayList<>();
fleet.charters().forEach((key, charter) -> {
if (!valid.contains(key)) {
bad.add("fleet.charters." + key + " is not a role wire name (valid: " + valid + ").");
} else if (charter == null || charter.isBlank()) {
bad.add("fleet.charters." + key + " is blank; a configured role needs charter text.");
}
});
if (!bad.isEmpty()) {
throw new IllegalStateException("refusing to start: " + String.join(" ", bad));
}
}
/**
* Reject a member slot whose {@code role} or {@code profile} does not resolve.
*
@@ -27,8 +27,8 @@ import java.util.function.Supplier;
*
* <ul>
* <li><strong>Hot</strong> — re-read per use, so a reload takes effect on the next spawn:
* {@code fleet:} (every role pool and {@code tabLabel}), {@code placement:}, and an existing
* profile's {@code weight} / {@code maxLoad}. Those three are read through a supplier on
* {@code fleet:} (every role pool, {@code charters}, and {@code tabLabel}),
* {@code placement:}, and an existing profile's {@code weight} / {@code maxLoad}. Those three are read through a supplier on
* {@code CompositePeerLauncher}, which is what makes them hot — not the fact that they are
* config.</li>
* <li><strong>Deferred</strong> — accepted into the new snapshot, but the wiring built at startup
@@ -150,6 +150,7 @@ public final class ConfigRef implements Supplier<BridgedConfig> {
fresh.validateAuthExposure();
fresh.validateLeadTabPrefixes();
fresh.validateSubscriptionProfiles();
fresh.validateCharters();
fresh.validateMembers();
} catch (RuntimeException e) {
String msg = e.getMessage() == null ? e.toString() : e.getMessage();