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 65440ff..f751599 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -2680,10 +2680,13 @@ public record FleetConfig( /** * Reject a member tab-label template that could render as a configured lead tab or match a - * lead-tab naming convention. + * lead-tab naming convention, and reject two {@code fleet.leaders} entries that share one exact + * tab. * * @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 + * 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) */ public void validateLeadTabPrefixes() { if (fleet == null || fleet.leaders().isEmpty()) { @@ -2719,13 +2722,40 @@ public record FleetConfig( } }); }); - if (bad.isEmpty()) { + 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."); + } + + List collisions = new ArrayList<>(); + List leadNames = fleet.leaders().keySet().stream().sorted().toList(); + for (int i = 0; i < leadNames.size(); i++) { + String nameA = leadNames.get(i); + Leader a = fleet.leaders().get(nameA); + if (a == null || a.tab() == null || a.tab().isBlank()) { + continue; + } + for (int j = i + 1; j < leadNames.size(); j++) { + String nameB = leadNames.get(j); + Leader b = fleet.leaders().get(nameB); + if (b == null || b.tab() == null || b.tab().isBlank()) { + continue; + } + if (a.tab().equalsIgnoreCase(b.tab())) { + collisions.add("lead '" + nameA + "' and lead '" + nameB + "' both use tab \"" + + a.tab() + "\""); + } + } + } + if (collisions.isEmpty()) { return; } - 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."); + 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."); } private static boolean templateCanRenderAs(String template, String tab) { 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 379858a..47e93b2 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java @@ -774,6 +774,51 @@ class FleetConfigTest { "a label that collides with a convention nobody reads is not a problem"); } + /** + * fleetd #677: identity is matched on a lead's exact {@code tab} alone, so two leads sharing + * one tab means only one of them is ever found — the guard must catch this independently of + * the member-template checks above. + */ + @Test + void twoLeadsSharingTheSameExactTabRefusesToStart(@TempDir Path dir) throws Exception { + Path f = dir.resolve("shared-tab.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + fleet: + leaders: + opus: + tab: "shared tab" + sonnet: + tab: "shared tab" + """); + FleetConfig cfg = FleetConfig.load(f); + + IllegalStateException e = + assertThrows(IllegalStateException.class, cfg::validateLeadTabPrefixes); + assertTrue(e.getMessage().contains("opus"), "the message must name one offending lead"); + assertTrue(e.getMessage().contains("sonnet"), "the message must name the other offending lead"); + } + + /** Control for {@link #twoLeadsSharingTheSameExactTabRefusesToStart}: distinct tabs load cleanly. */ + @Test + void twoLeadsWithDistinctExactTabsAreAllowed(@TempDir Path dir) throws Exception { + Path f = dir.resolve("distinct-tabs.yaml"); + Files.writeString(f, """ + bind: + port: 8080 + fleet: + leaders: + opus: + tab: "opus tab" + sonnet: + tab: "sonnet tab" + """); + FleetConfig cfg = FleetConfig.load(f); + + assertDoesNotThrow(cfg::validateLeadTabPrefixes); + } + // ── validatePanePlacementAgainstLeadTabs ──────────────────────────────────────────────────── /**