diff --git a/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java b/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java new file mode 100644 index 0000000..664af23 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/FleetdCompletionResolverAssemblyTest.java @@ -0,0 +1,307 @@ +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.inject.CompletionResolver; +import dev.ltms.fleet.msg.Rendezvous; +import dev.ltms.fleet.msg.TurnToken; +import dev.ltms.fleet.placement.PlacementException; +import dev.ltms.fleet.session.MemberSession; +import dev.ltms.fleet.session.WorktreeRequest; +import io.javalin.Javalin; +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.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #612 step 2, Unit B1 — replaces {@code FleetdCompletionResolverWiringTest} (deleted in + * this same commit), whose four tests read {@code Fleetd.java}'s source text and asserted the + * {@code CompletionResolver} construction call still named the right arguments. That proved the + * call site's spelling, never that the assembled resolver actually behaves differently when an + * argument is dropped. + * + *
These tests drive {@link FleetdAssembly#assembleAndStart} — the real boot composition, + * fleetd #612 Unit A — and read {@link FleetdRuntime#completion()}: the exact {@link + * CompletionResolver} instance the assembled daemon uses, never a copy built alongside it for the + * test's benefit. Two behaviours are pinned, matching the ticket's own two measured mutations: + * + *
Both tests bypass {@link dev.ltms.fleet.inject.StatusPoller} and drive {@link
+ * CompletionResolver#onDelivered} / {@link CompletionResolver#resolveBeforePostAction} directly —
+ * the same public, synchronous entry points {@code CompletionResolverTest} uses — with a
+ * hand-built {@link CompletableFuture} waiter, so no real poller loop or herdr status poll is
+ * needed. The pane scrape comes from {@link FakeHerdr#readText}; the elapsed-time floor
+ * ({@code CompletionResolver.MIN_TURN_NANOS}) is controlled via a fake, advanceable {@link
+ * ResourcePorts#nanoClock()} rather than a real sleep.
+ */
+class FleetdCompletionResolverAssemblyTest {
+
+ /** Same shape as {@code FleetdAssemblyLifecycleTest}'s fake, plus a nanoClock this test can advance. */
+ private static final class ControllableResourcePorts implements ResourcePorts {
+
+ final FakeHerdr herdr;
+ final AtomicLong nowNanos = new AtomicLong(1_000_000_000L); // arbitrary non-zero start
+ Runnable shutdownHook;
+
+ ControllableResourcePorts(FakeHerdr herdr) {
+ this.herdr = herdr;
+ }
+
+ void advanceSeconds(long seconds) {
+ nowNanos.addAndGet(TimeUnit.SECONDS.toNanos(seconds));
+ }
+
+ @Override
+ public Map Classification proof: this test's profile configures {@code errorPattern: "credential
+ * outage"} — text the built-in {@code (?i)\bAPI Error\s*:} fallback ({@code legacy()}'s only
+ * behaviour) never matches. So a real {@code Fleetd.backendErrorPatternLookup(...)} wiring
+ * classifies the send as {@code FAILED}; {@code legacy()} would fall through to the plain
+ * completion path instead ({@code Kind.COMPLETION}).
+ *
+ * Cool-off proof: two distinct targets on the same profile/credential each classified as a
+ * backend error inside the 60s window must cool the credential off ({@link
+ * dev.ltms.fleet.placement.BackendOutagePolicy}, fleetd#201 Unit 5) — observable two ways: (1)
+ * the real {@code Fleetd.backendErrorSink(...)} marks each session {@code BACKEND_ERROR} (only
+ * the real sink calls {@code sessions.onBackendError}; {@code BackendErrorSink.none()} never
+ * does), and (2) a third explicit-profile spawn attempt is refused with a {@link
+ * PlacementException} naming the cool-off — only reachable because the real sink's {@code
+ * outagePolicy.record(...)} call actually ran.
+ */
+ @Test
+ void assembledResolverClassifiesAndCoolsOffOnAConfiguredBackendErrorPattern(@TempDir Path dir) throws Exception {
+ FleetConfig cfg = writeConfig(dir, """
+ coolprofile:
+ baseUrl: http://coolhost.local:8000
+ model: sonnet
+ errorPattern: "credential outage"
+ """, "coolhost.local", null);
+ ConfigRef config = new ConfigRef(dir.resolve("fleetd.yaml"), cfg);
+ SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet());
+ ControllableResourcePorts ports = new ControllableResourcePorts(new FakeHerdr());
+
+ FleetdRuntime runtime = FleetdAssembly.assembleAndStart(new AssemblyInputs(cfg, config, guard), ports);
+ try {
+ MemberSession session1 = runtime.sessions().acquire("coolprofile", null, dir.toString(), null);
+ MemberSession session2 = runtime.sessions().acquire("coolprofile", null, dir.toString(), null);
+ String target1 = session1.terminalId();
+ String target2 = session2.terminalId();
+ assertTrue(!target1.equals(target2), "sanity: the two spawns must be distinct targets");
+
+ CompletionResolver completion = runtime.completion();
+
+ CompletableFuture Neither mutation could be caught by any test that constructs its own {@code
- * CompletionResolver} (every test before this one did exactly that) or by a test of {@link
- * Fleetd#worktreeBranchLookup}, {@link Fleetd#backendErrorPatternLookup}, or {@link
- * Fleetd#backendErrorSink} in isolation (see {@code FleetdWorktreeBranchLookupTest}, {@code
- * FleetdBackendErrorPatternLookupTest}, {@code FleetdBackendErrorSinkTest}) — those prove the
- * factories work, never that {@code main} still calls them. This class is a plain source-text
- * assertion on {@code Fleetd.java} — crude, but honest about what it checks, and it turns red the
- * instant the wiring is dropped, mirroring the same fallback shape {@link
- * FleetdFleetAppConstructionTest} already uses for a different constructor argument.
- *
- * This test checks source text, not runtime behaviour. It never constructs a {@code
- * CompletionResolver} and never runs {@code main}.
- */
-class FleetdCompletionResolverWiringTest {
-
- private static String fleetdSource() throws Exception {
- return Files.readString(Path.of("src/main/java/dev/ltms/fleet/Fleetd.java"));
- }
-
- @Test
- @DisplayName("[SOURCE TEXT] CompletionResolver's construction call still names backendErrorPatterns and backendErrorSink")
- void backendErrorArgumentsAreStillNamedAtTheCallSite() throws Exception {
- String source = fleetdSource();
- assertTrue(source.contains(
- "exhaustionSink, backendErrorPatterns, backendErrorSink, System::nanoTime,"),
- "CompletionResolver's construction call must still pass backendErrorPatterns and "
- + "backendErrorSink as its 5th/6th arguments. Replacing them with "
- + "BackendErrorPatternLookup.legacy()/BackendErrorSink.none() (fleetd #248's measured "
- + "mutation) compiles with 0 errors and leaves every behavioural test green — this "
- + "source check is what must go red instead.");
- }
-
- @Test
- @DisplayName("[SOURCE TEXT] CompletionResolver's construction call still passes worktreeBranchLookup(sessions::roster)")
- void worktreeBranchLookupIsStillPassedAtTheCallSite() throws Exception {
- String source = fleetdSource();
- assertTrue(source.contains("worktreeBranchLookup(sessions::roster)"),
- "CompletionResolver's construction call must still pass worktreeBranchLookup(sessions::roster) "
- + "as its 8th (last) argument. Replacing it with the inert `_ -> null` (fleetd #248's "
- + "other measured mutation) compiles with 0 errors and leaves every behavioural test "
- + "green — this source check is what must go red instead.");
- assertFalse(source.contains("System::nanoTime,\n _ -> null"),
- "the worktree/branch argument must never regress to the inert `_ -> null` literal");
- }
-
- @Test
- @DisplayName("[SOURCE TEXT] backendErrorPatterns is assigned from the extracted backendErrorPatternLookup(...) factory")
- void backendErrorPatternsComesFromTheFactory() throws Exception {
- String source = fleetdSource();
- assertTrue(source.contains(
- "BackendErrorPatternLookup backendErrorPatterns = backendErrorPatternLookup(sessions::roster,"),
- "backendErrorPatterns must be assigned from Fleetd.backendErrorPatternLookup(...), not an "
- + "inline lambda that a source check on the CompletionResolver call alone cannot see "
- + "through");
- }
-
- @Test
- @DisplayName("[SOURCE TEXT] backendErrorSink is assigned from the extracted backendErrorSink(...) factory")
- void backendErrorSinkComesFromTheFactory() throws Exception {
- String source = fleetdSource();
- assertTrue(source.contains(
- "BackendErrorSink backendErrorSink = backendErrorSink(sessions, () -> config.get().profiles(),"),
- "backendErrorSink must be assigned from Fleetd.backendErrorSink(...), not an inline lambda "
- + "that a source check on the CompletionResolver call alone cannot see through");
- }
-}
- *
- *
- *