Merge CB-557: member taxonomy in config
CI / contract (push) Successful in 45s
CI / build (push) Successful in 1m32s

This commit is contained in:
Dai Ha
2026-08-14 07:02:22 +02:00
24 changed files with 783 additions and 354 deletions
+25 -2
View File
@@ -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.
@@ -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<String, BridgedConfig.Worker> claudeProfiles = new LinkedHashMap<>();
Map<String, BridgedConfig.Worker> opencodeProfiles = new LinkedHashMap<>();
cfg.workerProfiles().forEach((name, w) -> {
Map<String, BridgedConfig.Profile> claudeProfiles = new LinkedHashMap<>();
Map<String, BridgedConfig.Profile> 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<Function<String, Integer>> 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<Map<String, String>> leads;
if (cfg.leadScan() != null) {
var scan = cfg.leadScan();
Set<String> workerSpaces = cfg.workerProfiles().values().stream()
.map(BridgedConfig.Worker::workspace)
Set<String> 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)");
}
@@ -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<Map<String, String>> leadTerminals,
Supplier<Map<String, String>> architectTerminals) {
@@ -183,7 +183,7 @@ public final class CallerResolver {
* here but would not <em>resolve</em> (or the reverse) cannot drift apart. Live for the same
* reason as {@link #leads()}.
*/
public Map<String, String> architects() {
public Map<String, String> members() {
return architectTerminals.get();
}
@@ -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<String, BridgedConfig.Architect> slots;
private final Map<String, BridgedConfig.Member> slots;
/** Live {@code terminal_id → slot name}; guarded by {@code this}. */
private final Map<String, String> terminalToSlot = new HashMap<>();
public ArchitectRegistry(Map<String, BridgedConfig.Architect> slots) {
public MemberRegistry(Map<String, BridgedConfig.Member> slots) {
this.slots = slots == null ? Map.of() : Map.copyOf(slots);
}
/** The configured slots, keyed by gateway-local unique name. Unmodifiable snapshot. */
public Map<String, BridgedConfig.Architect> slots() {
public Map<String, BridgedConfig.Member> 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();
}
@@ -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 <em>which backend</em> — 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 <em>not</em> 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 <em>not</em> 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<String, Worker> workers,
String defaultWorker,
Map<String, Profile> profiles,
String defaultProfile,
Guard guard,
String worktreeRoot,
Lifecycle lifecycle,
@@ -75,12 +81,36 @@ public record BridgedConfig(
Broker broker,
Primary primary,
Map<String, Leader> leaders,
Map<String, Architect> architects,
Map<String, Member> members,
LeadScan leadScan,
LeadHeartbeat leadHeartbeat,
String placement,
Auth auth) {
/**
* Normalize {@code profiles} once, at construction, so every reader sees the same map.
*
* <p>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.
*
* <p>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<String, Profile> 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<String> 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<String> argv,
String placement, String workspace, String tabLabel, String mcpUrl,
String cwd, List<String> 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<String> argv,
String placement, String workspace, String tabLabel, String mcpUrl,
String cwd, List<String> 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<String> argv,
String placement, String workspace, String tabLabel, String mcpUrl,
String cwd, List<String> 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<String> argv,
String placement, String workspace, String tabLabel, String mcpUrl,
String cwd, List<String> 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.
*
* <p>A slot is <em>declared</em>, 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.
* <p><b>Role and profile are separate axes.</b> The {@code role} answers <em>which contract</em>
* — it picks the launch charter, the role file, the playbook skill and the authz row. The
* {@code profile} answers <em>which backend</em> — 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.
*
* <p>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).
* <p>Only members that need a <em>stable identity</em> 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()}
* <p>A slot is <em>declared</em>, 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 <em>not</em> an on-host worker
* pane proves it is the primary.
*
* <p>Worker identity never depends on this block: a loopback peer PID that maps to a herdr
* <p>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
* <em>everyone else</em>.
@@ -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}.
*
* <p>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<String, Worker> workerProfiles() {
if (workers != null && !workers.isEmpty()) {
Map<String, Worker> 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<String, Worker> 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<String> 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).
*
* <p>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
* <em>top-level</em> {@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
* <em>top-level</em> {@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 <em>direct child</em> key of the (already-positioned) {@code architects:}
* Reject a duplicated <em>direct child</em> key of the (already-positioned) {@code members:}
* mapping — i.e. a duplicated {@code slot name}.
*
* <p>Each slot's value is consumed whole by {@link #skipValue}, so a duplicated field <em>inside</em>
* 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<String> 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.
*
* <p>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 <em>zero</em> 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.
*
* <p>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<String, String> 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<String> 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<String> 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<String> clashing = workerProfiles().entrySet().stream()
List<String> 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<String> bad = new java.util.ArrayList<>();
workerProfiles().forEach((name, w) -> {
profiles().forEach((name, w) -> {
if (w.isSubscription() && w.envCarriesAnthropicBinding()) {
List<String> 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.
*
* <p>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 <em>does</em> 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.
* <p>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.
*
* <p>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.
*
* <p>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<String, Worker> profiles = workerProfiles();
List<String> 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));
@@ -0,0 +1,88 @@
package dev.ltms.bridged.peer;
import java.util.Locale;
/**
* What a member is <em>for</em> — the contract it runs under.
*
* <p>A member is anything a lead spawns. Every member carries two independent attributes:
*
* <ul>
* <li><b>role</b> (this enum) — <em>which contract</em>: the launch charter it is given, the role
* file it reads, the playbook skill it loads, and its authorization row.</li>
* <li><b>profile</b> (a {@code profiles:} key) — <em>which backend</em>: model, CLI adapter,
* credentials, cost.</li>
* </ul>
*
* <p>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.
*
* <p>A lead is deliberately <em>not</em> 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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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);
}
}
@@ -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<String, BridgedConfig.Worker> profiles, String defaultProfile,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> 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<String, BridgedConfig.Worker> profiles, String defaultProfile,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> 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<String, BridgedConfig.Worker> profiles, String defaultProfile,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> 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<String> argvWithBridge(BridgedConfig.Worker cfg) {
private List<String> 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<String> argvWithModel(List<String> argv, BridgedConfig.Worker cfg) {
private static List<String> argvWithModel(List<String> 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 -------------------
@@ -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<String, HerdrPeerLauncher> spawnedBy = new ConcurrentHashMap<>();
private final Map<String, BridgedConfig.Worker> profileConfigs;
private final Map<String, BridgedConfig.Profile> profileConfigs;
private final PlacementPolicy placementPolicy;
private final Function<String, Integer> liveCount;
@@ -93,7 +93,7 @@ public final class CompositePeerLauncher implements PeerLauncher {
*/
public CompositePeerLauncher(List<HerdrPeerLauncher> delegates,
String defaultProfile,
Map<String, BridgedConfig.Worker> profileConfigs,
Map<String, BridgedConfig.Profile> profileConfigs,
PlacementPolicy placementPolicy,
Function<String, Integer> 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.
*
* <p>maxLoad is a documented, unconditional capacity limit (see {@code BridgedConfig.Worker#maxLoad}),
* <p>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<PlacementCandidate> candidates() {
List<PlacementCandidate> out = new ArrayList<>();
for (Map.Entry<String, BridgedConfig.Worker> e : profileConfigs.entrySet()) {
BridgedConfig.Worker w = e.getValue();
for (Map.Entry<String, BridgedConfig.Profile> e : profileConfigs.entrySet()) {
BridgedConfig.Profile w = e.getValue();
out.add(new PlacementCandidate(e.getKey(), null, w.weight(), w.maxLoad()));
}
return out;
@@ -42,7 +42,7 @@ import java.util.regex.Pattern;
* <li>{@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.</li>
* <li>{@link #buildLaunch(BridgedConfig.Worker)} — the peer-specific env map + argv, including any
* <li>{@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}.</li>
* </ul>
@@ -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<String, BridgedConfig.Worker> profiles; // profile name → spawn settings
private final Map<String, BridgedConfig.Profile> 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<String, BridgedConfig.Worker> profiles, String defaultProfile,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> 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<BridgedConfig.Worker> profileConfigs() {
protected Collection<BridgedConfig.Profile> 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<String, String> workerEnv,
private Agent spawnInTab(BridgedConfig.Profile cfg, Map<String, String> workerEnv,
List<String> 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<String, String> workerEnv,
private Agent spawnAsPane(BridgedConfig.Profile cfg, Map<String, String> workerEnv,
List<String> 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<String> argv, String paneId) {
private Started startUniquelyNamed(BridgedConfig.Profile cfg, List<String> 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<String> 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<String, String> workerEnv, BridgedConfig.Worker cfg) {
protected void applyGitToken(Map<String, String> 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<String, String> baseEnv(BridgedConfig.Worker cfg) {
protected Map<String, String> baseEnv(BridgedConfig.Profile cfg) {
Map<String, String> workerEnv = new LinkedHashMap<>();
String path = env.apply("PATH");
if (path != null && !path.isBlank()) {
@@ -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<String, BridgedConfig.Worker> profiles, String defaultProfile,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> 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<String, BridgedConfig.Worker> profiles, String defaultProfile,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> 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<String, BridgedConfig.Worker> profiles, String defaultProfile,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> 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<String, String> 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<String> argvWithAuto(BridgedConfig.Worker cfg) {
private List<String> argvWithAuto(BridgedConfig.Profile cfg) {
List<String> 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<String> argvWithModel(List<String> argv, BridgedConfig.Worker cfg) {
private List<String> argvWithModel(List<String> 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 {
* <p>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 -----------------
@@ -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<String, String> 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<String, String> 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"));
@@ -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<String, BridgedConfig.Architect> SLOTS = Map.of(
"lead-designer", new BridgedConfig.Architect("sonnet"),
"reviewer", new BridgedConfig.Architect("gx10"));
private static final Map<String, BridgedConfig.Member> 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<String, BridgedConfig.Architect> mutable = new HashMap<>(SLOTS);
ArchitectRegistry r = new ArchitectRegistry(mutable);
Map<String, BridgedConfig.Member> 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");
}
@@ -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.
*
* <p>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");
}
}
@@ -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),
@@ -52,7 +52,7 @@ class BridgeMcpTest {
}
private static ClaudeCodeLauncher workerService(FakeHerdr h, String baseUrl, Set<String> 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),
@@ -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"));
}
}
@@ -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(
@@ -62,7 +62,7 @@ class BridgedAppTest {
}
private int start(FakeHerdr herdr, String workerBaseUrl, Set<String> 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);
@@ -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);
@@ -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);
@@ -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);
@@ -24,7 +24,7 @@ import static org.junit.jupiter.api.Assertions.*;
class ClaudeCodeLauncherTest {
private ClaudeCodeLauncher service(FakeHerdr herdr, List<String> 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<String, BridgedConfig.Worker> workerConfigMap(String profile, String mcpUrl) {
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
private static Map<String, BridgedConfig.Profile> 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,
@@ -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<String, Integer> spawnCounts = new HashMap<>();
StubLauncher(String prefix, FakeHerdr herdr,
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Set<String> 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<String, BridgedConfig.Worker> ordered(String first, BridgedConfig.Worker a,
String second, BridgedConfig.Worker b) {
Map<String, BridgedConfig.Worker> m = new LinkedHashMap<>();
private static Map<String, BridgedConfig.Profile> ordered(String first, BridgedConfig.Profile a,
String second, BridgedConfig.Profile b) {
Map<String, BridgedConfig.Profile> 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<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> 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<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> 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<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> 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<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> 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<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> 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<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> 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<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> 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<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> 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<String, BridgedConfig.Worker> profiles = ordered(
Map<String, BridgedConfig.Profile> profiles = ordered(
"a", stubWorker("a", 1.0f, 1),
"b", stubWorker("b", 1.0f, 1));
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
@@ -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