976eff8ad1
Leader.terminal -> Leader.tab (exact tab label, case-insensitive match). LeadTabScanner matches an exact tab->name map instead of stripping a shared tabPrefix, and no longer merges configured leads into every scan result -- a stale pin can no longer outlive its tab. LeadLauncher.tabLabel() returns the configured tab directly; the terminalId pinned-terminal fallback in liveLeads() is gone. Config load now rejects a leftover fleet.leaders.*.terminal key instead of silently ignoring it. primary.terminal is untouched.
327 lines
13 KiB
Java
327 lines
13 KiB
Java
package dev.ltms.bridged.herdr;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.concurrent.atomic.AtomicLong;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
/**
|
|
* CB-531/CB-579. A lead is never spawned, so the daemon has to <em>find</em> it: these assert that
|
|
* an operator-labelled tab matching a configured {@code tab:} is what makes a pane a lead, and —
|
|
* just as importantly — what does not, and that a stale entry does not linger forever.
|
|
*/
|
|
class LeadTabScannerTest {
|
|
|
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
private static final long TTL = TimeUnit.SECONDS.toNanos(10);
|
|
|
|
/**
|
|
* A herdr whose workspace/tab/pane topology is declared per test. Counts calls so the caching
|
|
* contract can be asserted, and can be made to fail on demand.
|
|
*/
|
|
private static final class TopologyHerdr implements HerdrClient {
|
|
/** workspace_id → label. */
|
|
final Map<String, String> workspaces = new LinkedHashMap<>();
|
|
/** tab_id → [workspace_id, label]. */
|
|
final Map<String, String[]> tabs = new LinkedHashMap<>();
|
|
/** pane_id → [tab_id, terminal_id]. */
|
|
final Map<String, String[]> panes = new LinkedHashMap<>();
|
|
int calls;
|
|
boolean failing;
|
|
|
|
TopologyHerdr workspace(String id, String label) {
|
|
workspaces.put(id, label);
|
|
return this;
|
|
}
|
|
|
|
TopologyHerdr tab(String tabId, String workspaceId, String label) {
|
|
tabs.put(tabId, new String[]{workspaceId, label});
|
|
return this;
|
|
}
|
|
|
|
TopologyHerdr pane(String paneId, String tabId, String terminalId) {
|
|
panes.put(paneId, new String[]{tabId, terminalId});
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public JsonNode call(String method, Object params) {
|
|
calls++;
|
|
if (failing) {
|
|
throw new HerdrException("socket closed");
|
|
}
|
|
List<String> items = new ArrayList<>();
|
|
switch (method) {
|
|
case "workspace.list" -> {
|
|
workspaces.forEach((id, label) -> items.add(
|
|
"{\"workspace_id\":\"%s\",\"label\":\"%s\"}".formatted(id, label)));
|
|
return read("{\"workspaces\":[%s]}".formatted(String.join(",", items)));
|
|
}
|
|
case "tab.list" -> {
|
|
String ws = String.valueOf(((Map<?, ?>) params).get("workspace_id"));
|
|
tabs.forEach((id, t) -> {
|
|
if (ws.equals(t[0])) {
|
|
items.add(("{\"tab_id\":\"%s\",\"workspace_id\":\"%s\",\"label\":%s,"
|
|
+ "\"pane_count\":1}").formatted(id, t[0],
|
|
t[1] == null ? "null" : "\"" + t[1] + "\""));
|
|
}
|
|
});
|
|
return read("{\"tabs\":[%s]}".formatted(String.join(",", items)));
|
|
}
|
|
case "pane.list" -> {
|
|
panes.forEach((id, p) -> items.add(
|
|
"{\"pane_id\":\"%s\",\"tab_id\":\"%s\",\"terminal_id\":\"%s\"}"
|
|
.formatted(id, p[0], p[1])));
|
|
return read("{\"panes\":[%s]}".formatted(String.join(",", items)));
|
|
}
|
|
default -> throw new AssertionError("unexpected herdr call: " + method);
|
|
}
|
|
}
|
|
|
|
private static JsonNode read(String json) {
|
|
try {
|
|
return MAPPER.readTree(json);
|
|
} catch (Exception e) {
|
|
throw new AssertionError(e);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void close() {
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The usual shape: one user space with lead tabs, one worker space bridged owns.
|
|
*
|
|
* <p>Not closed: {@code close()} is a no-op on this fake, and every test needs the handle after
|
|
* the scanner is built (to mutate the topology or read {@code calls}).
|
|
*/
|
|
@SuppressWarnings("resource")
|
|
private TopologyHerdr twoLeads() {
|
|
return new TopologyHerdr()
|
|
.workspace("w1", "main")
|
|
.workspace("w9", "bridged-workers")
|
|
.tab("w1:t1", "w1", "lead: opus-5.0")
|
|
.tab("w1:t2", "w1", "lead: gpt-sol-5.6")
|
|
.tab("w1:t3", "w1", "notes")
|
|
.tab("w9:t1", "w9", "worker: gx10 #1")
|
|
.pane("w1:p1", "w1:t1", "term_opus")
|
|
.pane("w1:p2", "w1:t2", "term_gpt")
|
|
.pane("w1:p3", "w1:t3", "term_notes")
|
|
.pane("w9:p1", "w9:t1", "term_worker");
|
|
}
|
|
|
|
/** The {@code tab:} → name map {@code twoLeads()}'s two lead tabs are configured under. */
|
|
private static Map<String, String> twoLeadsConfigured() {
|
|
return Map.of("lead: opus-5.0", "opus-5.0", "lead: gpt-sol-5.6", "gpt-sol-5.6");
|
|
}
|
|
|
|
private LeadTabScanner scanner(TopologyHerdr herdr, Map<String, String> tabToName,
|
|
AtomicLong clock) {
|
|
return new LeadTabScanner(herdr, tabToName, Set.of("bridged-workers"), TTL, clock::get);
|
|
}
|
|
|
|
@Test
|
|
void everyConfiguredTabBecomesALeadNamedByItsEntry() {
|
|
Map<String, String> leads = scanner(twoLeads(), twoLeadsConfigured(), new AtomicLong()).get();
|
|
|
|
assertEquals(Map.of("term_opus", "opus-5.0", "term_gpt", "gpt-sol-5.6"), leads,
|
|
"two leads discovered by their configured tab — no terminal_id was ever configured");
|
|
}
|
|
|
|
@Test
|
|
void anUnconfiguredTabContributesNothing() {
|
|
assertFalse(scanner(twoLeads(), twoLeadsConfigured(), new AtomicLong())
|
|
.get().containsKey("term_notes"));
|
|
}
|
|
|
|
/**
|
|
* CB-579: matching is exact against the configured map now, not a shared prefix — two leads with
|
|
* completely different labels are both discovered by one scanner, no convention required.
|
|
*/
|
|
@Test
|
|
void twoLeadsWithCompletelyDifferentLabelsAreBothDiscovered() {
|
|
TopologyHerdr herdr = new TopologyHerdr()
|
|
.workspace("w1", "main")
|
|
.tab("w1:t1", "w1", "orchestrator: opus")
|
|
.tab("w1:t2", "w1", "captain: sol")
|
|
.pane("w1:p1", "w1:t1", "term_opus")
|
|
.pane("w1:p2", "w1:t2", "term_sol");
|
|
Map<String, String> tabToName = Map.of("orchestrator: opus", "opus", "captain: sol", "sol");
|
|
|
|
Map<String, String> leads = scanner(herdr, tabToName, new AtomicLong()).get();
|
|
|
|
assertEquals(Map.of("term_opus", "opus", "term_sol", "sol"), leads,
|
|
"no shared prefix needed — each lead is matched by its own configured tab");
|
|
}
|
|
|
|
/**
|
|
* The guard that matters: bridged labels its own worker tabs, so if a worker space were scanned
|
|
* a naming accident would promote the fleet. The exclusion is by workspace, not by hoping the
|
|
* worker template never collides.
|
|
*/
|
|
@Test
|
|
void aTabInAWorkerSpaceIsNeverALeadEvenWhenItsLabelMatches() {
|
|
TopologyHerdr herdr = twoLeads().tab("w9:t2", "w9", "lead: impostor")
|
|
.pane("w9:p2", "w9:t2", "term_impostor");
|
|
Map<String, String> tabToName = new LinkedHashMap<>(twoLeadsConfigured());
|
|
tabToName.put("lead: impostor", "impostor");
|
|
|
|
assertFalse(scanner(herdr, tabToName, new AtomicLong()).get().containsKey("term_impostor"));
|
|
}
|
|
|
|
@Test
|
|
void aLabelWithNoConfiguredEntryIsIgnored() {
|
|
TopologyHerdr herdr = new TopologyHerdr().workspace("w1", "main")
|
|
.tab("w1:t1", "w1", "lead: nobody-configured").pane("w1:p1", "w1:t1", "term_a");
|
|
|
|
assertEquals(Map.of(), scanner(herdr, twoLeadsConfigured(), new AtomicLong()).get(),
|
|
"a label that names no configured lead resolves nobody");
|
|
}
|
|
|
|
@Test
|
|
void matchingIsCaseInsensitiveAndToleratesSurroundingWhitespace() {
|
|
TopologyHerdr herdr = new TopologyHerdr().workspace("w1", "main")
|
|
.tab("w1:t1", "w1", " LEAD: Opus-5.0 ").pane("w1:p1", "w1:t1", "term_a");
|
|
|
|
assertEquals(Map.of("term_a", "opus-5.0"),
|
|
scanner(herdr, Map.of("lead: Opus-5.0", "opus-5.0"), new AtomicLong()).get());
|
|
}
|
|
|
|
@Test
|
|
void everyPaneInALeadTabResolvesAsThatLead() {
|
|
// A human may split their own lead tab. Both panes are theirs, so both are that lead —
|
|
// nothing bridged placed can land here (see the worker-space test above).
|
|
TopologyHerdr herdr = twoLeads().pane("w1:p1b", "w1:t1", "term_opus_split");
|
|
|
|
assertEquals("opus-5.0",
|
|
scanner(herdr, twoLeadsConfigured(), new AtomicLong()).get().get("term_opus_split"));
|
|
}
|
|
|
|
/**
|
|
* CB-579 acceptance (6): this is the bug the ticket closes. A stale pin used to be merged back
|
|
* over every scan and never expire; now a scan is the whole answer, so a lead whose tab is gone
|
|
* drops out on the very next scan.
|
|
*/
|
|
@Test
|
|
void aTabNoLongerPresentDropsTheLeadOnTheNextScan() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
AtomicLong clock = new AtomicLong();
|
|
LeadTabScanner s = scanner(herdr, twoLeadsConfigured(), clock);
|
|
assertTrue(s.get().containsKey("term_opus"));
|
|
|
|
// The session behind term_opus restarted — herdr no longer reports that tab or pane at all.
|
|
herdr.tabs.remove("w1:t1");
|
|
herdr.panes.remove("w1:p1");
|
|
clock.addAndGet(TTL);
|
|
|
|
assertFalse(s.get().containsKey("term_opus"),
|
|
"a stale entry must expire once the tab it named is gone, not be merged back forever");
|
|
}
|
|
|
|
/**
|
|
* CB-579 acceptance (5): the whole point of matching by tab instead of {@code terminal_id} — a
|
|
* restart changes the terminal, not the tab, so the lead resolves under the same name with no
|
|
* config edit.
|
|
*/
|
|
@Test
|
|
void aLeadRestartingInTheSameTabResolvesUnderTheSameName() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
AtomicLong clock = new AtomicLong();
|
|
LeadTabScanner s = scanner(herdr, twoLeadsConfigured(), clock);
|
|
assertEquals("opus-5.0", s.get().get("term_opus"));
|
|
|
|
// The session restarts: herdr assigns the pane a new terminal_id, same tab (w1:t1).
|
|
herdr.panes.remove("w1:p1");
|
|
herdr.pane("w1:p1", "w1:t1", "term_opus_v2");
|
|
clock.addAndGet(TTL);
|
|
|
|
Map<String, String> leads = s.get();
|
|
assertEquals("opus-5.0", leads.get("term_opus_v2"), "the new terminal resolves immediately");
|
|
assertFalse(leads.containsKey("term_opus"), "the old terminal_id is simply gone, not carried");
|
|
}
|
|
|
|
// ── caching ─────────────────────────────────────────────────────────────────────────────────
|
|
|
|
@Test
|
|
void aSecondLookupWithinTheTtlDoesNotTouchHerdr() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
AtomicLong clock = new AtomicLong();
|
|
LeadTabScanner s = scanner(herdr, twoLeadsConfigured(), clock);
|
|
|
|
s.get();
|
|
int afterFirst = herdr.calls;
|
|
clock.addAndGet(TTL - 1);
|
|
s.get();
|
|
|
|
assertEquals(afterFirst, herdr.calls,
|
|
"resolve() runs on every request — an un-cached scan would put herdr on that path");
|
|
}
|
|
|
|
@Test
|
|
void aTabLabelledAfterStartupIsPickedUpOnceTheTtlExpires() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
AtomicLong clock = new AtomicLong();
|
|
Map<String, String> tabToName = new LinkedHashMap<>(twoLeadsConfigured());
|
|
tabToName.put("lead: late-arrival", "late-arrival");
|
|
LeadTabScanner s = scanner(herdr, tabToName, clock);
|
|
assertFalse(s.get().containsKey("term_notes"));
|
|
|
|
herdr.tab("w1:t3", "w1", "lead: late-arrival"); // the operator renames their tab
|
|
clock.addAndGet(TTL);
|
|
|
|
assertEquals("late-arrival", s.get().get("term_notes"),
|
|
"the whole point over a config-held terminal_id: no config edit, no restart");
|
|
}
|
|
|
|
@Test
|
|
void aFailedScanKeepsTheLeadsAlreadyKnownRatherThanDemotingThem() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
AtomicLong clock = new AtomicLong();
|
|
LeadTabScanner s = scanner(herdr, twoLeadsConfigured(), clock);
|
|
Map<String, String> before = s.get();
|
|
|
|
herdr.failing = true;
|
|
clock.addAndGet(TTL);
|
|
|
|
assertEquals(before, s.get(),
|
|
"a herdr hiccup must not silently demote a live lead to a worker mid-session");
|
|
}
|
|
|
|
@Test
|
|
void aFailedFirstScanReturnsEmptyRatherThanThrowing() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
herdr.failing = true;
|
|
|
|
Map<String, String> leads = scanner(herdr, twoLeadsConfigured(), new AtomicLong()).get();
|
|
|
|
assertEquals(Map.of(), leads,
|
|
"with nothing scanned yet and no override to fall back on, the map is simply empty");
|
|
}
|
|
|
|
@Test
|
|
void aDownHerdrIsRetriedOncePerTtlNotOncePerRequest() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
herdr.failing = true;
|
|
AtomicLong clock = new AtomicLong();
|
|
LeadTabScanner s = scanner(herdr, twoLeadsConfigured(), clock);
|
|
|
|
s.get();
|
|
int afterFirst = herdr.calls;
|
|
s.get();
|
|
s.get();
|
|
|
|
assertEquals(afterFirst, herdr.calls, "the failure path must be rate-limited too");
|
|
}
|
|
}
|