diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 97ab029..04c104b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -87,12 +87,18 @@ jobs: apt-get update && apt-get install -y --no-install-recommends maven mvn -version - # The `contract` profile clears the default-excludes group, so the @Tag("contract") AMQP test - # runs against the RabbitMQ service container (AMQP_URI). Pinned to the one contract test to - # avoid re-running the unit suite already covered by the `build` job. + # The `contract` profile clears the default-excludes group, so `-Dgroups=contract` runs every + # @Tag("contract") test and nothing from the unit suite the `build` job already covered — a + # tag selects the whole group, so a test added to it later runs here automatically. A prior + # version of this step pinned `-Dtest=AmqpReplyInboxContractTest` by class name instead: that + # silently excluded every other contract test (including the herdr ones) from CI, and nobody + # noticed until the herdr protocol drifted out from under a test that never ran here + # (fleetd #449). If this runner has no herdr socket, the herdr-backed tests in the group + # skip on their own `assumeTrue` and only the broker-backed ones actually run — check the + # step output rather than assuming which. - name: Contract tests working-directory: fleetd - run: mvn -B -Pcontract test -Dtest=AmqpReplyInboxContractTest + run: mvn -B -Pcontract test -Dgroups=contract - name: Failing test output if: failure() diff --git a/fleetd/src/main/java/dev/ltms/fleet/herdr/HerdrClient.java b/fleetd/src/main/java/dev/ltms/fleet/herdr/HerdrClient.java index ac83b52..4c9fada 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/herdr/HerdrClient.java +++ b/fleetd/src/main/java/dev/ltms/fleet/herdr/HerdrClient.java @@ -3,7 +3,7 @@ package dev.ltms.fleet.herdr; import com.fasterxml.jackson.databind.JsonNode; /** - * Client face onto the herdr daemon (protocol 14, herdr 0.7.0). + * Client face onto the herdr daemon (protocol 19, herdr 0.8.0). * *

