From 246f50b7781b472211bea80de90e3c74e0eda1e0 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 14 Aug 2026 07:02:17 +0200 Subject: [PATCH] CB-557: adopt the member taxonomy in config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member is anything a lead spawns. Every member carries two independent attributes: role — which contract: architect, dev or reviewer. It picks the launch charter, the role file, the playbook skill and the authz row. profile — which backend: model, CLI adapter, credentials, cost. They vary on their own. A reviewer may run on the same profile as the dev whose diff it reads, which is the case that proves the two cannot be one field. Config changes (breaking — we are in active development, so no aliases): workers: -> profiles: it was never a list of workers; it is a catalogue of backends defaultWorker: -> defaultProfile: architects: -> members: each slot now names its role An old config is rejected at load with the new key named, rather than being warned about once and then running with zero profiles — that failure would surface much later, at the first spawn, pointing nowhere near the cause. Also: - BridgedConfig.Worker -> BridgedConfig.Profile - ArchitectRegistry -> MemberRegistry - new peer.MemberRole enum, validated at startup - profiles map is normalized once in the compact constructor, so the raw map and the derived one can no longer disagree - the legacy singular worker: block is dropped - workerProfiles() -> profiles(); defaultProfile() -> effectiveDefaultProfile() (the record component now owns the plain name) mvn clean install: 578 tests, 0 failures, 0 errors, BUILD SUCCESS. --- bridged/bridged.example.yaml | 27 +- .../main/java/dev/ltms/bridged/Bridged.java | 38 +- .../dev/ltms/bridged/auth/CallerResolver.java | 4 +- ...itectRegistry.java => MemberRegistry.java} | 10 +- .../ltms/bridged/config/BridgedConfig.java | 297 +++++++++------ .../dev/ltms/bridged/peer/MemberRole.java | 88 +++++ .../bridged/worker/ClaudeCodeLauncher.java | 16 +- .../bridged/worker/CompositePeerLauncher.java | 14 +- .../bridged/worker/HerdrPeerLauncher.java | 38 +- .../ltms/bridged/worker/OpenCodeLauncher.java | 22 +- .../ltms/bridged/auth/CallerResolverTest.java | 14 +- ...istryTest.java => MemberRegistryTest.java} | 16 +- .../bridged/config/BridgedConfigTest.java | 360 +++++++++++++----- .../ltms/bridged/mcp/BridgeMcpAuthzTest.java | 2 +- .../dev/ltms/bridged/mcp/BridgeMcpTest.java | 2 +- .../dev/ltms/bridged/peer/MemberRoleTest.java | 67 ++++ .../ltms/bridged/rest/BridgedAppAuthTest.java | 4 +- .../dev/ltms/bridged/rest/BridgedAppTest.java | 2 +- .../bridged/session/SessionManagerTest.java | 6 +- .../bridged/session/SessionReaperTest.java | 2 +- .../session/WorktreeSessionManagerTest.java | 4 +- .../worker/ClaudeCodeLauncherTest.java | 46 +-- .../worker/CompositePeerLauncherTest.java | 42 +- .../bridged/worker/OpenCodeLauncherTest.java | 16 +- 24 files changed, 783 insertions(+), 354 deletions(-) rename bridged/src/main/java/dev/ltms/bridged/auth/{ArchitectRegistry.java => MemberRegistry.java} (95%) create mode 100644 bridged/src/main/java/dev/ltms/bridged/peer/MemberRole.java rename bridged/src/test/java/dev/ltms/bridged/auth/{ArchitectRegistryTest.java => MemberRegistryTest.java} (94%) create mode 100644 bridged/src/test/java/dev/ltms/bridged/peer/MemberRoleTest.java diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index 01d5080..1179042 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -160,7 +160,7 @@ herdrSocket: ~/.config/herdr/herdr.sock # entry cannot repoint a worker past the SubscriptionGuard — which is checked # against `baseUrl` alone. # Put `defaultMode: "auto"` in each ccs profile so the worker runs autonomously. -workers: +profiles: gx10: # ccs profile name (NOT a hostname) kind: claude-code # which adapter spawns this profile (default; may omit) baseUrl: http://gx01.gw:8000 # the vLLM host this profile targets (gx00.gw / gx01.gw) @@ -239,7 +239,30 @@ workers: # How an unqualified spawn chooses a profile: fixed (default, reproduces pre-CB-518 behaviour), # round-robin, or weighted. Omitting this key is a strict no-op for existing configs. placement: weighted -defaultWorker: gx10 +defaultProfile: gx10 + +# Named member slots. A member is anything a lead spawns. Every member has two independent +# attributes: +# role — which contract: architect, dev or reviewer. It picks the launch charter, the role +# file, the playbook skill and the authz row. +# profile — which backend: one of the `profiles:` keys above (model, CLI adapter, cost). +# They vary on their own. A reviewer may run on the same profile as the dev whose diff it reads, +# which is why the two cannot be one field. +# +# Only members that need a STABLE IDENTITY are declared here, because a lead addresses the same +# slot across many tickets. Architects are such members. A dev or a reviewer is anonymous and +# short-lived — the lead spawns it per task and tears it down after — so it never appears here. +# +# A slot is declared, not recognised: config gives no terminal, so every slot is idle at boot and +# nothing resolves to it until the spawn lifecycle binds a live terminal. Nothing here spawns one. +# members: +# architect-1: +# role: architect +# profile: opus # a strong model, on the operator's subscription +# architect-2: +# role: architect +# profile: sol # a different vendor on purpose — two architects that share a +# # model share its blind spots # Subscription boundary. A worker's base_url host MUST be one of these; the primary # must carry none. Every profile above must have its host listed here. diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 9e6b474..99ff0b4 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -14,7 +14,7 @@ import dev.ltms.bridged.inject.Injector; import dev.ltms.bridged.inject.StatusPoller; import dev.ltms.bridged.inject.TurnListener; import dev.ltms.bridged.inject.WorkerPresence; -import dev.ltms.bridged.auth.ArchitectRegistry; +import dev.ltms.bridged.auth.MemberRegistry; import dev.ltms.bridged.auth.CallerResolver; import dev.ltms.bridged.mcp.BridgeMcp; import dev.ltms.bridged.mcp.ConnectionIdentity; @@ -94,7 +94,7 @@ public final class Bridged { cfg.validateSubscriptionProfiles(); // CB-548: every architect slot must name a configured workers: profile — the strong-model // backend the future spawn lifecycle would read. A stale reference dies here, not later. - cfg.validateArchitects(); + cfg.validateMembers(); Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank() ? Path.of(cfg.herdrSocket()) @@ -107,9 +107,9 @@ public final class Bridged { // CB-402: one adapter per configured peer kind, fronted by a composite router. A profile's // `kind:` selects its adapter — claude-code (the default) and opencode partition the profile // set — and the composite dispatches each SPI call to the adapter that owns the profile/pane. - Map claudeProfiles = new LinkedHashMap<>(); - Map opencodeProfiles = new LinkedHashMap<>(); - cfg.workerProfiles().forEach((name, w) -> { + Map claudeProfiles = new LinkedHashMap<>(); + Map opencodeProfiles = new LinkedHashMap<>(); + cfg.profiles().forEach((name, w) -> { if (w.isOpenCode()) { opencodeProfiles.put(name, w); } else { @@ -122,19 +122,19 @@ public final class Bridged { // unless opencode is the only kind configured. if (!claudeProfiles.isEmpty() || opencodeProfiles.isEmpty()) { adapters.add(new ClaudeCodeLauncher(agents, spaces, guard, - claudeProfiles, cfg.defaultProfile(), System::getenv, + claudeProfiles, cfg.effectiveDefaultProfile(), System::getenv, cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs())); } if (!opencodeProfiles.isEmpty()) { adapters.add(new OpenCodeLauncher(agents, spaces, - opencodeProfiles, cfg.defaultProfile(), System::getenv, + opencodeProfiles, cfg.effectiveDefaultProfile(), System::getenv, cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs())); } AtomicReference> liveCountRef = new AtomicReference<>(_ -> 0); PeerLauncher workers = new CompositePeerLauncher( adapters, - cfg.defaultProfile(), - cfg.workerProfiles(), + cfg.effectiveDefaultProfile(), + cfg.profiles(), PlacementPolicies.fromName(cfg.placement()), profileName -> liveCountRef.get().apply(profileName)); // CB-504: under supervision (launchd/systemd) bridged can start before herdr's socket @@ -191,8 +191,8 @@ public final class Bridged { final Supplier> leads; if (cfg.leadScan() != null) { var scan = cfg.leadScan(); - Set workerSpaces = cfg.workerProfiles().values().stream() - .map(BridgedConfig.Worker::workspace) + Set workerSpaces = cfg.profiles().values().stream() + .map(BridgedConfig.Profile::workspace) .filter(Objects::nonNull) .collect(Collectors.toSet()); leads = new LeadTabScanner(herdr, scan.tabPrefix(), workerSpaces, leadTerminals, @@ -209,12 +209,12 @@ public final class Bridged { // pane resolves to an architect until the later spawn lifecycle binds one. The registry is // what CallerResolver resolves against and what that lifecycle will read profiles from; // nothing here spawns a slot. - ArchitectRegistry architects = new ArchitectRegistry( - cfg.architects() == null ? Map.of() : cfg.architects()); - if (!architects.slots().isEmpty()) { + MemberRegistry members = new MemberRegistry( + cfg.members() == null ? Map.of() : cfg.members()); + if (!members.slots().isEmpty()) { log.info("architect slots: {} configured {} — none bound yet (a slot is idle until the " + "spawn lifecycle binds a live terminal to it)", - architects.slots().size(), architects.slots().keySet()); + members.slots().size(), members.slots().keySet()); } // Status-gated injector (CB-103): the single writer into workers, fed by a poller. @@ -343,13 +343,13 @@ public final class Bridged { throw new IllegalStateException("auth.mode=token but env var " + cfg.auth().tokenEnv() + " is unset or empty — export it before starting bridged"); } - callers = CallerResolver.withLeadsAndArchitects(identity, true, token, leads, - architects::snapshot); + callers = CallerResolver.withLeadsAndMembers(identity, true, token, leads, + members::snapshot); log.info("auth: token mode (bearer required for non-worker callers, env {})", cfg.auth().tokenEnv()); } else { - callers = CallerResolver.withLeadsAndArchitects(identity, false, null, leads, - architects::snapshot); + callers = CallerResolver.withLeadsAndMembers(identity, false, null, leads, + members::snapshot); log.info("auth: loopback-trust (any loopback non-worker caller is the primary)"); } diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java b/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java index ccc5ad6..b55382c 100644 --- a/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java +++ b/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java @@ -136,7 +136,7 @@ public final class CallerResolver { * {@link #pinnedTo}: too many {@code Map}/{@code Supplier} combinations to make {@code null} * unambiguous. */ - public static CallerResolver withLeadsAndArchitects(ConnectionIdentity identity, + public static CallerResolver withLeadsAndMembers(ConnectionIdentity identity, boolean tokenMode, String token, Supplier> leadTerminals, Supplier> architectTerminals) { @@ -183,7 +183,7 @@ public final class CallerResolver { * here but would not resolve (or the reverse) cannot drift apart. Live for the same * reason as {@link #leads()}. */ - public Map architects() { + public Map members() { return architectTerminals.get(); } diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/ArchitectRegistry.java b/bridged/src/main/java/dev/ltms/bridged/auth/MemberRegistry.java similarity index 95% rename from bridged/src/main/java/dev/ltms/bridged/auth/ArchitectRegistry.java rename to bridged/src/main/java/dev/ltms/bridged/auth/MemberRegistry.java index 1e7c673..2338776 100644 --- a/bridged/src/main/java/dev/ltms/bridged/auth/ArchitectRegistry.java +++ b/bridged/src/main/java/dev/ltms/bridged/auth/MemberRegistry.java @@ -26,18 +26,18 @@ import java.util.Map; * exposes the map the resolver resolves against plus the profile lookup lifecycle will call. * Nothing here creates or manages an architect session. */ -public final class ArchitectRegistry { +public final class MemberRegistry { - private final Map slots; + private final Map slots; /** Live {@code terminal_id → slot name}; guarded by {@code this}. */ private final Map terminalToSlot = new HashMap<>(); - public ArchitectRegistry(Map slots) { + public MemberRegistry(Map slots) { this.slots = slots == null ? Map.of() : Map.copyOf(slots); } /** The configured slots, keyed by gateway-local unique name. Unmodifiable snapshot. */ - public Map slots() { + public Map slots() { return slots; } @@ -71,7 +71,7 @@ public final class ArchitectRegistry { * declares none */ public String profileForSlot(String slotName) { - BridgedConfig.Architect a = slots.get(slotName); + BridgedConfig.Member a = slots.get(slotName); return (a == null || a.profile() == null) ? null : a.profile(); } diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index c538320..2b22b5f 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import dev.ltms.bridged.peer.MemberRole; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,10 +28,14 @@ import java.util.Set; * * @param bind REST/MCP listen host:port * @param herdrSocket path to herdr's Unix socket ({@code null} → client default) - * @param worker single worker profile (legacy; superseded by {@code workers}) - * @param workers named worker profiles, keyed by profile name (multi-backend fleet) - * @param defaultWorker which {@code workers} key a no-argument spawn uses ({@code null} → the - * single {@code worker}, or the sole/first profile) + * @param profiles named backend profiles, keyed by profile name (multi-backend fleet). A + * profile answers which backend — model, CLI adapter, credentials, + * cost. It says nothing about what the member spawned on it is for; that is + * the member's {@code role}. Each value's {@code profile} field is defaulted + * to its key at construction, so this map is always normalized + * @param defaultProfile which {@code profiles} key a no-argument spawn uses ({@code null} → the + * sole/first profile). See {@link #effectiveDefaultProfile()} for the + * resolved value * @param guard subscription-boundary allowlist * @param worktreeRoot nullable root directory for provisioned worktrees; defaults to a sibling * of the repo root @@ -46,16 +51,18 @@ import java.util.Set; * @param leaders named panes that orchestrate rather than are orchestrated (CB-530), keyed by * lead name; supersedes the singular {@code primary} pin, which stays honoured. * See {@link #leaderTerminals()} for how the two merge - * @param architects CB-548 architect slots, keyed by gateway-local unique slot name; each points - * at a strong-model profile the future spawn lifecycle reads back. An architect - * is not recognised like a lead: config declares the slots only, and a - * live session becomes an architect when the spawn lifecycle binds its terminal - * to a slot. Nothing here spawns a slot. + * @param members named member slots, keyed by gateway-local unique slot name; each pairs a + * {@code role} with the {@code profile} it runs on. Only members that need a + * stable identity are declared here — architects, today. A {@code dev} or + * {@code reviewer} is anonymous and short-lived, so the lead spawns it per + * task and it never appears in config. A slot is not recognised like + * a lead: config declares it only, and a live session becomes that member when + * the spawn lifecycle binds its terminal to the slot. Nothing here spawns one. * @param leadScan opt-in discovery of leads by tab label (CB-531); {@code null} ⇒ no scanning, * and only {@code leaders:}/{@code primary:} name a lead * @param leadHeartbeat opt-in idle-lead heartbeat (CB-551); {@code null} ⇒ off, and an upgraded * daemon never nudges an idle lead on its own initiative - * @param placement how to choose a worker profile for an unqualified spawn: + * @param placement how to choose a profile for an unqualified spawn: * {@code fixed} (default), {@code round-robin}, or {@code weighted} * @param auth API authentication mode ({@code null} → {@code loopback-trust}, the * historical behaviour), CB-501 @@ -64,9 +71,8 @@ import java.util.Set; public record BridgedConfig( Bind bind, String herdrSocket, - Worker worker, - Map workers, - String defaultWorker, + Map profiles, + String defaultProfile, Guard guard, String worktreeRoot, Lifecycle lifecycle, @@ -75,12 +81,36 @@ public record BridgedConfig( Broker broker, Primary primary, Map leaders, - Map architects, + Map members, LeadScan leadScan, LeadHeartbeat leadHeartbeat, String placement, Auth auth) { + /** + * Normalize {@code profiles} once, at construction, so every reader sees the same map. + * + *

