diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml
index 48dc7505..f233b94f 100644
--- a/fleetd/fleetd.example.yaml
+++ b/fleetd/fleetd.example.yaml
@@ -669,6 +669,15 @@ fleet:
# kind: opencode
# model: openai/gpt-5.6-terra
+ # A collaborator tab, keyed by name (fleetd #669). This block is parsed and validated today;
+ # nothing yet recognises or addresses the tab it names. Recognise-only, like a profile-less
+ # `leaders:` entry above: there is no `profile:`, no `instances:` and no `kind:`. `tab:` is
+ # REQUIRED and is the only field identity depends on, matched case-insensitively — the same
+ # GET-THE-VALUE-RIGHT warning above the `leaders:` block applies here too.
+ # collaborators:
+ # reviewer-alex:
+ # tab: "collab: alex"
+
# architects:
# architect-1:
# profile: opus # a strong model, on the operator's subscription
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 f751599b..b73838a1 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java
@@ -1176,6 +1176,25 @@ public record FleetConfig(
}
}
+ /**
+ * A tab fleetd recognises as a collaborator, keyed by name (fleetd #669).
+ *
+ *
Recognise-only: there is no {@code profile}, no {@code instances} and no {@code kind}.
+ * Nothing here ever launches a pane.
+ *
+ *
{@code tabPrefix} is absent. Identity is matched on the exact {@code tab} alone.
+ *
+ * @param tab the exact tab label hosting this collaborator, matched case-insensitively; the
+ * only field identity depends on. Required — an entry with no {@code tab} can
+ * never be discovered.
+ */
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record Collaborator(String tab) {
+ public Collaborator {
+ tab = (tab == null || tab.isBlank()) ? null : tab.strip();
+ }
+ }
+
/**
* One entry of a {@code fleet:} role pool — a role paired with the backend it runs on.
*
@@ -1213,15 +1232,18 @@ public record FleetConfig(
* is exactly compatible with that. The pool is also what replaced {@code defaultProfile:} — an
* unqualified spawn names a role, and the role's pool supplies the candidates.
*
- * @param leaders panes that orchestrate rather than are orchestrated, keyed by lead name
- * @param architects profiles the {@code architect} role may run on
- * @param developers profiles the {@code dev} role may run on
- * @param hunters profiles the {@code hunter} 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}
+ * @param leaders panes that orchestrate rather than are orchestrated, keyed by lead name
+ * @param architects profiles the {@code architect} role may run on
+ * @param developers profiles the {@code dev} role may run on
+ * @param hunters profiles the {@code hunter} 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}
+ * @param collaborators tabs fleetd recognises as collaborators (fleetd #669), keyed by name.
+ * Recognise-only, exactly like a {@code profile}-less {@link Leader}:
+ * nothing here is ever auto-launched.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Fleet(Map leaders,
@@ -1230,7 +1252,8 @@ public record FleetConfig(
Map hunters,
Map reviewers,
Map charters,
- String tabLabel) {
+ String tabLabel,
+ Map collaborators) {
/**
* Role first, so the tab bar identifies the member's fleet role.
@@ -1245,26 +1268,30 @@ public record FleetConfig(
reviewers = unmodifiableOrEmpty(reviewers);
charters = unmodifiableOrEmpty(charters);
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
+ collaborators = unmodifiableOrEmpty(collaborators);
}
/**
- * A fleet with no configured launch charters — the shape every deployment had before
- * CB-566, and what most tests want.
+ * A fleet with no configured launch charters and no collaborators — the shape every
+ * deployment had before CB-566, and what most tests want.
*
* Kept deliberately, even though an overload that drops a new field is normally the
* shape to avoid. It is safe here because nothing reads a charter through a
* constructor: the launcher reads {@code fleet.charters()} from the live config. Jackson
* binds the canonical constructor, so this one cannot swallow an operator's YAML.
+ * {@code collaborators} is dropped the same way and for the same reason: no caller of
+ * this overload has ever needed to set it, so it defaults to empty here exactly as the
+ * canonical constructor would default an absent YAML key.
*/
public Fleet(Map leaders, Map architects,
Map developers, Map reviewers,
Map charters, String tabLabel) {
- this(leaders, architects, developers, null, reviewers, charters, tabLabel);
+ this(leaders, architects, developers, null, reviewers, charters, tabLabel, null);
}
public Fleet(Map leaders, Map architects,
Map developers, Map reviewers, String tabLabel) {
- this(leaders, architects, developers, null, reviewers, null, tabLabel);
+ this(leaders, architects, developers, null, reviewers, null, tabLabel, null);
}
/**
@@ -1910,7 +1937,7 @@ public record FleetConfig(
/** The {@code fleet:} child blocks whose direct children are slot names. */
private static final Set FLEET_POOL_KEYS =
- Set.of("leaders", "architects", "developers", "hunters", "reviewers");
+ Set.of("leaders", "architects", "developers", "hunters", "reviewers", "collaborators");
/**
* Reject a {@code fleet:} role pool whose slot names repeat (CB-548, re-homed by CB-557).
@@ -1920,7 +1947,7 @@ public record FleetConfig(
* daemon would never know. Jackson's YAML parser does not fail on duplicate mapping keys by
* default, so duplicates are caught here, at parse time, before the map is built.
*
- * Only the five pools directly under the top-level {@code fleet:} are considered,
+ *
Only the six pools directly under the top-level {@code fleet:} are considered,
* and only their direct child keys (the slot names). A nested field elsewhere, even one also
* named {@code developers:}, is ignored, so parsing of the rest of the config is unaffected.
*
@@ -2679,17 +2706,21 @@ public record FleetConfig(
}
/**
- * Reject a member tab-label template that could render as a configured lead tab or match a
- * lead-tab naming convention, and reject two {@code fleet.leaders} entries that share one exact
- * tab.
+ * Reject a member tab-label template that could render as a configured lead or collaborator
+ * tab or match a lead-tab naming convention, and reject two {@code fleet.leaders} or
+ * {@code fleet.collaborators} entries — across either registry — that share one exact tab.
+ *
+ *
{@code fleet.collaborators} has no {@code tabPrefix}: identity is matched on the exact
+ * {@code tab} alone, so only the exact-render check applies there, not the prefix check.
*
* @throws IllegalStateException when the fleet template or a profile {@code tabLabel} override
- * can render as a configured lead tab or match a lead-tab prefix,
- * or when two {@code fleet.leaders} entries carry the same exact
- * {@code tab} (case-insensitively)
+ * can render as a configured lead or collaborator tab or match a
+ * lead-tab prefix, or when two entries — of either registry, or
+ * one of each — carry the same exact {@code tab}
+ * (case-insensitively)
*/
public void validateLeadTabPrefixes() {
- if (fleet == null || fleet.leaders().isEmpty()) {
+ if (fleet == null) {
return;
}
List bad = new ArrayList<>();
@@ -2722,11 +2753,32 @@ public record FleetConfig(
}
});
});
+ fleet.collaborators().forEach((collabName, collaborator) -> {
+ if (collaborator == null) {
+ return;
+ }
+ String tab = collaborator.tab();
+ if (templateCanRenderAs(fleet.tabLabel(), tab)) {
+ bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" can render as the tab of "
+ + "collaborator '" + collabName + "' (\"" + tab + "\")");
+ }
+ profiles().entrySet().stream()
+ .map(Map.Entry::getKey)
+ .sorted()
+ .forEach(p -> {
+ String label = profiles().get(p).tabLabel();
+ if (templateCanRenderAs(label, tab)) {
+ bad.add("profile '" + p + "' overrides tabLabel with \"" + label
+ + "\", which can render as the tab of collaborator '"
+ + collabName + "' (\"" + tab + "\")");
+ }
+ });
+ });
if (!bad.isEmpty()) {
throw new IllegalStateException("refusing to start: " + String.join("; ", bad)
- + ". Every member labelled that way would be read back as a lead and granted "
- + "spawn/stop/send on the whole fleet. Change one of the two so member tabs "
- + "and lead tabs cannot be confused.");
+ + ". Every member labelled that way would be read back as a lead or "
+ + "collaborator and granted that identity's authority. Change one of the two "
+ + "so member tabs cannot be confused with a lead's or collaborator's tab.");
}
List collisions = new ArrayList<>();
@@ -2749,13 +2801,49 @@ public record FleetConfig(
}
}
}
+ List collabNames = fleet.collaborators().keySet().stream().sorted().toList();
+ for (int i = 0; i < collabNames.size(); i++) {
+ String nameA = collabNames.get(i);
+ Collaborator a = fleet.collaborators().get(nameA);
+ if (a == null || a.tab() == null || a.tab().isBlank()) {
+ continue;
+ }
+ for (int j = i + 1; j < collabNames.size(); j++) {
+ String nameB = collabNames.get(j);
+ Collaborator b = fleet.collaborators().get(nameB);
+ if (b == null || b.tab() == null || b.tab().isBlank()) {
+ continue;
+ }
+ if (a.tab().equalsIgnoreCase(b.tab())) {
+ collisions.add("collaborator '" + nameA + "' and collaborator '" + nameB
+ + "' both use tab \"" + a.tab() + "\"");
+ }
+ }
+ }
+ for (String leadName : leadNames) {
+ Leader lead = fleet.leaders().get(leadName);
+ if (lead == null || lead.tab() == null || lead.tab().isBlank()) {
+ continue;
+ }
+ for (String collabName : collabNames) {
+ Collaborator collaborator = fleet.collaborators().get(collabName);
+ if (collaborator == null || collaborator.tab() == null
+ || collaborator.tab().isBlank()) {
+ continue;
+ }
+ if (lead.tab().equalsIgnoreCase(collaborator.tab())) {
+ collisions.add("lead '" + leadName + "' and collaborator '" + collabName
+ + "' both use tab \"" + lead.tab() + "\"");
+ }
+ }
+ }
if (collisions.isEmpty()) {
return;
}
throw new IllegalStateException("refusing to start: " + String.join("; ", collisions)
- + ". Tab identity is matched exactly, so only one of two leads sharing a tab can "
- + "ever be found — the other is silently unreachable. Give each lead its own "
- + "exact tab.");
+ + ". Tab identity is matched exactly, so only one of two entries sharing a tab can "
+ + "ever be found — the other is silently unreachable. Give each lead and "
+ + "collaborator its own exact tab.");
}
private static boolean templateCanRenderAs(String template, String tab) {
@@ -2785,25 +2873,28 @@ public record FleetConfig(
/**
* Reject a profile that places its members by {@code "pane"} while any {@code fleet.leaders}
- * entry names a {@code tab}. A pane-placed member lands inside the focused tab rather than its
- * own, so it can land inside a lead's own labelled tab. {@link
- * dev.ltms.fleet.herdr.LeadTabScanner} identifies a lead purely by that tab's label — it does
- * not exclude the member space — so a member that ends up there would be read back as the lead
- * and granted spawn/stop/send on the whole fleet.
+ * or {@code fleet.collaborators} entry names a {@code tab}. A pane-placed member lands inside
+ * the focused tab rather than its own, so it can land inside a lead's or collaborator's own
+ * labelled tab. {@link dev.ltms.fleet.herdr.LeadTabScanner} identifies a lead or collaborator
+ * purely by that tab's label — it does not exclude the member space — so a member that ends up
+ * there would be read back as that lead or collaborator and granted that identity's authority.
*
- * Only a leader with a non-blank {@code tab} is in scope: one with no {@code tab} feeds
+ *
Only an entry with a non-blank {@code tab} is in scope: one with no {@code tab} feeds
* nothing into {@link dev.ltms.fleet.herdr.LeadTabScanner}, so it creates no hazard here.
*
* @throws IllegalStateException when any {@code profiles:} entry is pane-placed while any
- * {@code fleet.leaders} entry names a non-blank {@code tab}
+ * {@code fleet.leaders} or {@code fleet.collaborators} entry
+ * names a non-blank {@code tab}
*/
public void validatePanePlacementAgainstLeadTabs() {
- if (fleet == null || fleet.leaders().isEmpty()) {
+ if (fleet == null) {
return;
}
boolean anyLeaderHasTab = fleet.leaders().values().stream()
.anyMatch(leader -> leader != null && leader.tab() != null && !leader.tab().isBlank());
- if (!anyLeaderHasTab) {
+ boolean anyCollaboratorHasTab = fleet.collaborators().values().stream()
+ .anyMatch(c -> c != null && c.tab() != null && !c.tab().isBlank());
+ if (!anyLeaderHasTab && !anyCollaboratorHasTab) {
return;
}
List bad = new ArrayList<>();
@@ -2816,10 +2907,11 @@ public record FleetConfig(
return;
}
throw new IllegalStateException("refusing to start: profile(s) " + bad
- + " use placement: pane while fleet.leaders names a tab. A pane-placed member can "
- + "land inside a lead's labelled tab and be read back as the lead, granted "
- + "spawn/stop/send on the whole fleet. Set placement: tab for each named profile, "
- + "or remove the tab from every fleet.leaders entry.");
+ + " use placement: pane while fleet.leaders or fleet.collaborators names a tab. A "
+ + "pane-placed member can land inside that labelled tab and be read back as the "
+ + "lead or collaborator, granted that identity's authority. Set placement: tab for "
+ + "each named profile, or remove the tab from every fleet.leaders and "
+ + "fleet.collaborators entry.");
}
/**
@@ -2926,8 +3018,14 @@ public record FleetConfig(
* so duplicates are unrepresentable by construction once loaded — and {@link #load(Path)}
* already rejects a duplicated slot name at parse time, before the map collapses.
*
- * @throws IllegalStateException when a slot names no profile or an unknown one, or when a lead
- * can be neither found nor created, naming the offending entry
+ * Also rejects a {@code fleet.collaborators} entry with no (or a blank) {@code tab}. A
+ * {@code profile}-less lead is still useful recognise-only — {@code tab} is the only field
+ * that matters to it either way. A collaborator carries no other field at all, so a blank
+ * {@code tab} leaves nothing for the entry to mean.
+ *
+ * @throws IllegalStateException when a slot names no profile or an unknown one, when a lead
+ * can be neither found nor created, or when a collaborator names
+ * no tab, naming the offending entry
*/
public void validateMembers() {
if (fleet == null) {
@@ -2965,6 +3063,16 @@ public record FleetConfig(
+ "auto-launched, labelled) purely by its tab, so every entry must name one.");
}
});
+ fleet.collaborators().forEach((name, collaborator) -> {
+ if (collaborator == null) {
+ return;
+ }
+ if (collaborator.tab() == null || collaborator.tab().isBlank()) {
+ bad.add("fleet.collaborators." + name + " has no tab: — a collaborator is "
+ + "recognised purely by its tab, and carries no other field, so every "
+ + "entry must name one.");
+ }
+ });
if (!bad.isEmpty()) {
throw new IllegalStateException("refusing to start: " + String.join(" ", bad));
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
index 11002d31..8e8cf88b 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
@@ -1000,6 +1000,273 @@ class FleetConfigTest {
"no primary.terminal pin ⇒ nothing registered, even with fleet.leaders configured");
}
+ // ── fleetd #669: the collaborators registry ────────────────────────────────────────────────
+
+ /**
+ * {@code Fleet} is {@code @JsonIgnoreProperties(ignoreUnknown = true)}, so a config naming
+ * {@code fleet.collaborators..tab} loads with no exception whether or not the key is
+ * ever read into the object model. Asserting only "no exception" would pass both before and
+ * after the real fix, so this asserts the parsed value is actually reachable from the loaded
+ * {@code FleetConfig} — the one thing a vacuous "no exception" test cannot tell apart.
+ */
+ @Test
+ void collaboratorsBlockIsActuallyParsedNotSilentlyDropped(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("collaborators.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ fleet:
+ collaborators:
+ reviewer-alex:
+ tab: "collab: alex"
+ """);
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertEquals("collab: alex", cfg.fleet().collaborators().get("reviewer-alex").tab());
+ }
+
+ /** A collaborator carries no field other than {@code tab}, so a blank one is meaningless. */
+ @Test
+ void aCollaboratorWithNoTabRefusesToStart(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("useless-collaborator.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ fleet:
+ collaborators:
+ ghost: {}
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers);
+ assertTrue(e.getMessage().contains("ghost"), "the message must name the useless entry");
+ assertTrue(e.getMessage().contains("tab:"), "the message must say what is missing");
+ }
+
+ /** Control for {@link #aCollaboratorWithNoTabRefusesToStart}: a named tab loads cleanly. */
+ @Test
+ void aCollaboratorWithATabIsAllowed(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("named-collaborator.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ fleet:
+ collaborators:
+ reviewer-alex:
+ tab: "collab: alex"
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ assertDoesNotThrow(cfg::validateMembers);
+ }
+
+ /**
+ * fleetd #669: the fleet-wide {@code tabLabel} template can render as a collaborator tab, the
+ * same hazard {@link #aFleetTabLabelTemplateThatCanRenderAsALeadTabRefusesToStart} covers on
+ * the lead side. Drives the fleet-wide branch directly, with no profile override involved.
+ */
+ @Test
+ void aFleetTabLabelTemplateThatCanRenderAsACollaboratorTabRefusesToStart(@TempDir Path dir)
+ throws Exception {
+ Path f = dir.resolve("collide-template-collaborator.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ fleet:
+ tabLabel: "al{profile}"
+ collaborators:
+ alex:
+ tab: "alpha"
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ IllegalStateException e =
+ assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
+ assertTrue(e.getMessage().contains("fleet.tabLabel"));
+ assertTrue(e.getMessage().contains("alex"), "the message must name the offending collaborator");
+ }
+
+ /**
+ * fleetd #669: a member tabLabel that can render as a configured collaborator tab is the same
+ * hazard as the lead case above — a member labelled that way is read back as the collaborator.
+ */
+ @Test
+ void aProfileTabLabelOverrideMatchingACollaboratorTabRefusesToStart(@TempDir Path dir)
+ throws Exception {
+ Path f = dir.resolve("collide-collaborator.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ gx10:
+ tabLabel: "collab-tab"
+ fleet:
+ collaborators:
+ alex:
+ tab: "collab-tab"
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ IllegalStateException e =
+ assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
+ assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
+ assertTrue(e.getMessage().contains("collab-tab"), "the message must name the offending label");
+ }
+
+ /** Control: a profile tabLabel that cannot render as the collaborator tab is allowed. */
+ @Test
+ void aProfileTabLabelThatCannotRenderAsACollaboratorTabIsAllowed(@TempDir Path dir)
+ throws Exception {
+ Path f = dir.resolve("ok-collaborator.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ gx10:
+ tabLabel: "worker-{profile}"
+ fleet:
+ collaborators:
+ alex:
+ tab: "collab-tab"
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ assertDoesNotThrow(cfg::validateLeadTabPrefixes);
+ }
+
+ /** fleetd #669: identity is matched on a collaborator's exact tab, so two sharing one are unreachable. */
+ @Test
+ void twoCollaboratorsSharingTheSameExactTabRefusesToStart(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("shared-collaborator-tab.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ fleet:
+ collaborators:
+ alex:
+ tab: "shared tab"
+ sam:
+ tab: "Shared Tab"
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ IllegalStateException e =
+ assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
+ assertTrue(e.getMessage().contains("alex"), "the message must name one offending collaborator");
+ assertTrue(e.getMessage().contains("sam"), "the message must name the other offending collaborator");
+ }
+
+ /** Control for {@link #twoCollaboratorsSharingTheSameExactTabRefusesToStart}: distinct tabs load cleanly. */
+ @Test
+ void twoCollaboratorsWithDistinctExactTabsAreAllowed(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("distinct-collaborator-tabs.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ fleet:
+ collaborators:
+ alex:
+ tab: "alex tab"
+ sam:
+ tab: "sam tab"
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ assertDoesNotThrow(cfg::validateLeadTabPrefixes);
+ }
+
+ /**
+ * fleetd #669: a collaborator tab equal to a lead tab crosses a privilege boundary — the worst
+ * of the three new collisions, since only one of the two identities is ever found.
+ */
+ @Test
+ void aCollaboratorTabEqualToALeadTabRefusesToStart(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("lead-collaborator-collision.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ fleet:
+ leaders:
+ opus:
+ tab: "shared tab"
+ collaborators:
+ alex:
+ tab: "Shared Tab"
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ IllegalStateException e =
+ assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes);
+ assertTrue(e.getMessage().contains("opus"), "the message must name the offending lead");
+ assertTrue(e.getMessage().contains("alex"), "the message must name the offending collaborator");
+ }
+
+ /** Control for {@link #aCollaboratorTabEqualToALeadTabRefusesToStart}: distinct tabs load cleanly. */
+ @Test
+ void aLeadAndACollaboratorWithDistinctTabsAreAllowed(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("lead-collaborator-ok.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ fleet:
+ leaders:
+ opus:
+ tab: "lead tab"
+ collaborators:
+ alex:
+ tab: "collab tab"
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ assertDoesNotThrow(cfg::validateLeadTabPrefixes);
+ }
+
+ /**
+ * fleetd #669: a pane-placed member can land in a collaborator's labelled tab exactly as it
+ * can land in a lead's — {@code validatePanePlacementAgainstLeadTabs()} must fire even when
+ * {@code fleet.leaders} is empty, which is the early-return the brief flagged as the bug.
+ */
+ @Test
+ void aPanePlacedProfileWithACollaboratorTabRefusesToStartEvenWithNoLeaders(@TempDir Path dir)
+ throws Exception {
+ Path f = dir.resolve("pane-hazard-collaborator.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ gx10:
+ placement: pane
+ fleet:
+ collaborators:
+ alex:
+ tab: "collab: alex"
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ IllegalStateException e = assertThrows(IllegalStateException.class,
+ cfg::validatePanePlacementAgainstLeadTabs);
+ assertTrue(e.getMessage().contains("gx10"), "the message must name the offending profile");
+ }
+
+ /** Control: a pane-placed profile with no lead or collaborator tab configured is allowed. */
+ @Test
+ void aPanePlacedProfileWithNoLeaderOrCollaboratorTabIsAllowed(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("pane-no-tab-at-all.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ gx10:
+ placement: pane
+ fleet:
+ collaborators:
+ alex: {}
+ """);
+
+ assertDoesNotThrow(() -> FleetConfig.load(f).validatePanePlacementAgainstLeadTabs(),
+ "a collaborator with no tab feeds nothing into the scanner, so pane placement is safe");
+ }
+
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
@Test
@@ -1272,6 +1539,60 @@ class FleetConfigTest {
assertEquals(Set.of("sonnet"), cfg.fleet().pool(MemberRole.REVIEWER).keySet());
}
+ /**
+ * A duplicated name in {@code fleet.collaborators} is refused at parse time, like any other
+ * {@code fleet:} pool. See {@link #duplicateSlotNamesInOnePoolAreRejectedAtParseTime}.
+ */
+ @Test
+ void duplicateCollaboratorNamesAreRejectedAtParseTime(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("collaborator-dup.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ fleet:
+ collaborators:
+ alex:
+ tab: "collab: alex"
+ alex:
+ tab: "collab: alex, second"
+ """);
+
+ IllegalStateException e =
+ assertThrows(IllegalStateException.class, () -> FleetConfig.load(f));
+ assertTrue(e.getMessage().contains("alex"),
+ "the refusal names the duplicated entry, was: " + e.getMessage());
+ assertTrue(e.getMessage().contains("fleet.collaborators"),
+ "the refusal names the pool the duplicate is in, was: " + e.getMessage());
+ }
+
+ /**
+ * Control for {@link #duplicateCollaboratorNamesAreRejectedAtParseTime}: the same name reused
+ * across the collaborators registry and a member role pool is the role × profile matrix doing
+ * its job in the other pool, not a mistake — only a repeat within one pool loses an entry.
+ */
+ @Test
+ void theSameNameInCollaboratorsAndAnotherPoolIsNotADuplicate(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("collaborator-cross-pool.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ sonnet:
+ baseUrl: http://gx10.gw:8000
+ fleet:
+ architects:
+ alex:
+ profile: sonnet
+ collaborators:
+ alex:
+ tab: "collab: alex"
+ """);
+
+ FleetConfig cfg = assertDoesNotThrow(() -> FleetConfig.load(f));
+ assertEquals(Set.of("alex"), cfg.fleet().pool(MemberRole.ARCHITECT).keySet());
+ assertEquals(Set.of("alex"), cfg.fleet().collaborators().keySet());
+ }
+
@Test
void duplicateKeysOutsideTheFleetPoolsAreUnaffected(@TempDir Path dir) throws Exception {
// The duplicate check is scoped to the fleet pools — a duplicate elsewhere is not this