CB-557: fleet role pools — role is the config key, and the tab label says it

Four top-level keys (leaders:, members:, leadScan:, defaultProfile:) become one
`fleet:` block, and a member's role becomes the map key that contains it rather
than a `role:` field inside it.

Why the key and not a field: a misspelled `role: architct` used to produce a
member with no contract, which nothing rejected. A misspelled pool name declares
nothing, which is a shape the loader can see.

`fleet.architects/developers/reviewers` are pools of profiles a role MAY run on.
That replaces the single global `defaultProfile:`, so an unqualified spawn now
resolves its profile from the pool of the role it asked for. Role and profile
stay orthogonal: a reviewer may run on the same profile as the dev it reviews,
and one profile may appear in several pools.

Tab labels are role-first — `dev: sonnet #4`. The template lives on `fleet:`
because a profile cannot know the role of the member launched on it; a profile
may still override it. The `{n}` counter is scoped per role+profile, so a dev
and a reviewer on one profile each start at #1. Making {role} the first field
also turns the lead/member namespace check into a structural guarantee: roles
are a closed enum, so only hand-written templates can still collide with a lead
tabPrefix.

Removed keys are hard errors that name their successor. `defaultProfile:` has no
single successor key, so its message explains the new model instead of pointing
at a key that does not exist.

Map order is kept with LinkedHashMap, deliberately not Map.copyOf — the latter
salts iteration order per JVM run, which would destroy the YAML definition order
that `placement: fixed` selects on.

Not yet wired: SessionManager still hands the launchers one effectiveDefault-
Profile, so pools are not enforced at spawn time yet, and placement still ranges
over all profiles.

