bc13b8e92c
Two leads now work as peers rather than one primary plus workers. The arc:
CB-530/531 lead identity: `leaders:` names panes, `leadScan:` discovers them by
tab label (LeadTabScanner, TTL-cached, worker spaces excluded).
CB-532 leads can message each other AND be answered. Principal.leader now
carries its terminal, so ownsSession() can be true for a lead; the
"and you must be a worker" conjunct beside it protected nothing.
Retires `primary:` — reply nudges follow the delegating lead, a
binding recorded at bridge_send where both halves are known.
CB-533 ClaudeCodeLauncher passes --model. argv is usually a wrapper
(`ccs <profile>`) that re-exports its own model family, so
ANTHROPIC_MODEL alone was silently overruled.
CB-534 a lead is deliverable. The CB-113 readiness gate only opened for
terminals in WorkerPresence, which only workers ever enter, so every
lead->lead send waited out the ~60s grace and failed having never
been typed. The gate guards a *spawned* peer's boot window; a lead
is never spawned.
CB-535 bridge_list returns `leads` alongside `workers`, with `self` on the
caller's row. An empty worker roster no longer reads as "no peers".
CB-536 CLAUDE.md: lead<->lead is coordinate-only, never sideways delegation.
Propagated byte-identically to wiki/7-Use-Cases.md.
MIXED PROVENANCE — recorded deliberately rather than hidden. This tree also carries
in-progress CB-537 (context separation) authored by the peer lead gpt-sol-5.6 and
its worker: Capability.CONTEXT_RESET, SessionManager.clearAfterTurn, and the
Injector/TurnListener/CompletionResolver/launcher changes around it. That work was
done in this shared working tree rather than a worktree, and is entangled with the
above in BridgedConfig.java, Bridged.java and ClaudeCodeLauncher.java, so neither
lead could stage its own half without sweeping in the other's. Committing the whole
green state is the honest resolution; the peer branches from here.
Note for whoever picks CB-537 up: the design in this commit is SUPERSEDED. Both
leads agreed to replace the global `clearAfterTurn` boolean with per-delivery
policy (inherit|fresh|thread) applied PRE-delivery, because a post-turn reset races
by construction — Injector.onStatus clears awaitingCompletion and dequeues the next
message in the same tick. `fresh` is also a correctness guarantee, so an adapter
without a reset capability must refuse it rather than log a no-op.
mvn clean install: Tests run: 464, Failures: 0, Errors: 0, Skipped: 0. BUILD SUCCESS.
263 lines
10 KiB
Java
263 lines
10 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. A lead is never spawned, so the daemon has to <em>find</em> it: these assert that an
|
|
* operator-labelled tab is what makes a pane a lead, and — just as importantly — what does not.
|
|
*/
|
|
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");
|
|
}
|
|
|
|
private LeadTabScanner scanner(TopologyHerdr herdr, Map<String, String> configured,
|
|
AtomicLong clock) {
|
|
return new LeadTabScanner(herdr, "lead:", Set.of("bridged-workers"), configured, TTL,
|
|
clock::get);
|
|
}
|
|
|
|
@Test
|
|
void everyLabelledTabBecomesALeadNamedByItsLabel() {
|
|
Map<String, String> leads = scanner(twoLeads(), Map.of(), new AtomicLong()).get();
|
|
|
|
assertEquals(Map.of("term_opus", "opus-5.0", "term_gpt", "gpt-sol-5.6"), leads,
|
|
"two leads discovered from labels alone — no terminal_id was ever configured");
|
|
}
|
|
|
|
@Test
|
|
void anUnlabelledTabContributesNothing() {
|
|
assertFalse(scanner(twoLeads(), Map.of(), new AtomicLong()).get().containsKey("term_notes"));
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
|
|
assertFalse(scanner(herdr, Map.of(), new AtomicLong()).get().containsKey("term_impostor"));
|
|
}
|
|
|
|
@Test
|
|
void aBarePrefixNamesNobodyAndIsRejected() {
|
|
TopologyHerdr herdr = new TopologyHerdr().workspace("w1", "main")
|
|
.tab("w1:t1", "w1", "lead:").pane("w1:p1", "w1:t1", "term_a");
|
|
|
|
assertEquals(Map.of(), scanner(herdr, Map.of(), new AtomicLong()).get(),
|
|
"a lead with no name would resolve as PRIMARY with nothing to attribute it to");
|
|
}
|
|
|
|
@Test
|
|
void thePrefixMatchesCaseInsensitivelyAndTheNameIsTrimmed() {
|
|
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(), 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, Map.of(), new AtomicLong()).get().get("term_opus_split"));
|
|
}
|
|
|
|
@Test
|
|
void anExplicitlyConfiguredLeadIsMergedInAndOutranksALabel() {
|
|
Map<String, String> configured = Map.of("term_opus", "pinned-name", "term_extra", "from-config");
|
|
|
|
Map<String, String> leads = scanner(twoLeads(), configured, new AtomicLong()).get();
|
|
|
|
assertEquals("pinned-name", leads.get("term_opus"), "an explicit pin is the operator's last word");
|
|
assertEquals("from-config", leads.get("term_extra"), "a configured lead needs no tab at all");
|
|
assertEquals("gpt-sol-5.6", leads.get("term_gpt"));
|
|
}
|
|
|
|
// ── caching ─────────────────────────────────────────────────────────────────────────────────
|
|
|
|
@Test
|
|
void aSecondLookupWithinTheTtlDoesNotTouchHerdr() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
AtomicLong clock = new AtomicLong();
|
|
LeadTabScanner s = scanner(herdr, Map.of(), 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();
|
|
LeadTabScanner s = scanner(herdr, Map.of(), 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 `leaders:`: no config edit, no restart");
|
|
}
|
|
|
|
@Test
|
|
void aFailedScanKeepsTheLeadsAlreadyKnownRatherThanDemotingThem() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
AtomicLong clock = new AtomicLong();
|
|
LeadTabScanner s = scanner(herdr, Map.of(), 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 aFailedFirstScanStillHonoursTheConfiguredLeads() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
herdr.failing = true;
|
|
|
|
Map<String, String> leads = scanner(herdr, Map.of("term_x", "opus-5.0"), new AtomicLong()).get();
|
|
|
|
assertEquals(Map.of("term_x", "opus-5.0"), leads,
|
|
"config-named leads must not depend on herdr answering at all");
|
|
}
|
|
|
|
@Test
|
|
void aDownHerdrIsRetriedOncePerTtlNotOncePerRequest() {
|
|
TopologyHerdr herdr = twoLeads();
|
|
herdr.failing = true;
|
|
AtomicLong clock = new AtomicLong();
|
|
LeadTabScanner s = scanner(herdr, Map.of(), clock);
|
|
|
|
s.get();
|
|
int afterFirst = herdr.calls;
|
|
s.get();
|
|
s.get();
|
|
|
|
assertEquals(afterFirst, herdr.calls, "the failure path must be rate-limited too");
|
|
}
|
|
}
|