From 799668e129def33d384ffff49b884d92893f20b6 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 04:49:53 +0200 Subject: [PATCH] CB-566: add fleet charter config --- bridged/bridged.example.yaml | 22 ++++++- .../main/java/dev/ltms/bridged/Bridged.java | 1 + .../ltms/bridged/config/BridgedConfig.java | 45 +++++++++++++- .../dev/ltms/bridged/config/ConfigRef.java | 5 +- .../bridged/config/BridgedConfigTest.java | 22 +++++++ .../ltms/bridged/config/ConfigRefTest.java | 58 +++++++++++++++++++ 6 files changed, 148 insertions(+), 5 deletions(-) diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index 0752f19..6b68030 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -231,8 +231,8 @@ placement: weighted # # Not every key can move under a running daemon, and the difference is about what already exists # when the reload happens — not about how important the key is: -# HOT → takes effect on the next spawn: the whole `fleet:` block (every role pool and -# `tabLabel`), `placement:`, and an existing profile's weight / maxLoad. Those are +# HOT → takes effect on the next spawn: the whole `fleet:` block (every role pool, +# `charters`, and `tabLabel`), `placement:`, and an existing profile's weight / maxLoad. Those are # hot because the placement policy reads them through a supplier — being config is # not by itself enough to make a key hot. # DEFERRED → accepted into the new config, but the wiring built at startup keeps the old value @@ -274,6 +274,24 @@ placement: weighted # the candidates, in definition order. A dev and a reviewer staying anonymous is exactly compatible # with being listed here; the entry key just names the entry. fleet: + # Optional launch-charter text, keyed only by the singular role wire names: architect, dev, + # reviewer. Changes are HOT and reach the next spawn without a daemon restart. Do not put secrets + # here: a later launch step writes this text to a world-readable temp file, and ${ENV} interpolation + # is deliberately not supported. + charters: + architect: |- + You are an architect in this fleet. You refine work before anyone builds it: + scope, acceptance criteria, risks, and a unit split. You read the repo and + write analysis. You never commit production code and never open a PR. + A design task is worked by two architects. Design alone first, then exchange + and say plainly where you disagree. Do not concede just to agree. + dev: |- + You implement the one unit you were given, and nothing else. You test it, + commit it, and open your own pull request. You never merge. + reviewer: |- + You review the diff you were given. You report bugs, risks and missing tests. + You do not change code. + # Optional. Template for a member tab's label; {role}, {profile}, {model} and {n} are substituted. # {n} counts per role+profile, so `dev: sonnet #2` really is the second sonnet dev. Because {role} # comes from a closed enum, a generated label can never begin with a lead's tabPrefix. diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index f2be472..73f6e06 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -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(); diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index 547edc9..d6dfb82 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -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 architects, Map developers, Map reviewers, + Map 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 leaders, Map architects, + Map developers, Map 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. + * + *

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 valid = java.util.Arrays.stream(MemberRole.values()) + .map(MemberRole::wireName) + .toList(); + List 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. * diff --git a/bridged/src/main/java/dev/ltms/bridged/config/ConfigRef.java b/bridged/src/main/java/dev/ltms/bridged/config/ConfigRef.java index 37001d5..ccce915 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/ConfigRef.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/ConfigRef.java @@ -27,8 +27,8 @@ import java.util.function.Supplier; * *

    *
  • Hot — 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.
  • *
  • Deferred — accepted into the new snapshot, but the wiring built at startup @@ -150,6 +150,7 @@ public final class ConfigRef implements Supplier { fresh.validateAuthExposure(); fresh.validateLeadTabPrefixes(); fresh.validateSubscriptionProfiles(); + fresh.validateCharters(); fresh.validateMembers(); } catch (RuntimeException e) { String msg = e.getMessage() == null ? e.toString() : e.getMessage(); diff --git a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java index 9475e69..52f2284 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java @@ -111,6 +111,28 @@ class BridgedConfigTest { assertDoesNotThrow(() -> BridgedConfig.load(f)); } + @Test + void absentChartersRemainValidAndPresentChartersUseRoleWireNames(@TempDir Path dir) throws Exception { + Path absent = dir.resolve("absent.yaml"); + Files.writeString(absent, "fleet: {}\n"); + BridgedConfig withoutCharters = BridgedConfig.load(absent); + assertDoesNotThrow(withoutCharters::validateCharters); + assertNull(withoutCharters.fleet().charterFor(MemberRole.ARCHITECT)); + + Path blank = dir.resolve("blank.yaml"); + Files.writeString(blank, "fleet:\n charters:\n architect: ' '\n"); + IllegalStateException blankError = assertThrows(IllegalStateException.class, + () -> BridgedConfig.load(blank).validateCharters()); + assertTrue(blankError.getMessage().contains("fleet.charters.architect is blank")); + + Path unknown = dir.resolve("unknown.yaml"); + Files.writeString(unknown, "fleet:\n charters:\n architetc: text\n"); + IllegalStateException unknownError = assertThrows(IllegalStateException.class, + () -> BridgedConfig.load(unknown).validateCharters()); + assertTrue(unknownError.getMessage().contains("architetc")); + assertTrue(unknownError.getMessage().contains("[architect, dev, reviewer]")); + } + /** * CB-530. Unknown keys stay ignored — config must be allowed to run ahead of the code — but they * must be NAMED at load. A whole block that parses, is dropped, and is never mentioned again is diff --git a/bridged/src/test/java/dev/ltms/bridged/config/ConfigRefTest.java b/bridged/src/test/java/dev/ltms/bridged/config/ConfigRefTest.java index f9550d7..735cd96 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/ConfigRefTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/ConfigRefTest.java @@ -66,6 +66,64 @@ class ConfigRefTest { assertEquals("[{profile}] {role}", ref.get().fleet().tabLabel()); } + @Test + void aCharterChangeIsHotAndReachesTheLiveConfig(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, yaml(""" + fleet: + charters: + architect: old charter + """)); + ConfigRef ref = refFor(f); + assertEquals("old charter", ref.get().fleet().charterFor( + dev.ltms.bridged.peer.MemberRole.ARCHITECT)); + + Files.writeString(f, yaml(""" + fleet: + charters: + architect: new charter + """)); + ConfigRef.Outcome out = ref.reload(); + + assertTrue(out.applied()); + assertTrue(out.deferred().isEmpty()); + assertEquals("new charter", ref.get().fleet().charterFor( + dev.ltms.bridged.peer.MemberRole.ARCHITECT)); + } + + @Test + void invalidChartersRefuseReloadAndKeepTheRunningConfig(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, yaml(""" + fleet: + charters: + architect: valid charter + """)); + ConfigRef ref = refFor(f); + BridgedConfig before = ref.get(); + + Files.writeString(f, yaml(""" + fleet: + charters: + architect: " " + """)); + ConfigRef.Outcome blank = ref.reload(); + assertFalse(blank.applied()); + assertTrue(blank.error().contains("fleet.charters.architect is blank")); + assertSame(before, ref.get()); + + Files.writeString(f, yaml(""" + fleet: + charters: + architetc: valid charter + """)); + ConfigRef.Outcome unknown = ref.reload(); + assertFalse(unknown.applied()); + assertTrue(unknown.error().contains("architetc")); + assertTrue(unknown.error().contains("architect")); + assertSame(before, ref.get()); + } + /** * The point of the whole class: a consumer holding the ref sees the new value without being * rebuilt. A component that captured {@code get()} into a field would still show the old one.