diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java
index b15eefb..d5d0e25 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
@@ -38,12 +39,31 @@ import static org.junit.jupiter.api.Assertions.fail;
* #absentLeadRolloverConfigMeansNoRolloverIsBuilt} — a claim this ticket found was NOT actually
* covered behaviourally anywhere else: {@code LeadRolloverTest}'s only related assertion is
* vacuous, {@code assertNull(null)}, and never calls the real factory).
+ *
+ *
fleetd #612 B3 correction (ticket comment 17553): the first version of this
+ * test configured a single shared {@link FakeHerdr} for both the lead and member herdr sockets.
+ * {@code FleetdAssembly.java:140-142} falls back to {@code memberHerdr = herdr} whenever no
+ * distinct {@code memberHerdrSocket} is configured, so with one fake, {@code
+ * router.leadAgents()} and {@code router.memberAgents()} wrapped the identical client — a
+ * mutation swapping {@code Fleetd.leadRollover(cfg, router.leadAgents(), config, leads)} for
+ * {@code ..., router.memberAgents(), ...} at {@code FleetdAssembly.java:408} was therefore
+ * invisible to this test, even though the two are genuinely different daemons in production. This
+ * version configures two distinct sockets and two distinct {@link FakeHerdr} instances (the same
+ * pattern {@code FleetdAssemblyConnectionIdentityTest}, fleetd #612 B2, already uses to separate
+ * lead from member) and asserts the roll's {@code /clear}/bootstrap sends land on the LEAD fake
+ * and never on the MEMBER one.
*/
class FleetdLeadRolloverAssemblyTest {
+ private static final Path LEAD_SOCKET = Path.of("/fake/lead-herdr.sock");
+ private static final Path MEMBER_SOCKET = Path.of("/fake/member-herdr.sock");
+
+ /** Keys {@code connectHerdr} by socket path so the lead and member daemons can be two
+ * DIFFERENT {@link FakeHerdr}s — same shape as B2's {@code FleetdAssemblyConnectionIdentityTest
+ * .TwoHerdrResourcePorts}. */
private static final class RecordingResourcePorts implements ResourcePorts {
- final FakeHerdr herdr = new FakeHerdr();
+ final Map herdrsBySocket = new LinkedHashMap<>();
final SentinelReplyInbox replyInbox = new SentinelReplyInbox();
@Override
@@ -53,7 +73,11 @@ class FleetdLeadRolloverAssemblyTest {
@Override
public HerdrClient connectHerdr(Path socketPath) {
- return herdr;
+ HerdrClient client = herdrsBySocket.get(socketPath);
+ if (client == null) {
+ throw new IllegalStateException("no fake herdr registered for socket " + socketPath);
+ }
+ return client;
}
@Override
@@ -127,6 +151,8 @@ class FleetdLeadRolloverAssemblyTest {
bind:
host: 127.0.0.1
port: 8765
+ herdrSocket: "%s"
+ memberHerdrSocket: "%s"
idleSleepGuard:
enabled: false
broker:
@@ -139,7 +165,7 @@ class FleetdLeadRolloverAssemblyTest {
leadRollover:
handoverPath: handover.md
requireOperatorConfirm: false
- """.formatted(leadCwd.toString()));
+ """.formatted(LEAD_SOCKET, MEMBER_SOCKET, leadCwd.toString()));
return FleetConfig.load(f);
}
@@ -154,7 +180,13 @@ class FleetdLeadRolloverAssemblyTest {
ConfigRef config = new ConfigRef(dir.resolve("fleetd.yaml"), cfg);
SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet());
RecordingResourcePorts ports = new RecordingResourcePorts();
- ports.herdr.withTab("w2", "w2:t7", "lead: opus");
+ // Two DISTINCT fakes — one per configured socket — so leadAgents()/memberAgents() wrap
+ // genuinely different clients, exactly like production when memberHerdrSocket is set.
+ FakeHerdr lead = new FakeHerdr();
+ lead.withTab("w2", "w2:t7", "lead: opus");
+ FakeHerdr member = new FakeHerdr();
+ ports.herdrsBySocket.put(LEAD_SOCKET, lead);
+ ports.herdrsBySocket.put(MEMBER_SOCKET, member);
FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports);
@@ -188,19 +220,30 @@ class FleetdLeadRolloverAssemblyTest {
+ "the turn-boundary wait settles immediately and the post-/clear wait "
+ "releases via its pickup-grace path — detail: " + status.detail());
- // Prove the real herdr router actually sent BOTH messages, in order, to the real pane —
- // this is the one thing a source-text pin on the call site could never show.
- List prompts = ports.herdr.calls.stream()
+ // Prove the real herdr router actually sent BOTH messages, in order, to the real LEAD
+ // pane — this is the one thing a source-text pin on the call site could never show.
+ List prompts = lead.calls.stream()
.filter(c -> c.method().equals("agent.prompt"))
.toList();
- assertTrue(prompts.size() >= 2, "expected at least a /clear send and a bootstrapText send, "
- + "got " + prompts.size() + " agent.prompt calls: " + prompts);
+ assertTrue(prompts.size() >= 2, "expected at least a /clear send and a bootstrapText send "
+ + "on the LEAD daemon, got " + prompts.size() + " agent.prompt calls: " + prompts);
assertEquals("/clear", ((Map) prompts.get(0).params()).get("text"),
"the first send must be the literal /clear housekeeping command");
Object secondText = ((Map) prompts.get(1).params()).get("text");
assertTrue(secondText instanceof String && ((String) secondText).contains(expectedHandoverPath),
"the second send must be the default bootstrapText naming the resolved handover "
+ "path, got: " + secondText);
+
+ // fleetd #612 B3 correction: prove the roll never touches the MEMBER daemon. A mutation
+ // swapping router.leadAgents() for router.memberAgents() at the real call site would move
+ // both sends above onto `member` instead, which this assertion catches — the thing the
+ // single-fake version of this test could never see, because both wrapped the same client.
+ List memberPrompts = member.calls.stream()
+ .filter(c -> c.method().equals("agent.prompt"))
+ .toList();
+ assertTrue(memberPrompts.isEmpty(), "the roll must be wired to the LEAD daemon only — got "
+ + memberPrompts.size() + " agent.prompt call(s) on the MEMBER daemon instead: "
+ + memberPrompts);
}
private static LeadRollover.RollStatus pollUntilTerminal(LeadRollover rollover, String token)