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)