CB-557: fleet role pools — role is the config key, and the tab label says it

Four top-level keys (leaders:, members:, leadScan:, defaultProfile:) become one
`fleet:` block, and a member's role becomes the map key that contains it rather
than a `role:` field inside it.

Why the key and not a field: a misspelled `role: architct` used to produce a
member with no contract, which nothing rejected. A misspelled pool name declares
nothing, which is a shape the loader can see.

`fleet.architects/developers/reviewers` are pools of profiles a role MAY run on.
That replaces the single global `defaultProfile:`, so an unqualified spawn now
resolves its profile from the pool of the role it asked for. Role and profile
stay orthogonal: a reviewer may run on the same profile as the dev it reviews,
and one profile may appear in several pools.

Tab labels are role-first — `dev: sonnet #4`. The template lives on `fleet:`
because a profile cannot know the role of the member launched on it; a profile
may still override it. The `{n}` counter is scoped per role+profile, so a dev
and a reviewer on one profile each start at #1. Making {role} the first field
also turns the lead/member namespace check into a structural guarantee: roles
are a closed enum, so only hand-written templates can still collide with a lead
tabPrefix.

Removed keys are hard errors that name their successor. `defaultProfile:` has no
single successor key, so its message explains the new model instead of pointing
at a key that does not exist.

Map order is kept with LinkedHashMap, deliberately not Map.copyOf — the latter
salts iteration order per JVM run, which would destroy the YAML definition order
that `placement: fixed` selects on.

Not yet wired: SessionManager still hands the launchers one effectiveDefault-
Profile, so pools are not enforced at spawn time yet, and placement still ranges
over all profiles.

