diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 7181b7a..f8ee3cb 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -352,7 +352,11 @@ public final class Fleetd { // pane resolves to an architect until the later spawn lifecycle binds one. The registry is // what CallerResolver resolves against and what that lifecycle will read profiles from; // nothing here spawns a slot. - MemberRegistry members = new MemberRegistry(cfg.fleet()); + // fleetd #424: MemberRegistry.live re-reads fleet.architects through `config` on every + // reserve/requireSlotFor call, so a reload that removes or adds an architect slot governs + // the next spawn with no restart — the frozen `new MemberRegistry(cfg.fleet())` this used + // to be let a "revoked" slot keep granting new architect spawns forever. + MemberRegistry members = MemberRegistry.live(() -> config.get().fleet()); sessions.setMemberLifecycle(members); if (!members.slots().isEmpty()) { log.info("member slots: {} configured {} — none bound yet (a slot is idle until the " diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java b/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java index 972cf9c..3007842 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java @@ -11,6 +11,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Supplier; /** * The architect-slot registry (CB-548): every gateway-local architect name and the strong-model @@ -19,16 +20,43 @@ import java.util.Objects; * *
Two halves, split by who owns each: *
The binding rule (fleetd #424): config governs what a bound slot still grants, as + * well as what may be bound next. Removing a slot from config revokes it — that is the + * ticket's entire point ("Revoking an architect slot does not revoke it"). Revoking it means an + * architect already bound to that slot loses the ARCHITECT privilege on its very next request: + * {@link #roleForSlot} and {@link #nameForSlot} read {@link #slots()} directly, with no cache, so + * the moment a slot drops out of config, {@link CallerResolver#resolve} (which calls both on every + * request from a bound pane, {@code CallerResolver.java:220}) can no longer confirm the pane's slot + * is an architect slot, and the pane falls through to {@code Principal.worker(...)}. What does + * not change is the {@code terminalToSlot} occupancy — the binding created by + * {@link #bind} is untouched by a reload, on purpose: unbinding it here would double-book the slot + * key (a second terminal could then bind to the "freed" key while the first is still the terminal + * the operator actually meant to demote) and would silently break {@link #unbind}'s compare-safe + * contract, which needs the original {@code terminal → slot} pair intact to remove it cleanly. So + * the demoted session keeps occupying its slot — {@link #slotForTerminal} and {@link #snapshot()} + * still name it — it just no longer resolves as an architect through that occupancy, and a fresh + * spawn still cannot bind to the same key while it is occupied ({@link #reserve}/ + * {@link #requireSlotFor} refuse it anyway, since it is gone from {@link #slots()}). The demoted + * session's own turn is unaffected: {@code fleet_reply}'s authorization + * ({@code Authz.Action.REPLY}) is {@code caller.ownsSession(targetSession)} — identity by terminal, + * not by role — so a demoted architect can still end its own turn normally. + * *
Spawning/lifecycle is deliberately a separate unit: this class only owns the bindings and
* exposes the map the resolver resolves against plus the profile lookup lifecycle will call.
* Nothing here creates or manages an architect session.
@@ -55,14 +83,42 @@ public final class MemberRegistry implements MemberLifecycle {
}
}
- private final Map Every test here drives a REAL {@link ConfigRef#reload()} against a {@code @TempDir} file and
+ * asserts {@link ConfigRef.Outcome#applied()}, rather than comparing two frozen
+ * {@code MemberRegistry} instances in memory — the defect this ticket fixes is specifically that
+ * {@link MemberRegistry} used to ignore a live reload, so a test that never reloads cannot tell the
+ * fixed registry from the broken one. {@code requireSlotFor} and {@code reserve} are pinned in
+ * separate tests, in both directions (removed and added), so a registry that simply refuses (or
+ * simply allows) everything cannot pass by accident — see {@link MemberRegistryTest} for the
+ * registry's other invariants (bind/unbind cardinality, thread-safety), which are unaffected by
+ * this ticket and still exercised against the frozen constructor.
+ */
+class MemberRegistryLiveTest {
+
+ private static String yaml(String fleetBlock) {
+ return """
+ bind:
+ host: 127.0.0.1
+ port: 8765
+ herdrSocket: ~/.config/herdr/herdr.sock
+ profiles:
+ sonnet:
+ baseUrl: http://gx00.gw:8000
+ model: sonnet
+ opus:
+ baseUrl: http://gx00.gw:8001
+ model: opus
+ guard:
+ offSubscriptionHosts:
+ - gx00.gw
+ """ + fleetBlock;
+ }
+
+ private static final String WITH_SONNET_SLOT = """
+ fleet:
+ architects:
+ designer:
+ profile: sonnet
+ """;
+
+ /** No architect pool at all — developers is unrelated dead data for this registry (#424 out of scope). */
+ private static final String WITHOUT_ARCHITECT_SLOTS = """
+ fleet:
+ developers:
+ dev1:
+ profile: sonnet
+ """;
+
+ private static ConfigRef refFor(Path f) {
+ return new ConfigRef(f, FleetConfig.load(f));
+ }
+
+ // ── requireSlotFor is live (criteria 1, 2, 4) ──────────────────────────────────────────────
+
+ @Test
+ void requireSlotForRefusesAProfileWhoseSlotWasRemovedByReload(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, yaml(WITH_SONNET_SLOT));
+ ConfigRef ref = refFor(f);
+ MemberRegistry registry = MemberRegistry.live(() -> ref.get().fleet());
+
+ assertDoesNotThrow(() -> registry.requireSlotFor(MemberRole.ARCHITECT, "sonnet"),
+ "the slot is configured before the reload");
+
+ Files.writeString(f, yaml(WITHOUT_ARCHITECT_SLOTS));
+ ConfigRef.Outcome out = ref.reload();
+ assertTrue(out.applied(), "the reload must actually take effect: " + out.summary());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> registry.requireSlotFor(MemberRole.ARCHITECT, "sonnet"),
+ "revoking the slot must refuse the NEXT spawn that names it");
+ }
+
+ @Test
+ void requireSlotForAllowsAProfileWhoseSlotWasAddedByReload(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, yaml(WITHOUT_ARCHITECT_SLOTS));
+ ConfigRef ref = refFor(f);
+ MemberRegistry registry = MemberRegistry.live(() -> ref.get().fleet());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> registry.requireSlotFor(MemberRole.ARCHITECT, "sonnet"),
+ "no architect slot is configured yet");
+
+ Files.writeString(f, yaml(WITH_SONNET_SLOT));
+ ConfigRef.Outcome out = ref.reload();
+ assertTrue(out.applied(), "the reload must actually take effect: " + out.summary());
+
+ assertDoesNotThrow(() -> registry.requireSlotFor(MemberRole.ARCHITECT, "sonnet"),
+ "a slot added by reload must be usable with no restart");
+ }
+
+ // ── reserve is live too — tested separately from requireSlotFor (criteria 1, 2, 4) ────────
+
+ @Test
+ void reserveRefusesAProfileWhoseSlotWasRemovedByReload(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, yaml(WITH_SONNET_SLOT));
+ ConfigRef ref = refFor(f);
+ MemberRegistry registry = MemberRegistry.live(() -> ref.get().fleet());
+
+ MemberLifecycle.SlotReservation before = registry.reserve(MemberRole.ARCHITECT, "sonnet");
+ assertEquals("architect:designer", before.slot());
+ registry.release(before); // free it back up so the reload-side reserve below starts clean
+
+ Files.writeString(f, yaml(WITHOUT_ARCHITECT_SLOTS));
+ ConfigRef.Outcome out = ref.reload();
+ assertTrue(out.applied(), "the reload must actually take effect: " + out.summary());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> registry.reserve(MemberRole.ARCHITECT, "sonnet"),
+ "revoking the slot must refuse the NEXT reservation for it");
+ }
+
+ @Test
+ void reserveAllowsAProfileWhoseSlotWasAddedByReload(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, yaml(WITHOUT_ARCHITECT_SLOTS));
+ ConfigRef ref = refFor(f);
+ MemberRegistry registry = MemberRegistry.live(() -> ref.get().fleet());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> registry.reserve(MemberRole.ARCHITECT, "sonnet"),
+ "no architect slot is configured yet");
+
+ Files.writeString(f, yaml(WITH_SONNET_SLOT));
+ ConfigRef.Outcome out = ref.reload();
+ assertTrue(out.applied(), "the reload must actually take effect: " + out.summary());
+
+ MemberLifecycle.SlotReservation after = registry.reserve(MemberRole.ARCHITECT, "sonnet");
+ assertEquals("architect:designer", after.slot(),
+ "a slot added by reload must be reservable with no restart");
+ }
+
+ // ── a bound architect is demoted, but the binding itself is not touched (fleetd #424) ───────
+ // The lead's corrected ruling: the PRIVILEGE a slot grants is revoked on the bound session's
+ // very next request, but the terminalToSlot BINDING itself is untouched by a reload — dropping
+ // it would double-book the slot key and break unbind's compare-safe contract. See the class
+ // doc's binding rule.
+
+ /** A caller identity resolving the one canned pane (terminal {@code term_a}) in {@link FakeHerdr}. */
+ private static ConnectionIdentity boundPaneIdentity() {
+ return new ConnectionIdentity(new PaneLocator(new FakeHerdr()), _ -> FakeHerdr.WORKER_PID);
+ }
+
+ @Test
+ void anArchitectAlreadyBoundToASlotIsDemotedByReload(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, yaml(WITH_SONNET_SLOT));
+ ConfigRef ref = refFor(f);
+ MemberRegistry registry = MemberRegistry.live(() -> ref.get().fleet());
+
+ MemberLifecycle.SlotReservation reservation = registry.reserve(MemberRole.ARCHITECT, "sonnet");
+ assertTrue(registry.bind(reservation, "term_a"));
+ assertEquals("architect:designer", registry.slotForTerminal("term_a"));
+
+ // Drive the real caller path, not the roleForSlot seam directly: CallerResolver.resolve is
+ // what a live request actually goes through (CallerResolver.java:220), and a resolver that
+ // ignored roleForSlot entirely would still pass a test that only checked the seam.
+ CallerResolver resolver = CallerResolver.withLeadsAndMembers(
+ boundPaneIdentity(), false, null, Map::of, registry);
+
+ Principal before = resolver.resolve("127.0.0.1", 42, null);
+ assertEquals(Role.ARCHITECT, before.role(), "sanity check: the harness binds term_a as an architect");
+ assertEquals("designer", before.name());
+
+ Files.writeString(f, yaml(WITHOUT_ARCHITECT_SLOTS));
+ ConfigRef.Outcome out = ref.reload();
+ assertTrue(out.applied(), "the reload must actually take effect: " + out.summary());
+
+ Principal after = resolver.resolve("127.0.0.1", 42, null);
+ assertEquals(Role.WORKER, after.role(),
+ "removing the slot from config must demote the bound session to worker on its "
+ + "NEXT request — this is the ticket's whole point");
+ assertEquals("term_a", after.terminal(), "same pane, same terminal — only the role changed");
+ }
+
+ @Test
+ void theOriginalBindingStillOccupiesTheRemovedSlotSoASecondTerminalCannotClaimIt(@TempDir Path dir)
+ throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, yaml(WITH_SONNET_SLOT));
+ ConfigRef ref = refFor(f);
+ MemberRegistry registry = MemberRegistry.live(() -> ref.get().fleet());
+
+ MemberLifecycle.SlotReservation reservation = registry.reserve(MemberRole.ARCHITECT, "sonnet");
+ assertTrue(registry.bind(reservation, "term_a"));
+
+ Files.writeString(f, yaml(WITHOUT_ARCHITECT_SLOTS));
+ ConfigRef.Outcome removed = ref.reload();
+ assertTrue(removed.applied(), "the reload must actually take effect: " + removed.summary());
+
+ // The binding survives the removal untouched.
+ assertEquals("architect:designer", registry.slotForTerminal("term_a"),
+ "a live binding must never be retroactively unbound by a config edit");
+ assertEquals(Map.of("term_a", "architect:designer"), registry.snapshot());
+
+ // Bring the slot back into config. If the binding had been silently dropped by the removal
+ // (rather than merely losing the privilege it grants), a second terminal could now claim
+ // the "freed" key — the exact double-booking the class doc's binding rule rules out.
+ Files.writeString(f, yaml(WITH_SONNET_SLOT));
+ ConfigRef.Outcome restored = ref.reload();
+ assertTrue(restored.applied(), "the reload must actually take effect: " + restored.summary());
+
+ assertFalse(registry.bind("architect:designer", "term_b"),
+ "the slot is still occupied by term_a — a second terminal must not bind to it");
+ assertThrows(IllegalArgumentException.class,
+ () -> registry.reserve(MemberRole.ARCHITECT, "sonnet"),
+ "the slot is still occupied by term_a — a fresh reservation must not find it free");
+ assertEquals("architect:designer", registry.slotForTerminal("term_a"),
+ "the original binding is unchanged throughout");
+ }
+
+ @Test
+ void unbindStillSucceedsForTheOriginalTerminalAfterItsSlotIsRemoved(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, yaml(WITH_SONNET_SLOT));
+ ConfigRef ref = refFor(f);
+ MemberRegistry registry = MemberRegistry.live(() -> ref.get().fleet());
+
+ MemberLifecycle.SlotReservation reservation = registry.reserve(MemberRole.ARCHITECT, "sonnet");
+ assertTrue(registry.bind(reservation, "term_a"));
+
+ Files.writeString(f, yaml(WITHOUT_ARCHITECT_SLOTS));
+ ConfigRef.Outcome out = ref.reload();
+ assertTrue(out.applied(), "the reload must actually take effect: " + out.summary());
+
+ assertTrue(registry.unbind("architect:designer", "term_a"),
+ "unbind must still work for a slot that config has since removed, or a session "
+ + "that outlives its slot's removal could never release it");
+ assertNull(registry.slotForTerminal("term_a"));
+ assertEquals(Map.of(), registry.snapshot());
+ }
+}