From 2b52324d9ad73f9a2d2ce60c132a96f40f91e262 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Tue, 22 Sep 2026 12:18:48 +0700 Subject: [PATCH 1/2] fleetd #612 step 2 unit B3: behavioural replacements for lead-seat, quarantine and lead-rollover source-text guards Replaces three FleetdAssembly.java source-text guards (each scraped Fleetd.java for a call site that fleetd #612 Unit A moved into FleetdAssembly.java) with tests that drive the real assembled objects through FleetdAssembly.assembleAndStart(...) -> FleetdRuntime.mcp(), per the step-2 B-unit split (issue #612 comment 17513). - Deleted FleetdLeadSeatWiringTest (fleetd #176): pinned that FleetMcp's LeadSeatSource construction still wires Fleetd.leadSeatLookup(...) by scraping the constructor call's text. Replaced by FleetdLeadSeatAssemblyTest, which seeds one FakeHerdr tab labelled to match a configured fleet.leaders.opus.tab and asserts the REAL assembled LeadSeatSource (via runtime.mcp().leadSeatSource()) reports the live lead's seat against its own subscription profile -- 1, not the 0 LeadSeatSource.none() (the inert stand-in) could ever report. - Deleted FleetdBackendQuarantineWiringTest (fleetd #466): pinned that the escalating BackendQuarantine.withEscalation(...) text was present and the flat two-argument constructor's text was absent. Replaced by FleetdBackendQuarantineAssemblyTest, which quarantines the same credential twice through the REAL assembled BackendQuarantine (via runtime.mcp().quarantineSource().quarantine()) at controlled fake-clock offsets and asserts the second cooldown doubles (200s vs 100s) -- the one behavioural difference escalation and the flat constructor actually produce. - Deleted FleetdLeadRolloverWiringTest (fleetd #480), all three methods: unrelatedAnchorStillPresent was a scaffold anchor with no independent claim, needing no replacement. mainStillCallsTheLeadRolloverFactory pinned the leadRollover assignment's call-site text. factoryGatesOnConfigPresence pinned that an absent leadRollover: config yields no LeadRollover. Replaced by FleetdLeadRolloverAssemblyTest's two tests: assembledLeadRolloverRunsTheRealClearAndBootstrapSequence drives the REAL assembled LeadRollover (via runtime.mcp().leadRollover()) through open()/confirm() end to end and asserts /clear then bootstrapText were actually sent through the real herdr router, reaching ROLLED. absentLeadRolloverConfigMeansNoRolloverIsBuilt calls Fleetd.leadRollover(...) directly with no leadRollover: block and asserts null -- this claim was found uncovered elsewhere (LeadRolloverTest's only related assertion is vacuous, assertNull (null), and never calls the real factory). Each of the three FleetdAssembly.java call sites (quarantine line 179-180, leadRollover line 408, lead seats line 479) was mutated to its named inert variant, run against ONLY its new test (RED), reverted, touch'd (Maven mtime trap) and re-run (GREEN) -- six proven runs, pasted in the PR body. FleetMcp.java: adds three accessors (quarantineSource(), leadSeatSource(), leadRollover()) alongside the existing registeredTools() -- but public, not package-private, and this is a deliberate deviation from that precedent, not an oversight: these new assembly tests cannot live in package dev.ltms.fleet.mcp the way registeredTools()'s callers do, because they also build the ResourcePorts FleetdAssembly.assembleAndStart(...) needs, and ResourcePorts' methods return Fleetd-nested types visible only from package dev.ltms.fleet. Package-private would compile but be unreachable from there. Full mvn -o test in fleetd/: Tests run: 1879, Failures: 6 (down from the branch baseline's 1880/9 by exactly the 3 guards this unit deletes) -- the remaining 6 are FleetdCompletionResolverWiringTest (4) and FleetdConnectionIdentityConstructionTest / FleetdFleetAppConstructionTest (1 each), all out of this unit's scope (B1/B2). --- .../java/dev/ltms/fleet/mcp/FleetMcp.java | 40 +++ .../FleetdBackendQuarantineAssemblyTest.java | 199 ++++++++++++++ .../FleetdBackendQuarantineWiringTest.java | 65 ----- .../fleet/FleetdLeadRolloverAssemblyTest.java | 242 ++++++++++++++++++ .../fleet/FleetdLeadRolloverWiringTest.java | 89 ------- .../fleet/FleetdLeadSeatAssemblyTest.java | 172 +++++++++++++ .../ltms/fleet/FleetdLeadSeatWiringTest.java | 43 ---- 7 files changed, 653 insertions(+), 197 deletions(-) create mode 100644 fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java delete mode 100644 fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineWiringTest.java create mode 100644 fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java delete mode 100644 fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverWiringTest.java create mode 100644 fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java delete mode 100644 fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatWiringTest.java diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index 556d6b2..22cb8e2 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -780,6 +780,46 @@ public final class FleetMcp { return server.listTools(); } + /** + * fleetd #612 B3 — same reason as {@link #registeredTools()}: a test that must drive the REAL + * {@link QuarantineSource} (and the real {@link BackendQuarantine} it wraps) this daemon was + * assembled with, rather than scraping {@code FleetdAssembly.java}'s source text for the + * constructor call that built it. Unlike {@link #registeredTools()}'s callers, that test cannot + * live in this package: it also builds the {@code ResourcePorts} that drives + * {@code FleetdAssembly.assembleAndStart}, and {@code ResourcePorts}' methods return + * {@code Fleetd}-nested types that are only visible from package {@code dev.ltms.fleet} — so + * this accessor is {@code public}, not package-private, to stay reachable from there. {@code + * FleetdBackendQuarantineAssemblyTest} quarantines a credential twice through this exact + * instance and checks the second cooldown is longer than the first — the one behavioural + * difference {@link BackendQuarantine#withEscalation} and the flat two-argument constructor + * actually produce. + */ + public QuarantineSource quarantineSource() { + return quarantine; + } + + /** + * fleetd #612 B3 — as {@link #quarantineSource()}, {@code public} for the same cross-package + * reason, for the real {@link LeadSeatSource} this daemon was assembled with. {@code + * FleetdLeadSeatAssemblyTest} calls {@code seatsFor} on this exact instance and checks it + * reports a live lead's seat, which {@link LeadSeatSource#none()} can never do (it is a + * constant-zero function regardless of input). + */ + public LeadSeatSource leadSeatSource() { + return leadSeats; + } + + /** + * fleetd #612 B3 — as {@link #quarantineSource()}, {@code public} for the same cross-package + * reason, for the real {@link LeadRollover} (or {@code null}) this daemon was assembled with. + * {@code FleetdLeadRolloverAssemblyTest} drives {@code open}/{@code confirm} on this exact + * instance and waits for the real continuation to send {@code /clear} and {@code bootstrapText} + * through the real {@code router.leadAgents()}. + */ + public LeadRollover leadRollover() { + return leadRollover; + } + // --- tool logic (thin adapters over the services; unit-testable) --------------------------- /** diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java new file mode 100644 index 0000000..2852739 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineAssemblyTest.java @@ -0,0 +1,199 @@ +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.ReplyInbox; +import dev.ltms.fleet.placement.BackendQuarantine; +import io.javalin.Javalin; +import org.junit.jupiter.api.DisplayName; +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.OptionalLong; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #612 B3 — replaces {@code FleetdBackendQuarantineWiringTest} (fleetd #466), a source-text + * test that scraped {@code Fleetd.java} (now {@code FleetdAssembly.java}, moved there by fleetd #612 + * Unit A) for the {@code BackendQuarantine.withEscalation(...)} call, and separately asserted the + * flat two-argument constructor's text was ABSENT. That proves the right method NAME appears in + * source; it proves nothing about what the constructed object actually DOES. + * + *

