diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index 43896bf..37fa27e 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -42,6 +42,7 @@ import dev.ltms.fleet.mcp.LsofProcessCwdLookup; import dev.ltms.fleet.msg.AmqpReplyInbox; import dev.ltms.fleet.msg.InMemoryReplyInbox; import dev.ltms.fleet.msg.LeadChannel; +import dev.ltms.fleet.msg.LeadChannelHandle; import dev.ltms.fleet.msg.LeadCoordLoop; import dev.ltms.fleet.msg.LeadMailbox; import dev.ltms.fleet.msg.MessageService; @@ -177,27 +178,17 @@ public final class Fleetd { // and the Fleetd-startup tests actually pin — see FleetConfig#validateAll's javadoc for // why a name-by-name list here would have the same defect it replaces. cfg.validateAll(); - // fleetd #613: validateAll() (validateMembers() inside it) only refuses a slot that names a - // bad role or profile — it says nothing about a role that has NO pool or NO charter at all, - // because both are legitimate ("unconstrained") states, not errors. Report them here, right - // after validation passes, so an operator sees the gap once per restart instead of finding - // it later in a roster row (see reportRoleFallbackGaps' javadoc for the measured cause). - reportRoleFallbackGaps(cfg); - // fleetd #469, follow-up to #464: validateAll() (and validateCharters() inside it) only - // checks that a charter's KEY is a role wire name and its text is non-blank — it never - // looks at what the text actually names. This is the separate check that does: it asks - // dev.ltms.fleet.mcp.FleetTool (the canonical registered-tool set) whether every fleet_*/ - // bridge_* token a charter names is a tool this server actually registers. It cannot live - // inside FleetConfig#validateCharters() — config loads before the MCP server exists, and - // must not gain a dependency on the mcp package — so it runs here instead, at the one seam - // that already holds both a loaded FleetConfig and the mcp package, before anything below - // opens a socket or spawns a member. fleetd #474: the same check is also wired into `config` - // above as ConfigRef's extraValidation, so a reload refuses what this line refuses at startup. - assertChartersNameOnlyRegisteredTools(cfg); - // fleetd #612 Unit A: everything from here on used to run inline in this method. It now - // lives in FleetdAssembly.assembleAndStart, built against a real ResourcePorts — see that - // class's javadoc for the full boot-order contract this preserves exactly. + // fleetd #612 A-gaps (gap 2): everything from here on — including the two post-validation + // reports that used to run inline right here (reportRoleFallbackGaps, + // assertChartersNameOnlyRegisteredTools) — now lives in FleetdAssembly.assembleAndStart, + // built against a real ResourcePorts. Unit A originally moved only the socket/broker/HTTP + // composition and left those two calls here, between validateAll() and the assembly call — + // outside the boundary FleetdAssemblyLifecycleTest drives, so deleting either call + // compiled clean and left the whole suite green. Moving the boundary to start immediately + // after validateAll() (this line) puts both back under test, in the same relative order, + // before either one does any I/O — see FleetdAssembly's javadoc for the full boot-order + // contract this preserves exactly. FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ResourcePorts.system()); } @@ -1224,10 +1215,18 @@ public final class Fleetd { ReplyInbox open(String uri, int prefetch); } - /** Injection seam for {@link #openLeadMailbox}: production binds {@link LeadMailbox#open}. */ + /** + * Injection seam for {@link #openLeadMailbox}: production binds {@link LeadMailbox#open}. + * + *
fleetd #612 A-gaps (gap 1): returns {@link LeadChannelHandle}, not the concrete {@link
+ * LeadMailbox}, so a test can supply a fake closeable channel instead of a real broker
+ * connection — see {@link LeadChannelHandle}'s own javadoc for why the narrower type existed
+ * and what widening it to add {@code close()} costs (nothing: {@code LeadMailbox} already
+ * implements it).
+ */
@FunctionalInterface
interface LeadMailboxOpener {
- LeadMailbox open(String uri, String selfCoordId, int prefetch);
+ LeadChannelHandle open(String uri, String selfCoordId, int prefetch);
}
/**
@@ -1250,7 +1249,7 @@ public final class Fleetd {
* fleet still works exactly as it did before this feature existed.
*
*/
- static LeadMailbox openLeadMailbox(FleetConfig.Coordinator coordinator, Map Package-private so a test can call it directly the same way the other startup-report
* helpers above are tested, without needing to drive {@link #main} for a unit-level check;
- * {@code FleetdStartupValidationTest} proves the startup call site, and {@code
+ * {@code FleetdStartupValidationTest} proves the startup call site by driving {@code main}
+ * itself end to end (the throw still happens before {@code main} reaches any real socket or
+ * broker work, since the assembly runs this before either), and {@code
* FleetdConfigRefCharterToolSurfaceWiringTest} — by constructing {@code ConfigRef} with this
* exact method reference, the same way {@code main} does above — proves the reload call site.
* {@code dev.ltms.fleet.config.ConfigRefTest} pins the same reload behaviour too, through an
diff --git a/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java b/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java
index ebba783..03ebcdc 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java
@@ -36,9 +36,9 @@ import dev.ltms.fleet.member.HerdrPeerLauncher;
import dev.ltms.fleet.member.MemberCredentialPolicyView;
import dev.ltms.fleet.metrics.FleetMetrics;
import dev.ltms.fleet.metrics.Metrics;
+import dev.ltms.fleet.msg.LeadChannelHandle;
import dev.ltms.fleet.msg.LeadCoordLoop;
import dev.ltms.fleet.msg.LeadHeartbeatLoop;
-import dev.ltms.fleet.msg.LeadMailbox;
import dev.ltms.fleet.msg.MessageService;
import dev.ltms.fleet.msg.Rendezvous;
import dev.ltms.fleet.msg.ReplyInbox;
@@ -77,8 +77,14 @@ import java.util.stream.Collectors;
* fleetd #612 Unit A: the real boot assembly, extracted out of {@code Fleetd.main} so a test can
* drive it directly. {@link #assembleAndStart} is the same statements {@code main} used to run
* inline, in the same order, against a real {@link ResourcePorts} in production and a fake one
- * in a test — see {@code FleetdAssemblyLifecycleTest}. {@code Fleetd.main} keeps config loading,
- * the startup reports and validation; everything from the herdr socket onward moved here.
+ * in a test — see {@code FleetdAssemblyLifecycleTest}. {@code Fleetd.main} keeps config loading and
+ * {@code cfg.validateAll()}; everything from immediately after that call onward moved here —
+ * including, since fleetd #612 A-gaps (gap 2), the two post-validation reports ({@code
+ * reportRoleFallbackGaps}, {@code assertChartersNameOnlyRegisteredTools}) that Unit A originally
+ * left behind in {@code main}. Those two calls do no I/O themselves, but leaving them outside this
+ * method meant deleting either one compiled clean and left the whole suite green — nothing drove
+ * {@code main} itself, so nothing could notice. They run first here, in the same relative order,
+ * before the herdr socket or anything else that touches the outside world.
*
* Construction and start order is preserved exactly, on purpose. This is not
* rebuilt into "construct everything, then start everything" — that would change boot timing. The
@@ -119,6 +125,13 @@ final class FleetdAssembly {
ConfigRef config = inputs.config();
SubscriptionGuard guard = inputs.guard();
+ // fleetd #612 A-gaps (gap 2): moved in from Fleetd.main, immediately after cfg.validateAll()
+ // there — the exact point main used to call these two, and still the first thing this
+ // method does, before any socket or broker work below. See this class's javadoc and each
+ // method's own for why they run here rather than in FleetConfig#validateAll() itself.
+ Fleetd.reportRoleFallbackGaps(cfg);
+ Fleetd.assertChartersNameOnlyRegisteredTools(cfg);
+
Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank()
? Path.of(cfg.herdrSocket())
: UnixSocketHerdrClient.defaultSocketPath();
@@ -346,7 +359,7 @@ final class FleetdAssembly {
// broker from the reply inbox by design. Absent a coordinator: block this is null and every
// lead path below is simply not wired, exactly the behaviour before this ticket. It owns a
// broker connection, so keep the reference for the ordered shutdown hook.
- final LeadMailbox leadMailbox = Fleetd.openLeadMailbox(cfg.coordinator(), ports.environment(),
+ final LeadChannelHandle leadMailbox = Fleetd.openLeadMailbox(cfg.coordinator(), ports.environment(),
ports.leadMailboxOpener());
// CB-307: learn the primary's terminal from orchestration tool calls (or pin from config).
String pinnedPrimaryTerminal = cfg.primary() != null ? cfg.primary().terminal() : null;
diff --git a/fleetd/src/main/java/dev/ltms/fleet/FleetdRuntime.java b/fleetd/src/main/java/dev/ltms/fleet/FleetdRuntime.java
index 4ae5e44..07d332c 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/FleetdRuntime.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/FleetdRuntime.java
@@ -8,9 +8,9 @@ import dev.ltms.fleet.inject.CompletionResolver;
import dev.ltms.fleet.inject.Injector;
import dev.ltms.fleet.inject.StatusPoller;
import dev.ltms.fleet.mcp.FleetMcp;
+import dev.ltms.fleet.msg.LeadChannelHandle;
import dev.ltms.fleet.msg.LeadCoordLoop;
import dev.ltms.fleet.msg.LeadHeartbeatLoop;
-import dev.ltms.fleet.msg.LeadMailbox;
import dev.ltms.fleet.msg.MessageService;
import dev.ltms.fleet.msg.ReplyInbox;
import dev.ltms.fleet.msg.ReplyPushLoop;
@@ -55,7 +55,7 @@ final class FleetdRuntime implements AutoCloseable {
private final SessionReaper reaper; // nullable — lifecycle.idleTtlSeconds opt-in
private final IdleSleepGuard idleSleepGuard; // nullable — idleSleepGuard.enabled: false
private final ReplyInbox replyInbox;
- private final LeadMailbox leadMailbox; // nullable — coordinator: opt-in
+ private final LeadChannelHandle leadMailbox; // nullable — coordinator: opt-in
private final CompletionResolver completion;
private final Injector injector;
/**
@@ -72,7 +72,7 @@ final class FleetdRuntime implements AutoCloseable {
LeadCoordLoop leadCoordLoop, ScheduledExecutorService leadCoordScheduler,
FleetHealthMonitor healthMonitor, ConfigWatcher configWatcher, FleetMcp mcp,
SessionReaper reaper, IdleSleepGuard idleSleepGuard, ReplyInbox replyInbox,
- LeadMailbox leadMailbox, CompletionResolver completion, Injector injector) {
+ LeadChannelHandle leadMailbox, CompletionResolver completion, Injector injector) {
this.cfg = cfg;
this.sessions = sessions;
this.router = router;
@@ -113,7 +113,7 @@ final class FleetdRuntime implements AutoCloseable {
SessionReaper reaper() { return reaper; }
IdleSleepGuard idleSleepGuard() { return idleSleepGuard; }
ReplyInbox replyInbox() { return replyInbox; }
- LeadMailbox leadMailbox() { return leadMailbox; }
+ LeadChannelHandle leadMailbox() { return leadMailbox; }
CompletionResolver completion() { return completion; }
Injector injector() { return injector; }
Javalin app() { return app; }
diff --git a/fleetd/src/main/java/dev/ltms/fleet/msg/LeadChannelHandle.java b/fleetd/src/main/java/dev/ltms/fleet/msg/LeadChannelHandle.java
new file mode 100644
index 0000000..702729e
--- /dev/null
+++ b/fleetd/src/main/java/dev/ltms/fleet/msg/LeadChannelHandle.java
@@ -0,0 +1,24 @@
+package dev.ltms.fleet.msg;
+
+/**
+ * fleetd #612 A-gaps (gap 1): a {@link LeadChannel} that its owner can also close.
+ *
+ * {@link LeadChannel}'s own javadoc says plainly that {@code close()} is deliberately left out
+ * of that interface — draining is a caller convenience nobody uses, and closing is the
+ * owner's job. This interface is that owner's own, wider view: whoever opens the
+ * coordination mailbox (the assembly that builds the daemon) also needs to close it from the
+ * shutdown path, and a test standing in for a real broker connection needs a fake it can mark
+ * closed, without ever holding a live connection. Every ordinary consumer ({@code FleetMcp},
+ * {@link LeadCoordLoop}) keeps taking the narrower {@link LeadChannel} exactly as before — only
+ * the owner speaks this wider one.
+ *
+ * {@link LeadMailbox} is still the only production implementation. This only generalises the
+ * TYPE its owner holds it as (previously the concrete class), so a test can substitute a fake
+ * closeable channel instead of a real AMQP connection.
+ */
+public interface LeadChannelHandle extends LeadChannel, AutoCloseable {
+
+ /** Release the underlying connection. Declared with no checked exception, unlike the plain {@link AutoCloseable#close()}. */
+ @Override
+ void close();
+}
diff --git a/fleetd/src/main/java/dev/ltms/fleet/msg/LeadMailbox.java b/fleetd/src/main/java/dev/ltms/fleet/msg/LeadMailbox.java
index e57a6ad..120c0a0 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/msg/LeadMailbox.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/msg/LeadMailbox.java
@@ -63,7 +63,7 @@ import java.util.concurrent.TimeoutException;
* which messages reached a lead. Any publish still awaiting its confirm is failed rather than left to idle out
* the confirm timeout against a sequence number that means nothing on the new channel.
*/
-public final class LeadMailbox implements LeadChannel, AutoCloseable {
+public final class LeadMailbox implements LeadChannelHandle {
private static final Logger log = LoggerFactory.getLogger(LeadMailbox.class);
diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java
new file mode 100644
index 0000000..d5262b0
--- /dev/null
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdAssemblyCoordinatorLifecycleTest.java
@@ -0,0 +1,211 @@
+package dev.ltms.fleet;
+
+import dev.ltms.fleet.config.ConfigRef;
+import dev.ltms.fleet.config.FleetConfig;
+import dev.ltms.fleet.guard.SubscriptionGuard;
+import dev.ltms.fleet.herdr.FakeHerdr;
+import dev.ltms.fleet.herdr.HerdrClient;
+import dev.ltms.fleet.msg.LeadChannel;
+import dev.ltms.fleet.msg.LeadChannelHandle;
+import dev.ltms.fleet.msg.LeadMessage;
+import dev.ltms.fleet.msg.ReplyInbox;
+import io.javalin.Javalin;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.LongSupplier;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * fleetd #612 A-gaps (gap 1): {@code FleetdAssemblyLifecycleTest}'s own class javadoc says plainly
+ * that it leaves {@code coordinator:} unset, so {@code leadMailbox} and {@code leadCoordLoop} stay
+ * {@code null} throughout — the configured-coordinator path is never exercised by Unit A's own
+ * test. This class drives that path instead: a real {@code coordinator:} block, a fake {@link
+ * Fleetd.LeadMailboxOpener} returning a fake closeable channel (never a real broker connection),
+ * and proof that {@link FleetdAssembly#assembleAndStart} both builds it and, on shutdown, closes it.
+ *
+ * Made possible by generalising {@code Fleetd.LeadMailboxOpener}'s return type (and {@code
+ * FleetdRuntime}'s field) from the concrete {@code LeadMailbox} to {@link LeadChannelHandle} — a
+ * {@link LeadChannel} its owner can also close. {@code FleetMcp} and {@code LeadCoordLoop} already
+ * consumed the narrower {@link LeadChannel}; this only widens the one seam that owns and closes it.
+ */
+class FleetdAssemblyCoordinatorLifecycleTest {
+
+ private static final String SELF_COORD_ID = "test-lead";
+
+ /** A fake {@link LeadChannelHandle}: never touches a broker, and records whether it was closed. */
+ private static final class FakeLeadChannel implements LeadChannelHandle {
+ volatile boolean closed = false;
+
+ @Override
+ public void publish(String toCoordId, LeadMessage m) {
+ }
+
+ @Override
+ public List This drives {@link FleetdAssembly#assembleAndStart} directly — never a copy of its logic —
+ * with a config that has no {@code fleet:} pools or charters configured for any role, so every role
+ * trips both of {@code reportRoleFallbackGaps}' log branches, and asserts on the real log line a
+ * {@code ListAppender} attached to the shared {@code Fleetd}/{@code FleetdAssembly} logger
+ * captures. Deleting the call from {@code FleetdAssembly} (verified by hand, see the ticket) turns
+ * this test red; deleting it from {@code Fleetd.main} instead (its old location) would not, which
+ * is exactly the gap this test closes.
+ */
+class FleetdAssemblyRoleFallbackBoundaryTest {
+
+ /** Minimal fake {@link ResourcePorts}: enough for {@code assembleAndStart} to run with no real I/O. */
+ private static final class RecordingResourcePorts implements ResourcePorts {
+
+ final FakeHerdr herdr = new FakeHerdr();
+ final SentinelReplyInbox replyInbox = new SentinelReplyInbox();
+ Runnable shutdownHook;
+
+ @Override
+ public Map