595 tests pass.
This commit is contained in:
Dai Ha
2026-08-14 16:32:54 +02:00
parent 3aa145cbef
commit 4b48d2d921
13 changed files with 1243 additions and 495 deletions
@@ -1,12 +1,14 @@
package dev.ltms.bridged.auth;
import dev.ltms.bridged.config.BridgedConfig;
import dev.ltms.bridged.peer.MemberRole;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -22,23 +24,52 @@ import static org.junit.jupiter.api.Assertions.*;
*/
class MemberRegistryTest {
private static final Map<String, BridgedConfig.Member> SLOTS = Map.of(
"lead-designer", new BridgedConfig.Member("architect", "sonnet"),
"reviewer", new BridgedConfig.Member("architect", "gx10"));
/**
* Slot keys are qualified by role (CB-557): a bare name is unique only within its pool, so
* {@code sonnet} can be both a developer and a reviewer, while a terminal binds to exactly one.
*/
private static final String DESIGNER = "architect:lead-designer";
private static final String REVIEWER = "architect:code-reviewer";
private final MemberRegistry registry = new MemberRegistry(SLOTS);
private static BridgedConfig.Fleet fleetWith(Map<String, BridgedConfig.Slot> architects) {
return new BridgedConfig.Fleet(Map.of(), architects, Map.of(), Map.of(), null);
}
private static Map<String, BridgedConfig.Slot> architects() {
Map<String, BridgedConfig.Slot> pool = new LinkedHashMap<>();
pool.put("lead-designer", new BridgedConfig.Slot("sonnet"));
pool.put("code-reviewer", new BridgedConfig.Slot("gx10"));
return pool;
}
private final MemberRegistry registry = new MemberRegistry(fleetWith(architects()));
@Test
void exposesTheConfiguredSlots() {
assertEquals(SLOTS.keySet(), registry.slots().keySet());
assertTrue(registry.isSlot("reviewer"));
void exposesTheConfiguredSlotsQualifiedByRole() {
assertEquals(Set.of(DESIGNER, REVIEWER), registry.slots().keySet());
assertTrue(registry.isSlot(REVIEWER));
assertFalse(registry.isSlot("nope"));
assertFalse(registry.isSlot("lead-designer"),
"the bare name is not the key — it is unique only inside its pool");
}
/** The case the pool shape exists for: one profile serving two roles is not a duplicate. */
@Test
void oneProfileMayServeTwoRolesUnderTheSameSlotName() {
Map<String, BridgedConfig.Slot> devs = Map.of("sonnet", new BridgedConfig.Slot("sonnet"));
Map<String, BridgedConfig.Slot> revs = Map.of("sonnet", new BridgedConfig.Slot("sonnet"));
MemberRegistry r = new MemberRegistry(
new BridgedConfig.Fleet(Map.of(), Map.of(), devs, revs, null));
assertEquals(Set.of("dev:sonnet", "reviewer:sonnet"), r.slots().keySet());
assertEquals(MemberRole.DEV, r.roleForSlot("dev:sonnet"));
assertEquals(MemberRole.REVIEWER, r.roleForSlot("reviewer:sonnet"));
}
@Test
void theSpawnLifecycleReadsTheProfileBackFromASlot() {
assertEquals("sonnet", registry.profileForSlot("lead-designer"));
assertEquals("gx10", registry.profileForSlot("reviewer"));
assertEquals("sonnet", registry.profileForSlot(DESIGNER));
assertEquals("gx10", registry.profileForSlot(REVIEWER));
assertNull(registry.profileForSlot("unknown"), "an unknown slot has no profile");
}
@@ -54,9 +85,9 @@ class MemberRegistryTest {
@Test
void bindResolvesTheTerminalToTheSlot() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertEquals("lead-designer", registry.slotForTerminal("term_design"));
assertEquals(Map.of("term_design", "lead-designer"), registry.snapshot());
assertTrue(registry.bind(DESIGNER, "term_design"));
assertEquals(DESIGNER, registry.slotForTerminal("term_design"));
assertEquals(Map.of("term_design", DESIGNER), registry.snapshot());
}
@Test
@@ -68,27 +99,27 @@ class MemberRegistryTest {
@Test
void bindRefusesATerminalInTwoSlots() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertFalse(registry.bind("reviewer", "term_design"),
assertTrue(registry.bind(DESIGNER, "term_design"));
assertFalse(registry.bind(REVIEWER, "term_design"),
"a terminal may occupy at most one slot");
assertEquals("lead-designer", registry.slotForTerminal("term_design"),
assertEquals(DESIGNER, registry.slotForTerminal("term_design"),
"the first binding survives the refused second");
}
@Test
void bindRefusesASlotWithTwoTerminals() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertFalse(registry.bind("lead-designer", "term_other"),
assertTrue(registry.bind(DESIGNER, "term_design"));
assertFalse(registry.bind(DESIGNER, "term_other"),
"a slot may host at most one terminal");
assertEquals("lead-designer", registry.slotForTerminal("term_design"),
assertEquals(DESIGNER, registry.slotForTerminal("term_design"),
"the first binding survives the refused second");
assertNull(registry.slotForTerminal("term_other"));
}
@Test
void rebindingTheSamePairIsAnIdempotentNoOp() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertTrue(registry.bind("lead-designer", "term_design"),
assertTrue(registry.bind(DESIGNER, "term_design"));
assertTrue(registry.bind(DESIGNER, "term_design"),
"the same terminal → slot is harmless to repeat");
assertEquals(1, registry.snapshot().size());
}
@@ -97,8 +128,8 @@ class MemberRegistryTest {
@Test
void unbindRemovesTheExactBinding() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertTrue(registry.unbind("lead-designer", "term_design"));
assertTrue(registry.bind(DESIGNER, "term_design"));
assertTrue(registry.unbind(DESIGNER, "term_design"));
assertNull(registry.slotForTerminal("term_design"));
assertTrue(registry.snapshot().isEmpty());
}
@@ -106,32 +137,32 @@ class MemberRegistryTest {
@Test
void aStaleUnbindDoesNotRemoveAReplacement() {
// Bind, tear down, and stand the slot back up with a NEW terminal.
assertTrue(registry.bind("lead-designer", "term_design"));
registry.unbind("lead-designer", "term_design");
assertTrue(registry.bind("lead-designer", "term_new"));
assertTrue(registry.bind(DESIGNER, "term_design"));
registry.unbind(DESIGNER, "term_design");
assertTrue(registry.bind(DESIGNER, "term_new"));
// A late unbind naming the OLD terminal must not remove the replacement binding.
assertFalse(registry.unbind("lead-designer", "term_design"));
assertEquals("lead-designer", registry.slotForTerminal("term_new"),
assertFalse(registry.unbind(DESIGNER, "term_design"));
assertEquals(DESIGNER, registry.slotForTerminal("term_new"),
"the replacement terminal stays bound");
}
@Test
void aStaleUnbindForATerminalThatMovedSlotsDoesNothing() {
// term_design starts in lead-designer, is torn down, and stands back up in a FREE slot.
assertTrue(registry.bind("lead-designer", "term_design"));
registry.unbind("lead-designer", "term_design");
assertTrue(registry.bind("reviewer", "term_design"));
assertTrue(registry.bind(DESIGNER, "term_design"));
registry.unbind(DESIGNER, "term_design");
assertTrue(registry.bind(REVIEWER, "term_design"));
// Unbinding against the slot it no longer occupies is refused; the new binding is intact.
assertFalse(registry.unbind("lead-designer", "term_design"),
assertFalse(registry.unbind(DESIGNER, "term_design"),
"the old slot must not unbind a terminal that moved elsewhere");
assertEquals("reviewer", registry.slotForTerminal("term_design"));
assertEquals(REVIEWER, registry.slotForTerminal("term_design"));
}
@Test
void unbindOfNothingIsAFalseNoOp() {
assertFalse(registry.unbind("lead-designer", "term_design"),
assertFalse(registry.unbind(DESIGNER, "term_design"),
"nothing was bound, so nothing is removed");
}
@@ -139,14 +170,14 @@ class MemberRegistryTest {
@Test
void theSnapshotIsAnImmutableCopyNotAliveState() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertTrue(registry.bind(DESIGNER, "term_design"));
Map<String, String> snap = registry.snapshot();
assertThrows(UnsupportedOperationException.class, () -> snap.put("x", "y"),
"a handed-out snapshot cannot be mutated in place");
// Later binds must not leak into an earlier snapshot.
assertTrue(registry.bind("reviewer", "term_review"));
assertTrue(registry.bind(REVIEWER, "term_review"));
assertFalse(snap.containsKey("term_review"),
"a snapshot is a point-in-time copy, not a live view");
}
@@ -164,7 +195,7 @@ class MemberRegistryTest {
final String term = "term_" + i; // every thread races for the SAME slot
results.add(pool.submit(() -> {
go.await();
return registry.bind("lead-designer", term);
return registry.bind(DESIGNER, term);
}));
}
go.countDown();
@@ -191,7 +222,7 @@ class MemberRegistryTest {
CountDownLatch go = new CountDownLatch(1);
List<Future<String>> results = new ArrayList<>();
for (int i = 0; i < n; i++) {
final String slot = (i % 2 == 0) ? "lead-designer" : "reviewer"; // all race for ONE terminal
final String slot = (i % 2 == 0) ? DESIGNER : REVIEWER; // all race for ONE terminal
results.add(pool.submit(() -> {
go.await();
return registry.bind(slot, "shared_term")
@@ -228,11 +259,11 @@ class MemberRegistryTest {
/** A handed-over slot map is snapshotted at construction, not offered as live state. */
@Test
void theSlotSnapshotIsFixedByConstruction() {
Map<String, BridgedConfig.Member> mutable = new HashMap<>(SLOTS);
MemberRegistry r = new MemberRegistry(mutable);
Map<String, BridgedConfig.Slot> mutable = architects();
MemberRegistry r = new MemberRegistry(fleetWith(mutable));
mutable.put("hijack", new BridgedConfig.Member("architect", "gx10"));
mutable.put("hijack", new BridgedConfig.Slot("gx10"));
assertFalse(r.isSlot("hijack"), "a handed-over map is not offered as live state");
assertFalse(r.isSlot("architect:hijack"), "a handed-over map is not offered as live state");
}
}
@@ -1,6 +1,7 @@
package dev.ltms.bridged.config;
import dev.ltms.bridged.auth.MemberRegistry;
import dev.ltms.bridged.peer.MemberRole;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -63,13 +64,12 @@ class BridgedConfigTest {
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(Set.of("ltms-local"), cfg.profiles().keySet());
assertNull(cfg.defaultProfile(), "nothing named a default");
assertEquals("ltms-local", cfg.effectiveDefaultProfile(),
"with one profile configured there is nothing to choose between");
}
@Test
void loadsMultipleWorkerProfilesWithADefault(@TempDir Path dir) throws Exception {
void loadsMultipleProfilesAndPicksADevDefaultFromTheirPool(@TempDir Path dir) throws Exception {
Path f = dir.resolve("multi.yaml");
Files.writeString(f, """
profiles:
@@ -79,7 +79,12 @@ class BridgedConfigTest {
ollama:
baseUrl: http://ollama.ltms.dev
argv: ["ccs", "ollama"]
defaultProfile: gx10
fleet:
developers:
gx10:
profile: gx10
ollama:
profile: ollama
guard:
offSubscriptionHosts: [gx10.gw, ollama.ltms.dev]
""");
@@ -91,7 +96,10 @@ class BridgedConfigTest {
assertEquals(java.util.List.of("gx10", "ollama"),
java.util.List.copyOf(cfg.profiles().keySet()),
"profiles must preserve YAML definition order");
assertEquals("gx10", cfg.defaultProfile());
assertEquals(List.of("gx10", "ollama"), cfg.candidateProfiles(MemberRole.DEV),
"the dev pool supplies the candidates, in definition order");
assertEquals("gx10", cfg.effectiveDefaultProfile(),
"the fixed policy answers with the pool's first entry");
assertEquals("ollama", cfg.profiles().get("ollama").profile(), "profile defaults to its map key");
assertEquals("http://gx10.gw:8000", cfg.profiles().get("gx10").baseUrl());
}
@@ -124,7 +132,6 @@ class BridgedConfigTest {
port: 8080
herdrSocket: /tmp/s
profiles: {}
defaultProfile: a
guard: {}
worktreeRoot: /tmp
lifecycle: {}
@@ -132,9 +139,7 @@ class BridgedConfigTest {
spawnReadyPollMs: 1
broker: {}
primary: {}
leaders: {}
members: {}
leadScan: {}
fleet: {}
leadHeartbeat: {}
placement: fixed
auth: {}
@@ -150,38 +155,93 @@ class BridgedConfigTest {
// ── CB-531: lead discovery by tab label ─────────────────────────────────────────────────────
@Test
void leadScanIsOffUnlessTheBlockIsPresent(@TempDir Path dir) throws Exception {
void leadScanIsOffUnlessALeadIsConfigured(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-scan.yaml");
Files.writeString(f, "bind:\n port: 8080\n");
assertNull(BridgedConfig.load(f).leadScan(),
assertTrue(BridgedConfig.load(f).fleet().leaders().isEmpty(),
"turning this on widens who resolves as PRIMARY — upgrading the daemon must not do that");
}
@Test
void leadScanDefaultsItsFieldsWhenTheBlockIsPresentButBare(@TempDir Path dir) throws Exception {
void aLeadDefaultsItsScanFieldsWhenPresentButBare(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bare-scan.yaml");
Files.writeString(f, "bind:\n port: 8080\nleadScan: {}\n");
Files.writeString(f, """
bind:
port: 8080
fleet:
leaders:
opus:
terminal: term_opus
""");
BridgedConfig.LeadScan scan = BridgedConfig.load(f).leadScan();
assertEquals("lead:", scan.tabPrefix());
assertEquals(10, scan.intervalSeconds());
BridgedConfig.Leader lead = BridgedConfig.load(f).fleet().leaders().get("opus");
assertEquals("lead:", lead.tabPrefix());
assertEquals(10, lead.scanIntervalSeconds());
assertEquals(1, lead.instances(), "one of a lead is the assumption worth defaulting to");
}
@Test
void leadScanReadsAnExplicitPrefixAndInterval(@TempDir Path dir) throws Exception {
void aLeadReadsAnExplicitPrefixAndInterval(@TempDir Path dir) throws Exception {
Path f = dir.resolve("scan.yaml");
Files.writeString(f, """
bind:
port: 8080
leadScan:
tabPrefix: "drive:"
intervalSeconds: 30
fleet:
leaders:
opus:
terminal: term_opus
tabPrefix: "drive:"
scanIntervalSeconds: 30
""");
BridgedConfig.LeadScan scan = BridgedConfig.load(f).leadScan();
assertEquals("drive:", scan.tabPrefix());
assertEquals(30, scan.intervalSeconds());
BridgedConfig.Leader lead = BridgedConfig.load(f).fleet().leaders().get("opus");
assertEquals("drive:", lead.tabPrefix());
assertEquals(30, lead.scanIntervalSeconds());
}
/**
* The pane no longer has to exist before the daemon does (CB-557): a lead naming a profile may
* be launched, while one that names only a terminal is recognised and never created.
*/
@Test
void aLeadIsCreatableOnlyWhenItNamesAProfile(@TempDir Path dir) throws Exception {
Path f = dir.resolve("creatable.yaml");
Files.writeString(f, """
bind:
port: 8080
profiles:
opus:
subscription: true
fleet:
leaders:
launched:
profile: opus
pinned:
terminal: term_opus
""");
var leaders = BridgedConfig.load(f).fleet().leaders();
assertTrue(leaders.get("launched").isCreatable());
assertFalse(leaders.get("pinned").isCreatable(),
"no profile to launch on ⇒ recognise-only, the pre-CB-557 behaviour");
}
@Test
void aLeadThatCanBeNeitherFoundNorCreatedRefusesToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("useless-lead.yaml");
Files.writeString(f, """
bind:
port: 8080
fleet:
leaders:
ghost:
tabPrefix: "lead:"
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
assertTrue(e.getMessage().contains("ghost"), "the message must name the useless entry");
}
// ── CB-551: the idle-lead heartbeat ─────────────────────────────────────────────────────────
@@ -234,7 +294,8 @@ class BridgedConfigTest {
* Overlap the two and every worker it spawns is read back as a lead.
*/
@Test
void aLeadPrefixThatAWorkerTabLabelAlsoMatchesRefusesToStart(@TempDir Path dir) throws Exception {
void aLeadPrefixThatAProfileTabLabelOverrideAlsoMatchesRefusesToStart(@TempDir Path dir)
throws Exception {
Path f = dir.resolve("collide.yaml");
Files.writeString(f, """
bind:
@@ -242,17 +303,45 @@ class BridgedConfigTest {
profiles:
gx10:
tabLabel: "lead: {profile} #{n}"
leadScan:
tabPrefix: "lead:"
fleet:
leaders:
opus:
terminal: term_opus
tabPrefix: "lead:"
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateLeadScan);
IllegalStateException e =
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
}
/** A bad fleet-wide template promotes every member, not one profile — so it is checked too. */
@Test
void theDefaultWorkerTabLabelDoesNotCollideWithTheDefaultLeadPrefix(@TempDir Path dir) throws Exception {
void aFleetTabLabelThatMatchesALeadPrefixRefusesToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("collide-template.yaml");
Files.writeString(f, """
bind:
port: 8080
fleet:
tabLabel: "lead: {role} {profile}"
leaders:
opus:
terminal: term_opus
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e =
assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
assertTrue(e.getMessage().contains("fleet.tabLabel"));
}
/**
* The point of making role the label's first field: {@code {role}} comes from a closed enum, so
* a generated label cannot begin with {@code "lead:"} however the fleet is configured.
*/
@Test
void theDefaultTabLabelCannotCollideWithTheDefaultLeadPrefix(@TempDir Path dir) throws Exception {
Path f = dir.resolve("ok.yaml");
Files.writeString(f, """
bind:
@@ -260,14 +349,22 @@ class BridgedConfigTest {
profiles:
gx10:
baseUrl: http://gx00.gw:8000
leadScan: {}
fleet:
leaders:
opus:
terminal: term_opus
""");
assertDoesNotThrow(() -> BridgedConfig.load(f).validateLeadScan());
assertDoesNotThrow(() -> BridgedConfig.load(f).validateLeadTabPrefixes());
for (MemberRole role : MemberRole.values()) {
assertFalse(BridgedConfig.Fleet.DEFAULT_TAB_LABEL
.replace("{role}", role.wireName()).startsWith("lead:"),
"no role renders a label that reads as a lead");
}
}
@Test
void theCollisionGuardIsANoOpWhenScanningIsOff(@TempDir Path dir) throws Exception {
void theCollisionGuardIsANoOpWhenNoLeadIsConfigured(@TempDir Path dir) throws Exception {
Path f = dir.resolve("off.yaml");
Files.writeString(f, """
bind:
@@ -277,7 +374,7 @@ class BridgedConfigTest {
tabLabel: "lead: {profile}"
""");
assertDoesNotThrow(() -> BridgedConfig.load(f).validateLeadScan(),
assertDoesNotThrow(() -> BridgedConfig.load(f).validateLeadTabPrefixes(),
"a label that collides with a convention nobody reads is not a problem");
}
@@ -289,21 +386,23 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
leaders:
opus-5.0:
terminal: term_opus
kind: claude
gpt-sol-5.6:
terminal: term_sol
kind: opencode
model: openai/gpt-5.6-terra
fleet:
leaders:
opus-5.0:
terminal: term_opus
kind: claude
gpt-sol-5.6:
terminal: term_sol
kind: opencode
model: openai/gpt-5.6-terra
""");
BridgedConfig cfg = BridgedConfig.load(f);
var leaders = cfg.fleet().leaders();
assertEquals(Set.of("opus-5.0", "gpt-sol-5.6"), cfg.leaders().keySet());
assertEquals("opencode", cfg.leaders().get("gpt-sol-5.6").kind());
assertEquals("openai/gpt-5.6-terra", cfg.leaders().get("gpt-sol-5.6").model());
assertEquals(Set.of("opus-5.0", "gpt-sol-5.6"), leaders.keySet());
assertEquals("opencode", leaders.get("gpt-sol-5.6").kind());
assertEquals("openai/gpt-5.6-terra", leaders.get("gpt-sol-5.6").model());
// The whole point: BOTH panes resolve as leads, so neither is demoted to worker.
assertEquals(Map.of("term_opus", "opus-5.0", "term_sol", "gpt-sol-5.6"),
cfg.leaderTerminals());
@@ -326,9 +425,10 @@ class BridgedConfigTest {
port: 8080
primary:
terminal: term_shared
leaders:
opus-5.0:
terminal: term_shared
fleet:
leaders:
opus-5.0:
terminal: term_shared
""");
assertEquals(Map.of("term_shared", "opus-5.0"), BridgedConfig.load(f).leaderTerminals(),
@@ -343,9 +443,10 @@ class BridgedConfigTest {
port: 8080
primary:
terminal: term_pinned
leaders:
gpt-sol-5.6:
terminal: term_sol
fleet:
leaders:
gpt-sol-5.6:
terminal: term_sol
""");
assertEquals(Map.of("term_pinned", "primary", "term_sol", "gpt-sol-5.6"),
@@ -367,11 +468,12 @@ class BridgedConfigTest {
Files.writeString(f, """
bind:
port: 8080
leaders:
sketch:
kind: opencode
real:
terminal: term_real
fleet:
leaders:
sketch:
kind: opencode
real:
terminal: term_real
""");
assertEquals(Map.of("term_real", "real"), BridgedConfig.load(f).leaderTerminals());
@@ -380,7 +482,7 @@ class BridgedConfigTest {
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
@Test
void architectsBlockDeclaresSlotsByNameAndProfileOnly(@TempDir Path dir) throws Exception {
void aRolePoolDeclaresSlotsByNameAndProfileOnly(@TempDir Path dir) throws Exception {
Path f = dir.resolve("architects.yaml");
Files.writeString(f, """
bind:
@@ -388,28 +490,81 @@ class BridgedConfigTest {
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
members:
lead-designer:
role: architect
profile: sonnet
reviewer:
role: architect
profile: sonnet
fleet:
architects:
lead-designer:
profile: sonnet
second-opinion:
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(Set.of("lead-designer", "reviewer"), cfg.members().keySet(),
"slot names are the keys — gateway-local unique by construction");
assertEquals("sonnet", cfg.members().get("lead-designer").profile(),
"each slot carries its strong-model profile reference");
assertEquals("sonnet", cfg.members().get("reviewer").profile());
var pool = cfg.fleet().pool(MemberRole.ARCHITECT);
assertEquals(Set.of("lead-designer", "second-opinion"), pool.keySet(),
"slot names are the keys — unique within their pool by construction");
assertEquals("sonnet", pool.get("lead-designer").profile(),
"each slot carries its backend reference");
assertEquals("sonnet", pool.get("second-opinion").profile());
}
/**
* The role is the containing key now (CB-557), so it cannot be misspelled into a member with no
* contract. A pool name that is not a role is simply not a pool.
*/
@Test
void theRoleIsTheContainingKeyNotAField(@TempDir Path dir) throws Exception {
Path f = dir.resolve("role-by-key.yaml");
Files.writeString(f, """
bind:
port: 8080
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
fleet:
developers:
a:
profile: sonnet
reviewers:
b:
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(List.of("sonnet"), cfg.fleet().profilesFor(MemberRole.DEV));
assertEquals(List.of("sonnet"), cfg.fleet().profilesFor(MemberRole.REVIEWER));
assertTrue(cfg.fleet().profilesFor(MemberRole.ARCHITECT).isEmpty());
assertEquals(List.of(MemberRole.DEV, MemberRole.REVIEWER), cfg.fleet().rolesConfigured());
}
/** The case the two axes exist for: one backend, two roles, and neither is a duplicate. */
@Test
void oneProfileMayServeSeveralRoles(@TempDir Path dir) throws Exception {
Path f = dir.resolve("shared.yaml");
Files.writeString(f, """
bind:
port: 8080
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
fleet:
developers:
sonnet:
profile: sonnet
reviewers:
sonnet:
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertDoesNotThrow(cfg::validateMembers);
assertEquals("sonnet", cfg.defaultProfileFor(MemberRole.DEV));
assertEquals("sonnet", cfg.defaultProfileFor(MemberRole.REVIEWER));
}
@Test
void anArchitectCarriesNoConfigTerminalSoNothingIsRecognisedYet(@TempDir Path dir) throws Exception {
// The corrected CB-548 premise: config declares slots (name + profile) only. A `terminal:`
// key left over from the earlier premise is ignored — an architect is NOT recognised from
// config the way a lead is, so it binds nothing at startup and resolves no architect.
void aSlotCarriesNoConfigTerminalSoNothingIsRecognisedYet(@TempDir Path dir) throws Exception {
// Config declares slots (name + profile) only. A `terminal:` key is ignored — a member is
// NOT recognised from config the way a lead is, so it binds nothing at startup.
Path f = dir.resolve("arch-stale-terminal.yaml");
Files.writeString(f, """
bind:
@@ -417,35 +572,38 @@ class BridgedConfigTest {
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
members:
lead-designer:
terminal: term_design
profile: sonnet
fleet:
architects:
lead-designer:
terminal: term_design
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals("sonnet", cfg.members().get("lead-designer").profile(),
assertEquals("sonnet", cfg.fleet().pool(MemberRole.ARCHITECT).get("lead-designer").profile(),
"the profile is still read even when a stray terminal is ignored");
// The registry built from this config owns no bindings: the slot is idle at startup.
MemberRegistry r = new MemberRegistry(cfg.members());
MemberRegistry r = new MemberRegistry(cfg.fleet());
assertTrue(r.snapshot().isEmpty());
assertNull(r.slotForTerminal("term_design"),
"a config terminal must not resolve an architect — slots start idle");
"a config terminal must not resolve a member — slots start idle");
}
@Test
void noArchitectsBlockLeavesNothingConfigured(@TempDir Path dir) throws Exception {
void noFleetBlockLeavesEveryPoolEmpty(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-arch.yaml");
Files.writeString(f, "bind:\n port: 8080\n");
assertNull(BridgedConfig.load(f).members(),
"no members: block ⇒ no architect identity, exactly as before CB-548");
BridgedConfig cfg = BridgedConfig.load(f);
assertNotNull(cfg.fleet(), "an absent block is an empty fleet, not a null one");
assertTrue(cfg.fleet().rolesConfigured().isEmpty(),
"no fleet: block ⇒ no member identity, exactly as before CB-548");
}
@Test
void anArchitectSlotMayResolveToTheSoleProfileWithoutPrivileging(@TempDir Path dir) throws Exception {
// Even a single unqualified worker profile can back an architect slot — the reference is
// by name, not by position, so an explicit name is required.
void aSlotMayResolveToTheSoleProfileWithoutPrivileging(@TempDir Path dir) throws Exception {
// Even a single unqualified profile must be named explicitly — the reference is by name,
// not by position.
Path f = dir.resolve("arch-single.yaml");
Files.writeString(f, """
bind:
@@ -453,15 +611,16 @@ class BridgedConfigTest {
profiles:
ltms-local:
baseUrl: http://gx10.gw:8000
members:
lead-designer:
role: architect
profile: ltms-local
fleet:
architects:
lead-designer:
profile: ltms-local
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertDoesNotThrow(cfg::validateMembers);
assertEquals("ltms-local", cfg.members().get("lead-designer").profile());
assertEquals("ltms-local",
cfg.fleet().pool(MemberRole.ARCHITECT).get("lead-designer").profile());
}
@Test
@@ -473,10 +632,10 @@ class BridgedConfigTest {
profiles:
gx10:
baseUrl: http://gx10.gw:8000
members:
lead-designer:
role: architect
profile: sonnet
fleet:
architects:
lead-designer:
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
@@ -494,10 +653,10 @@ class BridgedConfigTest {
profiles:
gx10:
baseUrl: http://gx10.gw:8000
members:
lead-designer:
role: architect
profile: ""
fleet:
architects:
lead-designer:
profile: ""
""");
BridgedConfig cfg = BridgedConfig.load(f);
@@ -516,13 +675,13 @@ class BridgedConfigTest {
baseUrl: http://gx10.gw:8000
gx10:
baseUrl: http://gx10.gw:8000
members:
lead-designer:
role: architect
profile: sonnet
reviewer:
role: architect
profile: gx10
fleet:
architects:
lead-designer:
profile: sonnet
reviewers:
second-pair-of-eyes:
profile: gx10
""");
assertDoesNotThrow(() -> BridgedConfig.load(f).validateMembers());
@@ -537,7 +696,7 @@ class BridgedConfigTest {
}
@Test
void duplicateArchitectSlotNamesAreRejectedAtParseTime(@TempDir Path dir) throws Exception {
void duplicateSlotNamesInOnePoolAreRejectedAtParseTime(@TempDir Path dir) throws Exception {
Path f = dir.resolve("arch-dup.yaml");
Files.writeString(f, """
bind:
@@ -545,26 +704,52 @@ class BridgedConfigTest {
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
members:
lead-designer:
role: architect
profile: sonnet
lead-designer:
role: architect
profile: sonnet
fleet:
architects:
lead-designer:
profile: sonnet
lead-designer:
profile: sonnet
""");
IllegalStateException e =
assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f));
assertTrue(e.getMessage().contains("lead-designer"),
"the refusal names the duplicated slot, was: " + e.getMessage());
assertTrue(e.getMessage().contains("duplicate member"),
"the refusal says the slot name is duplicated");
assertTrue(e.getMessage().contains("fleet.architects"),
"the refusal names the pool the duplicate is in, was: " + e.getMessage());
}
/**
* The same name in two different pools is the role × profile matrix, not a mistake — only a
* repeat <em>within</em> one pool loses an entry.
*/
@Test
void theSameSlotNameInTwoPoolsIsNotADuplicate(@TempDir Path dir) throws Exception {
Path f = dir.resolve("cross-pool.yaml");
Files.writeString(f, """
bind:
port: 8080
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
fleet:
developers:
sonnet:
profile: sonnet
reviewers:
sonnet:
profile: sonnet
""");
BridgedConfig cfg = assertDoesNotThrow(() -> BridgedConfig.load(f));
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.DEV).keySet());
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.REVIEWER).keySet());
}
@Test
void duplicateKeysOutsideArchitectsAreUnaffected(@TempDir Path dir) throws Exception {
// The duplicate check is scoped to the architects block — a duplicate elsewhere is not this
void duplicateKeysOutsideTheFleetPoolsAreUnaffected(@TempDir Path dir) throws Exception {
// The duplicate check is scoped to the fleet pools — a duplicate elsewhere is not this
// guard's concern and must not change parsing of the rest of the config.
Path f = dir.resolve("dup-other.yaml");
Files.writeString(f, """
@@ -576,47 +761,47 @@ class BridgedConfigTest {
sonnet:
baseUrl: http://gx10.gw:8000
""");
// Last-wins for a non-architect duplicate is untouched: only the architects block is walked.
// Last-wins for a non-pool duplicate is untouched: only the fleet pools are walked.
assertEquals(Set.of("sonnet"), BridgedConfig.load(f).profiles().keySet());
}
@Test
void aNestedArchitectsFieldDoesNotSuppressRealDuplicateDetection(@TempDir Path dir) throws Exception {
// A field ALSO named `architects` nested under another block carries its own duplicate and
// sits BEFORE the real top-level block. Only the top-level block is ever inspected: the
// refusal must name the real slot (lead-designer), not the nested one (nested-slot).
void aNestedFleetFieldDoesNotSuppressRealDuplicateDetection(@TempDir Path dir) throws Exception {
// A field ALSO named `fleet` nested under another block carries its own duplicate and sits
// BEFORE the real top-level block. Only the top-level block is ever inspected: the refusal
// must name the real slot (lead-designer), not the nested one (nested-slot).
Path f = dir.resolve("nested-arch.yaml");
Files.writeString(f, """
bind:
port: 8080
members:
nested-slot:
k: v
nested-slot:
k: v
members:
lead-designer:
role: architect
profile: sonnet
lead-designer:
role: architect
profile: sonnet
fleet:
architects:
nested-slot:
k: v
nested-slot:
k: v
fleet:
architects:
lead-designer:
profile: sonnet
lead-designer:
profile: sonnet
""");
IllegalStateException e =
assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f));
assertTrue(e.getMessage().contains("lead-designer"),
"the real top-level duplicate must be reported, was: " + e.getMessage());
assertTrue(e.getMessage().contains("duplicate member"),
"the refusal says the slot name is duplicated");
assertFalse(e.getMessage().contains("nested-slot"),
"the nested block must not be inspected, was: " + e.getMessage());
}
@Test
void nestedDuplicateFieldsInsideASlotAreNotDuplicateSlotNames(@TempDir Path dir) throws Exception {
// A duplicated field nested inside one slot's own value (here inside an ignored `extra:`
// sub-block) is not a duplicate SLOT name — it must not be rejected as one. Only the direct
// child keys of the architects mapping are slot names; whatever is deeper is the slot's
// business and must not masquerade as a duplicate slot.
// child keys of a pool mapping are slot names; whatever is deeper is the slot's business and
// must not masquerade as a duplicate slot.
Path f = dir.resolve("nested-dup-inside-slot.yaml");
Files.writeString(f, """
bind:
@@ -624,20 +809,21 @@ class BridgedConfigTest {
profiles:
sonnet:
baseUrl: http://gx10.gw:8000
members:
lead-designer:
role: architect
profile: sonnet
extra:
a: 1
a: 1
fleet:
architects:
lead-designer:
profile: sonnet
extra:
a: 1
a: 1
""");
BridgedConfig cfg = assertDoesNotThrow(() -> BridgedConfig.load(f));
assertDoesNotThrow(cfg::validateMembers,
"a nested duplicate inside a slot is not a duplicate slot and must not refuse startup");
assertEquals(Set.of("lead-designer"), cfg.members().keySet());
assertEquals("sonnet", cfg.members().get("lead-designer").profile());
var pool = cfg.fleet().pool(MemberRole.ARCHITECT);
assertEquals(Set.of("lead-designer"), pool.keySet());
assertEquals("sonnet", pool.get("lead-designer").profile());
}
@Test
@@ -853,7 +1039,7 @@ class BridgedConfigTest {
BridgedConfig cfg = BridgedConfig.load(example);
assertEquals(8765, cfg.bind().port(), "example binds the documented default port");
assertTrue(cfg.profiles().containsKey("gx10"), "example documents the gx10 profile");
assertEquals("gx10", cfg.defaultProfile(), "example's defaultWorker resolves");
assertEquals("gx10", cfg.effectiveDefaultProfile(), "example's dev pool resolves");
assertTrue(cfg.guard().hostSet().contains("gx01.gw"),
"every example profile's base_url host must be in the example allowlist");
}
@@ -1095,8 +1281,8 @@ class BridgedConfigTest {
""");
IllegalStateException e = assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f));
assertTrue(e.getMessage().contains("'architects' is now 'members'"), e.getMessage());
assertTrue(e.getMessage().contains("'defaultWorker' is now 'defaultProfile'"), e.getMessage());
assertTrue(e.getMessage().contains("'architects' is now 'fleet.architects'"), e.getMessage());
assertTrue(e.getMessage().contains("'defaultWorker' is now a role pool"), e.getMessage());
assertTrue(e.getMessage().contains("'workers' is now 'profiles'"), e.getMessage());
}
@@ -1109,20 +1295,21 @@ class BridgedConfigTest {
baseUrl: http://gx00.gw:8000
opus:
baseUrl: http://gx00.gw:8000
members:
architect-1:
role: architect
profile: opus
reviewer-1:
role: reviewer
profile: gx10
fleet:
architects:
architect-1:
profile: opus
reviewers:
reviewer-1:
profile: gx10
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertDoesNotThrow(cfg::validateMembers);
assertEquals("architect", cfg.members().get("architect-1").role());
assertEquals("opus", cfg.members().get("architect-1").profile());
assertEquals("reviewer", cfg.members().get("reviewer-1").role());
assertEquals("opus", cfg.fleet().pool(MemberRole.ARCHITECT).get("architect-1").profile());
assertEquals("gx10", cfg.fleet().pool(MemberRole.REVIEWER).get("reviewer-1").profile());
assertEquals(List.of(MemberRole.ARCHITECT, MemberRole.REVIEWER),
cfg.fleet().rolesConfigured(), "the pool a slot sits in IS its role");
}
/**
@@ -1136,56 +1323,77 @@ class BridgedConfigTest {
profiles:
gx10:
baseUrl: http://gx00.gw:8000
members:
dev-1:
role: dev
profile: gx10
reviewer-1:
role: reviewer
profile: gx10
fleet:
developers:
dev-1:
profile: gx10
reviewers:
reviewer-1:
profile: gx10
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertDoesNotThrow(cfg::validateMembers);
assertEquals(cfg.members().get("dev-1").profile(), cfg.members().get("reviewer-1").profile());
assertNotEquals(cfg.members().get("dev-1").role(), cfg.members().get("reviewer-1").role());
assertEquals(cfg.fleet().pool(MemberRole.DEV).get("dev-1").profile(),
cfg.fleet().pool(MemberRole.REVIEWER).get("reviewer-1").profile(),
"one backend, two roles — the axes are independent");
assertEquals("gx10", cfg.defaultProfileFor(MemberRole.DEV));
assertEquals("gx10", cfg.defaultProfileFor(MemberRole.REVIEWER));
}
/**
* What the pool shape bought over the old {@code role:} field. A misspelled role used to parse
* into a member with no contract, so it needed catching by name. Now it is a pool name nobody
* reads: the slot simply does not exist, and no member can run under a role that is not one.
*/
@Test
void aMemberSlotWithAnUnknownRoleIsRejectedAndListsTheValidRoles(@TempDir Path dir) throws Exception {
void aMisspelledPoolNameDeclaresNoMembersRatherThanRolelessOnes(@TempDir Path dir)
throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, """
profiles:
gx10:
baseUrl: http://gx00.gw:8000
members:
slot-1:
role: archtiect
profile: gx10
fleet:
archtiects:
slot-1:
profile: gx10
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
assertTrue(e.getMessage().contains("archtiect"), e.getMessage());
assertTrue(e.getMessage().contains("architect, dev, reviewer"),
"the error must list the valid spellings so the typo is fixable from it: " + e.getMessage());
assertDoesNotThrow(cfg::validateMembers);
assertTrue(cfg.fleet().rolesConfigured().isEmpty(),
"a pool name that is not a role declares nothing at all");
assertNull(MemberRole.fromConfigKey("archtiects"));
}
/** Every role's pool key must round-trip, or a correctly-spelled block would be dropped. */
@Test
void everyRolePoolKeyRoundTrips() {
for (MemberRole role : MemberRole.values()) {
assertEquals(role, MemberRole.fromConfigKey(role.configKey()),
role + " must be readable back from the key it is written under");
}
assertNull(MemberRole.fromConfigKey("leaders"), "a lead is not a member role");
assertNull(MemberRole.fromConfigKey("tabLabel"), "a non-pool fleet key is not a role");
}
@Test
void aMemberSlotWithNoRoleIsRejected(@TempDir Path dir) throws Exception {
void aSlotWithNoProfileIsRejectedAndNamesItsPool(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, """
profiles:
gx10:
baseUrl: http://gx00.gw:8000
members:
slot-1:
profile: gx10
fleet:
developers:
slot-1: {}
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
assertTrue(e.getMessage().contains("has no role:"), e.getMessage());
assertTrue(e.getMessage().contains("fleet.developers.slot-1"), e.getMessage());
assertTrue(e.getMessage().contains("has no profile:"), e.getMessage());
}
@Test
@@ -1221,7 +1429,9 @@ class BridgedConfigTest {
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertNull(cfg.defaultProfile(), "the raw config value is absent");
assertEquals("gx10", cfg.effectiveDefaultProfile(), "the resolved value is the first profile");
assertTrue(cfg.fleet().profilesFor(MemberRole.DEV).isEmpty(), "no dev pool is configured");
assertEquals("gx10", cfg.effectiveDefaultProfile(),
"with no pool to choose from, every configured profile is a candidate and the "
+ "first one wins");
}
}
@@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.AgentControl;
import dev.ltms.bridged.herdr.FakeHerdr;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.peer.Capability;
import dev.ltms.bridged.peer.MemberRole;
import dev.ltms.bridged.peer.PeerHandle;
import dev.ltms.bridged.peer.PeerUnreachableException;
import dev.ltms.bridged.peer.SpawnRequest;
@@ -782,4 +783,79 @@ class ClaudeCodeLauncherTest {
assertEquals("/opt/jdk", env.get("JAVA_HOME"),
"only the Anthropic binding keys are stripped; the rest of env: still applies");
}
// ── CB-557: role-aware tab labels ─────────────────────────────────────────────────────────
/** The {@code label} of every {@code tab.rename}, in call order. */
private List<String> tabLabels(FakeHerdr herdr) {
return herdr.calls.stream()
.filter(c -> c.method().equals("tab.rename"))
.map(c -> (String) ((Map<?, ?>) c.params()).get("label"))
.toList();
}
/** A profile with no {@code tabLabel:} of its own — the fleet template decides. */
private ClaudeCodeLauncher labelService(FakeHerdr herdr, String fleetTemplate) {
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"sonnet", "http://gx00.gw:8000", "sonnet", null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", null, null, null, null);
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
_ -> null, 0, 0L, fleetTemplate);
}
/**
* The knob must reach the rename call. It was inert once — {@code HerdrPeerLauncher} accepted a
* template while {@code Bridged} passed none, and the label stayed right only because the
* fallback happened to match. Pin the wiring, not the coincidence.
*/
@Test
void theFleetTemplateNamesTheRoleTheMemberWasSpawnedFor() {
FakeHerdr herdr = new FakeHerdr();
ClaudeCodeLauncher svc = labelService(herdr, "{role}: {profile} #{n}");
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER));
assertEquals(List.of("reviewer: sonnet #1"), tabLabels(herdr));
}
/** The counter is per role+profile, so a dev and a reviewer on one profile both start at #1. */
@Test
void theCounterRunsPerRoleAndProfileNotPerFleet() {
FakeHerdr herdr = new FakeHerdr();
ClaudeCodeLauncher svc = labelService(herdr, "{role}: {profile} #{n}");
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.DEV));
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER));
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.DEV));
assertEquals(List.of("dev: sonnet #1", "reviewer: sonnet #1", "dev: sonnet #2"),
tabLabels(herdr));
}
/** No fleet template configured ⇒ the built-in default, still role-first. */
@Test
void aBlankFleetTemplateFallsBackToTheRoleFirstDefault() {
FakeHerdr herdr = new FakeHerdr();
labelService(herdr, null).spawn(
new SpawnRequest("sonnet", null, null, null, null, MemberRole.ARCHITECT));
assertEquals(List.of("architect: sonnet #1"), tabLabels(herdr));
assertEquals("{role}: {profile} #{n}", BridgedConfig.Fleet.DEFAULT_TAB_LABEL);
}
/** A profile that wants its own label still outranks the fleet template. */
@Test
void aProfileTabLabelOverridesTheFleetTemplate() {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"sonnet", "http://gx00.gw:8000", "sonnet", null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", "pinned {profile}", null, null, null);
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
_ -> null, 0, 0L, "{role}: {profile} #{n}")
.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER));
assertEquals(List.of("pinned sonnet"), tabLabels(herdr));
}
}