This test instead drives the REAL {@link BackendQuarantine} the real {@link + * FleetdAssembly#assembleAndStart} builds — reached through {@link + * dev.ltms.fleet.mcp.FleetMcp#quarantineSource()} on the real, live {@code FleetMcp} {@code + * FleetdRuntime} owns — and asserts the ONE behavioural difference {@code withEscalation} and the + * flat constructor actually produce (see {@link BackendQuarantine}'s own class doc, "Mechanism"): + * quarantining the same credential twice in a row, within one base cooldown of the first deadline, + * must escalate the second cooldown past the first. A flat instance reports the identical cooldown + * both times. + */ +class FleetdBackendQuarantineAssemblyTest { + + /** Base cooldown used throughout — long enough that rounding never blurs the 2x escalation. */ + private static final int COOLDOWN_SECONDS = 100; + + private static final class RecordingResourcePorts implements ResourcePorts { + + final FakeHerdr herdr = new FakeHerdr(); + final SentinelReplyInbox replyInbox = new SentinelReplyInbox(); + final AtomicLong clockNanos = new AtomicLong(0L); + + @Override + public Map environment() { + return Map.of(); + } + + @Override + public HerdrClient connectHerdr(Path socketPath) { + return herdr; + } + + @Override + public Fleetd.AmqpOpener replyInboxOpener() { + return (uri, prefetch) -> replyInbox; + } + + @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() { + // Controllable: the SAME LongSupplier instance BackendQuarantine.withEscalation(...) is + // built with, so advancing clockNanos after assembly moves the quarantine tracker's own + // clock, with no real sleep needed to observe escalation. + return clockNanos::get; + } + + @Override + public LongSupplier wallClockNanos() { + return clockNanos::get; + } + + @Override + public ScheduledExecutorService newScheduler(String purpose) { + return Executors.newSingleThreadScheduledExecutor(); + } + + @Override + public void addShutdownHook(Runnable hook) { + } + + @Override + public void startHttp(Javalin app, String host, int port) { + } + } + + private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable { + @Override + public void own(String target) { + } + + @Override + public void release(String target) { + } + + @Override + public void publish(String target, String msgId, String content) { + } + + @Override + public List peek(String target) { + return List.of(); + } + + @Override + public boolean ack(String target, String msgId) { + return false; + } + + @Override + public void close() { + } + } + + 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 + idleSleepGuard: + enabled: false + broker: + uri: "amqp://fake-test-broker/vh" + quarantineCooldownSeconds: %d + """.formatted(COOLDOWN_SECONDS)); + return FleetConfig.load(f); + } + + @Test + @DisplayName("[BEHAVIOURAL] the real assembled BackendQuarantine escalates a repeated exhaustion, " + + "which the flat two-argument constructor can never do") + void assembledQuarantineEscalatesOnARepeatedExhaustion(@TempDir Path dir) throws Exception { + FleetConfig cfg = writeConfig(dir); + ConfigRef config = new ConfigRef(dir.resolve("fleetd.yaml"), cfg); + SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet()); + RecordingResourcePorts ports = new RecordingResourcePorts(); + + FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports); + + BackendQuarantine quarantine = runtime.mcp().quarantineSource().quarantine(); + + // First exhaustion, at clock=0: a fresh occurrence, blocked for exactly the base cooldown. + quarantine.quarantine("cred-x"); + BackendQuarantine.Status first = quarantine.status("cred-x").orElseThrow( + () -> new AssertionError("credential must be quarantined immediately after quarantine()")); + assertEquals(1, first.repeatCount(), "the first call is repeat #1"); + assertEquals(COOLDOWN_SECONDS, first.remainingSeconds(), + "a fresh quarantine blocks for exactly the base cooldown"); + + // Second exhaustion, arriving just after the first deadline — well within one base cooldown + // of it, so this is a CONTINUATION of the same streak (repeat #2), not a fresh occurrence. + long firstDeadlineNanos = COOLDOWN_SECONDS * 1_000_000_000L; + ports.clockNanos.set(firstDeadlineNanos + 1); + quarantine.quarantine("cred-x"); + BackendQuarantine.Status second = quarantine.status("cred-x").orElseThrow( + () -> new AssertionError("credential must be quarantined immediately after the second " + + "quarantine() call")); + assertEquals(2, second.repeatCount(), "the second call, arriving within one base cooldown of " + + "the first deadline, continues the streak as repeat #2"); + + // The one behavioural difference: withEscalation doubles the cooldown on repeat #2 (capped + // well above this at 12x base), the flat two-argument constructor never grows past the base + // cooldown no matter how many times quarantine() is called in a row. + assertEquals(2 * COOLDOWN_SECONDS, second.remainingSeconds(), + "withEscalation's default backoff doubles the cooldown on the second consecutive " + + "exhaustion — this is the exact call FleetdAssembly.java makes at the " + + "BackendQuarantine.withEscalation(...) call site"); + assertTrue(second.remainingSeconds() > first.remainingSeconds(), + "the flat two-argument BackendQuarantine constructor would report the SAME remaining " + + "seconds both times — this inequality is what a mutation to the flat " + + "constructor at that call site must fail"); + + // Also confirm isQuarantined/remainingSeconds agree, exercising the accessors a real caller + // (fleet_profiles / fleet_list, per BackendQuarantine's own class doc) actually reads. + assertTrue(quarantine.isQuarantined("cred-x")); + OptionalLong remaining = quarantine.remainingSeconds("cred-x"); + assertTrue(remaining.isPresent()); + assertEquals(2 * COOLDOWN_SECONDS, remaining.getAsLong()); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineWiringTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineWiringTest.java deleted file mode 100644 index 98d9c6e..0000000 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdBackendQuarantineWiringTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package dev.ltms.fleet; - -import java.nio.file.Files; -import java.nio.file.Path; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * fleetd #466 follow-up: {@code Fleetd.main} builds the daemon's one {@code BackendQuarantine} - * from {@link dev.ltms.fleet.placement.BackendQuarantine#withEscalation(java.util.function.LongSupplier, - * long)} — the escalating factory — rather than the plain two-argument constructor, which is still a - * flat cooldown (kept for backward compatibility, see that class's doc). {@code - * BackendQuarantineTest} proves {@code withEscalation} itself escalates, is ceilinged, and resets; - * it says nothing about which one {@code main} actually calls. - * - *

Measured directly: reverting {@code main} to {@code new BackendQuarantine(System::nanoTime, - * TimeUnit.SECONDS.toNanos(cfg.quarantineCooldownSeconds()))} — the pre-#466 flat call — compiles - * with 0 errors and leaves the entire 1608-test suite (including every {@code BackendQuarantineTest} - * case) green, because no other test constructs its {@code BackendQuarantine} through {@code main}; - * every one of them builds its own instance directly. That silent regression is exactly the shape - * {@link FleetdLeadSeatWiringTest} and {@link FleetdCompletionResolverWiringTest} already guard - * against for their own constructor arguments — this is the same class of gap for fleetd #466's - * factory choice, following their approach. - * - *

This test checks source text, not runtime behaviour. It never constructs a {@code - * BackendQuarantine} and never runs {@code main} — a green result here proves only that the exact - * text {@code main} calls {@code BackendQuarantine.withEscalation(...)} rather than the flat - * constructor. It does not prove that call actually executes at startup (no test here starts the - * daemon), and it does not prove the escalation reaches a real backend or credential — only - * {@code BackendQuarantineTest} proves the factory's own behaviour, and only a live daemon proves - * the wiring runs. - */ -class FleetdBackendQuarantineWiringTest { - - private static String fleetdSource() throws Exception { - return Files.readString(Path.of("src/main/java/dev/ltms/fleet/Fleetd.java")); - } - - @Test - @DisplayName("[SOURCE TEXT] main's BackendQuarantine local is still built from BackendQuarantine.withEscalation(...)") - void mainStillWiresTheEscalatingQuarantineFactory() throws Exception { - String source = fleetdSource(); - assertTrue(source.contains( - "BackendQuarantine quarantine = BackendQuarantine.withEscalation(System::nanoTime,\n" - + " TimeUnit.SECONDS.toNanos(cfg.quarantineCooldownSeconds()));"), - "Fleetd.main's BackendQuarantine local must still be built from " - + "BackendQuarantine.withEscalation(System::nanoTime, " - + "TimeUnit.SECONDS.toNanos(cfg.quarantineCooldownSeconds())). Reverting to the flat " - + "two-argument constructor (fleetd #466's measured regression) compiles with 0 errors " - + "and leaves the whole suite green, including every BackendQuarantineTest case that " - + "proves the escalation itself works — this source check is what must go red instead. " - + "A reverted daemon would go back to retrying a weekly subscription limit on every " - + "flat ~30-minute cooldown, about 336 times across the week."); - - // Negative form of the same check: the pre-#466 flat call, if it ever reappears at this - // declaration, must not be mistaken for the escalating one by a looser positive-only check. - assertFalse(source.contains( - "BackendQuarantine quarantine = new BackendQuarantine(System::nanoTime,\n" - + " TimeUnit.SECONDS.toNanos(cfg.quarantineCooldownSeconds()));"), - "main's BackendQuarantine local must never regress to the flat two-argument constructor"); - } -} diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java new file mode 100644 index 0000000..b15eefb --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverAssemblyTest.java @@ -0,0 +1,242 @@ +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.AgentControl; +import dev.ltms.fleet.herdr.FakeHerdr; +import dev.ltms.fleet.herdr.HerdrClient; +import dev.ltms.fleet.lead.LeadRollover; +import dev.ltms.fleet.msg.ReplyInbox; +import io.javalin.Javalin; +import org.junit.jupiter.api.DisplayName; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * fleetd #612 B3 — replaces {@code FleetdLeadRolloverWiringTest} (fleetd #480). That class was a + * source-text test scraping {@code Fleetd.java} (now {@code FleetdAssembly.java}, moved there by + * fleetd #612 Unit A) with three methods: {@code unrelatedAnchorStillPresent} (a scaffold anchor, + * not an independent claim — needs no replacement of its own), {@code + * mainStillCallsTheLeadRolloverFactory} (the call-site pin replaced by {@link + * #assembledLeadRolloverRunsTheRealClearAndBootstrapSequence}), and {@code + * factoryGatesOnConfigPresence} (the absent-config claim replaced by {@link + * #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). + */ +class FleetdLeadRolloverAssemblyTest { + + private static final class RecordingResourcePorts implements ResourcePorts { + + final FakeHerdr herdr = new FakeHerdr(); + final SentinelReplyInbox replyInbox = new SentinelReplyInbox(); + + @Override + public Map environment() { + return Map.of(); + } + + @Override + public HerdrClient connectHerdr(Path socketPath) { + return herdr; + } + + @Override + public Fleetd.AmqpOpener replyInboxOpener() { + return (uri, prefetch) -> replyInbox; + } + + @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) { + return Executors.newSingleThreadScheduledExecutor(); + } + + @Override + public void addShutdownHook(Runnable hook) { + } + + @Override + public void startHttp(Javalin app, String host, int port) { + } + } + + private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable { + @Override + public void own(String target) { + } + + @Override + public void release(String target) { + } + + @Override + public void publish(String target, String msgId, String content) { + } + + @Override + public List peek(String target) { + return List.of(); + } + + @Override + public boolean ack(String target, String msgId) { + return false; + } + + @Override + public void close() { + } + } + + private static FleetConfig writeConfig(Path dir, Path leadCwd) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, """ + bind: + host: 127.0.0.1 + port: 8765 + idleSleepGuard: + enabled: false + broker: + uri: "amqp://fake-test-broker/vh" + fleet: + leaders: + opus: + tab: "lead: opus" + cwd: "%s" + leadRollover: + handoverPath: handover.md + requireOperatorConfirm: false + """.formatted(leadCwd.toString())); + return FleetConfig.load(f); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("[BEHAVIOURAL] the real assembled LeadRollover runs the full open/confirm/continuation " + + "sequence — /clear, then bootstrapText — through the real herdr router") + void assembledLeadRolloverRunsTheRealClearAndBootstrapSequence(@TempDir Path dir) throws Exception { + Path leadCwd = dir.resolve("lead-workspace"); + Files.createDirectories(leadCwd); + FleetConfig cfg = writeConfig(dir, leadCwd); + 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"); + + FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports); + + LeadRollover rollover = runtime.mcp().leadRollover(); + assertNotNull(rollover, "leadRollover: is present in this test's config, so " + + "FleetdAssembly.assembleAndStart must have built a real LeadRollover through the " + + "Fleetd.leadRollover(...) call site — a mutation to `LeadRollover leadRollover = " + + "null;` at that call site can never pass this"); + + LeadRollover.PendingRollover pending = rollover.open("term_a", "fleetd #612 B3 test"); + String expectedHandoverPath = leadCwd.resolve("handover.md").normalize().toString(); + assertEquals(expectedHandoverPath, pending.handoverPath()); + + // Ensure the handover file's mtime lands strictly AFTER open()'s requestedAtMillis — + // LeadRollover.checkHandover refuses on mtime <= requestedAt (HANDOVER_STALE). + Thread.sleep(50); + Files.writeString(Path.of(pending.handoverPath()), "handover content for fleetd #612 B3"); + + LeadRollover.RollDecision decision = rollover.confirm("term_a", pending.token(), true); + assertTrue(decision.accepted(), "confirm() must approve: requireOperatorConfirm is false, " + + "the caller terminal matches open()'s, and the handover file exists, is non-empty " + + "and fresh — got: " + decision); + + // The production LeadRollover constructor always runs the post-confirm continuation on a + // real virtual thread (see Fleetd.leadRollover, which never passes the package-private test + // constructor), so this polls the real FleetMcp.leadRollover() instance's status(token) + // until the real continuation finishes. + LeadRollover.RollStatus status = pollUntilTerminal(rollover, pending.token()); + assertEquals(LeadRollover.RollState.ROLLED, status.state(), + "the full happy path must complete: FakeHerdr's default agent status is 'idle', so " + + "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() + .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); + 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); + } + + private static LeadRollover.RollStatus pollUntilTerminal(LeadRollover rollover, String token) + throws InterruptedException { + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + LeadRollover.RollStatus status = rollover.status(token); + if (status.state() != LeadRollover.RollState.PENDING + && status.state() != LeadRollover.RollState.IN_PROGRESS) { + return status; + } + Thread.sleep(50); + } + fail("the real continuation did not reach a terminal state within 10s — last status: " + + rollover.status(token)); + throw new AssertionError("unreachable"); + } + + @Test + @DisplayName("[BEHAVIOURAL] Fleetd.leadRollover(...) returns null when leadRollover: is absent " + + "from config — the opt-in gate FleetdLeadRolloverWiringTest's " + + "factoryGatesOnConfigPresence pinned by source text alone") + void absentLeadRolloverConfigMeansNoRolloverIsBuilt(@TempDir Path dir) throws Exception { + Path yaml = dir.resolve("fleetd.yaml"); + Files.writeString(yaml, """ + bind: + host: 127.0.0.1 + port: 8765 + """); + ConfigRef config = new ConfigRef(yaml, FleetConfig.load(yaml)); + AgentControl agents = new AgentControl(new FakeHerdr()); + + LeadRollover rollover = Fleetd.leadRollover(config.get(), agents, config, Map::of); + + assertNull(rollover, "leadRollover: is absent from this config, so the factory's opt-in " + + "gate (`if (cfg.leadRollover() == null) return null;`) must fire and no " + + "LeadRollover must be constructed at all"); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverWiringTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverWiringTest.java deleted file mode 100644 index c792e53..0000000 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadRolloverWiringTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package dev.ltms.fleet; - -import java.nio.file.Files; -import java.nio.file.Path; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * fleetd #480 Unit A, hard requirement 6: pin {@code Fleetd.main}'s construction of {@link - * dev.ltms.fleet.lead.LeadRollover} with a source-text assertion, mirroring {@code - * FleetdCompletionResolverWiringTest}'s pattern — five log-only reporters in {@code Fleetd.main} - * already survived mutation batteries this exact way (fleetd #415's extraction antidote note). - * - *

