diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java index 5564b5c..6fb1e52 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java @@ -615,7 +615,7 @@ public final class ConfigRef implements Supplier { // may bind to, AND what a slot already bound still grants) through its own instance of that // same supplier shape — see MemberRegistry.live and its class doc for the binding rule: // removing a slot revokes ARCHITECT on the bound pane's very next request, and only the slot - // OCCUPANCY survives, so the demoted session keeps its slot key until it unbinds. Only + // OCCUPANCY survives, so the demoted session keeps its slot key until it unbinds. // fleet.leaders is frozen (Fleetd.java:281 reads cfg.fleet().leaders() off the startup // snapshot to build both the LeadTabScanner's tab-label-to-name map, wired into // CallerResolver.withLeadsAndMembers at Fleetd.java:620/624, and — when herdr answered — @@ -635,6 +635,11 @@ public final class ConfigRef implements Supplier { + "live through that same supplier for placement AND through a separate supplier " + "on MemberRegistry for spawn-time identity — both already applied"); } + if (!Objects.equals(collaboratorsOf(old), collaboratorsOf(fresh))) { + changed.add("fleet: fleet.collaborators (each collaborator's tab) is read once at " + + "startup to build the LeadTabScanner's identity map, which is not rebuilt on " + + "reload, so a collaborator added, removed, or given a new tab: needs a restart"); + } // Kept in step with SPLIT_KEYS the same way changedColdKeys is kept in step with COLD_KEYS — // every message here must be traceable to one of the split keys the class doc documents. // NOTE what this does NOT prove, per the javadoc above: it does not catch a SPLIT_KEYS @@ -650,6 +655,11 @@ public final class ConfigRef implements Supplier { return cfg.fleet() == null ? Map.of() : cfg.fleet().leaders(); } + /** {@code cfg.fleet().collaborators()}, defensively, in case a caller hands in a non-defaulted config. */ + private static Map collaboratorsOf(FleetConfig cfg) { + return cfg.fleet() == null ? Map.of() : cfg.fleet().collaborators(); + } + /** * {@link FleetConfig.Profile} record components deliberately left out of * {@link #sameLaunchSettings} because they are read live, not baked in at spawn — see diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTest.java index 4cf81ba..c8d6d58 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTest.java @@ -107,6 +107,8 @@ class ConfigRefTest { assertTrue(out.applied()); assertTrue(out.deferred().isEmpty()); + assertTrue(out.split().stream().noneMatch(s -> s.contains("fleet.collaborators")), + out.split().toString()); assertEquals("new charter", ref.get().fleet().charterFor( dev.ltms.fleet.peer.MemberRole.ARCHITECT)); } @@ -786,14 +788,100 @@ class ConfigRefTest { assertTrue(out.split().getFirst().startsWith("fleet:"), out.split().toString()); assertTrue(out.split().getFirst().contains("restart"), out.split().toString()); assertTrue(out.split().getFirst().contains("live"), out.split().toString()); + assertTrue(out.split().stream().noneMatch(s -> s.contains("fleet.collaborators")), + out.split().toString()); assertTrue(out.summary().contains("partially live"), out.summary()); // The snapshot still carries the new value — the LeadTabScanner's identity map and // LeadLauncher's auto-launch are what wait for a restart; a reload rebuilds neither. assertEquals("lead: opus-b", ref.get().fleet().leaders().get("opus").tab()); } + @Test + void addingAFleetCollaboratorIsReportedAsSplit(@TempDir Path dir) throws Exception { + assertCollaboratorChangeIsReported(dir, """ + fleet: + collaborators: + alex: + tab: "collaborator: alex" + """, "add a collaborator"); + } + + @Test + void removingAFleetCollaboratorIsReportedAsSplit(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, yaml(""" + fleet: + collaborators: + alex: + tab: "collaborator: alex" + """)); + ConfigRef ref = refFor(f); + + Files.writeString(f, yaml("fleet: {}\n")); + assertCollaboratorSplit(ref.reload(), "remove a collaborator"); + } + + @Test + void changingAFleetCollaboratorTabIsReportedAsSplit(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, yaml(""" + fleet: + collaborators: + alex: + tab: "collaborator: alex-a" + """)); + ConfigRef ref = refFor(f); + + Files.writeString(f, yaml(""" + fleet: + collaborators: + alex: + tab: "collaborator: alex-b" + """)); + assertCollaboratorSplit(ref.reload(), "change a collaborator tab"); + } + + @Test + void unchangedFleetCollaboratorsProduceNoSplitReport(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + String config = yaml(""" + fleet: + collaborators: + alex: + tab: "collaborator: alex" + """); + Files.writeString(f, config); + ConfigRef ref = refFor(f); + + Files.writeString(f, config); + ConfigRef.Outcome out = ref.reload(); + + assertTrue(out.applied()); + assertTrue(out.split().isEmpty(), out.split().toString()); + } + + private static void assertCollaboratorChangeIsReported(Path dir, String changed, String action) + throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, yaml("fleet: {}\n")); + ConfigRef ref = refFor(f); + + Files.writeString(f, yaml(changed)); + assertCollaboratorSplit(ref.reload(), action); + } + + private static void assertCollaboratorSplit(ConfigRef.Outcome out, String action) { + assertTrue(out.applied(), action); + assertTrue(out.deferred().isEmpty(), out.deferred().toString()); + assertEquals(1, out.split().size(), out.split().toString()); + String report = out.split().getFirst(); + assertTrue(report.startsWith("fleet: fleet.collaborators"), report); + assertTrue(report.contains("read once at startup"), report); + assertTrue(report.contains("restart"), report); + } + /** - * fleetd #333: {@code fleet.leaders} is the ONLY frozen part of {@code fleet:}. A reload that + * fleetd #333: {@code fleet.leaders} is a frozen part of {@code fleet:}. A reload that * changes {@code tabLabel} (or charters, or a role pool) without touching {@code fleet.leaders} * must stay fully hot with nothing reported — proving {@link ConfigRef#changedSplitKeys} * compares {@code fleet.leaders} specifically rather than the whole {@code Fleet} record, which