595 tests pass.
This commit is contained in:
Dai Ha
2026-08-14 16:32:54 +02:00
parent 3aa145cbef
commit 4b48d2d921
13 changed files with 1243 additions and 495 deletions
@@ -88,7 +88,7 @@ public final class Bridged {
// refuses remote connections to a loopback socket. This throws rather than warns so the
// dangerous configuration cannot be reached by ignoring a log line.
cfg.validateAuthExposure();
cfg.validateLeadScan();
cfg.validateLeadTabPrefixes();
// CB-542: a subscription:true profile whose env: reseats ANTHROPIC_BASE_URL/AUTH_TOKEN would
// reach an unguarded endpoint (the launcher skips SubscriptionGuard for it). Refuse at load.
cfg.validateSubscriptionProfiles();
@@ -123,12 +123,14 @@ public final class Bridged {
if (!claudeProfiles.isEmpty() || opencodeProfiles.isEmpty()) {
adapters.add(new ClaudeCodeLauncher(agents, spaces, guard,
claudeProfiles, cfg.effectiveDefaultProfile(), System::getenv,
cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs()));
cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs(),
cfg.fleet().tabLabel()));
}
if (!opencodeProfiles.isEmpty()) {
adapters.add(new OpenCodeLauncher(agents, spaces,
opencodeProfiles, cfg.effectiveDefaultProfile(), System::getenv,
cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs()));
cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs(),
cfg.fleet().tabLabel()));
}
AtomicReference<Function<String, Integer>> liveCountRef = new AtomicReference<>(_ -> 0);
PeerLauncher workers = new CompositePeerLauncher(
@@ -186,20 +188,33 @@ public final class Bridged {
log.info("leads: {} panes recognised {}", leadTerminals.size(), leadTerminals.values());
}
// CB-531: on top of the static registry, discover leads by the tab labels the operator
// writes. Opt-in, so a config with no `leadScan:` block resolves exactly as it did under
// CB-530 — the supplier is then a constant and never touches herdr.
// writes. CB-557 moved the settings onto the lead they describe, so scanning is on whenever
// a `fleet.leaders:` entry exists — with no leads configured the supplier is a constant and
// never touches herdr, exactly as a missing `leadScan:` block used to behave.
final Supplier<Map<String, String>> leads;
if (cfg.leadScan() != null) {
var scan = cfg.leadScan();
Set<String> workerSpaces = cfg.profiles().values().stream()
var leaders = cfg.fleet().leaders();
if (!leaders.isEmpty()) {
// One scanner, so one prefix and one interval. Distinct per-lead prefixes would need a
// scanner each; until a config actually wants that, take the first entry's settings and
// say so, rather than silently honouring one lead's prefix and dropping another's.
var scan = leaders.values().iterator().next();
Set<String> memberSpaces = cfg.profiles().values().stream()
.map(BridgedConfig.Profile::workspace)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
leads = new LeadTabScanner(herdr, scan.tabPrefix(), workerSpaces, leadTerminals,
TimeUnit.SECONDS.toNanos(scan.intervalSeconds()), System::nanoTime);
log.info("lead scan: tabs labelled '{}…' host a lead (rescan every {}s, worker spaces {} "
leads = new LeadTabScanner(herdr, scan.tabPrefix(), memberSpaces, leadTerminals,
TimeUnit.SECONDS.toNanos(scan.scanIntervalSeconds()), System::nanoTime);
log.info("lead scan: tabs labelled '{}…' host a lead (rescan every {}s, member spaces {} "
+ "excluded)",
scan.tabPrefix(), scan.intervalSeconds(), workerSpaces);
scan.tabPrefix(), scan.scanIntervalSeconds(), memberSpaces);
long distinctPrefixes = leaders.values().stream()
.map(BridgedConfig.Leader::tabPrefix).distinct().count();
if (distinctPrefixes > 1) {
log.warn("fleet.leaders declares {} different tabPrefix values; only '{}' is scanned "
+ "for. Give every lead the same tabPrefix, or leads under the others "
+ "will not be discovered.",
distinctPrefixes, scan.tabPrefix());
}
} else {
leads = () -> leadTerminals;
}
@@ -209,10 +224,9 @@ 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.
MemberRegistry members = new MemberRegistry(
cfg.members() == null ? Map.of() : cfg.members());
MemberRegistry members = new MemberRegistry(cfg.fleet());
if (!members.slots().isEmpty()) {
log.info("architect slots: {} configured {} — none bound yet (a slot is idle until the "
log.info("member slots: {} configured {} — none bound yet (a slot is idle until the "
+ "spawn lifecycle binds a live terminal to it)",
members.slots().size(), members.slots().keySet());
}
@@ -1,8 +1,11 @@
package dev.ltms.bridged.auth;
import dev.ltms.bridged.config.BridgedConfig;
import dev.ltms.bridged.peer.MemberRole;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
@@ -28,19 +31,60 @@ import java.util.Map;
*/
public final class MemberRegistry {
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 MemberRegistry(Map<String, BridgedConfig.Member> slots) {
this.slots = slots == null ? Map.of() : Map.copyOf(slots);
/**
* One flattened {@code fleet:} entry.
*
* <p>Flattened because a slot name is unique only <em>within</em> its pool — {@code sonnet} may
* legitimately be both a developer and a reviewer — while a terminal binds to exactly one thing.
* The qualified {@link #key()} is what that binding uses.
*
* @param name the slot's key inside its pool
* @param role the pool it came from
* @param profile the backend it runs on
*/
public record Entry(String name, MemberRole role, String profile) {
/** {@code "architect:opus"} — unique across pools, unlike {@link #name()}. */
public String key() {
return role.wireName() + ":" + name;
}
}
/** The configured slots, keyed by gateway-local unique name. Unmodifiable snapshot. */
public Map<String, BridgedConfig.Member> slots() {
private final Map<String, Entry> slots;
/** Live {@code terminal_id → qualified slot key}; guarded by {@code this}. */
private final Map<String, String> terminalToSlot = new HashMap<>();
/** Flatten every role pool in {@code fleet} into one registry. Leaders are not members. */
public MemberRegistry(BridgedConfig.Fleet fleet) {
Map<String, Entry> flat = new LinkedHashMap<>();
if (fleet != null) {
for (MemberRole role : MemberRole.values()) {
fleet.pool(role).forEach((name, slot) -> {
if (slot != null) {
Entry e = new Entry(name, role, slot.profile());
flat.put(e.key(), e);
}
});
}
}
this.slots = Collections.unmodifiableMap(flat);
}
/** The configured slots, keyed by qualified {@link Entry#key()}. Unmodifiable snapshot. */
public Map<String, Entry> slots() {
return slots;
}
/** The slots belonging to {@code role}, in definition order. */
public Map<String, Entry> slotsFor(MemberRole role) {
Map<String, Entry> out = new LinkedHashMap<>();
slots.forEach((key, e) -> {
if (e.role() == role) {
out.put(key, e);
}
});
return Collections.unmodifiableMap(out);
}
/**
* An immutable copy of the live {@code terminal_id → slot name} bindings.
*
@@ -71,8 +115,14 @@ public final class MemberRegistry {
* declares none
*/
public String profileForSlot(String slotName) {
BridgedConfig.Member a = slots.get(slotName);
return (a == null || a.profile() == null) ? null : a.profile();
Entry e = slots.get(slotName);
return (e == null || e.profile() == null) ? null : e.profile();
}
/** The role a qualified slot key belongs to, or {@code null} when the key is unknown. */
public MemberRole roleForSlot(String slotName) {
Entry e = slots.get(slotName);
return e == null ? null : e.role();
}
/** True when {@code slotName} is a configured architect slot. */
@@ -13,6 +13,8 @@ import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -33,9 +35,6 @@ import java.util.Set;
* 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
@@ -48,18 +47,11 @@ import java.util.Set;
* @param primary optional pinned primary terminal config ({@code null} → derived from connection);
* a non-blank {@code terminal} seeds {@code PrimaryRegistry} and prevents
* connection-derived overrides, CB-307
* @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 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 fleet who the daemon may run and under which role (CB-557). One block replacing
* the former {@code leaders:}, {@code members:}, {@code leadScan:} and
* {@code defaultProfile:}. Role is the containing key — {@code leaders},
* {@code architects}, {@code developers}, {@code reviewers} — and each entry
* names the {@code profiles:} backend it runs on. See {@link Fleet}
* @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 profile for an unqualified spawn:
@@ -72,7 +64,6 @@ public record BridgedConfig(
Bind bind,
String herdrSocket,
Map<String, Profile> profiles,
String defaultProfile,
Guard guard,
String worktreeRoot,
Lifecycle lifecycle,
@@ -80,9 +71,7 @@ public record BridgedConfig(
Integer spawnReadyPollMs,
Broker broker,
Primary primary,
Map<String, Leader> leaders,
Map<String, Member> members,
LeadScan leadScan,
Fleet fleet,
LeadHeartbeat leadHeartbeat,
String placement,
Auth auth) {
@@ -203,7 +192,10 @@ public record BridgedConfig(
tokenEnv = (tokenEnv == null || tokenEnv.isBlank()) ? "BRIDGED_WORKER_TOKEN" : tokenEnv;
placement = (placement == null || placement.isBlank()) ? "tab" : placement.toLowerCase();
workspace = (workspace == null || workspace.isBlank()) ? "bridged-workers" : workspace;
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? "worker: {profile} #{n}" : tabLabel;
// CB-557: no per-profile default any more. A label is generated from the member's ROLE
// ("dev: sonnet #2"), which a profile cannot know, so the template lives on `fleet:` and
// this field is only an override for a profile that wants its own. Blank ⇒ defer.
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? null : tabLabel;
// CB-525: .mcp.json is deliberately NOT here. Replicating the primary's MCP config gave a
// worker the primary's IDE servers, which are bound to the primary's checkout — so its
// navigation returned paths outside its own worktree. GitWorktrees now neutralizes that
@@ -327,11 +319,24 @@ public record BridgedConfig(
}
/**
* Render {@link #tabLabel} for the {@code n}-th worker (substitutes
* {@code {profile}}/{@code {model}}/{@code {n}}), so sibling worker tabs are distinct.
* Render this member's tab label (CB-557): {@code {role}}, {@code {profile}},
* {@code {model}} and {@code {n}} are substituted.
*
* <p>Template precedence is this profile's own {@link #tabLabel} override, then
* {@code fleetTemplate}, then {@link Fleet#DEFAULT_TAB_LABEL}. Role leads the default
* template so the tab bar reads as the fleet, and {@code n} counts per role+profile — a
* global counter left gaps in the numbering, which reads as though a sibling had died.
*
* @param fleetTemplate the {@code fleet.tabLabel} template; {@code null}/blank ⇒ the default
* @param role the member's role; {@code null} renders {@code {role}} as empty
* @param n this member's number within its role+profile pair
*/
public String renderTabLabel(long n) {
return tabLabel
public String renderTabLabel(String fleetTemplate, MemberRole role, long n) {
String template = (tabLabel != null && !tabLabel.isBlank()) ? tabLabel
: (fleetTemplate != null && !fleetTemplate.isBlank()) ? fleetTemplate
: Fleet.DEFAULT_TAB_LABEL;
return template
.replace("{role}", role == null ? "" : role.wireName())
.replace("{profile}", profile == null ? "" : profile)
.replace("{model}", model == null ? "" : model)
.replace("{n}", Long.toString(n));
@@ -409,74 +414,155 @@ public record BridgedConfig(
* lead drives a fleet, and wrong the moment two leads (say an Opus lead and an opencode lead)
* work as peers — the second is silently demoted and refused every orchestration call.
*
* <p>{@code kind} and {@code model} are descriptive only at this stage: they document what runs
* in the pane and are reported back by {@code bridge_whoami}. Nothing spawns a lead — a lead
* pre-exists, which is precisely why it must be recognised by configuration rather than created.
* <p>{@code kind} and {@code model} are descriptive only: they document what runs in the pane
* and are reported back by {@code bridge_whoami}.
*
* @param terminal the lead's herdr {@code terminal_id}; the only field identity depends on
* @param kind which agent runs there ({@code claude}, {@code opencode}, …); descriptive
* @param model the model or selector it runs, for operators reading the roster; descriptive
* <p><b>A lead is now also creatable (CB-557).</b> Before, nothing spawned one — a lead
* pre-existed, which is why it had to be recognised by configuration rather than created. With
* {@code profile} and {@code instances} the daemon may stand one up when none is live, so the
* pane no longer has to exist before the daemon does. Recognition still comes first: a lead
* already running under {@code tabPrefix} is adopted, and only the shortfall is launched.
*
* @param profile the {@code profiles:} entry to launch this lead on when one must
* be created; {@code null} ⇒ recognise-only, never create
* @param terminal the lead's herdr {@code terminal_id} when pinned by hand; the only
* field identity depends on. {@code null} ⇒ found by {@code tabPrefix}
* @param instances how many of this lead should be live (default 1). The daemon
* launches only the shortfall, so a restart adopts rather than doubles
* @param tabPrefix label prefix marking this lead's tab, matched case-insensitively;
* the remainder is the lead's name ({@code "lead: opus"} →
* {@code opus}). Default {@code "lead:"}
* @param scanIntervalSeconds how long a tab scan is cached before herdr is asked again; also the
* worst case before a newly-labelled tab is recognised. Default 10
* @param kind which agent runs there ({@code claude}, {@code opencode}, …)
* @param model the model or selector it runs, for operators reading the roster
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Leader(String terminal, String kind, String model) {
public record Leader(String profile, String terminal, Integer instances, String tabPrefix,
Integer scanIntervalSeconds, String kind, String model) {
public Leader {
instances = (instances == null || instances < 0) ? 1 : instances;
tabPrefix = (tabPrefix == null || tabPrefix.isBlank()) ? "lead:" : tabPrefix.strip();
scanIntervalSeconds =
(scanIntervalSeconds == null || scanIntervalSeconds <= 0) ? 10 : scanIntervalSeconds;
}
/** True when this lead may be launched by the daemon rather than only recognised. */
public boolean isCreatable() {
return profile != null && !profile.isBlank() && instances > 0;
}
}
/**
* One entry of the {@code members:} registry — a gateway-local named slot that pairs a role
* with the profile it runs on.
* One entry of a {@code fleet:} role pool — a role paired with the backend it runs on.
*
* <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
* <p><b>Role and profile are separate axes.</b> The role (the pool this slot sits in) answers
* <em>which contract</em> — launch charter, role file, playbook skill, 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>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.
* <p>An entry declares that the role <em>may</em> run there; it is not an identity. The key
* names the entry for operators and for error messages, nothing more. Which of a pool's
* profiles an unqualified spawn actually lands on is the placement policy's choice, and
* definition order is the {@code fixed} policy's answer.
*
* <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)
* validated against {@link #profiles()}, so a stale or typo'd reference fails at
* startup rather than silently spawning the wrong backend later
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Member(String role, String profile) {
public record Slot(String profile) {
}
/**
* Discover leads by tab label instead of by pasted {@code terminal_id} (CB-531).
* Who the daemon may run, and under which role (CB-557).
*
* <p>Why: a lead is not spawned, so its {@code terminal_id} exists only once a human has opened
* the tab and started the agent — which makes {@code leaders:} a three-step ritual (start it,
* ask it its id, edit config, restart) repeated per lead. Naming the tab is one step, done at
* the moment the operator is already there. The convention also survives what an id does not:
* close the tab and reopen it and the id changes, while the label is retyped as-is.
* <p>This one block replaced four top-level keys — {@code leaders:}, {@code members:},
* {@code leadScan:} and {@code defaultProfile:}. The change is not only a move. Role used to be
* a <em>field</em> on a slot ({@code role: architect}); it is now the containing key, so the
* config states the role × profile matrix directly and a role can no longer be misspelled into
* something that parses.
*
* <p>Deliberately opt-in ({@code null} ⇒ off). Turning it on widens who resolves as
* {@link dev.ltms.bridged.auth.Role#PRIMARY}, and a config that never asked for it must not
* acquire that by upgrading the daemon.
* <p>It also reverses an earlier rule. Devs and reviewers were kept out of config because they
* are anonymous and spawned per task. They belong here now because these are <em>pools</em>,
* not identities: a pool says which backends a role is allowed to run on, and staying anonymous
* is exactly compatible with that. The pool is also what replaced {@code defaultProfile:} — an
* unqualified spawn names a role, and the role's pool supplies the candidates.
*
* <p>bridged never writes these labels — see {@link dev.ltms.bridged.herdr.LeadTabScanner} for
* why that one-way direction is what keeps the convention trustworthy.
*
* @param tabPrefix label prefix marking a lead's tab, matched case-insensitively; the
* remainder is the lead's name ({@code "lead: opus-5.0"} → {@code
* opus-5.0}). Default {@code "lead:"}
* @param intervalSeconds how long a scan is cached before herdr is asked again; also the worst
* case before a newly-labelled tab is recognised. Default 10
* @param leaders panes that orchestrate rather than are orchestrated, keyed by lead name
* @param architects profiles the {@code architect} role may run on
* @param developers profiles the {@code dev} role may run on
* @param reviewers profiles the {@code reviewer} role may run on
* @param tabLabel template for a member tab's label; {@code {role}}, {@code {profile}},
* {@code {model}} and {@code {n}} (a per role+profile counter) are
* substituted. Default {@link #DEFAULT_TAB_LABEL}
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record LeadScan(String tabPrefix, Integer intervalSeconds) {
public LeadScan {
tabPrefix = (tabPrefix == null || tabPrefix.isBlank()) ? "lead:" : tabPrefix.strip();
intervalSeconds = (intervalSeconds == null || intervalSeconds <= 0) ? 10 : intervalSeconds;
public record Fleet(Map<String, Leader> leaders,
Map<String, Slot> architects,
Map<String, Slot> developers,
Map<String, Slot> reviewers,
String tabLabel) {
/**
* Role first, so the tab bar reads as the fleet and so the label shares a namespace with a
* lead's {@code tabPrefix}. Because {@code {role}} comes from a closed enum, a generated
* member label can never begin with {@code "lead:"} — the clash that
* {@link #validateLeadTabPrefixes()} used to have to check for is unrepresentable here.
*/
public static final String DEFAULT_TAB_LABEL = "{role}: {profile} #{n}";
public Fleet {
leaders = unmodifiableOrEmpty(leaders);
architects = unmodifiableOrEmpty(architects);
developers = unmodifiableOrEmpty(developers);
reviewers = unmodifiableOrEmpty(reviewers);
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
}
/**
* Deliberately not {@code Map.copyOf}: its iteration order is salted per JVM run, which
* would discard YAML definition order. The {@code fixed} placement policy answers with a
* pool's first entry and {@code weighted} tie-breaks on candidate order, so losing that
* order makes placement unpredictable between restarts.
*/
private static <V> Map<String, V> unmodifiableOrEmpty(Map<String, V> m) {
return (m == null || m.isEmpty())
? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(m));
}
/** The pool for {@code role}, in definition order; empty when the role has none. */
public Map<String, Slot> pool(MemberRole role) {
if (role == null) {
return Map.of();
}
return switch (role) {
case ARCHITECT -> architects;
case DEV -> developers;
case REVIEWER -> reviewers;
};
}
/**
* The profile names {@code role} may run on, in definition order, without repeats.
*
* <p>These are the candidates an unqualified spawn chooses between — the per-role successor
* to the old global {@code defaultProfile}.
*/
public List<String> profilesFor(MemberRole role) {
return pool(role).values().stream()
.filter(s -> s != null && s.profile() != null && !s.profile().isBlank())
.map(Slot::profile)
.distinct()
.toList();
}
/** Every role that has at least one profile configured, in enum order. */
public List<MemberRole> rolesConfigured() {
return Arrays.stream(MemberRole.values())
.filter(r -> !profilesFor(r).isEmpty())
.toList();
}
}
@@ -528,8 +614,8 @@ public record BridgedConfig(
*/
public Map<String, String> leaderTerminals() {
Map<String, String> byTerminal = new LinkedHashMap<>();
if (leaders != null) {
leaders.forEach((name, leader) -> {
if (fleet != null) {
fleet.leaders().forEach((name, leader) -> {
if (leader != null && leader.terminal() != null && !leader.terminal().isBlank()) {
byTerminal.put(leader.terminal(), name);
}
@@ -596,17 +682,38 @@ public record BridgedConfig(
}
/**
* The profile a no-argument spawn uses: {@code defaultProfile} if set, else the sole/first
* configured profile, else {@code null}.
* The candidate profiles an unqualified spawn of {@code role} chooses between, in definition
* order (CB-557).
*
* <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.
* <p>This replaced the global {@code defaultProfile:}. A single default could not survive roles
* being first-class: "the profile a no-argument spawn uses" has no one answer once a reviewer
* and a dev may legitimately want different backends. Asking per role gives each one its own
* pool, and definition order is the {@code fixed} policy's answer within it.
*
* <p>Falls back to <em>every</em> configured profile when the role has no pool, so a config that
* declares {@code profiles:} but no {@code fleet:} still spawns rather than failing — the
* pre-CB-557 behaviour for a config that named no slots.
*/
public List<String> candidateProfiles(MemberRole role) {
List<String> pool = (fleet == null) ? List.of() : fleet.profilesFor(role);
return pool.isEmpty() ? List.copyOf(profiles.keySet()) : pool;
}
/**
* The profile an unqualified spawn of {@code role} lands on under the {@code fixed} policy: the
* first of {@link #candidateProfiles(MemberRole)}, or {@code null} when nothing is configured.
*/
public String defaultProfileFor(MemberRole role) {
List<String> candidates = candidateProfiles(role);
return candidates.isEmpty() ? null : candidates.getFirst();
}
/**
* The last-resort profile for a spawn that names no role at all — the {@code dev} pool's first
* entry, since an unqualified spawn is a unit of work rather than a review or a design.
*/
public String effectiveDefaultProfile() {
if (defaultProfile != null && !defaultProfile.isBlank()) {
return defaultProfile;
}
return profiles.isEmpty() ? null : profiles.keySet().iterator().next();
return defaultProfileFor(MemberRole.DEV);
}
private static final Logger log = LoggerFactory.getLogger(BridgedConfig.class);
@@ -618,9 +725,9 @@ 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", "profiles", "defaultProfile", "guard", "worktreeRoot",
"lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "leaders",
"members", "leadScan", "leadHeartbeat", "placement", "auth");
"bind", "herdrSocket", "profiles", "guard", "worktreeRoot",
"lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "fleet",
"leadHeartbeat", "placement", "auth");
/** Load and validate config from {@code path}. */
public static BridgedConfig load(Path path) {
@@ -636,38 +743,46 @@ public record BridgedConfig(
}
}
/** The {@code fleet:} child blocks whose direct children are slot names. */
private static final Set<String> FLEET_POOL_KEYS =
Set.of("leaders", "architects", "developers", "reviewers");
/**
* Reject an {@code members:} registry whose slot names repeat (CB-548).
* Reject a {@code fleet:} role pool whose slot names repeat (CB-548, re-homed by CB-557).
*
* <p>The registry is a {@code Map} keyed by slot name, so by the time it is read duplicate keys
* <p>Each pool 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 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.
* default, so duplicates are caught here, at parse time, before the map is built.
*
* @throws IllegalStateException when two {@code members:} entries share a slot name, naming it
* <p>Only the four pools <em>directly under the top-level {@code fleet:}</em> are considered,
* and only their direct child keys (the slot names). A nested field elsewhere, even one also
* named {@code developers:}, is ignored, so parsing of the rest of the config is unaffected.
*
* <p>Names repeat freely <em>across</em> pools and that is deliberate: {@code sonnet} appearing
* in both {@code developers:} and {@code reviewers:} is the role × profile matrix doing its job,
* not a mistake. Only a repeat within one pool is an error.
*
* @throws IllegalStateException when one pool has two entries sharing a slot name, naming both
*/
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 "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.
// Scan the TOP-LEVEL mapping only. Every other field's value (however deep, including a
// nested field also literally named "fleet") is consumed whole by skipValue, so the loop
// below can only ever see top-level field names.
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 ("members".equals(name)) {
if ("fleet".equals(name)) {
if (value == JsonToken.START_OBJECT) {
rejectDuplicateChildSlotKeys(p);
rejectDuplicateSlotsInPools(p);
}
return; // the single top-level members block is handled; nothing more to check
return; // the single top-level fleet block is handled; nothing more to check
}
skipValue(p, value);
}
@@ -678,24 +793,44 @@ public record BridgedConfig(
}
/**
* 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 members:}) is never seen
* here and cannot masquerade as a duplicated slot name.
*
* @throws IllegalStateException when two {@code members:} entries share a slot name, naming it
* Walk the (already-positioned) {@code fleet:} mapping and check each role pool it contains.
* Any other {@code fleet:} child — {@code tabLabel}, say — is consumed whole and ignored.
*/
private static void rejectDuplicateChildSlotKeys(JsonParser p) throws IOException {
private static void rejectDuplicateSlotsInPools(JsonParser p) throws IOException {
JsonToken t;
while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) {
if (t == JsonToken.FIELD_NAME) {
String pool = p.getCurrentName();
JsonToken value = p.nextToken();
if (FLEET_POOL_KEYS.contains(pool) && value == JsonToken.START_OBJECT) {
rejectDuplicateChildSlotKeys(p, pool);
} else {
skipValue(p, value);
}
}
}
}
/**
* Reject a duplicated <em>direct child</em> key of an already-positioned role pool — i.e. a
* duplicated slot name within that one pool.
*
* <p>Each slot's value is consumed whole by {@link #skipValue}, so a duplicated field
* <em>inside</em> a slot (two {@code profile:} keys, say) is never seen here and cannot
* masquerade as a duplicated slot name.
*
* @param pool the pool's key, named in the error so the operator knows which one to look at
* @throws IllegalStateException when two entries in {@code pool} share a slot name
*/
private static void rejectDuplicateChildSlotKeys(JsonParser p, String pool) throws IOException {
Set<String> seen = new HashSet<>();
JsonToken t;
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 member slot name '"
+ p.getCurrentName() + "' — slot names must be unique; a later entry would "
+ "silently overwrite the earlier one");
throw new IllegalStateException("refusing to start: duplicate slot name '"
+ p.getCurrentName() + "' in fleet." + pool + " — names must be unique "
+ "within a pool; a later entry would silently overwrite the earlier one");
}
skipValue(p, p.nextToken()); // the slot's entire value
}
@@ -774,12 +909,24 @@ public record BridgedConfig(
* <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.
*
* <p>Values are the advice shown to the operator, not bare key names: several of these did not
* move to one key. {@code defaultProfile:} has no successor at all — it became per-role — and a
* message naming a single replacement key would send the reader somewhere that does not exist.
*/
private static final Map<String, String> RENAMED_TOP_LEVEL_KEYS = Map.of(
"workers", "profiles",
"worker", "profiles",
"defaultWorker", "defaultProfile",
"architects", "members");
"workers", "'profiles'",
"worker", "'profiles'",
"defaultWorker", "a role pool under 'fleet:' — an unqualified spawn now names a role,"
+ " and that role's pool supplies the candidate profiles",
"defaultProfile", "a role pool under 'fleet:' — an unqualified spawn now names a role,"
+ " and that role's pool supplies the candidate profiles",
"architects", "'fleet.architects'",
"members", "a role pool under 'fleet:' — 'fleet.architects', 'fleet.developers' or"
+ " 'fleet.reviewers'; the role is the containing key, not a 'role:' field",
"leaders", "'fleet.leaders'",
"leadScan", "'fleet.leaders.<name>.tabPrefix' and '.scanIntervalSeconds' — lead"
+ " discovery is now configured on the lead it discovers");
/**
* Reject a config that still uses a pre-rename top-level key, naming its replacement.
@@ -801,12 +948,13 @@ public record BridgedConfig(
.map(String::valueOf)
.filter(RENAMED_TOP_LEVEL_KEYS::containsKey)
.sorted()
.map(k -> "'" + k + "' is now '" + RENAMED_TOP_LEVEL_KEYS.get(k) + "'")
.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.");
+ ". A profile says which backend to run; a fleet role pool says which role may"
+ " run on it.");
}
}
@@ -838,14 +986,15 @@ public record BridgedConfig(
String placementOrDefault = (placement != null && !placement.isBlank()) ? placement : "fixed";
// broker is left as-is: null (or an empty/blank uri) keeps the in-memory soft-state inbox.
// primary is left as-is: null defaults to connection-derived identity.
// leadScan is left as-is: null is "off", and LeadScan's own compact constructor defaults the
// fields of a block that IS present. Defaulting it here would switch the feature on for
// 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.
// 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, profiles, defaultProfile, g, worktreeRoot, l, timeout, pollMs, broker, primary, leaders, members, leadScan, leadHeartbeat, placementOrDefault, a);
// fleet IS defaulted, unlike the leadScan: block it replaced, because an empty Fleet is not
// the same as an enabled one: every pool is empty, so no lead is scanned for or created and
// no role has a pool. Constructing it saves every reader a null check for no behaviour change.
Fleet f = (fleet != null) ? fleet : new Fleet(null, null, null, null, null);
// leadHeartbeat is left as-is (CB-551): null is "off", and LeadHeartbeat's own compact
// constructor defaults the fields of a block that IS present. Defaulting it here would
// switch the feature on for every config that never mentioned it.
return new BridgedConfig(b, herdrSocket, profiles, g, worktreeRoot, l, timeout, pollMs,
broker, primary, f, leadHeartbeat, placementOrDefault, a);
}
/**
@@ -876,40 +1025,65 @@ public record BridgedConfig(
* Reject a lead-scan convention that a worker tab would also satisfy (CB-531).
*
* <p>The scan reads a tab label and concludes "a lead lives here". bridged also <em>writes</em>
* tab labels — every worker gets {@code tabLabel} rendered into its tab. Choose a
* {@code leadScan.tabPrefix} that a worker template matches and the daemon starts labelling its
* own workers as leads, promoting the entire fleet to {@link dev.ltms.bridged.auth.Role#PRIMARY}
* with no message and no diff. The worker-space exclusion in
* {@link dev.ltms.bridged.herdr.LeadTabScanner} already blocks the realistic path, but defence
* that depends on one workspace label holding is not defence enough for a privilege boundary.
* tab labels — every member gets one rendered into its tab. Choose a lead {@code tabPrefix} that
* a member template matches and the daemon starts labelling its own members as leads, promoting
* the entire fleet to {@link dev.ltms.bridged.auth.Role#PRIMARY} with no message and no diff.
* The member-space exclusion in {@link dev.ltms.bridged.herdr.LeadTabScanner} already blocks the
* realistic path, but defence that depends on one workspace label holding is not defence enough
* for a privilege boundary.
*
* <p>CB-557 shrank this check rather than removing it. The default template is
* {@code "{role}: {profile} #{n}"} and {@code {role}} comes from a closed enum, so a
* <em>generated</em> label can no longer collide by construction. What remains checkable is what
* an operator still writes by hand: the {@code fleet.tabLabel} template and any per-profile
* {@code tabLabel} override.
*
* <p>Fatal rather than a warning, unlike {@link #warnUnknownTopLevelKeys}: an unknown key means
* a feature does nothing, while this means a feature does the opposite of what it says.
*
* @throws IllegalStateException when any worker profile's {@code tabLabel} starts with the
* configured lead prefix
* @throws IllegalStateException when the fleet template or any profile's {@code tabLabel}
* override starts with a configured lead prefix
*/
public void validateLeadScan() {
if (leadScan == null) {
public void validateLeadTabPrefixes() {
if (fleet == null || fleet.leaders().isEmpty()) {
return;
}
String prefix = leadScan.tabPrefix();
List<String> clashing = profiles().entrySet().stream()
.filter(e -> e.getValue().tabLabel() != null
&& e.getValue().tabLabel().strip()
.regionMatches(true, 0, prefix, 0, prefix.length()))
.map(Map.Entry::getKey)
.sorted()
.toList();
if (clashing.isEmpty()) {
List<String> bad = new ArrayList<>();
fleet.leaders().forEach((leadName, leader) -> {
if (leader == null) {
return;
}
String prefix = leader.tabPrefix();
// The fleet-wide template is checked once per prefix: it labels every member that has no
// override, so one bad template promotes the entire fleet, not one profile.
if (startsWithIgnoreCase(fleet.tabLabel(), prefix)) {
bad.add("fleet.tabLabel=\"" + fleet.tabLabel() + "\" starts with the tabPrefix of "
+ "lead '" + leadName + "' (\"" + prefix + "\")");
}
profiles().entrySet().stream()
.filter(e -> startsWithIgnoreCase(e.getValue().tabLabel(), prefix))
.map(Map.Entry::getKey)
.sorted()
.forEach(p -> bad.add("profile '" + p + "' overrides tabLabel with \""
+ profiles().get(p).tabLabel() + "\", which starts with the tabPrefix of "
+ "lead '" + leadName + "' (\"" + prefix + "\")"));
});
if (bad.isEmpty()) {
return;
}
throw new IllegalStateException(
"refusing to start: leadScan.tabPrefix=\"" + prefix + "\" also matches the tabLabel "
+ "of worker profile(s) " + clashing + ". Every worker spawned under them "
+ "would be read back as a lead and granted spawn/stop/send on the whole "
+ "fleet. Change one of the two so worker tabs and lead tabs cannot be "
+ "confused.");
throw new IllegalStateException("refusing to start: " + String.join("; ", bad)
+ ". Every member labelled that way would be read back as a lead and granted "
+ "spawn/stop/send on the whole fleet. Change one of the two so member tabs and "
+ "lead tabs cannot be confused.");
}
/** Case-insensitive prefix test that tolerates a null/blank label. */
private static boolean startsWithIgnoreCase(String label, String prefix) {
if (label == null || prefix == null || prefix.isBlank()) {
return false;
}
String stripped = label.strip();
return stripped.regionMatches(true, 0, prefix, 0, prefix.length());
}
/**
@@ -959,39 +1133,52 @@ public record BridgedConfig(
* 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>The role itself needs no check any more (CB-557): it is the containing key, so an
* unrecognised pool name is simply not a pool and cannot become a member with no contract. That
* is the main thing the pool shape bought over the old {@code role:} field.
*
* <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.
* <p>Slot-name uniqueness needs no check here either: each pool 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 member slot is missing or names an unknown role or
* profile, naming the slot and the offending reference
* @throws IllegalStateException when a slot names no profile or an unknown one, or when a lead
* can be neither found nor created, naming the offending entry
*/
public void validateMembers() {
if (members == null) {
if (fleet == null) {
return;
}
List<String> bad = new java.util.ArrayList<>();
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()
List<String> bad = new ArrayList<>();
for (MemberRole role : MemberRole.values()) {
String pool = "fleet." + role.configKey();
fleet.pool(role).forEach((name, slot) -> {
if (slot == null || slot.profile() == null || slot.profile().isBlank()) {
bad.add(pool + "." + name + " has no profile: — give it the name of a profiles: "
+ "entry (the backend this role runs on).");
} else if (!profiles.containsKey(slot.profile())) {
bad.add(pool + "." + name + " references profile '" + slot.profile()
+ "', which is not a configured profiles: entry (have: "
+ profiles.keySet() + ").");
}
});
}
fleet.leaders().forEach((name, leader) -> {
if (leader == null) {
return;
}
// A lead's profile is optional: without one the lead is recognised but never created,
// which is the pre-CB-557 behaviour and still a legitimate choice. A profile that IS
// named must resolve, or the shortfall launch fails at the worst possible moment.
if (leader.profile() != null && !leader.profile().isBlank()
&& !profiles.containsKey(leader.profile())) {
bad.add("fleet.leaders." + name + " references profile '" + leader.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 (!leader.isCreatable() && (leader.terminal() == null || leader.terminal().isBlank())) {
bad.add("fleet.leaders." + name + " can neither be found nor created — it pins no "
+ "terminal: and names no profile: to launch one on. Give it one or the "
+ "other, or drop the entry.");
}
});
if (!bad.isEmpty()) {
@@ -79,9 +79,24 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> env,
long spawnReadyTimeoutMs, long spawnReadyPollMs) {
this(agents, spaces, guard, profiles, defaultProfile, env,
spawnReadyTimeoutMs, spawnReadyPollMs, null);
}
/**
* Production constructor carrying the fleet-wide tab-label template (CB-557). The template comes
* from {@code fleet.tabLabel}, which a profile cannot know because it names the member's
* <em>role</em>; a profile may still override it with its own {@code tabLabel}.
*/
public ClaudeCodeLauncher(AgentControl agents, WorkspaceControl spaces, SubscriptionGuard guard,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> env,
long spawnReadyTimeoutMs, long spawnReadyPollMs,
String tabLabelTemplate) {
this(agents, spaces, guard, profiles, defaultProfile, env,
spawnReadyTimeoutMs,
System::currentTimeMillis, () -> sleepUninterruptibly(spawnReadyPollMs));
System::currentTimeMillis, () -> sleepUninterruptibly(spawnReadyPollMs),
tabLabelTemplate);
}
/**
@@ -106,8 +121,24 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
Function<String, String> env,
long spawnReadyTimeoutMs,
LongSupplier nowMillis, Runnable sleeper) {
this(agents, spaces, guard, profiles, defaultProfile, env,
spawnReadyTimeoutMs, nowMillis, sleeper, null);
}
/**
* Full testability constructor, plus the fleet-wide tab-label template (CB-557).
*
* @param tabLabelTemplate {@code fleet.tabLabel}; {@code null}/blank ⇒
* {@link BridgedConfig.Fleet#DEFAULT_TAB_LABEL}
*/
public ClaudeCodeLauncher(AgentControl agents, WorkspaceControl spaces, SubscriptionGuard guard,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> env,
long spawnReadyTimeoutMs,
LongSupplier nowMillis, Runnable sleeper,
String tabLabelTemplate) {
super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env,
spawnReadyTimeoutMs, nowMillis, sleeper);
spawnReadyTimeoutMs, nowMillis, sleeper, tabLabelTemplate);
this.guard = guard;
}
@@ -174,9 +174,10 @@ public final class CompositePeerLauncher implements PeerLauncher {
}
// CB-547a: route the chosen profile but keep the caller's session identity — dropping it
// here would silently sever the resume handle on every policy-routed spawn.
// here would silently sever the resume handle on every policy-routed spawn. CB-557: the
// role rides along for the same reason, or a routed spawn would be labelled as a dev.
SpawnRequest routedReq = new SpawnRequest(chosen.profile(), req.requestedCwd(), req.callerCwd(),
req.sessionName(), req.resumeSessionId());
req.sessionName(), req.resumeSessionId(), req.role());
try {
PeerHandle handle = d.spawn(routedReq);
spawnedBy.put(handle.id(), d);
@@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.HerdrException;
import dev.ltms.bridged.herdr.Tab;
import dev.ltms.bridged.herdr.Workspace;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.peer.MemberRole;
import dev.ltms.bridged.peer.PeerHandle;
import dev.ltms.bridged.peer.PeerLauncher;
import dev.ltms.bridged.peer.PeerUnreachableException;
@@ -75,7 +76,27 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
/** Host env lookup (injectable for tests); adapters read it in {@link #buildLaunch}. */
protected final Function<String, String> env;
private final AtomicLong nameSeq = new AtomicLong(); // per-peer counter (also the tab #)
private final AtomicLong nameSeq = new AtomicLong(); // per-peer counter (herdr agent names only)
/**
* The {@code fleet.tabLabel} template; {@code null} ⇒ {@link BridgedConfig.Fleet#DEFAULT_TAB_LABEL}.
* A profile's own {@code tabLabel} still overrides it.
*/
private final String tabLabelTemplate;
/**
* Tab numbers, counted per {@code role/profile} pair (CB-557).
*
* <p>Deliberately not {@link #nameSeq}. That counter is shared by every profile this launcher
* serves, because its job is to make herdr <em>agent names</em> unique. Reusing it for the tab
* label made the numbers global, so sibling tabs read {@code #4}, {@code #9}, {@code #17} — gaps
* that look like a member died. Counting per role+profile makes {@code dev: sonnet #2} mean the
* second sonnet dev, which is what a reader assumes it means.
*
* <p>Resets when the daemon restarts, and that is fine: the label is a human-facing hint, not an
* identity. Identity is {@link PeerHandle#id()}.
*/
private final ConcurrentMap<String, AtomicLong> labelSeq = new ConcurrentHashMap<>();
private final long spawnReadyTimeoutMs; // 0 = disable gate (legacy non-blocking spawn)
private final LongSupplier nowMillis; // monotonic clock (injectable for tests)
@@ -110,6 +131,25 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
Function<String, String> env,
long spawnReadyTimeoutMs,
LongSupplier nowMillis, Runnable sleeper) {
this(namePrefix, agents, spaces, profiles, defaultProfile, env, spawnReadyTimeoutMs,
nowMillis, sleeper, null);
}
/**
* As above, plus the {@code fleet.tabLabel} template (CB-557).
*
* @param tabLabelTemplate fleet-wide tab-label template; {@code null}/blank ⇒
* {@link BridgedConfig.Fleet#DEFAULT_TAB_LABEL}. A separate constructor
* rather than a new parameter on the one above, so every existing call
* site keeps the default without an edit.
*/
protected HerdrPeerLauncher(String namePrefix, AgentControl agents, WorkspaceControl spaces,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> env,
long spawnReadyTimeoutMs,
LongSupplier nowMillis, Runnable sleeper,
String tabLabelTemplate) {
this.tabLabelTemplate = tabLabelTemplate;
this.namePrefix = namePrefix;
this.agents = agents;
this.spaces = spaces;
@@ -240,6 +280,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
return spawnInternal(profileName, requestedCwd, callerCwd, null, null).agent();
}
/** Pre-CB-557 shape: no explicit role, so the tab is labelled as a {@code dev}. */
protected Spawned spawnInternal(String profileName, String requestedCwd, String callerCwd,
String sessionName, String resumeSessionId) {
return spawnInternal(profileName, requestedCwd, callerCwd, sessionName, resumeSessionId,
MemberRole.DEV);
}
/**
* Spawn a peer with session identity (CB-547a). {@code sessionName} and {@code resumeSessionId}
* are threaded from the {@link SpawnRequest} into {@link #buildLaunch(BridgedConfig.Profile,
@@ -247,16 +294,27 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
* so the caller can put it on the {@link PeerHandle}.
*/
protected Spawned spawnInternal(String profileName, String requestedCwd, String callerCwd,
String sessionName, String resumeSessionId) {
String sessionName, String resumeSessionId, MemberRole role) {
BridgedConfig.Profile cfg = requireProfile(profileName);
Launch launch = buildLaunch(cfg, sessionName, resumeSessionId);
String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
Agent agent = cfg.tabPlacement()
? spawnInTab(cfg, launch.env(), launch.argv(), cwd)
? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role)
: spawnAsPane(cfg, launch.env(), launch.argv(), cwd);
return new Spawned(agent, launch.agentSessionId());
}
/**
* The next tab number for {@code role} on {@code profile}, starting at 1.
*
* <p>Starts at 1 rather than 0 because the number is read by a person: {@code "dev: sonnet #1"}
* is the first one, and {@code #0} invites the question of where {@code #1} went.
*/
private long nextLabelSeq(MemberRole role, String profile) {
String key = (role == null ? "" : role.wireName()) + "/" + profile;
return labelSeq.computeIfAbsent(key, _ -> new AtomicLong()).incrementAndGet();
}
/**
* {@inheritDoc}
*
@@ -272,7 +330,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
@Override
public PeerHandle spawn(SpawnRequest req) {
Spawned spawned = spawnInternal(req.profileName(), req.requestedCwd(), req.callerCwd(),
req.sessionName(), req.resumeSessionId());
req.sessionName(), req.resumeSessionId(), req.role());
Agent agent = spawned.agent();
String paneId = agent.paneId();
if (spawnReadyTimeoutMs > 0) {
@@ -317,7 +375,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.Profile cfg, Map<String, String> workerEnv,
List<String> argv, String cwd) {
List<String> argv, String cwd, MemberRole role) {
Workspace space = spaces.ensureWorkspace(cfg.workspace());
Tab.Created tab = spaces.createTab(space.workspaceId(), cwd, workerEnv);
log.info("spawning {} profile={} space={} tab={} cwd={}",
@@ -348,7 +406,8 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
// Labelling is cosmetic: it must not fail the spawn or orphan the running peer — on error
// we log and still return it so the caller gets its paneId and can tear it down.
tidy("label tab " + tab.tab().tabId(),
() -> spaces.renameTab(tab.tab().tabId(), cfg.renderTabLabel(started.seq())));
() -> spaces.renameTab(tab.tab().tabId(),
cfg.renderTabLabel(tabLabelTemplate, role, nextLabelSeq(role, cfg.profile()))));
log.info("{} started pane={} tab={} terminal={}",
namePrefix, started.agent().paneId(), started.agent().tabId(), started.agent().terminalId());
return started.agent();
@@ -112,9 +112,23 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> env,
long spawnReadyTimeoutMs, long spawnReadyPollMs) {
this(agents, spaces, profiles, defaultProfile, env,
spawnReadyTimeoutMs, spawnReadyPollMs, null);
}
/**
* Production constructor carrying the fleet-wide tab-label template (CB-557). The template comes
* from {@code fleet.tabLabel}, which a profile cannot know because it names the member's
* <em>role</em>; a profile may still override it with its own {@code tabLabel}.
*/
public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> env,
long spawnReadyTimeoutMs, long spawnReadyPollMs,
String tabLabelTemplate) {
this(agents, spaces, profiles, defaultProfile, env, spawnReadyTimeoutMs,
System::currentTimeMillis, () -> sleepUninterruptibly(spawnReadyPollMs),
defaultConfigRoot(), defaultDiscoveryRoot());
defaultConfigRoot(), defaultDiscoveryRoot(), tabLabelTemplate);
}
/**
@@ -142,8 +156,25 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
long spawnReadyTimeoutMs,
LongSupplier nowMillis, Runnable sleeper,
Path configRoot, Path discoveryRoot) {
this(agents, spaces, profiles, defaultProfile, env, spawnReadyTimeoutMs,
nowMillis, sleeper, configRoot, discoveryRoot, null);
}
/**
* Full testability constructor, plus the fleet-wide tab-label template (CB-557).
*
* @param tabLabelTemplate {@code fleet.tabLabel}; {@code null}/blank ⇒
* {@link BridgedConfig.Fleet#DEFAULT_TAB_LABEL}
*/
public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces,
Map<String, BridgedConfig.Profile> profiles, String defaultProfile,
Function<String, String> env,
long spawnReadyTimeoutMs,
LongSupplier nowMillis, Runnable sleeper,
Path configRoot, Path discoveryRoot,
String tabLabelTemplate) {
super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env,
spawnReadyTimeoutMs, nowMillis, sleeper);
spawnReadyTimeoutMs, nowMillis, sleeper, tabLabelTemplate);
this.configRoot = configRoot;
this.discovery = new OpenCodeSessionDiscovery(discoveryRoot);
}
@@ -57,6 +57,40 @@ public enum MemberRole {
return name().toLowerCase(Locale.ROOT);
}
/**
* The {@code fleet:} block that holds this role's pool — {@code architects},
* {@code developers}, {@code reviewers}.
*
* <p>Plural, and not always the wire name: the pool of things a {@code dev} may run on reads
* naturally as {@code developers:}. The wire name stays the singular {@code dev}, because that
* is what a tab label and a roster row say.
*/
public String configKey() {
return switch (this) {
case ARCHITECT -> "architects";
case DEV -> "developers";
case REVIEWER -> "reviewers";
};
}
/**
* The role owning the {@code fleet:} pool named {@code key}, or {@code null} when the key is not
* a role pool ({@code leaders}, {@code tabLabel}, …). Null rather than a throw: callers use this
* to sort a fleet block's children, where a non-pool key is normal rather than an error.
*/
public static MemberRole fromConfigKey(String key) {
if (key == null) {
return null;
}
String k = key.trim().toLowerCase(Locale.ROOT);
for (MemberRole r : values()) {
if (r.configKey().equals(k)) {
return r;
}
}
return null;
}
/**
* Parse a config/wire spelling, case-insensitively.
*
@@ -1,24 +1,32 @@
package dev.ltms.bridged.peer;
/**
* Parameters for a {@link PeerLauncher#spawn(SpawnRequest)} call — the peer-neutral
* aggregation of what the core knows at delegation time: which profile to use, the caller's
* requested working directory, and the caller's own cwd (to inherit when no other cwd is set).
* What a caller asks for when spawning a peer.
*
* <p>A null or blank {@code profileName} means "use the launcher's default profile."
* A null or blank {@code requestedCwd} means "inherit from config or caller."
* A null {@code callerCwd} means "the request came from the daemon itself (not a primary)."
*
* <p>{@code sessionName} and {@code resumeSessionId} carry the session's durable identity (CB-547a):
* the bridge's LOGICAL name for the session (stable across restarts, meaningful to an operator)
* and the peer's OWN prior session id to resume, respectively. Both are <em>opted in</em> — either
* may be null/blank, in which case the launcher derives a display name and mints a fresh session.
* @param profileName the {@code profiles:} entry to spawn on; {@code null}/blank ⇒ the caller
* did not choose, and the role's pool supplies the candidates
* @param requestedCwd working directory asked for by the caller ({@code null} ⇒ unset)
* @param callerCwd the caller's own working directory, used when nothing else pins one
* @param sessionName agent session name (CB-547a); {@code null} ⇒ the launcher mints one
* @param resumeSessionId prior agent session to resume; {@code null} ⇒ a fresh session
* @param role the contract this member runs under (CB-557). Picks the tab label and,
* with the profile, the counter its tab number comes from. {@code null} is
* read as {@link MemberRole#DEV} — an unqualified spawn is a unit of work
*/
public record SpawnRequest(String profileName, String requestedCwd, String callerCwd,
String sessionName, String resumeSessionId) {
String sessionName, String resumeSessionId, MemberRole role) {
public SpawnRequest {
role = (role == null) ? MemberRole.DEV : role;
}
/** Back-compat: a spawn with no session identity (fresh session, launcher-derived name). */
public SpawnRequest(String profileName, String requestedCwd, String callerCwd) {
this(profileName, requestedCwd, callerCwd, null, null);
this(profileName, requestedCwd, callerCwd, null, null, null);
}
/** Pre-CB-557 shape: session identity without an explicit role (defaults to {@code dev}). */
public SpawnRequest(String profileName, String requestedCwd, String callerCwd,
String sessionName, String resumeSessionId) {
this(profileName, requestedCwd, callerCwd, sessionName, resumeSessionId, null);
}
}