Each value's {@code profile} field is defaulted to its map key. This used to happen in a + * derived {@code workerProfiles()} accessor, which meant a caller that read the raw map got + * un-defaulted values — two spellings of the same thing, and a real source of bugs. Doing it + * here leaves one spelling. + * + *

Deliberately NOT {@code Map.copyOf}: its iteration order is salted per JVM run, which + * would discard the YAML definition order. Placement tie-breaks on candidate order (see + * {@code WeightedRoundRobinPolicy}), so losing it makes equal-weight placement + * non-reproducible across restarts. Unmodifiable-wrap instead of copy-and-scramble. + */ + public BridgedConfig { + if (profiles != null && !profiles.isEmpty()) { + Map normalized = new LinkedHashMap<>(); + profiles.forEach((name, p) -> normalized.put(name, + (p.profile() == null || p.profile().isBlank()) ? p.withProfile(name) : p)); + profiles = Collections.unmodifiableMap(normalized); + } else { + profiles = Map.of(); + } + } + @JsonIgnoreProperties(ignoreUnknown = true) public record Bind(String host, int port) { public Bind { @@ -144,7 +174,7 @@ public record BridgedConfig( * config load (CB-542): on the subscription path no guard would vet it. */ @JsonIgnoreProperties(ignoreUnknown = true) - public record Worker(String profile, String baseUrl, String model, + public record Profile(String profile, String baseUrl, String model, String configDir, String tokenEnv, List argv, String placement, String workspace, String tabLabel, String mcpUrl, String cwd, @@ -161,7 +191,7 @@ public record BridgedConfig( /** Peer kind spawned by the opencode adapter (CB-402). */ public static final String KIND_OPENCODE = "opencode"; - public Worker { + public Profile { // A claude-code worker defaults its launch command to `claude`; other kinds carry their own // argv (e.g. `opencode`) and must not inherit the Claude binary — so only default when unset // AND this is the claude-code kind. @@ -195,7 +225,7 @@ public record BridgedConfig( * granted no PR-create token (push over SSH is unaffected). Keeps pre-CB-302 call sites * (and any {@code workers:} YAML that omits the git keys) working unchanged. */ - public Worker(String profile, String baseUrl, String model, + public Profile(String profile, String baseUrl, String model, String configDir, String tokenEnv, List argv, String placement, String workspace, String tabLabel, String mcpUrl, String cwd, List parityOverlay) { @@ -207,7 +237,7 @@ public record BridgedConfig( * Backward-compatible constructor with the CB-302 git-forge fields but no explicit peer * {@code kind} — defaults to {@link #KIND_CLAUDE_CODE}. Keeps pre-CB-402 call sites working. */ - public Worker(String profile, String baseUrl, String model, + public Profile(String profile, String baseUrl, String model, String configDir, String tokenEnv, List argv, String placement, String workspace, String tabLabel, String mcpUrl, String cwd, List parityOverlay, String gitTokenEnv, String gitHostEnv) { @@ -219,7 +249,7 @@ public record BridgedConfig( * Backward-compatible constructor without the CB-511 {@code env:} passthrough — the worker * gets the daemon's PATH and nothing else. Keeps pre-CB-511 call sites working. */ - public Worker(String profile, String baseUrl, String model, + public Profile(String profile, String baseUrl, String model, String configDir, String tokenEnv, List argv, String placement, String workspace, String tabLabel, String mcpUrl, String cwd, List parityOverlay, String gitTokenEnv, String gitHostEnv, @@ -229,8 +259,8 @@ public record BridgedConfig( } /** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */ - public Worker withProfile(String p) { - return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, + public Profile withProfile(String p) { + return new Profile(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad, subscription); } @@ -258,7 +288,7 @@ public record BridgedConfig( * the off-subscription boundary (the default). Keeps pre-CB-539 call sites (and any YAML * that omits the flag) compiling and behaving identically. */ - public Worker(String profile, String baseUrl, String model, + public Profile(String profile, String baseUrl, String model, String configDir, String tokenEnv, List argv, String placement, String workspace, String tabLabel, String mcpUrl, String cwd, List parityOverlay, String gitTokenEnv, String gitHostEnv, @@ -392,26 +422,32 @@ public record BridgedConfig( } /** - * One entry of the CB-548 {@code architects:} registry — a gateway-local named slot that points - * at a strong-model profile. + * One entry of the {@code members:} registry — a gateway-local named slot that pairs a role + * with the profile it runs on. * - *

A slot is declared, not recognised: config names the slot and the profile it runs, - * and nothing else. Unlike a lead (which config pins by herdr {@code terminal_id} and is - * recognised at startup), an architect slot is idle at boot — config supplies no terminal, so no - * session resolves to one until the spawn lifecycle binds a live terminal to the slot. The - * stable name + profile pair is the only config-time identity; live identity is defined purely - * by the runtime {@link dev.ltms.bridged.auth.ArchitectRegistry} binding. + *

Role and profile are separate axes. The {@code role} answers which contract + * — it picks the launch charter, the role file, the playbook skill and the authz row. The + * {@code profile} answers which backend — model, CLI adapter, credentials, cost. They + * vary independently: a {@code reviewer} may run on the very same profile as the {@code dev} + * whose diff it reviews, and that case is what proves the two are not one axis. * - *

Why a {@code profile} reference: an architect is meant to run a strong model, and the slot - * records which {@code workers:} profile that is — the value the future spawn lifecycle reads. - * It must name a configured profile, enforced by {@link #validateArchitects()} (a stale or - * typo'd reference fails at startup rather than silently spawning the wrong backend later). + *

Only members that need a stable identity are declared here. Architects are, because + * a lead addresses the same pair of them across many tickets. A {@code dev} or {@code reviewer} + * is anonymous and fungible — spawned per task, torn down after — so it never appears in config. * - * @param profile the name of the strong-model {@code workers:} profile this slot runs; - * required and validated against {@link #workerProfiles()} + *

A slot is declared, not recognised: config names the slot, its role and its + * profile, and nothing else. Unlike a lead (which config pins by herdr {@code terminal_id} and + * is recognised at startup), a member slot is idle at boot — config supplies no terminal, so no + * session resolves to one until the spawn lifecycle binds a live terminal to the slot. + * + * @param role the contract this slot runs under — {@code architect}, {@code dev} or + * {@code reviewer}; required, and validated against {@link MemberRole} + * @param profile the name of the {@code profiles:} entry this slot runs on; required and + * validated against {@link #profiles()} (a stale or typo'd reference fails at + * startup rather than silently spawning the wrong backend later) */ @JsonIgnoreProperties(ignoreUnknown = true) - public record Architect(String profile) { + public record Member(String role, String profile) { } /** @@ -509,7 +545,7 @@ public record BridgedConfig( * API authentication (CB-501). Governs how a caller that is not an on-host worker * pane proves it is the primary. * - *

Worker identity never depends on this block: a loopback peer PID that maps to a herdr + *

Member identity never depends on this block: a loopback peer PID that maps to a herdr * pane is unforgeable and is always honoured (see * {@link dev.ltms.bridged.mcp.ConnectionIdentity}). This only decides what happens for * everyone else. @@ -560,41 +596,17 @@ public record BridgedConfig( } /** - * The effective worker profiles, keyed by profile name. Prefers the {@code workers} map (each - * value's {@code profile} defaulted to its key); falls back to the legacy singular {@code worker} - * (keyed by its own profile). Empty if neither is configured. + * The profile a no-argument spawn uses: {@code defaultProfile} if set, else the sole/first + * configured profile, else {@code null}. + * + *

Named {@code effective…} because the record component {@code defaultProfile()} returns the + * raw config value, which may be {@code null}. This is the resolved one. */ - public Map workerProfiles() { - if (workers != null && !workers.isEmpty()) { - Map out = new LinkedHashMap<>(); - workers.forEach((name, w) -> out.put(name, - (w.profile() == null || w.profile().isBlank()) ? w.withProfile(name) : w)); - // Deliberately NOT Map.copyOf: its iteration order is salted per JVM run, which would - // discard the YAML definition order built above. Placement tie-breaks on candidate - // order (see WeightedRoundRobinPolicy), so losing it makes equal-weight placement - // non-reproducible across restarts. Unmodifiable-wrap instead of copy-and-scramble. - return Collections.unmodifiableMap(out); + public String effectiveDefaultProfile() { + if (defaultProfile != null && !defaultProfile.isBlank()) { + return defaultProfile; } - if (worker != null) { - String name = (worker.profile() == null || worker.profile().isBlank()) ? "default" : worker.profile(); - return Map.of(name, worker); - } - return Map.of(); - } - - /** - * The profile a no-argument spawn uses: {@code defaultWorker} if set, else the legacy single - * {@code worker}'s profile, else the sole/first configured profile, else {@code null}. - */ - public String defaultProfile() { - if (defaultWorker != null && !defaultWorker.isBlank()) { - return defaultWorker; - } - if (worker != null && worker.profile() != null && !worker.profile().isBlank()) { - return worker.profile(); - } - Map p = workerProfiles(); - return p.isEmpty() ? null : p.keySet().iterator().next(); + return profiles.isEmpty() ? null : profiles.keySet().iterator().next(); } private static final Logger log = LoggerFactory.getLogger(BridgedConfig.class); @@ -606,16 +618,17 @@ public record BridgedConfig( * {@link #warnUnknownTopLevelKeys}. Keep in step with the record components. */ private static final Set KNOWN_TOP_LEVEL_KEYS = Set.of( - "bind", "herdrSocket", "worker", "workers", "defaultWorker", "guard", "worktreeRoot", + "bind", "herdrSocket", "profiles", "defaultProfile", "guard", "worktreeRoot", "lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "leaders", - "architects", "leadScan", "leadHeartbeat", "placement", "auth"); + "members", "leadScan", "leadHeartbeat", "placement", "auth"); /** Load and validate config from {@code path}. */ public static BridgedConfig load(Path path) { try { String yaml = Files.readString(path); + rejectRenamedTopLevelKeys(yaml); warnUnknownTopLevelKeys(yaml, path); - rejectDuplicateArchitectSlots(yaml); + rejectDuplicateMemberSlots(yaml); BridgedConfig cfg = YAML.readValue(yaml, BridgedConfig.class); return cfg.withDefaults(); } catch (IOException e) { @@ -624,37 +637,37 @@ public record BridgedConfig( } /** - * Reject an {@code architects:} registry whose slot names repeat (CB-548). + * Reject an {@code members:} registry whose slot names repeat (CB-548). * *

The registry is a {@code Map} keyed by slot name, so by the time it is read duplicate keys * have already collapsed last-wins — a duplicated slot name would silently drop one slot and the * daemon would never know. Jackson's YAML parser does not fail on duplicate mapping keys by * default, so duplicates are caught here, at parse time, before the map is built. Only the - * top-level {@code architects:} block is considered, and only its direct child keys (the - * slot names) — a nested field elsewhere, even one also named {@code architects:}, is ignored, so + * top-level {@code members:} block is considered, and only its direct child keys (the + * slot names) — a nested field elsewhere, even one also named {@code members:}, is ignored, so * parsing of the rest of the config is unaffected. * - * @throws IllegalStateException when two {@code architects:} entries share a slot name, naming it + * @throws IllegalStateException when two {@code members:} entries share a slot name, naming it */ - static void rejectDuplicateArchitectSlots(String yaml) { + static void rejectDuplicateMemberSlots(String yaml) { try (JsonParser p = YAML.createParser(yaml)) { if (p.nextToken() != JsonToken.START_OBJECT) { return; // not a mapping at top level — readValue reports the malformed file } // Scan the TOP-LEVEL mapping only. Every other field's value (however deep, including - // any nested field also literally named "architects") is consumed whole by skipValue, so - // the loop below can only ever see the top-level field names — a nested `architects:` can + // any nested field also literally named "members") is consumed whole by skipValue, so + // the loop below can only ever see the top-level field names — a nested `members:` can // neither suppress the real block nor be misread as one. JsonToken t; while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) { if (t == JsonToken.FIELD_NAME) { String name = p.getCurrentName(); JsonToken value = p.nextToken(); - if ("architects".equals(name)) { + if ("members".equals(name)) { if (value == JsonToken.START_OBJECT) { rejectDuplicateChildSlotKeys(p); } - return; // the single top-level architects block is handled; nothing more to check + return; // the single top-level members block is handled; nothing more to check } skipValue(p, value); } @@ -665,14 +678,14 @@ public record BridgedConfig( } /** - * Reject a duplicated direct child key of the (already-positioned) {@code architects:} + * Reject a duplicated direct child key of the (already-positioned) {@code members:} * mapping — i.e. a duplicated {@code slot name}. * *

Each slot's value is consumed whole by {@link #skipValue}, so a duplicated field inside - * a slot (e.g. two {@code profile:} keys, or a duplicate nested {@code architects:}) is never seen + * a slot (e.g. two {@code profile:} keys, or a duplicate nested {@code members:}) is never seen * here and cannot masquerade as a duplicated slot name. * - * @throws IllegalStateException when two {@code architects:} entries share a slot name, naming it + * @throws IllegalStateException when two {@code members:} entries share a slot name, naming it */ private static void rejectDuplicateChildSlotKeys(JsonParser p) throws IOException { Set seen = new HashSet<>(); @@ -680,7 +693,7 @@ public record BridgedConfig( while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) { if (t == JsonToken.FIELD_NAME) { if (!seen.add(p.getCurrentName())) { - throw new IllegalStateException("refusing to start: duplicate architect slot name '" + throw new IllegalStateException("refusing to start: duplicate member slot name '" + p.getCurrentName() + "' — slot names must be unique; a later entry would " + "silently overwrite the earlier one"); } @@ -692,7 +705,7 @@ public record BridgedConfig( /** * Consume the whole value that starts at {@code start}, including every nested structure, and * leave the parser positioned just past it. Used so depth is handled structurally rather than by - * a heuristic — a nested field is never interpreted as a top-level {@code architects:}. + * a heuristic — a nested field is never interpreted as a top-level {@code members:}. */ private static void skipValue(JsonParser p, JsonToken start) throws IOException { switch (start) { @@ -749,6 +762,54 @@ public record BridgedConfig( * @return empty when everything is known, or when {@code yaml} is not a mapping at all (a * malformed file is {@code readValue}'s error to report, not this method's) */ + /** + * Top-level keys renamed by the member taxonomy, mapped old → new. + * + *

These get a hard error rather than the usual unknown-key WARN. The reason is the failure + * they would otherwise cause: a config still saying {@code workers:} would load, warn once, and + * then run with zero profiles — so every spawn fails later with a message that points + * nowhere near the real cause. Naming the new key at startup turns a confusing runtime failure + * into a one-line fix. + * + *

We are in active development, so the old spellings are not accepted as aliases. Accepting + * both would leave two names for one thing in every config and doc, which is the cost the + * rename was meant to remove. + */ + private static final Map RENAMED_TOP_LEVEL_KEYS = Map.of( + "workers", "profiles", + "worker", "profiles", + "defaultWorker", "defaultProfile", + "architects", "members"); + + /** + * Reject a config that still uses a pre-rename top-level key, naming its replacement. + * + * @param yaml the raw config text + * @throws IllegalStateException when any renamed key is present + */ + static void rejectRenamedTopLevelKeys(String yaml) { + Map raw; + try { + raw = YAML.readValue(yaml, Map.class); + } catch (IOException | IllegalArgumentException e) { + return; // a malformed file is reported by the real parse, not here + } + if (raw == null) { + return; + } + List bad = raw.keySet().stream() + .map(String::valueOf) + .filter(RENAMED_TOP_LEVEL_KEYS::containsKey) + .sorted() + .map(k -> "'" + k + "' is now '" + RENAMED_TOP_LEVEL_KEYS.get(k) + "'") + .toList(); + if (!bad.isEmpty()) { + throw new IllegalStateException("refusing to start: this config uses renamed top-level " + + "keys — " + String.join("; ", bad) + + ". A profile says which backend to run; a member says which role runs on it."); + } + } + static List unknownTopLevelKeys(String yaml) { Map raw; try { @@ -782,9 +843,9 @@ public record BridgedConfig( // every config that never mentioned it. // leadHeartbeat is left as-is for the same reason (CB-551): null is "off", and LeadHeartbeat's // own compact constructor defaults the fields of a block that IS present. - // architects is left as-is: null is "none configured", and Architect's fields have no + // members is left as-is: null is "none configured", and Member's fields have no // defaults to fill. Defaulting it here would change nothing, so leave the call natural. - return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g, worktreeRoot, l, timeout, pollMs, broker, primary, leaders, architects, leadScan, leadHeartbeat, placementOrDefault, a); + return new BridgedConfig(b, herdrSocket, profiles, defaultProfile, g, worktreeRoot, l, timeout, pollMs, broker, primary, leaders, members, leadScan, leadHeartbeat, placementOrDefault, a); } /** @@ -833,7 +894,7 @@ public record BridgedConfig( return; } String prefix = leadScan.tabPrefix(); - List clashing = workerProfiles().entrySet().stream() + List clashing = profiles().entrySet().stream() .filter(e -> e.getValue().tabLabel() != null && e.getValue().tabLabel().strip() .regionMatches(true, 0, prefix, 0, prefix.length())) @@ -871,7 +932,7 @@ public record BridgedConfig( */ public void validateSubscriptionProfiles() { List bad = new java.util.ArrayList<>(); - workerProfiles().forEach((name, w) -> { + profiles().forEach((name, w) -> { if (w.isSubscription() && w.envCarriesAnthropicBinding()) { List keys = w.env().keySet().stream() .filter(k -> k.equals("ANTHROPIC_BASE_URL") || k.equals("ANTHROPIC_AUTH_TOKEN")) @@ -890,40 +951,48 @@ public record BridgedConfig( } /** - * Reject an architect slot whose profile reference does not resolve (CB-548). + * Reject a member slot whose {@code role} or {@code profile} does not resolve. * - *

An architect's {@code profile} is the strong-model {@code workers:} profile the future - * spawn lifecycle will read to stand the slot up. A reference that names no configured profile - * is a typo or a stale config — and unlike a worker spawn (which fails loudly at its call site - * when it cannot resolve), an architect slot fails only when something later tries to use it. - * This config class does have access to {@link #workerProfiles()}, so the reference - * is validated at startup and the mistake is named then, not discovered months later by a - * spawn that quietly has no backend to use. + *

A slot's {@code profile} is the {@code profiles:} entry the spawn lifecycle reads to stand + * the slot up. A reference that names no configured profile is a typo or a stale config — and + * unlike a spawn (which fails loudly at its call site when it cannot resolve), a slot fails only + * when something later tries to use it. Validating at startup names the mistake then, rather + * than leaving it to be discovered months later by a spawn that quietly has no backend. + * + *

The {@code role} is checked the same way and for the same reason: a typo'd role would + * otherwise pick no charter at all, and the member would run with no contract. * *

Slot-name uniqueness needs no check here: the registry is a {@code Map} keyed by name, so * duplicates are unrepresentable by construction once loaded — and {@link #load(Path)} already * rejects a duplicated slot name at parse time, before the map collapses. * - * @throws IllegalStateException when any architect slot is missing or names an unknown profile, - * naming the slot and the offending reference + * @throws IllegalStateException when any member slot is missing or names an unknown role or + * profile, naming the slot and the offending reference */ - public void validateArchitects() { - if (architects == null) { + public void validateMembers() { + if (members == null) { return; } - Map profiles = workerProfiles(); List bad = new java.util.ArrayList<>(); - architects.forEach((name, arch) -> { - if (arch == null || arch.profile() == null || arch.profile().isBlank()) { - bad.add("architect slot '" + name + "' has no profile: — give it the name of a " - + "workers: profile (the strong-model backend it runs)."); - return; - } - if (!profiles.containsKey(arch.profile())) { - bad.add("architect slot '" + name + "' references profile '" + arch.profile() - + "', which is not a configured workers: profile (have: " + profiles.keySet() + members.forEach((name, m) -> { + if (m == null || m.profile() == null || m.profile().isBlank()) { + bad.add("member slot '" + name + "' has no profile: — give it the name of a " + + "profiles: entry (the backend it runs on)."); + } else if (!profiles.containsKey(m.profile())) { + bad.add("member slot '" + name + "' references profile '" + m.profile() + + "', which is not a configured profiles: entry (have: " + profiles.keySet() + ")."); } + if (m == null || m.role() == null || m.role().isBlank()) { + bad.add("member slot '" + name + "' has no role: — give it one of architect, dev, " + + "reviewer."); + } else { + try { + MemberRole.parse(m.role()); + } catch (IllegalArgumentException e) { + bad.add("member slot '" + name + "': " + e.getMessage() + "."); + } + } }); if (!bad.isEmpty()) { throw new IllegalStateException("refusing to start: " + String.join(" ", bad)); diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/MemberRole.java b/bridged/src/main/java/dev/ltms/bridged/peer/MemberRole.java new file mode 100644 index 0000000..7185c3f --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/peer/MemberRole.java @@ -0,0 +1,88 @@ +package dev.ltms.bridged.peer; + +import java.util.Locale; + +/** + * What a member is for — the contract it runs under. + * + *

A member is anything a lead spawns. Every member carries two independent attributes: + * + *

    + *
  • role (this enum) — which contract: the launch charter it is given, the role + * file it reads, the playbook skill it loads, and its authorization row.
  • + *
  • profile (a {@code profiles:} key) — which backend: model, CLI adapter, + * credentials, cost.
  • + *
+ * + *

These are separate axes on purpose. A {@code REVIEWER} may run on the same profile as the + * {@code DEV} whose diff it reviews — same backend, different contract. That case is what proves + * role and profile cannot be collapsed into one field. + * + *

A lead is deliberately not a role here. A lead is not spawned: it is a pre-existing, + * human-facing session that config recognises. Only spawned peers have a member role. + */ +public enum MemberRole { + + /** + * Refines a ticket before anyone implements it: scope, acceptance criteria, risks, unit split. + * + *

Reads the repo and writes analysis. Never commits code and never opens a pull request — + * an architect that starts implementing has stopped doing the job that makes it useful. + * + *

Architects are the one member kind declared in config, because a lead addresses the same + * slots across many tickets and needs a stable name for them. + */ + ARCHITECT, + + /** + * Implements one unit of work: provisions a worktree, writes the code, commits, pushes, and + * opens its own pull request. + * + *

Never merges. The lead is the gate, and a member that merged its own work would remove the + * only independent check in the flow. + */ + DEV, + + /** + * Reviews a diff it did not write and reports one structured finding. + * + *

Never commits, never merges, and is never the member that wrote the scope under review. + * Briefed from the diff rather than from the implementer's rationale, because that rationale + * carries the same blind spot that produced the bug. + */ + REVIEWER; + + /** The lowercase spelling used in config and on the wire ({@code architect}, {@code dev}, …). */ + public String wireName() { + return name().toLowerCase(Locale.ROOT); + } + + /** + * Parse a config/wire spelling, case-insensitively. + * + * @param s the spelling to parse + * @return the matching role + * @throws IllegalArgumentException when {@code s} is null, blank, or not a known role — the + * message lists the valid spellings, because a typo'd role in + * config should fail at startup with the fix in the error + */ + public static MemberRole parse(String s) { + if (s != null && !s.isBlank()) { + String t = s.trim().toLowerCase(Locale.ROOT); + for (MemberRole r : values()) { + if (r.wireName().equals(t)) { + return r; + } + } + } + StringBuilder valid = new StringBuilder(); + for (MemberRole r : values()) { + if (!valid.isEmpty()) { + valid.append(", "); + } + valid.append(r.wireName()); + } + throw new IllegalArgumentException( + "unknown member role '" + s + "'; valid roles are: " + valid); + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java index 1215433..a8b74f5 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/ClaudeCodeLauncher.java @@ -65,7 +65,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * existing deployments and tests keep the legacy non-blocking spawn semantics. */ public ClaudeCodeLauncher(AgentControl agents, WorkspaceControl spaces, SubscriptionGuard guard, - Map profiles, String defaultProfile, + Map profiles, String defaultProfile, Function env) { this(agents, spaces, guard, profiles, defaultProfile, env, 0, System::currentTimeMillis, () -> sleepUninterruptibly(300)); @@ -76,7 +76,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * until the pane reports an injectable state or {@code spawnReadyTimeoutMs} elapses. */ public ClaudeCodeLauncher(AgentControl agents, WorkspaceControl spaces, SubscriptionGuard guard, - Map profiles, String defaultProfile, + Map profiles, String defaultProfile, Function env, long spawnReadyTimeoutMs, long spawnReadyPollMs) { this(agents, spaces, guard, profiles, defaultProfile, env, @@ -102,7 +102,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * (poll ms) is accepted for API symmetry but otherwise unused here */ public ClaudeCodeLauncher(AgentControl agents, WorkspaceControl spaces, SubscriptionGuard guard, - Map profiles, String defaultProfile, + Map profiles, String defaultProfile, Function env, long spawnReadyTimeoutMs, LongSupplier nowMillis, Runnable sleeper) { @@ -118,7 +118,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * the session-aware form with no name and no resume id. */ @Override - protected Launch buildLaunch(BridgedConfig.Worker cfg) { + protected Launch buildLaunch(BridgedConfig.Profile cfg) { return buildLaunch(cfg, null, null); } @@ -132,7 +132,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * applied here — see {@link #applySessionIdentity}. */ @Override - protected Launch buildLaunch(BridgedConfig.Worker cfg, String sessionName, String resumeSessionId) { + protected Launch buildLaunch(BridgedConfig.Profile cfg, String sessionName, String resumeSessionId) { // CB-539: a profile may deliberately opt into the subscription (subscription: true) when no // off-subscription endpoint exists for it — e.g. `sonnet` on `ccs`. That profile gets no // ANTHROPIC_BASE_URL/AUTH_TOKEN (there is nothing to point them at) and the guard's base_url @@ -219,7 +219,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * touches the profile's config; both are pure command-line flags. This inline-flag mount is * Claude Code specific — other adapters mount MCP and instructions their own way. */ - private List argvWithBridge(BridgedConfig.Worker cfg) { + private List argvWithBridge(BridgedConfig.Profile cfg) { if (!cfg.hasMcp()) { return cfg.argv(); } @@ -249,7 +249,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * {@code gx10} does) are untouched — this adds nothing when there is nothing to add. This is * the {@code kind: claude} counterpart of the opencode adapter's {@code -m provider/model}. */ - private static List argvWithModel(List argv, BridgedConfig.Worker cfg) { + private static List argvWithModel(List argv, BridgedConfig.Profile cfg) { if (cfg.model() == null || cfg.model().isBlank()) { return argv; } @@ -302,7 +302,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { /** Whether any configured profile opts into a git-forge token (required for {@link Capability#SELF_PR}). */ private boolean hasGitTokenProfile() { - return profileConfigs().stream().anyMatch(BridgedConfig.Worker::hasGitToken); + return profileConfigs().stream().anyMatch(BridgedConfig.Profile::hasGitToken); } // --- CB-117 reap predicate (Claude prefix), kept for direct unit testing ------------------- diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java index ed468fa..f800f95 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/CompositePeerLauncher.java @@ -64,7 +64,7 @@ public final class CompositePeerLauncher implements PeerLauncher { /** paneId → the delegate that spawned it, so {@link #stop} tears down through the right adapter. */ private final Map spawnedBy = new ConcurrentHashMap<>(); - private final Map profileConfigs; + private final Map profileConfigs; private final PlacementPolicy placementPolicy; private final Function liveCount; @@ -93,7 +93,7 @@ public final class CompositePeerLauncher implements PeerLauncher { */ public CompositePeerLauncher(List delegates, String defaultProfile, - Map profileConfigs, + Map profileConfigs, PlacementPolicy placementPolicy, Function liveCount) { if (delegates.isEmpty()) { @@ -141,7 +141,7 @@ public final class CompositePeerLauncher implements PeerLauncher { String requestedProfile = req.profileName(); if (requestedProfile != null && !requestedProfile.isBlank()) { // An explicit profile bypasses the placement policy, but not the capacity cap: maxLoad - // is documented as an unconditional limit on this profile (BridgedConfig.Worker), and + // is documented as an unconditional limit on this profile (BridgedConfig.Profile), and // the charter makes explicit-profile spawns the normal path — so skipping the check // here would leave the cap dead config in real operation. HerdrPeerLauncher d = route(requestedProfile); @@ -198,7 +198,7 @@ public final class CompositePeerLauncher implements PeerLauncher { /** * Refuse an explicit-profile spawn when the profile is at its {@code maxLoad} cap. * - *

maxLoad is a documented, unconditional capacity limit (see {@code BridgedConfig.Worker#maxLoad}), + *

maxLoad is a documented, unconditional capacity limit (see {@code BridgedConfig.Profile#maxLoad}), * and the charter makes explicit-profile spawns the normal path — so enforcing it only in placement * ({@link dev.ltms.bridged.placement.PlacementPolicyUtil}) would leave the cap dead config on every * call that names a profile. Same rule as placement: {@code live >= cap} is at capacity. @@ -219,7 +219,7 @@ public final class CompositePeerLauncher implements PeerLauncher { private void enforceMaxLoad(String profile) { // Absent config, or a config whose maxLoad normalized to null (non-positive ⇒ unlimited at // load), means no cap — never cap what wasn't configured. - BridgedConfig.Worker cfg = profileConfigs.get(profile); + BridgedConfig.Profile cfg = profileConfigs.get(profile); Integer cap = (cfg == null) ? null : cfg.maxLoad(); if (cap == null) { return; @@ -234,8 +234,8 @@ public final class CompositePeerLauncher implements PeerLauncher { /** Build the candidate list from the configured profiles, in definition order. */ private List candidates() { List out = new ArrayList<>(); - for (Map.Entry e : profileConfigs.entrySet()) { - BridgedConfig.Worker w = e.getValue(); + for (Map.Entry e : profileConfigs.entrySet()) { + BridgedConfig.Profile w = e.getValue(); out.add(new PlacementCandidate(e.getKey(), null, w.weight(), w.maxLoad())); } return out; diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java index 3edcd4a..d2aa874 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/HerdrPeerLauncher.java @@ -42,7 +42,7 @@ import java.util.regex.Pattern; *

  • {@code namePrefix} (constructor arg) — the label prefix ({@code claude}, {@code opencode}) * that drives both unique naming and the orphan-reap pattern, so each adapter reaps only its * own kind of pane and never another's.
  • - *
  • {@link #buildLaunch(BridgedConfig.Worker)} — the peer-specific env map + argv, including any + *
  • {@link #buildLaunch(BridgedConfig.Profile)} — the peer-specific env map + argv, including any * subscription/guard check, MCP mount, and instruction injection. The base never sees how the * peer is configured; it only places and starts the returned {@link Launch}.
  • * @@ -69,7 +69,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { private final String namePrefix; // label prefix: naming + reap scheme private final AgentControl agents; private final WorkspaceControl spaces; - private final Map profiles; // profile name → spawn settings + private final Map profiles; // profile name → spawn settings private final String defaultProfile; // profile a no-arg spawn uses (nullable) /** Host env lookup (injectable for tests); adapters read it in {@link #buildLaunch}. */ @@ -106,7 +106,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * interval is baked into this hook, so the base needs no poll field */ protected HerdrPeerLauncher(String namePrefix, AgentControl agents, WorkspaceControl spaces, - Map profiles, String defaultProfile, + Map profiles, String defaultProfile, Function env, long spawnReadyTimeoutMs, LongSupplier nowMillis, Runnable sleeper) { @@ -128,10 +128,10 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * Any subscription/guard check, MCP mount, and instruction injection happen here. The env map * and argv are adapter-private; the base only places and starts what is returned. */ - protected abstract Launch buildLaunch(BridgedConfig.Worker cfg); + protected abstract Launch buildLaunch(BridgedConfig.Profile cfg); /** - * Session-aware variant of {@link #buildLaunch(BridgedConfig.Worker)} (CB-547a). Default + * Session-aware variant of {@link #buildLaunch(BridgedConfig.Profile)} (CB-547a). Default * discards the session identity and delegates to the profile-only form, so an adapter that * carries no durable peer session (opencode, say) inherits byte-identical behaviour and needs * no change. An adapter that does (Claude Code) overrides this to mint/resume the id and to @@ -141,7 +141,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * @param sessionName the bridge's logical session name, or null/blank for launcher-derived * @param resumeSessionId the peer's own prior session id to resume, or null/blank for fresh */ - protected Launch buildLaunch(BridgedConfig.Worker cfg, String sessionName, String resumeSessionId) { + protected Launch buildLaunch(BridgedConfig.Profile cfg, String sessionName, String resumeSessionId) { return buildLaunch(cfg); } @@ -193,7 +193,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { if (name == null || name.isBlank()) { return List.of(); } - BridgedConfig.Worker cfg = profiles.get(name); + BridgedConfig.Profile cfg = profiles.get(name); return cfg == null ? List.of() : cfg.parityOverlay(); } @@ -204,18 +204,18 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { } /** The configured profiles, for adapter capability decisions (e.g. any git-token grant). */ - protected Collection profileConfigs() { + protected Collection profileConfigs() { return profiles.values(); } /** Resolve {@code profileName} (null/blank → default) to its config, or throw with the options. */ - protected BridgedConfig.Worker requireProfile(String profileName) { + protected BridgedConfig.Profile requireProfile(String profileName) { String name = (profileName == null || profileName.isBlank()) ? defaultProfile : profileName; if (name == null || name.isBlank()) { throw new IllegalArgumentException("no default worker profile is configured — " + "pass a profile; configured: " + profiles.keySet()); } - BridgedConfig.Worker cfg = profiles.get(name); + BridgedConfig.Profile cfg = profiles.get(name); if (cfg == null) { throw new IllegalArgumentException("unknown worker profile '" + name + "' — configured: " + profiles.keySet()); @@ -242,13 +242,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { /** * Spawn a peer with session identity (CB-547a). {@code sessionName} and {@code resumeSessionId} - * are threaded from the {@link SpawnRequest} into {@link #buildLaunch(BridgedConfig.Worker, + * are threaded from the {@link SpawnRequest} into {@link #buildLaunch(BridgedConfig.Profile, * String, String)}, and the launch's resolved agent-session id is returned alongside the agent * so the caller can put it on the {@link PeerHandle}. */ protected Spawned spawnInternal(String profileName, String requestedCwd, String callerCwd, String sessionName, String resumeSessionId) { - BridgedConfig.Worker cfg = requireProfile(profileName); + BridgedConfig.Profile cfg = requireProfile(profileName); Launch launch = buildLaunch(cfg, sessionName, resumeSessionId); String cwd = resolveCwd(requestedCwd, cfg, callerCwd); Agent agent = cfg.tabPlacement() @@ -304,7 +304,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * guaranteed last resort so a pathological environment with an unset {@code user.dir} still * honours the "never assume {@code $HOME}" contract rather than letting herdr default the pane. */ - private static String resolveCwd(String requestedCwd, BridgedConfig.Worker cfg, String callerCwd) { + private static String resolveCwd(String requestedCwd, BridgedConfig.Profile cfg, String callerCwd) { return firstNonBlank(requestedCwd, cfg.cwd(), callerCwd, System.getProperty("user.dir"), "."); } @@ -316,7 +316,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { } /** Dedicated worker space → own tab (carrying cwd+env) → start the peer into the seed pane. */ - private Agent spawnInTab(BridgedConfig.Worker cfg, Map workerEnv, + private Agent spawnInTab(BridgedConfig.Profile cfg, Map workerEnv, List argv, String cwd) { Workspace space = spaces.ensureWorkspace(cfg.workspace()); Tab.Created tab = spaces.createTab(space.workspaceId(), cwd, workerEnv); @@ -364,7 +364,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { } /** Legacy placement: split the currently-focused tab; the peer still starts in {@code cwd}. */ - private Agent spawnAsPane(BridgedConfig.Worker cfg, Map workerEnv, + private Agent spawnAsPane(BridgedConfig.Profile cfg, Map workerEnv, List argv, String cwd) { log.info("spawning {} (pane placement) profile={} cwd={} argv={}", namePrefix, cfg.profile(), cwd, argv); @@ -391,7 +391,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * backstop for the astronomically unlikely nonce+seq clash; the name is a label only — herdr * detects kind and status from terminal output, not from it. */ - private Started startUniquelyNamed(BridgedConfig.Worker cfg, List argv, String paneId) { + private Started startUniquelyNamed(BridgedConfig.Profile cfg, List argv, String paneId) { // Protocol 19 resolves the executable from the agent kind (== namePrefix here), so // argv[0] — the configured executable — is dropped and only the extra args are passed. List args = argv.isEmpty() ? argv : argv.subList(1, argv.size()); @@ -549,7 +549,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { /** Whether any configured profile places peers in their own tab (so tabs may need cleanup). */ private boolean usesTabPlacement() { - return profiles.values().stream().anyMatch(BridgedConfig.Worker::tabPlacement); + return profiles.values().stream().anyMatch(BridgedConfig.Profile::tabPlacement); } /** True when a herdr error means the target is already gone (safe to treat as done). */ @@ -608,7 +608,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * {@code GITEA_HOST}. Push over SSH is unaffected; the only incremental grant is PR-create. * Peer-neutral, so every herdr adapter reuses it unchanged. */ - protected void applyGitToken(Map workerEnv, BridgedConfig.Worker cfg) { + protected void applyGitToken(Map workerEnv, BridgedConfig.Profile cfg) { if (!cfg.hasGitToken()) { return; } @@ -637,7 +637,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * dev.ltms.bridged.guard.SubscriptionGuard}, which is checked against the profile's * {@code baseUrl} and nothing else. */ - protected Map baseEnv(BridgedConfig.Worker cfg) { + protected Map baseEnv(BridgedConfig.Profile cfg) { Map workerEnv = new LinkedHashMap<>(); String path = env.apply("PATH"); if (path != null && !path.isBlank()) { diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java index b2757e4..f6ea033 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/OpenCodeLauncher.java @@ -97,7 +97,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * matches the legacy non-blocking spawn semantics. Config dirs are created under the JVM temp dir. */ public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, - Map profiles, String defaultProfile, + Map profiles, String defaultProfile, Function env) { this(agents, spaces, profiles, defaultProfile, env, 0, System::currentTimeMillis, () -> sleepUninterruptibly(300), @@ -109,7 +109,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * the pane reports an injectable state or {@code spawnReadyTimeoutMs} elapses. */ public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, - Map profiles, String defaultProfile, + Map profiles, String defaultProfile, Function env, long spawnReadyTimeoutMs, long spawnReadyPollMs) { this(agents, spaces, profiles, defaultProfile, env, spawnReadyTimeoutMs, @@ -137,7 +137,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * {@link OpenCodeSessionDiscovery}) */ public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, - Map profiles, String defaultProfile, + Map profiles, String defaultProfile, Function env, long spawnReadyTimeoutMs, LongSupplier nowMillis, Runnable sleeper, @@ -167,7 +167,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * with {@code -m}. */ @Override - protected Launch buildLaunch(BridgedConfig.Worker cfg) { + protected Launch buildLaunch(BridgedConfig.Profile cfg) { Map workerEnv = baseEnv(cfg); // A config file is needed for the bridge MCP mount, for a pinned endpoint (CB-508), or both. if (cfg.hasMcp() || hasCustomProvider(cfg)) { @@ -187,7 +187,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * primary's Anthropic subscription, and an opencode process has no Anthropic credential path * at all. Pointing it at a local vLLM cannot leak the subscription. */ - private static boolean hasCustomProvider(BridgedConfig.Worker cfg) { + private static boolean hasCustomProvider(BridgedConfig.Profile cfg) { return cfg.baseUrl() != null && !cfg.baseUrl().isBlank(); } @@ -201,7 +201,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * own git worktree on its own branch, is off-subscription, and cannot merge — the lead is the * gate. */ - private List argvWithAuto(BridgedConfig.Worker cfg) { + private List argvWithAuto(BridgedConfig.Profile cfg) { List argv = mutableArgv(cfg.argv()); argv.add("--auto"); return argv; @@ -226,7 +226,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { } /** The launch argv plus, when a model is configured, the opencode {@code -m provider/model} flag. */ - private List argvWithModel(List argv, BridgedConfig.Worker cfg) { + private List argvWithModel(List argv, BridgedConfig.Profile cfg) { if (cfg.model() != null && !cfg.model().isBlank()) { argv.add("-m"); argv.add(cfg.model()); @@ -240,7 +240,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * {@code OPENCODE_CONFIG}. The dir is unique per spawn so concurrent workers never race on it; * it is best-effort cleaned on JVM exit (worker config is disposable — regenerated every spawn). */ - private Path writeConfig(BridgedConfig.Worker cfg) { + private Path writeConfig(BridgedConfig.Profile cfg) { try { Path dir = Files.createTempDirectory(configRoot, "bridged-opencode-"); dir.toFile().deleteOnExit(); @@ -297,7 +297,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { *

    The provider id comes from the {@code provider/model} selector in {@code model:}, so one * field drives both the declaration and the {@code -m} flag and they cannot drift apart. */ - private void addCustomProvider(ObjectNode root, BridgedConfig.Worker cfg) { + private void addCustomProvider(ObjectNode root, BridgedConfig.Profile cfg) { String[] parts = splitModelSelector(cfg); String providerId = parts[0]; String modelId = parts[1]; @@ -320,7 +320,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * needs both, so a bare model name is rejected loudly rather than silently falling back to the * default gateway — a worker quietly talking to the wrong endpoint is the failure this avoids. */ - private static String[] splitModelSelector(BridgedConfig.Worker cfg) { + private static String[] splitModelSelector(BridgedConfig.Profile cfg) { String model = cfg.model(); int slash = model == null ? -1 : model.indexOf('/'); if (model == null || model.isBlank() || slash <= 0 || slash == model.length() - 1) { @@ -459,7 +459,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { /** Whether any configured profile opts into a git-forge token (required for {@link Capability#SELF_PR}). */ private boolean hasGitTokenProfile() { - return profileConfigs().stream().anyMatch(BridgedConfig.Worker::hasGitToken); + return profileConfigs().stream().anyMatch(BridgedConfig.Profile::hasGitToken); } // --- CB-117 reap predicate (opencode prefix), kept for direct unit testing ----------------- diff --git a/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java b/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java index 46a79ed..d3bca5a 100644 --- a/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/auth/CallerResolverTest.java @@ -302,7 +302,7 @@ class CallerResolverTest { @Test void aBoundArchitectPaneResolvesToArchitectBeforeTheWorkerFallback() { - Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null, + Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of, () -> Map.of("term_a", "lead-designer")) .resolve("127.0.0.1", 42, null); @@ -315,7 +315,7 @@ class CallerResolverTest { @Test void anArchitectNeedsNoTokenEvenInTokenMode() { - Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), true, "s3cret", + Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), true, "s3cret", Map::of, () -> Map.of("term_a", "lead-designer")) .resolve("127.0.0.1", 42, null); @@ -326,7 +326,7 @@ class CallerResolverTest { @Test void anUnboundPaneStillResolvesAsAWorker() { Map arch = Map.of("term_elsewhere", "reviewer"); - Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null, + Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of, () -> arch).resolve("127.0.0.1", 42, null); assertEquals(Role.WORKER, p.role()); @@ -336,7 +336,7 @@ class CallerResolverTest { /** CB-548 precedence: lead > architect > worker, so a pane named in BOTH is still a lead. */ @Test void aLeadWinsOverAnArchitectBindingForTheSamePane() { - Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null, + Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, () -> Map.of("term_a", "opus-5.0"), () -> Map.of("term_a", "lead-designer")) .resolve("127.0.0.1", 42, null); @@ -350,7 +350,7 @@ class CallerResolverTest { @Test void anArchitectBoundAfterConstructionIsHonouredWithoutRebuildingTheResolver() { Map live = new java.util.HashMap<>(); - CallerResolver r = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null, + CallerResolver r = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of, () -> live); assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role()); @@ -358,7 +358,7 @@ class CallerResolverTest { live.put("term_a", "lead-designer"); // the later lifecycle binds the slot assertEquals(Role.ARCHITECT, r.resolve("127.0.0.1", 42, null).role()); - assertEquals("lead-designer", r.architects().get("term_a")); + assertEquals("lead-designer", r.members().get("term_a")); } @Test @@ -380,7 +380,7 @@ class CallerResolverTest { /** An architect acts only as its own pane — the same ownsSession rule as a worker or lead. */ @Test void anArchitectOwnsItsOwnPaneAndNoOther() { - Principal arch = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null, + Principal arch = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Map::of, () -> Map.of("term_a", "lead-designer")).resolve("127.0.0.1", 42, null); assertTrue(arch.ownsSession("term_a")); diff --git a/bridged/src/test/java/dev/ltms/bridged/auth/ArchitectRegistryTest.java b/bridged/src/test/java/dev/ltms/bridged/auth/MemberRegistryTest.java similarity index 94% rename from bridged/src/test/java/dev/ltms/bridged/auth/ArchitectRegistryTest.java rename to bridged/src/test/java/dev/ltms/bridged/auth/MemberRegistryTest.java index 1b642c2..53ea497 100644 --- a/bridged/src/test/java/dev/ltms/bridged/auth/ArchitectRegistryTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/auth/MemberRegistryTest.java @@ -20,13 +20,13 @@ import static org.junit.jupiter.api.Assertions.*; * {@link CallerResolverTest}; this pins the registry object itself — its invariants and their * thread-safety. */ -class ArchitectRegistryTest { +class MemberRegistryTest { - private static final Map SLOTS = Map.of( - "lead-designer", new BridgedConfig.Architect("sonnet"), - "reviewer", new BridgedConfig.Architect("gx10")); + private static final Map SLOTS = Map.of( + "lead-designer", new BridgedConfig.Member("architect", "sonnet"), + "reviewer", new BridgedConfig.Member("architect", "gx10")); - private final ArchitectRegistry registry = new ArchitectRegistry(SLOTS); + private final MemberRegistry registry = new MemberRegistry(SLOTS); @Test void exposesTheConfiguredSlots() { @@ -228,10 +228,10 @@ class ArchitectRegistryTest { /** A handed-over slot map is snapshotted at construction, not offered as live state. */ @Test void theSlotSnapshotIsFixedByConstruction() { - Map mutable = new HashMap<>(SLOTS); - ArchitectRegistry r = new ArchitectRegistry(mutable); + Map mutable = new HashMap<>(SLOTS); + MemberRegistry r = new MemberRegistry(mutable); - mutable.put("hijack", new BridgedConfig.Architect("gx10")); + mutable.put("hijack", new BridgedConfig.Member("architect", "gx10")); assertFalse(r.isSlot("hijack"), "a handed-over map is not offered as live state"); } diff --git a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java index a5bdb27..cb10a51 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java @@ -1,6 +1,6 @@ package dev.ltms.bridged.config; -import dev.ltms.bridged.auth.ArchitectRegistry; +import dev.ltms.bridged.auth.MemberRegistry; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -22,10 +22,10 @@ class BridgedConfigTest { host: 127.0.0.1 port: 8080 herdrSocket: ~/.config/herdr/herdr.sock - worker: - profile: ltms-local - baseUrl: http://gx00.gw:8000 - model: coder + profiles: + ltms-local: + baseUrl: http://gx00.gw:8000 + model: coder guard: offSubscriptionHosts: - gx00.gw @@ -34,7 +34,8 @@ class BridgedConfigTest { BridgedConfig cfg = BridgedConfig.load(f); assertEquals(8080, cfg.bind().port()); - assertEquals("ltms-local", cfg.worker().profile()); + // The profile field is defaulted to the map key by the compact constructor. + assertEquals("ltms-local", cfg.profiles().get("ltms-local").profile()); assertTrue(cfg.guard().hostSet().contains("gx00.gw")); assertTrue(cfg.guard().hostSet().contains("ollama.ltms.dev")); } @@ -52,45 +53,47 @@ class BridgedConfigTest { } @Test - void singleWorkerBecomesAOneEntryProfileMapWithItselfAsDefault(@TempDir Path dir) throws Exception { + void aSoleProfileIsTheDefaultWithoutBeingNamed(@TempDir Path dir) throws Exception { Path f = dir.resolve("single.yaml"); Files.writeString(f, """ - worker: - profile: ltms-local - baseUrl: http://gx00.gw:8000 + profiles: + ltms-local: + baseUrl: http://gx00.gw:8000 """); BridgedConfig cfg = BridgedConfig.load(f); - assertEquals(Set.of("ltms-local"), cfg.workerProfiles().keySet(), "legacy worker → one profile"); - assertEquals("ltms-local", cfg.defaultProfile()); + assertEquals(Set.of("ltms-local"), cfg.profiles().keySet()); + assertNull(cfg.defaultProfile(), "nothing named a default"); + assertEquals("ltms-local", cfg.effectiveDefaultProfile(), + "with one profile configured there is nothing to choose between"); } @Test void loadsMultipleWorkerProfilesWithADefault(@TempDir Path dir) throws Exception { Path f = dir.resolve("multi.yaml"); Files.writeString(f, """ - workers: + profiles: gx10: baseUrl: http://gx10.gw:8000 argv: ["ccs", "gx10"] ollama: baseUrl: http://ollama.ltms.dev argv: ["ccs", "ollama"] - defaultWorker: gx10 + defaultProfile: gx10 guard: offSubscriptionHosts: [gx10.gw, ollama.ltms.dev] """); BridgedConfig cfg = BridgedConfig.load(f); - assertEquals(Set.of("gx10", "ollama"), cfg.workerProfiles().keySet()); + assertEquals(Set.of("gx10", "ollama"), cfg.profiles().keySet()); // Order, not just membership: placement breaks an exact-weight tie on definition order, so a // hash-ordered map here would make equal-weight placement differ from one restart to the next. assertEquals(java.util.List.of("gx10", "ollama"), - java.util.List.copyOf(cfg.workerProfiles().keySet()), - "workerProfiles must preserve YAML definition order"); + java.util.List.copyOf(cfg.profiles().keySet()), + "profiles must preserve YAML definition order"); assertEquals("gx10", cfg.defaultProfile()); - assertEquals("ollama", cfg.workerProfiles().get("ollama").profile(), "profile defaults to its map key"); - assertEquals("http://gx10.gw:8000", cfg.workerProfiles().get("gx10").baseUrl()); + assertEquals("ollama", cfg.profiles().get("ollama").profile(), "profile defaults to its map key"); + assertEquals("http://gx10.gw:8000", cfg.profiles().get("gx10").baseUrl()); } @Test @@ -120,8 +123,8 @@ class BridgedConfigTest { bind: port: 8080 herdrSocket: /tmp/s - workers: {} - defaultWorker: a + profiles: {} + defaultProfile: a guard: {} worktreeRoot: /tmp lifecycle: {} @@ -130,7 +133,7 @@ class BridgedConfigTest { broker: {} primary: {} leaders: {} - architects: {} + members: {} leadScan: {} leadHeartbeat: {} placement: fixed @@ -236,7 +239,7 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: gx10: tabLabel: "lead: {profile} #{n}" leadScan: @@ -254,7 +257,7 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: gx10: baseUrl: http://gx00.gw:8000 leadScan: {} @@ -269,7 +272,7 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: gx10: tabLabel: "lead: {profile}" """); @@ -382,22 +385,24 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: sonnet: baseUrl: http://gx10.gw:8000 - architects: + members: lead-designer: + role: architect profile: sonnet reviewer: + role: architect profile: sonnet """); BridgedConfig cfg = BridgedConfig.load(f); - assertEquals(Set.of("lead-designer", "reviewer"), cfg.architects().keySet(), + assertEquals(Set.of("lead-designer", "reviewer"), cfg.members().keySet(), "slot names are the keys — gateway-local unique by construction"); - assertEquals("sonnet", cfg.architects().get("lead-designer").profile(), + assertEquals("sonnet", cfg.members().get("lead-designer").profile(), "each slot carries its strong-model profile reference"); - assertEquals("sonnet", cfg.architects().get("reviewer").profile()); + assertEquals("sonnet", cfg.members().get("reviewer").profile()); } @Test @@ -409,20 +414,20 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: sonnet: baseUrl: http://gx10.gw:8000 - architects: + members: lead-designer: terminal: term_design profile: sonnet """); BridgedConfig cfg = BridgedConfig.load(f); - assertEquals("sonnet", cfg.architects().get("lead-designer").profile(), + assertEquals("sonnet", cfg.members().get("lead-designer").profile(), "the profile is still read even when a stray terminal is ignored"); // The registry built from this config owns no bindings: the slot is idle at startup. - ArchitectRegistry r = new ArchitectRegistry(cfg.architects()); + MemberRegistry r = new MemberRegistry(cfg.members()); assertTrue(r.snapshot().isEmpty()); assertNull(r.slotForTerminal("term_design"), "a config terminal must not resolve an architect — slots start idle"); @@ -433,8 +438,8 @@ class BridgedConfigTest { Path f = dir.resolve("no-arch.yaml"); Files.writeString(f, "bind:\n port: 8080\n"); - assertNull(BridgedConfig.load(f).architects(), - "no architects: block ⇒ no architect identity, exactly as before CB-548"); + assertNull(BridgedConfig.load(f).members(), + "no members: block ⇒ no architect identity, exactly as before CB-548"); } @Test @@ -445,17 +450,18 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - worker: - profile: ltms-local - baseUrl: http://gx10.gw:8000 - architects: + profiles: + ltms-local: + baseUrl: http://gx10.gw:8000 + members: lead-designer: + role: architect profile: ltms-local """); BridgedConfig cfg = BridgedConfig.load(f); - assertDoesNotThrow(cfg::validateArchitects); - assertEquals("ltms-local", cfg.architects().get("lead-designer").profile()); + assertDoesNotThrow(cfg::validateMembers); + assertEquals("ltms-local", cfg.members().get("lead-designer").profile()); } @Test @@ -464,16 +470,17 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: gx10: baseUrl: http://gx10.gw:8000 - architects: + members: lead-designer: + role: architect profile: sonnet """); BridgedConfig cfg = BridgedConfig.load(f); - IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects); + IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers); assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot"); assertTrue(e.getMessage().contains("sonnet"), "the refusal names the offending profile"); } @@ -484,38 +491,41 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: gx10: baseUrl: http://gx10.gw:8000 - architects: + members: lead-designer: + role: architect profile: "" """); BridgedConfig cfg = BridgedConfig.load(f); - IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects); + IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers); assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot"); } @Test - void aValidArchitectRegistryPassesValidation(@TempDir Path dir) throws Exception { + void aValidMemberRegistryPassesValidation(@TempDir Path dir) throws Exception { Path f = dir.resolve("arch-ok.yaml"); Files.writeString(f, """ bind: port: 8080 - workers: + profiles: sonnet: baseUrl: http://gx10.gw:8000 gx10: baseUrl: http://gx10.gw:8000 - architects: + members: lead-designer: + role: architect profile: sonnet reviewer: + role: architect profile: gx10 """); - assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects()); + assertDoesNotThrow(() -> BridgedConfig.load(f).validateMembers()); } @Test @@ -523,7 +533,7 @@ class BridgedConfigTest { Path f = dir.resolve("no-arch.yaml"); Files.writeString(f, "bind:\n port: 8080\n"); - assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects()); + assertDoesNotThrow(() -> BridgedConfig.load(f).validateMembers()); } @Test @@ -532,13 +542,15 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: sonnet: baseUrl: http://gx10.gw:8000 - architects: + members: lead-designer: + role: architect profile: sonnet lead-designer: + role: architect profile: sonnet """); @@ -546,7 +558,7 @@ class BridgedConfigTest { assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f)); assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the duplicated slot, was: " + e.getMessage()); - assertTrue(e.getMessage().contains("duplicate architect"), + assertTrue(e.getMessage().contains("duplicate member"), "the refusal says the slot name is duplicated"); } @@ -558,14 +570,14 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: sonnet: baseUrl: http://gx10.gw:8000 sonnet: baseUrl: http://gx10.gw:8000 """); // Last-wins for a non-architect duplicate is untouched: only the architects block is walked. - assertEquals(Set.of("sonnet"), BridgedConfig.load(f).workerProfiles().keySet()); + assertEquals(Set.of("sonnet"), BridgedConfig.load(f).profiles().keySet()); } @Test @@ -577,15 +589,17 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - architects: + members: nested-slot: k: v nested-slot: k: v - architects: + members: lead-designer: + role: architect profile: sonnet lead-designer: + role: architect profile: sonnet """); @@ -593,7 +607,7 @@ class BridgedConfigTest { assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f)); assertTrue(e.getMessage().contains("lead-designer"), "the real top-level duplicate must be reported, was: " + e.getMessage()); - assertTrue(e.getMessage().contains("duplicate architect"), + assertTrue(e.getMessage().contains("duplicate member"), "the refusal says the slot name is duplicated"); } @@ -607,11 +621,12 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: sonnet: baseUrl: http://gx10.gw:8000 - architects: + members: lead-designer: + role: architect profile: sonnet extra: a: 1 @@ -619,10 +634,10 @@ class BridgedConfigTest { """); BridgedConfig cfg = assertDoesNotThrow(() -> BridgedConfig.load(f)); - assertDoesNotThrow(cfg::validateArchitects, + assertDoesNotThrow(cfg::validateMembers, "a nested duplicate inside a slot is not a duplicate slot and must not refuse startup"); - assertEquals(Set.of("lead-designer"), cfg.architects().keySet()); - assertEquals("sonnet", cfg.architects().get("lead-designer").profile()); + assertEquals(Set.of("lead-designer"), cfg.members().keySet()); + assertEquals("sonnet", cfg.members().get("lead-designer").profile()); } @Test @@ -701,14 +716,14 @@ class BridgedConfigTest { void workerKindDefaultsToClaudeCodeWhenOmitted(@TempDir Path dir) throws Exception { Path f = dir.resolve("kind-absent.yaml"); Files.writeString(f, """ - workers: + profiles: gx10: baseUrl: http://gx10.gw:8000 argv: ["ccs", "gx10"] """); BridgedConfig cfg = BridgedConfig.load(f); - assertEquals(BridgedConfig.Worker.KIND_CLAUDE_CODE, cfg.workerProfiles().get("gx10").kind(), + assertEquals(BridgedConfig.Profile.KIND_CLAUDE_CODE, cfg.profiles().get("gx10").kind(), "a worker with no kind: is a claude-code worker (backward compatible)"); } @@ -716,7 +731,7 @@ class BridgedConfigTest { void opencodeKindIsNormalizedToLowerCase(@TempDir Path dir) throws Exception { Path f = dir.resolve("kind-opencode.yaml"); Files.writeString(f, """ - workers: + profiles: gemini: kind: OpenCode model: google/gemini-2.5-pro @@ -724,7 +739,7 @@ class BridgedConfigTest { """); BridgedConfig cfg = BridgedConfig.load(f); - assertEquals(BridgedConfig.Worker.KIND_OPENCODE, cfg.workerProfiles().get("gemini").kind(), + assertEquals(BridgedConfig.Profile.KIND_OPENCODE, cfg.profiles().get("gemini").kind(), "kind is normalised to lower-case so YAML casing does not matter"); } @@ -732,7 +747,7 @@ class BridgedConfigTest { void kindPredicatesReflectTheResolvedKind(@TempDir Path dir) throws Exception { Path f = dir.resolve("kind-predicates.yaml"); Files.writeString(f, """ - workers: + profiles: claude: baseUrl: http://gx10.gw:8000 gemini: @@ -741,8 +756,8 @@ class BridgedConfigTest { """); BridgedConfig cfg = BridgedConfig.load(f); - BridgedConfig.Worker claude = cfg.workerProfiles().get("claude"); - BridgedConfig.Worker gemini = cfg.workerProfiles().get("gemini"); + BridgedConfig.Profile claude = cfg.profiles().get("claude"); + BridgedConfig.Profile gemini = cfg.profiles().get("gemini"); assertTrue(claude.isClaudeCode(), "the default-kind worker is claude-code"); assertFalse(claude.isOpenCode(), "a claude-code worker is not opencode"); assertTrue(gemini.isOpenCode(), "the kind: opencode worker is opencode"); @@ -753,7 +768,7 @@ class BridgedConfigTest { void argvDefaultsToTheKindBinaryWhenUnset(@TempDir Path dir) throws Exception { Path f = dir.resolve("kind-argv.yaml"); Files.writeString(f, """ - workers: + profiles: claude: baseUrl: http://gx10.gw:8000 gemini: @@ -762,9 +777,9 @@ class BridgedConfigTest { """); BridgedConfig cfg = BridgedConfig.load(f); - assertEquals(java.util.List.of("claude"), cfg.workerProfiles().get("claude").argv(), + assertEquals(java.util.List.of("claude"), cfg.profiles().get("claude").argv(), "a claude-code worker with no argv defaults to the claude binary"); - assertEquals(java.util.List.of("opencode"), cfg.workerProfiles().get("gemini").argv(), + assertEquals(java.util.List.of("opencode"), cfg.profiles().get("gemini").argv(), "an opencode worker with no argv defaults to the opencode binary, never claude"); } @@ -837,7 +852,7 @@ class BridgedConfigTest { BridgedConfig cfg = BridgedConfig.load(example); assertEquals(8765, cfg.bind().port(), "example binds the documented default port"); - assertTrue(cfg.workerProfiles().containsKey("gx10"), "example documents the gx10 profile"); + assertTrue(cfg.profiles().containsKey("gx10"), "example documents the gx10 profile"); assertEquals("gx10", cfg.defaultProfile(), "example's defaultWorker resolves"); assertTrue(cfg.guard().hostSet().contains("gx01.gw"), "every example profile's base_url host must be in the example allowlist"); @@ -858,7 +873,7 @@ class BridgedConfigTest { spawnReadyTimeoutMs: 25000 spawnReadyPollMs: 400 worktreeRoot: /tmp/bridged-worktrees - workers: + profiles: gx10: kind: claude-code baseUrl: http://gx01.gw:8000 @@ -892,7 +907,7 @@ class BridgedConfigTest { assertEquals(400, cfg.spawnReadyPollMs(), "spawnReadyPollMs is camelCase, not snake_case"); assertEquals("/tmp/bridged-worktrees", cfg.worktreeRoot()); - BridgedConfig.Worker w = cfg.workerProfiles().get("gx10"); + BridgedConfig.Profile w = cfg.profiles().get("gx10"); assertEquals("/tmp/ccs/gx10", w.configDir()); assertEquals("/tmp/repo", w.cwd()); assertEquals(java.util.List.of(".mcp.json", ".env"), w.parityOverlay()); @@ -921,7 +936,7 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: gx10: baseUrl: http://gx10.gw:8000 """); @@ -936,13 +951,13 @@ class BridgedConfigTest { Files.writeString(f, """ bind: port: 8080 - workers: + profiles: gx10: baseUrl: http://gx10.gw:8000 """); BridgedConfig cfg = BridgedConfig.load(f); - BridgedConfig.Worker w = cfg.workerProfiles().get("gx10"); + BridgedConfig.Profile w = cfg.profiles().get("gx10"); assertEquals(1.0f, w.weight(), 0.0001f, "absent weight defaults to 1.0"); assertNull(w.maxLoad(), "absent maxLoad defaults to unlimited (null)"); } @@ -951,7 +966,7 @@ class BridgedConfigTest { void subscriptionFlagBindsAndDefaultsFalse(@TempDir Path dir) throws Exception { Path f = dir.resolve("subscription.yaml"); Files.writeString(f, """ - workers: + profiles: sonnet: subscription: true argv: ["ccs", "sonnet"] @@ -960,9 +975,9 @@ class BridgedConfigTest { """); BridgedConfig cfg = BridgedConfig.load(f); - assertTrue(cfg.workerProfiles().get("sonnet").isSubscription(), + assertTrue(cfg.profiles().get("sonnet").isSubscription(), "subscription: true binds as an explicit opt-in"); - assertFalse(cfg.workerProfiles().get("opted").isSubscription(), + assertFalse(cfg.profiles().get("opted").isSubscription(), "a profile without the key stays off-subscription (the default)"); } @@ -972,7 +987,7 @@ class BridgedConfigTest { void aSubscriptionProfileWithAnthropicBaseUrlInEnvIsRejected(@TempDir Path dir) throws Exception { Path f = dir.resolve("baseUrl.yaml"); Files.writeString(f, """ - workers: + profiles: sonnet: subscription: true argv: ["ccs", "sonnet"] @@ -992,7 +1007,7 @@ class BridgedConfigTest { void aSubscriptionProfileWithAnthropicAuthTokenInEnvIsRejected(@TempDir Path dir) throws Exception { Path f = dir.resolve("authToken.yaml"); Files.writeString(f, """ - workers: + profiles: sonnet: subscription: true argv: ["ccs", "sonnet"] @@ -1012,7 +1027,7 @@ class BridgedConfigTest { void aSubscriptionProfileWithACleanEnvPassesValidation(@TempDir Path dir) throws Exception { Path f = dir.resolve("clean.yaml"); Files.writeString(f, """ - workers: + profiles: sonnet: subscription: true argv: ["ccs", "sonnet"] @@ -1031,7 +1046,7 @@ class BridgedConfigTest { // plain profile's env: is still overwritten by the launcher's guard-checked value (CB-511). Path f = dir.resolve("nonsub.yaml"); Files.writeString(f, """ - workers: + profiles: gx10: baseUrl: http://gx10.gw:8000 env: @@ -1042,4 +1057,171 @@ class BridgedConfigTest { assertDoesNotThrow(cfg::validateSubscriptionProfiles, "only subscription:true profiles are checked — off-subscription ones keep the baseUrl guard"); } + + // --- member taxonomy (CB-557) --------------------------------------------------------- + + /** + * A pre-rename config must fail loudly, not load empty. + * + *

    This is the whole point of the hard error. Without it, {@code workers:} would be an unknown + * top-level key: the load would warn once and then run with zero profiles, so the first spawn + * fails with a message that points nowhere near the real cause. + */ + @Test + void aConfigStillUsingTheOldWorkersKeyIsRejectedAndNamesTheNewKey(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, """ + workers: + gx10: + baseUrl: http://gx00.gw:8000 + """); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f)); + assertTrue(e.getMessage().contains("'workers' is now 'profiles'"), + "the error must name the replacement key, not just say the key is wrong: " + e.getMessage()); + } + + @Test + void everyRenamedTopLevelKeyIsReportedAtOnce(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, """ + workers: + gx10: + baseUrl: http://gx00.gw:8000 + defaultWorker: gx10 + architects: + architect-1: + profile: gx10 + """); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f)); + assertTrue(e.getMessage().contains("'architects' is now 'members'"), e.getMessage()); + assertTrue(e.getMessage().contains("'defaultWorker' is now 'defaultProfile'"), e.getMessage()); + assertTrue(e.getMessage().contains("'workers' is now 'profiles'"), e.getMessage()); + } + + @Test + void aMemberSlotCarriesBothItsRoleAndItsProfile(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, """ + profiles: + gx10: + baseUrl: http://gx00.gw:8000 + opus: + baseUrl: http://gx00.gw:8000 + members: + architect-1: + role: architect + profile: opus + reviewer-1: + role: reviewer + profile: gx10 + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertDoesNotThrow(cfg::validateMembers); + assertEquals("architect", cfg.members().get("architect-1").role()); + assertEquals("opus", cfg.members().get("architect-1").profile()); + assertEquals("reviewer", cfg.members().get("reviewer-1").role()); + } + + /** + * Role and profile are separate axes: two members may share a backend and still differ in what + * they are allowed to do. This is the case that stops the two collapsing into one field. + */ + @Test + void twoMembersWithDifferentRolesMayShareOneProfile(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, """ + profiles: + gx10: + baseUrl: http://gx00.gw:8000 + members: + dev-1: + role: dev + profile: gx10 + reviewer-1: + role: reviewer + profile: gx10 + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertDoesNotThrow(cfg::validateMembers); + assertEquals(cfg.members().get("dev-1").profile(), cfg.members().get("reviewer-1").profile()); + assertNotEquals(cfg.members().get("dev-1").role(), cfg.members().get("reviewer-1").role()); + } + + @Test + void aMemberSlotWithAnUnknownRoleIsRejectedAndListsTheValidRoles(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, """ + profiles: + gx10: + baseUrl: http://gx00.gw:8000 + members: + slot-1: + role: archtiect + profile: gx10 + """); + + BridgedConfig cfg = BridgedConfig.load(f); + IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers); + assertTrue(e.getMessage().contains("archtiect"), e.getMessage()); + assertTrue(e.getMessage().contains("architect, dev, reviewer"), + "the error must list the valid spellings so the typo is fixable from it: " + e.getMessage()); + } + + @Test + void aMemberSlotWithNoRoleIsRejected(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, """ + profiles: + gx10: + baseUrl: http://gx00.gw:8000 + members: + slot-1: + profile: gx10 + """); + + BridgedConfig cfg = BridgedConfig.load(f); + IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateMembers); + assertTrue(e.getMessage().contains("has no role:"), e.getMessage()); + } + + @Test + void theProfilesMapIsNormalizedOnceAtConstruction(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, """ + profiles: + gx10: + baseUrl: http://gx00.gw:8000 + terra: + profile: explicitly-set + baseUrl: http://gx01.gw:8000 + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals("gx10", cfg.profiles().get("gx10").profile(), + "a profile that names no profile: field is defaulted to its map key"); + assertEquals("explicitly-set", cfg.profiles().get("terra").profile(), + "an explicit profile: field is left alone"); + assertEquals(List.of("gx10", "terra"), List.copyOf(cfg.profiles().keySet()), + "YAML definition order survives — placement tie-breaks on it"); + } + + @Test + void effectiveDefaultProfileFallsBackToTheFirstConfiguredProfile(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, """ + profiles: + gx10: + baseUrl: http://gx00.gw:8000 + terra: + baseUrl: http://gx01.gw:8000 + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertNull(cfg.defaultProfile(), "the raw config value is absent"); + assertEquals("gx10", cfg.effectiveDefaultProfile(), "the resolved value is the first profile"); + } } diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java index 1f53a04..11d4d79 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java @@ -55,7 +55,7 @@ class BridgeMcpAuthzTest { /** A fully wired BridgeMcp on fakes — constructing it is itself part of what is under test. */ private BridgeMcp mcp(boolean enforce) { - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null, "tab", "bridged-workers", "worker: {profile} #{n}", null, null, null); ClaudeCodeLauncher workers = new ClaudeCodeLauncher(agents, new WorkspaceControl(herdr), diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java index 6be944c..84db6a4 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -52,7 +52,7 @@ class BridgeMcpTest { } private static ClaudeCodeLauncher workerService(FakeHerdr h, String baseUrl, Set allow) { - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", baseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null, "tab", "bridged-workers", "worker: {profile} #{n}", null, null, null); return new ClaudeCodeLauncher(new AgentControl(h), new WorkspaceControl(h), diff --git a/bridged/src/test/java/dev/ltms/bridged/peer/MemberRoleTest.java b/bridged/src/test/java/dev/ltms/bridged/peer/MemberRoleTest.java new file mode 100644 index 0000000..0924580 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/peer/MemberRoleTest.java @@ -0,0 +1,67 @@ +package dev.ltms.bridged.peer; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MemberRoleTest { + + @Test + void theThreeRolesAreArchitectDevAndReviewer() { + assertEquals(3, MemberRole.values().length, + "a new role changes the charter, the role file, the skill and the authz row — " + + "adding one is a deliberate act, so this count is meant to fail first"); + assertEquals("architect", MemberRole.ARCHITECT.wireName()); + assertEquals("dev", MemberRole.DEV.wireName()); + assertEquals("reviewer", MemberRole.REVIEWER.wireName()); + } + + @Test + void parseAcceptsTheWireSpelling() { + for (MemberRole r : MemberRole.values()) { + assertSame(r, MemberRole.parse(r.wireName())); + } + } + + @Test + void parseIsCaseInsensitiveAndTrimsSurroundingSpace() { + assertSame(MemberRole.ARCHITECT, MemberRole.parse("Architect")); + assertSame(MemberRole.DEV, MemberRole.parse(" DEV ")); + assertSame(MemberRole.REVIEWER, MemberRole.parse("ReViEwEr")); + } + + @Test + void parseRejectsAnUnknownRoleAndListsTheValidOnes() { + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("archtiect")); + assertTrue(e.getMessage().contains("archtiect"), e.getMessage()); + assertTrue(e.getMessage().contains("architect, dev, reviewer"), + "a typo in config should be fixable from the message alone: " + e.getMessage()); + } + + @Test + void parseRejectsNullAndBlank() { + assertThrows(IllegalArgumentException.class, () -> MemberRole.parse(null)); + assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("")); + assertThrows(IllegalArgumentException.class, () -> MemberRole.parse(" ")); + } + + /** + * A lead is not a member role. A lead is not spawned — it is a pre-existing session that config + * recognises — so it has no launch charter to pick and never appears in a {@code members:} slot. + */ + @Test + void leadIsNotAMemberRole() { + assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("lead")); + assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("primary")); + } + + /** "worker" is the name this taxonomy replaced; it must not quietly keep working. */ + @Test + void workerIsNoLongerARole() { + assertThrows(IllegalArgumentException.class, () -> MemberRole.parse("worker")); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppAuthTest.java b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppAuthTest.java index c6615ba..c0bf223 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppAuthTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppAuthTest.java @@ -52,7 +52,7 @@ class BridgedAppAuthTest { */ private int start(long pid, boolean tokenMode, String token) { FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker wcfg = new BridgedConfig.Worker( + BridgedConfig.Profile wcfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null, "tab", "bridged-workers", "worker: {profile} #{n}", null, null, null); AgentControl agents = new AgentControl(herdr); @@ -206,7 +206,7 @@ class BridgedAppAuthTest { // The 29 pre-existing acceptance tests rely on this: no auth fixture, no enforcement. FakeHerdr herdr = new FakeHerdr(); AgentControl agents = new AgentControl(herdr); - BridgedConfig.Worker wcfg = new BridgedConfig.Worker( + BridgedConfig.Profile wcfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null, "tab", "bridged-workers", "worker: {profile} #{n}", null, null, null); ClaudeCodeLauncher workers = new ClaudeCodeLauncher( diff --git a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java index e9bd2cb..9ecc9d3 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -62,7 +62,7 @@ class BridgedAppTest { } private int start(FakeHerdr herdr, String workerBaseUrl, Set allow, String placement, Worktrees worktrees) { - BridgedConfig.Worker wcfg = new BridgedConfig.Worker( + BridgedConfig.Profile wcfg = new BridgedConfig.Profile( "ltms-local", workerBaseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null, placement, "bridged-workers", "worker: {profile} #{n}", null, null, null); AgentControl agents = new AgentControl(herdr); diff --git a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java index f921eee..fc1d39f 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java @@ -25,7 +25,7 @@ import static org.junit.jupiter.api.Assertions.*; class SessionManagerTest { private SessionManager sessionManager(FakeHerdr herdr) { - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers", "worker: {profile} #{n}", null, null, null); @@ -44,7 +44,7 @@ class SessionManagerTest { private SessionManager sessionManager(FakeHerdr herdr, LongSupplier clock, int contextCap, boolean clearAfterTurn) { - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers", "worker: {profile} #{n}", null, null, null); @@ -432,7 +432,7 @@ class SessionManagerTest { long[] clock = {0}; // Gate-enabled launcher (1 ms timeout + no-op sleeper that advances clock past deadline) - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers", "worker: {profile} #{n}", null, null, null); diff --git a/bridged/src/test/java/dev/ltms/bridged/session/SessionReaperTest.java b/bridged/src/test/java/dev/ltms/bridged/session/SessionReaperTest.java index aafb465..80cbaf7 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionReaperTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionReaperTest.java @@ -29,7 +29,7 @@ class SessionReaperTest { private static ClaudeCodeLauncher launcher() { FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers", "worker: {profile} #{n}", null, null, null); diff --git a/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java index fd9ac91..ee270b7 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/WorktreeSessionManagerTest.java @@ -23,7 +23,7 @@ import static org.junit.jupiter.api.Assertions.*; class WorktreeSessionManagerTest { private static ClaudeCodeLauncher workerService(FakeHerdr herdr) { - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers", "worker: {profile} #{n}", null, null, null); @@ -245,7 +245,7 @@ class WorktreeSessionManagerTest { FakeHerdr herdr = new FakeHerdr(); FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt"); // Argument order matters: configDir is the 4th parameter, cwd the 11th (after mcpUrl). - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers", "worker: {profile} #{n}", null, "/pinned/dir", null); diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java index 24665c7..6ad943e 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/ClaudeCodeLauncherTest.java @@ -24,7 +24,7 @@ import static org.junit.jupiter.api.Assertions.*; class ClaudeCodeLauncherTest { private ClaudeCodeLauncher service(FakeHerdr herdr, List argv, String mcpUrl) { - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", argv, "tab", "bridged-workers", "worker: {profile} #{n}", mcpUrl, null, null); return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), @@ -59,7 +59,7 @@ class ClaudeCodeLauncherTest { ClaudeCodeLauncher svc = new ClaudeCodeLauncher( new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), - Map.of("ltms-local", new BridgedConfig.Worker( + Map.of("ltms-local", new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null)), "ltms-local", _ -> null, @@ -100,9 +100,9 @@ class ClaudeCodeLauncherTest { } private ClaudeCodeLauncher multiProfile(FakeHerdr herdr) { - BridgedConfig.Worker gx10 = new BridgedConfig.Worker("gx10", "http://gx10.gw:8000", "coder", + BridgedConfig.Profile gx10 = new BridgedConfig.Profile("gx10", "http://gx10.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null); - BridgedConfig.Worker ollama = new BridgedConfig.Worker("ollama", "http://ollama.ltms.dev", null, + BridgedConfig.Profile ollama = new BridgedConfig.Profile("ollama", "http://ollama.ltms.dev", null, null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null); return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx10.gw", "ollama.ltms.dev")), @@ -143,7 +143,7 @@ class ClaudeCodeLauncherTest { @Test void profileConfigCwdBeatsTheCallerCwd() { FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = new BridgedConfig.Worker("ltms-local", "http://gx00.gw:8000", "coder", + BridgedConfig.Profile cfg = new BridgedConfig.Profile("ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers", "w #{n}", null, "/pinned/dir", null); ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), @@ -170,7 +170,7 @@ class ClaudeCodeLauncherTest { @Test void injectsForgeTokenAndHostWhenProfileGrantsIt() { FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "impl", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "impl"), "tab", "bridged-workers", "w #{n}", null, null, null, "GITEA_ACCESS_TOKEN", null); // parityOverlay null; gitHostEnv null → defaults to GITEA_HOST @@ -192,7 +192,7 @@ class ClaudeCodeLauncherTest { FakeHerdr herdr = new FakeHerdr(); // gitTokenEnv unset (12-arg ctor); the env would resolve a token if asked, proving the gate // is the profile config, not a missing env var. - BridgedConfig.Worker cfg = new BridgedConfig.Worker("ltms-local", "http://gx00.gw:8000", "coder", + BridgedConfig.Profile cfg = new BridgedConfig.Profile("ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs"), "tab", "bridged-workers", "w #{n}", null, null, null); new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), @@ -323,7 +323,7 @@ class ClaudeCodeLauncherTest { @Test void capabilitiesIncludeSelfPrWhenProfileHasGitToken() { FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "impl", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "impl"), "tab", "bridged-workers", "w #{n}", null, null, null, "GITEA_ACCESS_TOKEN", null); @@ -476,8 +476,8 @@ class ClaudeCodeLauncherTest { // --- CB-306 spawn-readiness gate ----------------------------------------------------------- - private static Map workerConfigMap(String profile, String mcpUrl) { - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + private static Map workerConfigMap(String profile, String mcpUrl) { + BridgedConfig.Profile cfg = new BridgedConfig.Profile( profile, "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", profile), "tab", "bridged-workers", "worker: {profile} #{n}", mcpUrl, null, null); @@ -579,7 +579,7 @@ class ClaudeCodeLauncherTest { @Test void workerInheritsTheDaemonPath() { FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null); new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), @@ -593,7 +593,7 @@ class ClaudeCodeLauncherTest { @Test void profileEnvIsInjectedIntoTheWorker() { FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null, null, Map.of("JAVA_HOME", "/opt/jdk", "PATH", "/profile/bin"), null, null); @@ -614,7 +614,7 @@ class ClaudeCodeLauncherTest { @Test void profileEnvCannotOverrideGuardCheckedAnthropicVars() { FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null, null, Map.of("ANTHROPIC_BASE_URL", "http://evil.example.com"), null, null); @@ -630,14 +630,14 @@ class ClaudeCodeLauncherTest { /** A launcher for a profile identical but for its {@code model:} — the only variable here. */ private ClaudeCodeLauncher serviceWithModel(FakeHerdr herdr, String model) { - BridgedConfig.Worker cfg = profileWithModel(model); + BridgedConfig.Profile cfg = profileWithModel(model); return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); } - private static BridgedConfig.Worker profileWithModel(String model) { - return new BridgedConfig.Worker("sonnet", "http://gx00.gw:8000", model, null, + private static BridgedConfig.Profile profileWithModel(String model) { + return new BridgedConfig.Profile("sonnet", "http://gx00.gw:8000", model, null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "sonnet"), "tab", "bridged-workers", "w #{n}", "http://127.0.0.1:8765/mcp", null, null); } @@ -679,8 +679,8 @@ class ClaudeCodeLauncherTest { // --- CB-539: subscription-profile opt-in ---------------------------------------------------- /** A claude-code profile on the subscription: no baseUrl (by design), no off-sub endpoint. */ - private static BridgedConfig.Worker subscriptionCfg(String profile, String baseUrl) { - return new BridgedConfig.Worker( + private static BridgedConfig.Profile subscriptionCfg(String profile, String baseUrl) { + return new BridgedConfig.Profile( profile, baseUrl, "sonnet", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", profile), "tab", "bridged-workers", "w #{n}", null, null, null, null, null, null, Map.of(), null, null, true); @@ -691,7 +691,7 @@ class ClaudeCodeLauncherTest { // Requirement 1: absent subscription:true ⇒ byte-identical refusal to today. A claude-code // profile with no baseUrl and no subscription must still be refused (it would bill the sub). FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", null, "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ltms-local"), "tab", "bridged-workers", "w #{n}", null, null, null); ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), @@ -710,7 +710,7 @@ class ClaudeCodeLauncherTest { // ANTHROPIC_BASE_URL nor ANTHROPIC_AUTH_TOKEN is injected even though the token env would // resolve one if asked. FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = subscriptionCfg("sonnet", null); + BridgedConfig.Profile cfg = subscriptionCfg("sonnet", null); new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> "would-be-token").spawn(); @@ -727,7 +727,7 @@ class ClaudeCodeLauncherTest { // Requirement 2: subscription:true + a baseUrl state opposite intents — refuse at spawn, // naming the profile, rather than silently picking a winner. FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = subscriptionCfg("sonnet", "http://gx00.gw:8000"); + BridgedConfig.Profile cfg = subscriptionCfg("sonnet", "http://gx00.gw:8000"); ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); @@ -744,7 +744,7 @@ class ClaudeCodeLauncherTest { // Requirement 3: the guard keeps its teeth for every other profile — a base_url whose host is // not on the allowlist is still refused, whether or not any subscription profile exists. FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker rogue = new BridgedConfig.Worker( + BridgedConfig.Profile rogue = new BridgedConfig.Profile( "rogue", "http://evil.example.com:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "rogue"), "tab", "bridged-workers", "w #{n}", null, null, null); ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), @@ -763,7 +763,7 @@ class ClaudeCodeLauncherTest { // load refuses this loudly; this launcher-side strip is the belt-and-braces that makes the // invariant hold for a profile built in code that never passed through that validation. FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = new BridgedConfig.Worker( + BridgedConfig.Profile cfg = new BridgedConfig.Profile( "sonnet", null, "sonnet", null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "sonnet"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null, null, diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java index 0f74094..dbc6ffc 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/CompositePeerLauncherTest.java @@ -40,7 +40,7 @@ class CompositePeerLauncherTest { private ClaudeCodeLauncher claudeAdapter(FakeHerdr herdr) { // 12-arg back-compat Worker ctor → kind defaults to claude-code. - BridgedConfig.Worker claude = new BridgedConfig.Worker("claude", "http://gx00.gw:8000", "coder", + BridgedConfig.Profile claude = new BridgedConfig.Profile("claude", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null); return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), @@ -48,9 +48,9 @@ class CompositePeerLauncherTest { } private OpenCodeLauncher opencodeAdapter(FakeHerdr herdr) { - BridgedConfig.Worker gemini = new BridgedConfig.Worker("gemini", null, "google/gemini-2.5-pro", + BridgedConfig.Profile gemini = new BridgedConfig.Profile("gemini", null, "google/gemini-2.5-pro", null, "BRIDGED_WORKER_TOKEN", List.of("opencode"), "tab", "bridged-workers", "w #{n}", - null, null, null, "GITEA_ACCESS_TOKEN", null, BridgedConfig.Worker.KIND_OPENCODE); + null, null, null, "GITEA_ACCESS_TOKEN", null, BridgedConfig.Profile.KIND_OPENCODE); return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), Map.of("gemini", gemini), "gemini", _ -> "tok"); } @@ -70,7 +70,7 @@ class CompositePeerLauncherTest { private final Map spawnCounts = new HashMap<>(); StubLauncher(String prefix, FakeHerdr herdr, - Map profiles, String defaultProfile, + Map profiles, String defaultProfile, Set failProfiles) { super(prefix, new AgentControl(herdr), new WorkspaceControl(herdr), profiles, defaultProfile, _ -> null, 0L, System::currentTimeMillis, () -> { }); @@ -78,7 +78,7 @@ class CompositePeerLauncherTest { } @Override - protected Launch buildLaunch(BridgedConfig.Worker cfg) { + protected Launch buildLaunch(BridgedConfig.Profile cfg) { return new Launch(Map.of(), List.of()); } @@ -114,14 +114,14 @@ class CompositePeerLauncherTest { } } - private static BridgedConfig.Worker stubWorker(String profile) { - return new BridgedConfig.Worker(profile, "http://gx00.gw:8000", "coder", + private static BridgedConfig.Profile stubWorker(String profile) { + return new BridgedConfig.Profile(profile, "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null, null, null, null, null); } - private static BridgedConfig.Worker stubWorker(String profile, float weight, Integer maxLoad) { - return new BridgedConfig.Worker(profile, "http://gx00.gw:8000", "coder", + private static BridgedConfig.Profile stubWorker(String profile, float weight, Integer maxLoad) { + return new BridgedConfig.Profile(profile, "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null, null, null, weight, maxLoad); @@ -133,9 +133,9 @@ class CompositePeerLauncherTest { * so a {@code Map.of} would make "which profile is tried first" a coin flip per run and any * assertion about the first attempt intermittently false. */ - private static Map ordered(String first, BridgedConfig.Worker a, - String second, BridgedConfig.Worker b) { - Map m = new LinkedHashMap<>(); + private static Map ordered(String first, BridgedConfig.Profile a, + String second, BridgedConfig.Profile b) { + Map m = new LinkedHashMap<>(); m.put(first, a); m.put(second, b); return m; @@ -292,7 +292,7 @@ class CompositePeerLauncherTest { @Test void weightedPolicyGatesProfileAtMaxLoad() { FakeHerdr herdr = new FakeHerdr(); - Map profiles = ordered( + Map profiles = ordered( "a", stubWorker("a", 1.0f, 1), "b", stubWorker("b", 1.0f, null)); StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of()); @@ -308,7 +308,7 @@ class CompositePeerLauncherTest { @Test void weightedPolicyDistributesAccordingToWeightRatio() { FakeHerdr herdr = new FakeHerdr(); - Map profiles = ordered( + Map profiles = ordered( "a", stubWorker("a", 0.75f, null), "b", stubWorker("b", 0.25f, null)); StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of()); @@ -328,7 +328,7 @@ class CompositePeerLauncherTest { @Test void failoverRetriesNextCandidateWhenProfileIsUnreachable() { FakeHerdr herdr = new FakeHerdr(); - Map profiles = ordered( + Map profiles = ordered( "a", stubWorker("a"), "b", stubWorker("b")); StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("a")); @@ -349,7 +349,7 @@ class CompositePeerLauncherTest { @Test void reversingDefinitionOrderReversesWhichProfileIsTriedFirst() { FakeHerdr herdr = new FakeHerdr(); - Map profiles = ordered( + Map profiles = ordered( "b", stubWorker("b"), "a", stubWorker("a")); StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("b")); @@ -364,7 +364,7 @@ class CompositePeerLauncherTest { @Test void failoverBoundedByCandidateCount() { FakeHerdr herdr = new FakeHerdr(); - Map profiles = ordered( + Map profiles = ordered( "a", stubWorker("a"), "b", stubWorker("b")); StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("a", "b")); @@ -381,7 +381,7 @@ class CompositePeerLauncherTest { @Test void explicitSpawnAtMaxLoadThrowsPlacementExceptionNamingProfileLiveAndCap() { FakeHerdr herdr = new FakeHerdr(); - Map profiles = ordered( + Map profiles = ordered( "a", stubWorker("a", 1.0f, 2), "b", stubWorker("b")); StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of()); @@ -399,7 +399,7 @@ class CompositePeerLauncherTest { @Test void explicitSpawnUnderMaxLoadStillSucceeds() { FakeHerdr herdr = new FakeHerdr(); - Map profiles = ordered( + Map profiles = ordered( "a", stubWorker("a", 1.0f, 2), "b", stubWorker("b")); StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of()); @@ -414,7 +414,7 @@ class CompositePeerLauncherTest { @Test void explicitSpawnWithNullMaxLoadIsNeverCapped() { FakeHerdr herdr = new FakeHerdr(); - Map profiles = ordered( + Map profiles = ordered( "a", stubWorker("a", 1.0f, null), "b", stubWorker("b")); StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of()); @@ -429,7 +429,7 @@ class CompositePeerLauncherTest { @Test void emptyCandidateSetThrowsClearException() { FakeHerdr herdr = new FakeHerdr(); - Map profiles = ordered( + Map profiles = ordered( "a", stubWorker("a", 1.0f, 1), "b", stubWorker("b", 1.0f, 1)); StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of()); diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java index bf42fd1..fcf7ec5 100644 --- a/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/worker/OpenCodeLauncherTest.java @@ -27,14 +27,14 @@ import static org.junit.jupiter.api.Assertions.*; */ class OpenCodeLauncherTest { - private static BridgedConfig.Worker opencodeCfg(String model, String mcpUrl, String gitTokenEnv) { - return new BridgedConfig.Worker("gemini", null, model, null, "BRIDGED_WORKER_TOKEN", + private static BridgedConfig.Profile opencodeCfg(String model, String mcpUrl, String gitTokenEnv) { + return new BridgedConfig.Profile("gemini", null, model, null, "BRIDGED_WORKER_TOKEN", List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl, - null, null, gitTokenEnv, null, BridgedConfig.Worker.KIND_OPENCODE); + null, null, gitTokenEnv, null, BridgedConfig.Profile.KIND_OPENCODE); } /** Gate-disabled launcher whose per-spawn config dirs land under an inspectable temp root. */ - private OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Worker cfg) { + private OpenCodeLauncher service(FakeHerdr herdr, Path configRoot, BridgedConfig.Profile cfg) { return new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), Map.of(cfg.profile(), cfg), cfg.profile(), k -> "GITEA_ACCESS_TOKEN".equals(k) ? "tok" : null, 0, System::currentTimeMillis, () -> { }, configRoot, configRoot); @@ -205,7 +205,7 @@ class OpenCodeLauncherTest { @Test void productionConstructorsWireThroughToTheBase() { FakeHerdr herdr = new FakeHerdr(); - BridgedConfig.Worker cfg = opencodeCfg(null, null, null); + BridgedConfig.Profile cfg = opencodeCfg(null, null, null); // 5-arg (gate disabled) and 7-arg (gate enabled) production constructors both expose the profile. OpenCodeLauncher disabled = new OpenCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); @@ -247,10 +247,10 @@ class OpenCodeLauncherTest { // --- CB-508: pinned OpenAI-compatible endpoint (e.g. a local vLLM) --------------------------- /** A profile with a baseUrl but no model provider prefix cannot be resolved — fail loudly. */ - private static BridgedConfig.Worker pinnedCfg(String model, String baseUrl, String mcpUrl) { - return new BridgedConfig.Worker("local", baseUrl, model, null, "BRIDGED_WORKER_TOKEN", + private static BridgedConfig.Profile pinnedCfg(String model, String baseUrl, String mcpUrl) { + return new BridgedConfig.Profile("local", baseUrl, model, null, "BRIDGED_WORKER_TOKEN", List.of("opencode"), "tab", "bridged-workers", "opencode: {model} #{n}", mcpUrl, - null, null, null, null, BridgedConfig.Worker.KIND_OPENCODE); + null, null, null, null, BridgedConfig.Profile.KIND_OPENCODE); } @Test