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."); - } -}