Merge pull request 'fleetd #612 step 2 unit B2: behavioural replacements for the CB-185 pair' (#626) from worker/612-b2-cb185-176d3a-2 into worker/fleetd-612-unita-87807e-1
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 1m32s
CI / build (pull_request) Failing after 2m6s

This commit was merged in pull request #626.
This commit is contained in:
2026-09-22 07:19:28 +02:00
6 changed files with 490 additions and 60 deletions
@@ -31,6 +31,20 @@ public final class ConnectionIdentity {
this.cwds = cwds;
}
/**
* The {@link PaneLocator} this identity resolves callers against — fleetd #612 CB-185: lets a
* test drive the exact {@link PaneLocator} a real assembly wired up (e.g. {@code
* FleetdAssembly}'s {@code new ConnectionIdentity(new PaneLocator(herdr, memberHerdr), ...)})
* directly with a chosen pid, bypassing the OS-dependent {@link PeerPidLookup} that {@link
* #resolve} otherwise goes through. A full HTTP round trip cannot exercise this: {@code
* LsofPeerPidLookup} excludes its own pid, and an in-process test client and server share one
* JVM pid, so {@code pidForLocalPort} always returns {@code -1} and {@link PaneLocator} never
* gets called at all.
*/
public PaneLocator panes() {
return panes;
}
/**
* The caller resolved from the connection: its worker {@code terminal} (or {@code null} for the
* primary / an off-host client), its {@code pid} (or {@code -1} if not resolvable), and whether
@@ -107,6 +107,13 @@ public final class FleetMcp {
* untested identity heuristic.
*/
private final boolean authorizationEnforced;
/**
* fleetd #612 CB-185: kept as a field (rather than only captured by the {@code
* contextExtractor} closure built in the constructor) so a test can reach the exact {@link
* ConnectionIdentity} — and, through {@link ConnectionIdentity#panes()}, the exact {@link
* dev.ltms.fleet.herdr.PaneLocator} — that a real assembly wired up. See {@link #identity()}.
*/
private final ConnectionIdentity identity;
private final Metrics metrics; // CB-502: null → auth failures not counted
private final CapacitySource capacity;
private final HealthCoverageSource healthCoverage;
@@ -396,6 +403,7 @@ public final class FleetMcp {
Objects.requireNonNull(callers, "callers");
this.authorizationEnforced = Objects.requireNonNull(authorizationMode, "authorizationMode")
== AuthorizationMode.ENFORCED;
this.identity = identity;
this.leadChannel = leadChannel;
this.peers = peers == null ? List.of() : List.copyOf(peers);
this.capacity = capacity;
@@ -755,6 +763,15 @@ public final class FleetMcp {
return transport;
}
/**
* The {@link ConnectionIdentity} this server resolves every caller against — fleetd #612
* CB-185: lets a test reach the exact {@link dev.ltms.fleet.herdr.PaneLocator} a real assembly
* wired up (via {@link ConnectionIdentity#panes()}), rather than a copy built for the test.
*/
public ConnectionIdentity identity() {
return identity;
}
/** Mark a connected spawned member available for the injector readiness gate. */
static void markSpawnedMemberPresent(Principal caller, MemberPresence presence) {
if (caller.isSpawnedMember()) {
@@ -0,0 +1,222 @@
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.herdr.PaneLocator;
import io.javalin.Javalin;
import org.junit.jupiter.api.AfterEach;
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.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
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.assertNull;
/**
* fleetd #612 step 2, unit B2 (CB-185, identity half). Replaces the deleted
* {@code FleetdConnectionIdentityConstructionTest}, which pinned this claim by reading {@code
* Fleetd.java}'s source text for {@code "new PaneLocator(herdr, memberHerdr)"}. That claim moved
* to {@code FleetdAssembly.java} (fleetd #612 Unit A) and is pinned here instead, by driving the
* real {@link ConnectionIdentity} — via {@code runtime.mcp().identity()}, not a copy — that {@link
* FleetdAssembly#assembleAndStart} built.
*
* <p><strong>What this guards against</strong> (from the deleted test's own javadoc): pinning
* {@code PaneLocator} to {@code memberHerdr} alone leaves every LEAD's own MCP connection
* unresolvable ({@code callerTerminal == null}) the moment {@code memberHerdrSocket} names a
* second daemon, which breaks {@code fleet_reply}/{@code fleet_ask}/{@code fleet_whoami} for a
* lead. {@code PaneLocatorTest} already proves {@link PaneLocator} itself can search two clients
* given two — the gap this pins is that the assembly actually passes it two, and in the right
* order (lead first).
*
* <p><strong>Why this cannot be driven through a real MCP/HTTP round trip.</strong> The natural
* way to observe {@code ConnectionIdentity} would be a real {@code fleet_whoami} call over the
* built {@code FleetMcp}, the way {@code FleetMcpContextExtractorTest} drives its own
* hand-built one. That does not work for the REAL assembly, because {@code FleetdAssembly} wires
* {@code ConnectionIdentity} with a hardcoded {@code new LsofPeerPidLookup()} (see {@code
* FleetdAssembly.java:444}), and {@code LsofPeerPidLookup} explicitly excludes its own PID — see
* its javadoc: "we exclude our own PID and take the other end". In a JUnit test the HTTP client
* and the daemon under test run in the very same JVM, so the "client" and "server" ends of the
* loopback connection ARE the same PID, and {@code pidForLocalPort} always returns {@code -1}
* before {@link PaneLocator} is ever reached — proving nothing about which daemon(s) got searched.
* This test instead reaches the real {@link PaneLocator} the assembly built (through {@link
* ConnectionIdentity#panes()}, added for exactly this) and drives it with a chosen pid directly,
* bypassing the OS-dependent PID lookup entirely — a legitimate substitute, since the pid lookup
* is not what CB-185 is about.
*/
class FleetdAssemblyConnectionIdentityTest {
/** Same shape as {@code FleetdAssemblyLifecycleTest}'s fake, but keys {@code connectHerdr} by
* socket path so the lead and member daemons can be two DIFFERENT {@link FakeHerdr}s. */
private static final class TwoHerdrResourcePorts implements ResourcePorts {
final Map<Path, HerdrClient> herdrsBySocket = new LinkedHashMap<>();
final CopyOnWriteArrayList<ScheduledExecutorService> schedulers = new CopyOnWriteArrayList<>();
Runnable shutdownHook;
@Override
public Map<String, String> environment() {
return Map.of();
}
@Override
public HerdrClient connectHerdr(Path socketPath) {
HerdrClient client = herdrsBySocket.get(socketPath);
if (client == null) {
throw new IllegalStateException("no fake herdr registered for socket " + socketPath);
}
return client;
}
@Override
public Fleetd.AmqpOpener replyInboxOpener() {
return (uri, prefetch) -> new dev.ltms.fleet.msg.InMemoryReplyInbox();
}
@Override
public Fleetd.LeadMailboxOpener leadMailboxOpener() {
return (uri, selfCoordId, prefetch) -> {
throw new UnsupportedOperationException(
"leadMailboxOpener must not be called — no coordinator: block is configured");
};
}
@Override
public LongSupplier nanoClock() {
return System::nanoTime;
}
@Override
public LongSupplier wallClockNanos() {
return System::nanoTime;
}
@Override
public ScheduledExecutorService newScheduler(String purpose) {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
schedulers.add(scheduler);
return scheduler;
}
@Override
public void addShutdownHook(Runnable hook) {
this.shutdownHook = hook;
}
@Override
public void startHttp(Javalin app, String host, int port) {
// Deliberately never bind — this test never issues a real HTTP request.
}
}
private FleetdRuntime runtime;
private TwoHerdrResourcePorts ports;
@AfterEach
void tearDown() {
if (ports != null && ports.shutdownHook != null) {
ports.shutdownHook.run();
}
}
private static final Path LEAD_SOCKET = Path.of("/fake/lead-herdr.sock");
private static final Path MEMBER_SOCKET = Path.of("/fake/member-herdr.sock");
private static FleetConfig writeConfig(Path dir) throws Exception {
Path f = dir.resolve("fleetd.yaml");
Files.writeString(f, """
bind:
host: 127.0.0.1
port: 8765
herdrSocket: "%s"
memberHerdrSocket: "%s"
lifecycle:
idleTtlSeconds: 600
health:
enabled: false
broker:
uri: "amqp://fake-test-broker/vh"
""".formatted(LEAD_SOCKET, MEMBER_SOCKET));
return FleetConfig.load(f);
}
private FleetdRuntime assemble(Path dir, FakeHerdr lead, FakeHerdr member) throws Exception {
FleetConfig cfg = writeConfig(dir);
ConfigRef config = new ConfigRef(dir.resolve("fleetd.yaml"), cfg);
SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet());
ports = new TwoHerdrResourcePorts();
ports.herdrsBySocket.put(LEAD_SOCKET, lead);
ports.herdrsBySocket.put(MEMBER_SOCKET, member);
runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports);
return runtime;
}
/**
* The pin. {@code lead} carries the one pane {@link FakeHerdr}'s canned {@code
* pane.process_info} ties to {@link FakeHerdr#WORKER_PID} (pane {@code w2:p7}); {@code member}
* reports NO panes at all ({@link FakeHerdr#withNoPanes()}) — modelling a second daemon that
* simply does not host the caller's pane, exactly the CB-185 javadoc's scenario for a lead's
* own connection. If {@code PaneLocator} only ever searches the member daemon (the bug), this
* pid resolves to nothing, because the pane that owns it lives on the LEAD daemon the bug
* skips.
*/
@Test
void connectionIdentitySearchesTheLeadDaemonNotJustTheMemberOne(@TempDir Path dir) throws Exception {
FakeHerdr lead = new FakeHerdr();
FakeHerdr member = new FakeHerdr().withNoPanes();
assemble(dir, lead, member);
PaneLocator panes = runtime.mcp().identity().panes();
PaneLocator.Lookup lookup = panes.terminalForPid(FakeHerdr.WORKER_PID);
assertEquals("term_a", lookup.terminal(),
"the pane owning WORKER_PID lives on the LEAD daemon only (the member fake reports "
+ "no panes) — PaneLocator must still find it, which is only possible if it "
+ "searches the lead client and not just the member one");
}
/**
* The mirror control: when the pane instead lives ONLY on the member daemon (the lead reports
* no panes), the lookup must still find it — proving the member client is genuinely searched
* too, not merely tolerated as a second, always-losing argument.
*/
@Test
void connectionIdentityAlsoSearchesTheMemberDaemon(@TempDir Path dir) throws Exception {
FakeHerdr lead = new FakeHerdr().withNoPanes();
FakeHerdr member = new FakeHerdr();
assemble(dir, lead, member);
PaneLocator panes = runtime.mcp().identity().panes();
PaneLocator.Lookup lookup = panes.terminalForPid(FakeHerdr.WORKER_PID);
assertEquals("term_a", lookup.terminal(),
"the pane owning WORKER_PID lives on the MEMBER daemon only — PaneLocator must "
+ "find it there too");
}
/** Sanity control: a pid nobody owns resolves to nothing on either daemon. */
@Test
void aPidNoPaneOwnsResolvesToNoTerminalOnEitherDaemon(@TempDir Path dir) throws Exception {
FakeHerdr lead = new FakeHerdr();
FakeHerdr member = new FakeHerdr();
assemble(dir, lead, member);
PaneLocator panes = runtime.mcp().identity().panes();
PaneLocator.Lookup lookup = panes.terminalForPid(999_999L);
assertNull(lookup.terminal());
}
}
@@ -0,0 +1,237 @@
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 io.javalin.Javalin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.LongSupplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* fleetd #612 step 2, unit B2 (CB-185, {@code FleetApp} half). Replaces the deleted {@code
* FleetdFleetAppConstructionTest}, which pinned this claim by reading {@code Fleetd.java}'s
* source text for {@code "new FleetApp(herdr, memberHerdr, workers,"}. That claim moved to {@code
* FleetdAssembly.java} (fleetd #612 Unit A) and is pinned here instead, by driving the real {@code
* Javalin} app — via {@code runtime.app()}, not a copy — that {@link
* FleetdAssembly#assembleAndStart} built and handed to {@link FleetdRuntime}.
*
* <p><strong>What this guards against</strong> (from the deleted test's own javadoc): constructing
* {@code FleetApp} with the lead-only {@code herdr} client (dropping {@code memberHerdr}) makes
* {@code GET /healthz} report green while the MEMBER daemon is down — so every spawn fails
* invisibly — and silently drops every member workspace from {@code GET /sessions}. {@code
* FleetAppTwoDaemonTest} already proves {@code FleetApp} itself merges/gates correctly given two
* clients; the gap this pins is that the assembly actually passes it two.
*
* <p><strong>Both directions, not just one</strong> (fleetd #612 issue comment 17525): the deleted
* guard's positive assertion required the exact pair {@code "new FleetApp(herdr, memberHerdr,
* workers,"}, which does not survive EITHER daemon being dropped. An earlier version of this class
* only proved the member-dropped direction, which left {@code new FleetApp(memberHerdr,
* memberHerdr, ...)} — the symmetric bug, {@code /healthz} green while the LEAD daemon is down —
* an undetected regression. {@link #healthzGoesRedWhenTheLeadDaemonIsDownEvenThoughTheMemberIsUp}
* closes that.
*
* <p>Unlike the {@code ConnectionIdentity} half of CB-185 ({@code
* FleetdAssemblyConnectionIdentityTest}), {@code /healthz} needs no caller identity at all, so
* this test can bind {@link FleetdRuntime#app()} to a REAL ephemeral port (exactly {@code
* FleetAppTwoDaemonTest} does for its own hand-built {@code FleetApp}) and drive it with a real
* {@code HttpClient} — no accessor needed for this half.
*
* <p><strong>{@code GET /sessions} could not be driven the same way</strong>, so this class does
* not pin the merge half of the deleted test's javadoc. {@code /sessions} requires
* {@code Authz.Action.READ}, which — through the REAL assembly's real {@code
* CallerResolver}/{@code ConnectionIdentity} (built with a hardcoded {@code
* new LsofPeerPidLookup()}) — needs {@code Caller.resolved()}, i.e. a real positive pid from
* {@code lsof}. {@code LsofPeerPidLookup} excludes its own pid (see its javadoc), and a JUnit
* test's HTTP client and the daemon under test share one JVM pid, so the resolved pid is always
* {@code -1} and every such request is refused as {@code ANONYMOUS} (fleetd #317's fail-closed
* rule) before the route handler — and its {@code memberHerdr} merge — is ever reached. Verified
* directly: driving {@code GET /sessions} here returns {@code 401 unauthenticated}, not the
* merged body. {@code FleetAppTwoDaemonTest} avoids this because it builds {@code FleetApp} with
* {@code callers: null}, which is not what the real assembly passes. The {@code /healthz} pin
* below is what this class relies on for CB-185's {@code FleetApp} half; {@code
* FleetAppTwoDaemonTest} remains the full behavioural proof that {@code FleetApp} itself merges
* {@code /sessions} correctly once handed two clients.
*/
class FleetdAssemblyFleetAppTest {
private static final class TwoHerdrResourcePorts implements ResourcePorts {
final Map<Path, HerdrClient> herdrsBySocket = new LinkedHashMap<>();
final CopyOnWriteArrayList<ScheduledExecutorService> schedulers = new CopyOnWriteArrayList<>();
Runnable shutdownHook;
@Override
public Map<String, String> environment() {
return Map.of();
}
@Override
public HerdrClient connectHerdr(Path socketPath) {
HerdrClient client = herdrsBySocket.get(socketPath);
if (client == null) {
throw new IllegalStateException("no fake herdr registered for socket " + socketPath);
}
return client;
}
@Override
public Fleetd.AmqpOpener replyInboxOpener() {
return (uri, prefetch) -> new dev.ltms.fleet.msg.InMemoryReplyInbox();
}
@Override
public Fleetd.LeadMailboxOpener leadMailboxOpener() {
return (uri, selfCoordId, prefetch) -> {
throw new UnsupportedOperationException(
"leadMailboxOpener must not be called — no coordinator: block is configured");
};
}
@Override
public LongSupplier nanoClock() {
return System::nanoTime;
}
@Override
public LongSupplier wallClockNanos() {
return System::nanoTime;
}
@Override
public ScheduledExecutorService newScheduler(String purpose) {
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
schedulers.add(scheduler);
return scheduler;
}
@Override
public void addShutdownHook(Runnable hook) {
this.shutdownHook = hook;
}
@Override
public void startHttp(Javalin app, String host, int port) {
// Deliberately never bind here — this test binds runtime.app() itself, for real, below.
}
}
private static final Path LEAD_SOCKET = Path.of("/fake/lead-herdr.sock");
private static final Path MEMBER_SOCKET = Path.of("/fake/member-herdr.sock");
private final HttpClient http = HttpClient.newHttpClient();
private FleetdRuntime runtime;
private TwoHerdrResourcePorts ports;
private Javalin boundApp;
@AfterEach
void tearDown() {
if (boundApp != null) {
boundApp.stop();
}
if (ports != null && ports.shutdownHook != null) {
ports.shutdownHook.run();
}
}
private static FleetConfig writeConfig(Path dir) throws Exception {
Path f = dir.resolve("fleetd.yaml");
Files.writeString(f, """
bind:
host: 127.0.0.1
port: 8765
herdrSocket: "%s"
memberHerdrSocket: "%s"
lifecycle:
idleTtlSeconds: 600
health:
enabled: false
broker:
uri: "amqp://fake-test-broker/vh"
""".formatted(LEAD_SOCKET, MEMBER_SOCKET));
return FleetConfig.load(f);
}
/** Assembles the real graph, then binds the real {@code Javalin app} to an ephemeral port. */
private int assembleAndBind(Path dir, FakeHerdr lead, FakeHerdr member) throws Exception {
FleetConfig cfg = writeConfig(dir);
ConfigRef config = new ConfigRef(dir.resolve("fleetd.yaml"), cfg);
SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet());
ports = new TwoHerdrResourcePorts();
ports.herdrsBySocket.put(LEAD_SOCKET, lead);
ports.herdrsBySocket.put(MEMBER_SOCKET, member);
runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports);
boundApp = runtime.app().start("127.0.0.1", 0);
return boundApp.port();
}
private HttpResponse<String> get(int port, String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)).GET().build();
return http.send(req, HttpResponse.BodyHandlers.ofString());
}
/**
* The pin. The MEMBER daemon is down; the LEAD daemon is healthy. If the assembly built
* {@code FleetApp} with only the lead client (the bug: passing {@code herdr} where {@code
* memberHerdr} is expected), the down member is invisible and {@code /healthz} stays 200.
*/
@Test
void healthzGoesRedWhenTheMemberDaemonIsDownEvenThoughTheLeadIsUp(@TempDir Path dir) throws Exception {
FakeHerdr lead = new FakeHerdr();
FakeHerdr member = new FakeHerdr().healthy(false);
int port = assembleAndBind(dir, lead, member);
HttpResponse<String> res = get(port, "/healthz");
assertEquals(503, res.statusCode(),
"a down MEMBER daemon must not be masked by a healthy lead: " + res.body());
}
/** Sanity control: both daemons healthy must still be green through the real assembly. */
@Test
void healthzIsGreenWhenBothDaemonsAreUp(@TempDir Path dir) throws Exception {
FakeHerdr lead = new FakeHerdr();
FakeHerdr member = new FakeHerdr();
int port = assembleAndBind(dir, lead, member);
assertEquals(200, get(port, "/healthz").statusCode());
}
/**
* The symmetric pin (fleetd #612 issue comment 17525): the LEAD daemon is down; the MEMBER
* daemon is healthy. If the assembly built {@code FleetApp} with only the member client
* (dropping {@code herdr} — the mirror of the bug above, {@code new FleetApp(memberHerdr,
* memberHerdr, ...)}), the down LEAD is invisible and {@code /healthz} stays 200. Without this
* case the pair above is one-directional and does not cover the deleted guard's positive
* assertion (it required BOTH {@code herdr,} and {@code memberHerdr,} in that order).
*/
@Test
void healthzGoesRedWhenTheLeadDaemonIsDownEvenThoughTheMemberIsUp(@TempDir Path dir) throws Exception {
FakeHerdr lead = new FakeHerdr().healthy(false);
FakeHerdr member = new FakeHerdr();
int port = assembleAndBind(dir, lead, member);
HttpResponse<String> res = get(port, "/healthz");
assertEquals(503, res.statusCode(),
"a down LEAD daemon must not be masked by a healthy member: " + res.body());
}
}
@@ -1,31 +0,0 @@
package dev.ltms.fleet;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* CB-185: {@code ConnectionIdentity} must resolve a caller's pane on EITHER herdr daemon (a
* lead's MCP connection resolves against the lead daemon; a member's against the member daemon).
* Pinning {@code PaneLocator} to {@code memberHerdr} alone — the bug this guards against — leaves
* every lead's own connection unresolvable ({@code callerTerminal == null}) the moment
* {@code memberHerdrSocket} names a second daemon, which breaks {@code fleet_reply}/{@code
* fleet_ask} and {@code fleet_whoami} for a lead. A unit test on {@link
* dev.ltms.fleet.herdr.PaneLocator} alone (see {@code PaneLocatorTest}) proves the class CAN
* search two clients, but not that {@code Fleetd.main} actually wires it that way — hence this
* source-level assertion, the same technique {@code FleetdHerdrControlConstructionTest} uses.
*/
class FleetdConnectionIdentityConstructionTest {
@Test
void connectionIdentitySearchesBothDaemonsNotJustTheMemberOne() throws Exception {
String source = Files.readString(Path.of("src/main/java/dev/ltms/fleet/Fleetd.java"));
assertFalse(source.contains("new PaneLocator(memberHerdr)"),
"PaneLocator must not be pinned to the member daemon alone — a lead's own "
+ "connection resolves against the LEAD daemon and would never be found");
assertTrue(source.contains("new PaneLocator(herdr, memberHerdr)"),
"PaneLocator must search the lead daemon first, then the member daemon");
}
}
@@ -1,29 +0,0 @@
package dev.ltms.fleet;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* CB-185: {@code FleetApp} must be constructed with BOTH herdr clients (the lead's and the
* member's), never the raw lead-only {@code herdr}. Passing only {@code herdr} — the bug this
* guards against — makes {@code GET /healthz} green while the member daemon is down (so every
* spawn fails invisibly) and silently drops every member workspace from {@code GET /sessions}.
* A behavioural test on {@code FleetApp} alone (see {@code FleetAppTwoDaemonTest}) proves the
* class merges/gates correctly when given two clients, but not that {@code Fleetd.main} actually
* passes it two — hence this source-level assertion, mirroring
* {@code FleetdHerdrControlConstructionTest}.
*/
class FleetdFleetAppConstructionTest {
@Test
void fleetAppIsConstructedWithBothHerdrDaemons() throws Exception {
String source = Files.readString(Path.of("src/main/java/dev/ltms/fleet/Fleetd.java"));
assertFalse(source.contains("new FleetApp(herdr, workers,"),
"FleetApp must not be constructed with the lead-only herdr client");
assertTrue(source.contains("new FleetApp(herdr, memberHerdr, workers,"),
"FleetApp must be constructed with both the lead and the member herdr client");
}
}