This is the ONLY thing in {@code fleetd} that speaks to herdr. Every method * maps to a herdr JSON-RPC call over its Unix domain socket. Requests are diff --git a/fleetd/src/main/java/dev/ltms/fleet/herdr/HerdrCodec.java b/fleetd/src/main/java/dev/ltms/fleet/herdr/HerdrCodec.java index e946477..2c7fa40 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/herdr/HerdrCodec.java +++ b/fleetd/src/main/java/dev/ltms/fleet/herdr/HerdrCodec.java @@ -8,7 +8,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.nio.charset.StandardCharsets; /** - * Wire codec for herdr's newline-delimited JSON-RPC (protocol 14). + * Wire codec for herdr's newline-delimited JSON-RPC (protocol 19). * *

Split out from the socket so the framing rules — the ones that actually bit us * during the spike (id MUST be a string; response carries {@code result} or diff --git a/fleetd/src/test/java/dev/ltms/fleet/herdr/AgentControlContractTest.java b/fleetd/src/test/java/dev/ltms/fleet/herdr/AgentControlContractTest.java index 957fd30..333efe0 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/herdr/AgentControlContractTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/herdr/AgentControlContractTest.java @@ -17,15 +17,70 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; * SHELL directly (never {@code claude}, so no subscription/token involvement) and always tears * the throwaway space down. * + *

The seed shell's own startup (restoring its session, printing its banner) is asynchronous + * and its length is not a fleetd contract — measured here at ~2.5s on one host (fleetd #449). A + * fixed sleep before typing raced that startup: input typed before the shell reached its prompt + * was swallowed by the shell's own startup, and the pane showed the typed line followed by the + * startup banner with no command output at all — indistinguishable, at a glance, from the env + * map never reaching the shell. So this polls for a real signal (the pane's visible text + * settling, then the expected output appearing) instead of guessing a sleep length. + * *

Tagged {@code contract}; run with {@code mvn test -Pcontract}. */ @Tag("contract") class AgentControlContractTest { + private static final long POLL_INTERVAL_MS = 150; + /** Bound for the seed shell to settle: observed ~2.5s three times running; this leaves headroom. */ + private static final long SHELL_READY_TIMEOUT_MS = 8_000; + /** Bound for the typed command's output to appear once the shell is ready: observed ~0.2s. */ + private static final long OUTPUT_TIMEOUT_MS = 5_000; + private boolean noSocket() { return !Files.exists(UnixSocketHerdrClient.defaultSocketPath()); } + private static String readPane(UnixSocketHerdrClient herdr, String paneId) { + return herdr.call("pane.read", Map.of("pane_id", paneId, "source", "visible")) + .path("read").path("text").asText(""); + } + + /** + * Poll {@code pane.read} until two consecutive reads come back identical — the shell's own + * startup output (restore banner, prompt) has stopped changing — or {@code timeoutMs} elapses. + * Never asserts by itself; the caller's own assertion is what actually verifies the outcome, + * this only avoids sending input into a shell still mid-startup. + */ + private static String waitUntilSettled(UnixSocketHerdrClient herdr, String paneId, long timeoutMs) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + String previous = null; + while (System.currentTimeMillis() < deadline) { + Thread.sleep(POLL_INTERVAL_MS); + String current = readPane(herdr, paneId); + if (current.equals(previous) && !current.isBlank()) { + return current; + } + previous = current; + } + return previous == null ? "" : previous; + } + + /** Poll {@code pane.read} until {@code needle} appears or {@code timeoutMs} elapses. */ + private static String waitForText(UnixSocketHerdrClient herdr, String paneId, String needle, long timeoutMs) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + String last = ""; + while (System.currentTimeMillis() < deadline) { + last = readPane(herdr, paneId); + if (last.contains(needle)) { + return last; + } + Thread.sleep(POLL_INTERVAL_MS); + } + return last; + } + @Test void tabCreateInjectsEnvIntoTheSeedShell() throws Exception { assumeTrue(!noSocket(), "no herdr socket — skipping"); @@ -36,15 +91,13 @@ class AgentControlContractTest { Map.of("ANTHROPIC_BASE_URL", "http://gx00.gw:8000")); try { assertNotNull(tab.rootPaneId(), "tab.create must return the seed pane"); - Thread.sleep(1000); // let the seed shell reach its prompt + waitUntilSettled(herdr, tab.rootPaneId(), SHELL_READY_TIMEOUT_MS); herdr.call("pane.send_input", Map.of( "pane_id", tab.rootPaneId(), "text", "printf 'PROBE_BASE=[%s]\\n' \"$ANTHROPIC_BASE_URL\"", "keys", List.of("enter"))); - Thread.sleep(800); - String visible = herdr.call("pane.read", - Map.of("pane_id", tab.rootPaneId(), "source", "visible")) - .path("read").path("text").asText(""); + String visible = waitForText(herdr, tab.rootPaneId(), + "PROBE_BASE=[http://gx00.gw:8000]", OUTPUT_TIMEOUT_MS); assertTrue(visible.contains("PROBE_BASE=[http://gx00.gw:8000]"), "env map must reach the seed shell; saw: " + visible); } finally { diff --git a/fleetd/src/test/java/dev/ltms/fleet/herdr/HerdrContractTest.java b/fleetd/src/test/java/dev/ltms/fleet/herdr/HerdrContractTest.java index 49c8c32..a0e6e63 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/herdr/HerdrContractTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/herdr/HerdrContractTest.java @@ -14,7 +14,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; * Contract test against a REAL running herdr. Tagged {@code contract} so it is * excluded from {@code mvn test}; run it with {@code mvn test -Pcontract}. It fails * loudly if herdr drifts from the protocol {@code fleetd} was built against - * (0.7.0, protocol 14) — catching breakage that unit tests with canned frames cannot. + * (0.8.0, protocol 19) — catching breakage that unit tests with canned frames cannot. */ @Tag("contract") class HerdrContractTest { @@ -24,13 +24,13 @@ class HerdrContractTest { } @Test - void pingReturnsProtocol14() { + void pingReturnsProtocol19() { assumeTrue(Files.exists(socket()), "no herdr socket at " + socket() + " — skipping"); try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) { JsonNode pong = herdr.call("ping"); assertEquals("pong", pong.get("type").asText()); - assertEquals(14, pong.get("protocol").asInt(), - "fleetd is built against herdr protocol 14"); + assertEquals(19, pong.get("protocol").asInt(), + "fleetd is built against herdr protocol 19"); assertFalse(pong.get("version").asText().isBlank()); } }