What this class still covers, and what it never claimed to. {@code LeadRolloverTest} - * constructs its own {@code LeadRollover} directly (as every prior test of an extracted factory - * does) with a hand-built lookup, so a mutation that deletes the {@code leadRollover(...)} call - * from {@code main} — or replaces one of its arguments with something that still compiles, e.g. - * {@code router.leadAgents()} swapped for {@code null}, or the whole assignment swapped for a bare - * {@code null} literal — leaves every behavioural test green. This is a plain string read, guarded - * by an unrelated anchor assertion so a broken or empty file read cannot pass as a real change. - * - *

This test checks source text, not runtime behaviour. It never constructs a {@code - * LeadRollover} and never runs {@code main}. It pins the {@code leadRollover(...)} CALL SITE's - * argument list — that {@code main} still passes {@code leads} at all — never what the factory - * DOES with that argument once inside its own body. - * - *

Correction (fleetd #480 relative-handover-path follow-up): that gap used to be real, and - * now is not — but not here. This class's javadoc previously claimed "no behavioural test can - * catch this wiring dropping out" for the whole factory, including the lambda {@code - * leadRollover(...)} builds internally (terminal → lead name → {@code Leader.cwd()}). That claim - * was proven true at the time — mutating that lambda's body to {@code String leadName = null;} - * (always "no lead found", which silently reintroduces the daemon-cwd bug this ticket fixes) left - * the full suite green, {@code Tests run: 1669, Failures: 0}. It is no longer true: {@code - * FleetdLeadRolloverWorkspaceLookupTest} now calls {@code Fleetd.leadRollover(...)} directly with a - * real {@link dev.ltms.fleet.config.ConfigRef} built from a temp {@code fleetd.yaml}, and fails - * against that exact one-line mutation. So: THIS class still covers only the call site's argument - * list; {@code FleetdLeadRolloverWorkspaceLookupTest} is what now covers the lambda's body. Neither - * one subsumes the other — keep both. - */ -class FleetdLeadRolloverWiringTest { - - private static String fleetdSource() throws Exception { - return Files.readString(Path.of("src/main/java/dev/ltms/fleet/Fleetd.java")); - } - - @Test - @DisplayName("[SOURCE TEXT] unrelated anchor: Fleetd.java still declares the Fleetd class") - void unrelatedAnchorStillPresent() throws Exception { - // Guards the two assertions below: without this, a bad read (empty string, wrong file, - // truncated file) could vacuously fail to contain the leadRollover(...) call too, and a - // test that only asserts "contains X" would report a false pass for the wrong reason if X - // happened to match. Asserting an unrelated, structurally distant string first proves the - // read actually pulled real file content. - String source = fleetdSource(); - assertTrue(source.contains("public final class Fleetd"), - "sanity anchor failed — the file read did not return real Fleetd.java source; the " - + "leadRollover(...) wiring assertions below cannot be trusted until this passes"); - } - - @Test - @DisplayName("[SOURCE TEXT] main still constructs LeadRollover via the leadRollover(...) factory, exactly as heartbeat is constructed") - void mainStillCallsTheLeadRolloverFactory() throws Exception { - String source = fleetdSource(); - assertTrue(source.contains( - "LeadRollover leadRollover = leadRollover(cfg, router.leadAgents(), config, leads);"), - "Fleetd.main must still assign `LeadRollover leadRollover = leadRollover(cfg, " - + "router.leadAgents(), config, leads);`. Dropping this call, or swapping one of " - + "its arguments for something that still compiles (e.g. null in place of " - + "router.leadAgents()), leaves every behavioural test green — this source check is " - + "what must go red instead. fleetd #480 correction 2 deliberately dropped " - + "primaryRegistry from this call — see LeadRollover's class javadoc for why a " - + "single-slot lookup was wrong here. The fleetd #480 relative-handover-path " - + "follow-up added `leads` (terminal → lead name) so the factory can resolve a " - + "relative handoverPath against the calling lead's own workspace."); - } - - @Test - @DisplayName("[SOURCE TEXT] the leadRollover(...) factory itself gates construction on cfg.leadRollover() != null") - void factoryGatesOnConfigPresence() throws Exception { - String source = fleetdSource(); - assertTrue(source.contains("if (cfg.leadRollover() == null) {"), - "Fleetd.leadRollover(...) must refuse to construct a LeadRollover when the " - + "leadRollover: block is absent — an upgraded daemon must never silently acquire " - + "the ability to clear the lead's own pane. See LeadHeartbeatLoop's construction " - + "gate (cfg.leadHeartbeat() != null) for the pattern this mirrors."); - } -} diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java new file mode 100644 index 0000000..aff5704 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatAssemblyTest.java @@ -0,0 +1,172 @@ +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.mcp.FleetMcp; +import dev.ltms.fleet.msg.ReplyInbox; +import io.javalin.Javalin; +import org.junit.jupiter.api.DisplayName; +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; + +/** + * fleetd #612 B3 — replaces {@code FleetdLeadSeatWiringTest} (fleetd #176), a source-text test that + * scraped {@code Fleetd.java} (now {@code FleetdAssembly.java}, moved there by fleetd #612 Unit A) + * for the exact {@code new FleetMcp.LeadSeatSource(Fleetd.leadSeatLookup(...))} constructor-call + * text. That proves the right symbols appear in source; it proves nothing about what the daemon's + * live {@code fleet_list} actually reports. + * + *

This test instead drives the REAL {@link FleetMcp.LeadSeatSource} the real {@link + * FleetdAssembly#assembleAndStart} builds — including the REAL {@code LeadTabScanner} it wires + * {@code Fleetd.leadSeatLookup} through — reached via {@link FleetMcp#leadSeatSource()} on the + * live {@code FleetMcp} {@code FleetdRuntime} owns. It seeds one FakeHerdr tab labelled to match a + * configured {@code fleet.leaders.opus.tab}, with a live agent already in it (FakeHerdr's own + * default {@code agent.list}/{@code pane.list} entries for {@code term_a}/{@code w2:p7}/{@code + * w2:t7} — no FakeHerdr change needed), and asserts the assembled seat source reports exactly the + * seat {@link FleetMcp.LeadSeatSource#none()} (the inert stand-in) could never produce: 1, not 0. + */ +class FleetdLeadSeatAssemblyTest { + + private static final class RecordingResourcePorts implements ResourcePorts { + + final FakeHerdr herdr = new FakeHerdr(); + final SentinelReplyInbox replyInbox = new SentinelReplyInbox(); + + @Override + public Map environment() { + return Map.of(); + } + + @Override + public HerdrClient connectHerdr(Path socketPath) { + return herdr; + } + + @Override + public Fleetd.AmqpOpener replyInboxOpener() { + return (uri, prefetch) -> replyInbox; + } + + @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) { + return Executors.newSingleThreadScheduledExecutor(); + } + + @Override + public void addShutdownHook(Runnable hook) { + } + + @Override + public void startHttp(Javalin app, String host, int port) { + } + } + + private static final class SentinelReplyInbox implements ReplyInbox, AutoCloseable { + @Override + public void own(String target) { + } + + @Override + public void release(String target) { + } + + @Override + public void publish(String target, String msgId, String content) { + } + + @Override + public List peek(String target) { + return List.of(); + } + + @Override + public boolean ack(String target, String msgId) { + return false; + } + + @Override + public void close() { + } + } + + 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 + idleSleepGuard: + enabled: false + broker: + uri: "amqp://fake-test-broker/vh" + fleet: + leaders: + opus: + tab: "lead: opus" + profile: sonnet + profiles: + sonnet: + subscription: true + argv: ["ccs", "sonnet"] + """); + return FleetConfig.load(f); + } + + @Test + @DisplayName("[BEHAVIOURAL] the real assembled LeadSeatSource, backed by the real LeadTabScanner, " + + "reports a live lead's seat against its own subscription profile") + void assembledLeadSeatSourceReportsALiveLeadsSeat(@TempDir Path dir) throws Exception { + FleetConfig cfg = writeConfig(dir); + ConfigRef config = new ConfigRef(dir.resolve("fleetd.yaml"), cfg); + SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet()); + RecordingResourcePorts ports = new RecordingResourcePorts(); + // Label FakeHerdr's own default pane's tab (term_a / w2:p7 / w2:t7, already carrying a live + // agent) to match fleet.leaders.opus.tab exactly — no FakeHerdr change needed at all. + ports.herdr.withTab("w2", "w2:t7", "lead: opus"); + + FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports); + + FleetMcp.LeadSeatSource seatSource = runtime.mcp().leadSeatSource(); + assertEquals(1, seatSource.seatsFor().apply("sonnet"), + "the real LeadTabScanner recognises the labelled tab as a live 'opus' lead on " + + "profile 'sonnet' (same credential, matched by Fleetd.leadSeatLookup), so " + + "subscription profile 'sonnet' must be charged one seat — " + + "FleetMcp.LeadSeatSource.none() (the inert stand-in this test's mutation " + + "swaps the call site for) always reports 0, whatever the input"); + + // A profile no lead is running on gets no seat charged — the same seat source, applied to + // an input that must stay at the inert answer even on the real, non-inert instance. + assertEquals(0, seatSource.seatsFor().apply("no-such-profile")); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatWiringTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatWiringTest.java deleted file mode 100644 index 29ecdc3..0000000 --- a/fleetd/src/test/java/dev/ltms/fleet/FleetdLeadSeatWiringTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package dev.ltms.fleet; - -import java.nio.file.Files; -import java.nio.file.Path; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * fleetd #176: {@code Fleetd.main} builds its {@code FleetMcp} from a 14-argument constructor whose - * last argument is a {@code FleetMcp.LeadSeatSource} wrapping {@link Fleetd#leadSeatLookup}. That - * argument is exactly the kind of wiring fleetd #248 warned about: dropping it (or swapping it for - * the inert {@code FleetMcp.LeadSeatSource.none()}) compiles with 0 errors and leaves every test - * that builds its own {@code FleetMcp}/{@code CapacitySource} directly — every test that predates - * this ticket — green, because none of them go through {@code main} at all. - * - *

{@link FleetdLeadSeatLookupTest} proves the factory's own matching logic; this class is the - * plain source-text assertion that proves {@code main} still passes its result in, mirroring - * {@code FleetdCompletionResolverWiringTest}'s approach for the same class of gap. - * - *

This test checks source text, not runtime behaviour. It never constructs a - * {@code FleetMcp} and never runs {@code main}. - */ -class FleetdLeadSeatWiringTest { - - private static String fleetdSource() throws Exception { - return Files.readString(Path.of("src/main/java/dev/ltms/fleet/Fleetd.java")); - } - - @Test - @DisplayName("[SOURCE TEXT] FleetMcp's construction call still passes a LeadSeatSource built from leadSeatLookup(...)") - void fleetMcpConstructionStillWiresLeadSeatLookup() throws Exception { - String source = fleetdSource(); - assertTrue(source.contains("new FleetMcp.LeadSeatSource(leadSeatLookup(() -> config.get().profiles(), " - + "leaders, leads))"), - "FleetMcp's construction call must still pass a LeadSeatSource built from " - + "Fleetd.leadSeatLookup(...). Dropping it or swapping in " - + "FleetMcp.LeadSeatSource.none() (fleetd #176's would-be silent regression, the same " - + "shape as fleetd #248's measured mutations) compiles with 0 errors and leaves every " - + "existing behavioural test green — this source check is what must go red instead."); - } -} -- 2.52.0 From 6edeb70bc4e7fef3a4aed2003276f4777a3cbb32 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Tue, 22 Sep 2026 12:31:39 +0700 Subject: [PATCH 2/2] fleetd #612 B3 correction: distinguish lead vs member herdr in FleetdLeadRolloverAssemblyTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket comment 17553 on fleetd #612 found that the test's single shared FakeHerdr made router.leadAgents() and router.memberAgents() collapse to the identical client (FleetdAssembly.java:140-142's no-distinct-socket fallback), so a mutation swapping leadAgents() for memberAgents() at the FleetdAssembly.java:408 call site was invisible to this test even though the two are genuinely different daemons in production. Configure two distinct herdr sockets and two distinct FakeHerdr instances (the same TwoHerdrResourcePorts shape B2's FleetdAssemblyConnectionIdentityTest uses) and assert the roll's /clear + bootstrap sends land on the LEAD fake and never on the MEMBER one. Proven red against the router.memberAgents() mutation, reverted, touched, and re-run green — both outputs recorded in the PR. --- .../fleet/FleetdLeadRolloverAssemblyTest.java | 61 ++++++++++++++++--- 1 file changed, 52 insertions(+), 9 deletions(-) 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) -- 2.52.0