diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 73f6e06..7a16453 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -129,13 +129,13 @@ public final class Bridged { adapters.add(new ClaudeCodeLauncher(agents, spaces, guard, claudeProfiles, cfg.effectiveDefaultProfile(), System::getenv, cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs(), - () -> config.get().fleet().tabLabel())); + () -> config.get().fleet())); } if (!opencodeProfiles.isEmpty()) { adapters.add(new OpenCodeLauncher(agents, spaces, opencodeProfiles, cfg.effectiveDefaultProfile(), System::getenv, cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs(), - () -> config.get().fleet().tabLabel())); + () -> config.get().fleet())); } AtomicReference> liveCountRef = new AtomicReference<>(_ -> 0); PeerLauncher workers = new CompositePeerLauncher( diff --git a/bridged/src/main/java/dev/ltms/bridged/member/ClaudeCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/ClaudeCodeLauncher.java index 5d68d7f..ad4483b 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/ClaudeCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/ClaudeCodeLauncher.java @@ -44,23 +44,6 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { private final SubscriptionGuard guard; - /** - * Standing instruction appended to the worker's system prompt so it returns its result via - * {@code bridge_reply}. Injected as a launch flag, so nothing is written to the worker's - * profile — it is guidance, and a worker that never replies is caught by the send's timeout. - */ - static final String REPLY_CHARTER = - "You are an off-subscription worker in the claude-bridge fleet. Every message you " - + "receive arrives through the bridge, and the ONLY channel back to the sender is the " - + "bridge_reply MCP tool. Text you write in your terminal is NOT sent anywhere — the " - + "sender cannot see your screen, so an in-terminal answer is silently discarded. " - + "Therefore you MUST end EVERY turn by calling bridge_reply with `content` set to your " - + "complete response. This holds for every message without exception — tasks, questions, " - + "clarifications, acknowledgements, and ordinary back-and-forth conversation. Call " - + "bridge_reply exactly once, as the final action of your turn, with your full answer in " - + "`content`; never wait for confirmation first. If you end a turn without calling " - + "bridge_reply, the sender receives nothing and the exchange stalls."; - /** * Production constructor — disables the spawn-ready gate ({@code spawnReadyTimeoutMs == 0}) so * existing deployments and tests keep the legacy non-blocking spawn semantics. @@ -93,11 +76,11 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { Map profiles, String defaultProfile, Function env, long spawnReadyTimeoutMs, long spawnReadyPollMs, - Supplier tabLabelTemplate) { + Supplier fleet) { this(agents, spaces, guard, profiles, defaultProfile, env, spawnReadyTimeoutMs, System::currentTimeMillis, () -> sleepUninterruptibly(spawnReadyPollMs), - tabLabelTemplate); + fleet); } /** @@ -129,31 +112,19 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { /** * 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} + * @param fleet live fleet config, read once for each spawn */ public ClaudeCodeLauncher(AgentControl agents, WorkspaceControl spaces, SubscriptionGuard guard, Map profiles, String defaultProfile, Function env, long spawnReadyTimeoutMs, - LongSupplier nowMillis, Runnable sleeper, - Supplier tabLabelTemplate) { + LongSupplier nowMillis, Runnable sleeper, + Supplier fleet) { super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env, - spawnReadyTimeoutMs, nowMillis, sleeper, tabLabelTemplate); + spawnReadyTimeoutMs, nowMillis, sleeper, fleet); this.guard = guard; } - /** - * {@inheritDoc} - * - *

A legacy spawn with no session identity is a fresh, launcher-derived session — delegate to - * the session-aware form with no name and no resume id. - */ - @Override - protected Launch buildLaunch(BridgedConfig.Profile cfg) { - return buildLaunch(cfg, null, null); - } - /** * {@inheritDoc} * @@ -164,7 +135,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { * applied here — see {@link #applySessionIdentity}. */ @Override - protected Launch buildLaunch(BridgedConfig.Profile cfg, String sessionName, String resumeSessionId) { + protected Launch buildLaunch(BridgedConfig.Profile cfg, LaunchSpec spec) { // 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 @@ -212,7 +183,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { // mutableArgv: argvWithBridge may hand back the profile's own (immutable) List.of when it // has no MCP — session flags must be added into a list we own. List argv = mutableArgv(argvWithBridge(cfg)); - String agentSessionId = applySessionIdentity(argv, sessionName, resumeSessionId); + String agentSessionId = applySessionIdentity(argv, spec.sessionName(), spec.resumeSessionId()); return new Launch(workerEnv, argvWithModel(argv, cfg), agentSessionId); } diff --git a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java index eb27d8a..cb513c0 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java @@ -44,7 +44,7 @@ import java.util.regex.Pattern; *

  • {@code namePrefix} (constructor arg) — the label prefix ({@code claude}, {@code opencode}) * that drives both unique naming and the orphan-reap pattern, so each adapter reaps only its * own kind of pane and never another's.
  • - *
  • {@link #buildLaunch(BridgedConfig.Profile)} — the peer-specific env map + argv, including any + *
  • {@link #buildLaunch(BridgedConfig.Profile, LaunchSpec)} — 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}.
  • * @@ -80,15 +80,25 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { private final AtomicLong nameSeq = new AtomicLong(); // per-peer counter (herdr agent names only) /** - * The {@code fleet.tabLabel} template; a {@code null} supplier or a {@code null}/blank value ⇒ - * {@link BridgedConfig.Fleet#DEFAULT_TAB_LABEL}. A profile's own {@code tabLabel} still - * overrides it. + * Live fleet config, read once per spawn. A null supplier or value leaves tab labels at their + * default and supplies no role charter. A profile's own {@code tabLabel} still overrides it. * - *

    CB-559: a supplier rather than a String, so a config reload renames the next tab - * without a restart. Existing tabs keep the label they were given — bridged does not rewrite a - * label it already wrote. + *

    CB-559: a supplier rather than a snapshot, so a config reload affects the next launch + * without a restart. Existing tabs keep the label they were given. */ - private final Supplier tabLabelTemplate; + private final Supplier fleet; + + /** The final instruction always requires a bridge reply when the bridge MCP is mounted. */ + protected static final String REPLY_CHARTER = + "You are a spawned member in the claude-bridge fleet. Every message you receive arrives " + + "through the bridge, and the ONLY channel back to the sender is the bridge_reply MCP tool. " + + "Text you write in your terminal is NOT sent anywhere — the sender cannot see your screen, " + + "so an in-terminal answer is silently discarded. Therefore you MUST end EVERY turn by calling " + + "bridge_reply with `content` set to your complete response. This holds for every message without " + + "exception — tasks, questions, clarifications, acknowledgements, and ordinary back-and-forth " + + "conversation. Call bridge_reply exactly once, as the final action of your turn, with your full " + + "answer in `content`; never wait for confirmation first. If you end a turn without calling " + + "bridge_reply, the sender receives nothing and the exchange stalls."; /** * Tab numbers, counted per {@code role/profile} pair (CB-557). @@ -142,12 +152,11 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { } /** - * As above, plus the {@code fleet.tabLabel} template (CB-557). + * As above, plus the live {@code fleet} config (CB-557). * - * @param tabLabelTemplate fleet-wide tab-label template, read per spawn (CB-559); {@code null}, - * or a supplier yielding {@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 + * @param fleet live fleet config, read once per spawn; {@code null} ⇒ default tab label and no + * role charter. 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, @@ -155,8 +164,8 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { Function env, long spawnReadyTimeoutMs, LongSupplier nowMillis, Runnable sleeper, - Supplier tabLabelTemplate) { - this.tabLabelTemplate = tabLabelTemplate; + Supplier fleet) { + this.fleet = fleet; this.namePrefix = namePrefix; this.agents = agents; this.spaces = spaces; @@ -175,22 +184,7 @@ 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.Profile cfg); - - /** - * 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 - * surface it on the returned {@link Launch#agentSessionId()}. - * - * @param cfg the resolved profile to spawn - * @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.Profile cfg, String sessionName, String resumeSessionId) { - return buildLaunch(cfg); - } + protected abstract Launch buildLaunch(BridgedConfig.Profile cfg, LaunchSpec spec); /** Direct transport access for peer-specific, non-turn control operations. */ protected final AgentControl agents() { @@ -225,6 +219,10 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { } } + /** All per-spawn values adapters may need, including the base-composed effective charter. */ + protected record LaunchSpec(String sessionName, String resumeSessionId, MemberRole role, String charter) { + } + // --- profile surface ----------------------------------------------------------------------- /** The configured peer profile names (what {@code spawn(profile)} accepts). */ @@ -295,18 +293,23 @@ 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.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}. + * Spawn a peer with session identity (CB-547a). The session values, role, and charter are + * threaded from the {@link SpawnRequest} into {@link #buildLaunch(BridgedConfig.Profile, + * LaunchSpec)}, 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, MemberRole role) { BridgedConfig.Profile cfg = requireProfile(profileName); - Launch launch = buildLaunch(cfg, sessionName, resumeSessionId); + BridgedConfig.Fleet liveFleet = fleet == null ? null : fleet.get(); + String roleCharter = liveFleet == null ? null : liveFleet.charterFor(role); + String replyCharter = cfg.hasMcp() ? REPLY_CHARTER : null; + String charter = roleCharter == null ? replyCharter + : replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter; + Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter)); String cwd = resolveCwd(requestedCwd, cfg, callerCwd); Agent agent = cfg.tabPlacement() - ? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role) + ? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet) : spawnAsPane(cfg, launch.env(), launch.argv(), cwd); return new Spawned(agent, launch.agentSessionId()); } @@ -382,7 +385,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 workerEnv, - List argv, String cwd, MemberRole role) { + List argv, String cwd, MemberRole role, BridgedConfig.Fleet liveFleet) { Workspace space = spaces.ensureWorkspace(cfg.workspace()); Tab.Created tab = spaces.createTab(space.workspaceId(), cwd, workerEnv); log.info("spawning {} profile={} space={} tab={} cwd={}", @@ -415,7 +418,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { tidy("label tab " + tab.tab().tabId(), () -> spaces.renameTab(tab.tab().tabId(), cfg.renderTabLabel( - tabLabelTemplate == null ? null : tabLabelTemplate.get(), + liveFleet == null ? null : liveFleet.tabLabel(), role, nextLabelSeq(role, cfg.profile())))); log.info("{} started pane={} tab={} terminal={}", namePrefix, started.agent().paneId(), started.agent().tabId(), started.agent().terminalId()); diff --git a/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java index 50527ad..6bb0ee8 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java @@ -54,38 +54,9 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { /** Writer for the generated {@code opencode.json}. */ private static final ObjectMapper JSON = new ObjectMapper(); - /** - * Standing instruction written to the charter file and mounted via the config's - * {@code instructions} so the worker returns its result through {@code bridge_reply}. Kept on - * disk (not a launch flag) because opencode's {@code instructions} takes file paths, not inline - * text — the file is regenerated per spawn and never touches the worker's own profile. - */ - static final String REPLY_CHARTER = - "You are an off-subscription worker in the claude-bridge fleet, running under opencode. " - + "Every message you receive arrives through the bridge, and the ONLY channel back to the " - + "sender is the bridge_reply MCP tool. Text you write in your terminal is NOT sent " - + "anywhere — the sender cannot see your screen, so an in-terminal answer is silently " - + "discarded. Therefore you MUST end EVERY turn by calling bridge_reply with `content` set " - + "to your complete response. This holds for every message without exception — tasks, " - + "questions, clarifications, acknowledgements, and ordinary back-and-forth conversation. " - + "Call bridge_reply exactly once, as the final action of your turn, with your full answer " - + "in `content`; never wait for confirmation first. If you end a turn without calling " - + "bridge_reply, the sender receives nothing and the exchange stalls."; - /** Root under which per-spawn opencode config dirs are created (injectable for tests). */ private final Path configRoot; - /** - * The current spawn's resume-target session id, threaded from {@link #spawn(SpawnRequest)} to - * {@link #buildLaunch} across the base's {@code spawn -> spawnInternal -> buildLaunch} chain, - * which carries no request. A plain field would race under concurrent spawns (the base supports - * them), so it is thread-local: each spawn captures its own request's id on its own thread, and - * {@code buildLaunch}, synchronous and same-thread, reads exactly that one. Set only around the - * {@code super.spawn} call and cleared in {@code finally}, so a paused/leftover value can never - * bleed into the next spawn. - */ - private final ThreadLocal resumeSessionId = new ThreadLocal<>(); - /** * Session discovery against opencode's on-disk storage ({@link OpenCodeSessionDiscovery}) — * the one seam that knows opencode's private session-file layout. Its root is injectable for @@ -126,10 +97,10 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { Map profiles, String defaultProfile, Function env, long spawnReadyTimeoutMs, long spawnReadyPollMs, - Supplier tabLabelTemplate) { + Supplier fleet) { this(agents, spaces, profiles, defaultProfile, env, spawnReadyTimeoutMs, System::currentTimeMillis, () -> sleepUninterruptibly(spawnReadyPollMs), - defaultConfigRoot(), defaultDiscoveryRoot(), tabLabelTemplate); + defaultConfigRoot(), defaultDiscoveryRoot(), fleet); } /** @@ -164,8 +135,7 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { /** * 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} + * @param fleet live fleet config, read once for each spawn */ public OpenCodeLauncher(AgentControl agents, WorkspaceControl spaces, Map profiles, String defaultProfile, @@ -173,9 +143,9 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { long spawnReadyTimeoutMs, LongSupplier nowMillis, Runnable sleeper, Path configRoot, Path discoveryRoot, - Supplier tabLabelTemplate) { + Supplier fleet) { super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env, - spawnReadyTimeoutMs, nowMillis, sleeper, tabLabelTemplate); + spawnReadyTimeoutMs, nowMillis, sleeper, fleet); this.configRoot = configRoot; this.discovery = new OpenCodeSessionDiscovery(discoveryRoot); } @@ -199,14 +169,15 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * with {@code -m}. */ @Override - protected Launch buildLaunch(BridgedConfig.Profile cfg) { + protected Launch buildLaunch(BridgedConfig.Profile cfg, LaunchSpec spec) { Map workerEnv = baseEnv(cfg); // A config file is needed for the bridge MCP mount, for a pinned endpoint (CB-508), or both. if (cfg.hasMcp() || hasCustomProvider(cfg)) { workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg).toString()); } applyGitToken(workerEnv, cfg); - return new Launch(workerEnv, argvWithResume(argvWithModel(argvWithAuto(cfg), cfg))); + return new Launch(workerEnv, + argvWithResume(argvWithModel(argvWithAuto(cfg), cfg), spec.resumeSessionId())); } /** @@ -243,11 +214,9 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { * The launch argv plus, on a resumed spawn, opencode's {@code -s } flag to continue a prior * conversation by its session id. {@code -s, --session } resumes an existing session; on a * fresh spawn (no resume target) no flag is added, letting opencode start a brand-new session. - * The id comes from the current spawn request's {@code resumeSessionId}, threaded per-thread by - * {@link #spawn(SpawnRequest)}. + * The id comes from the base launch spec. */ - private List argvWithResume(List argv) { - String id = resumeSessionId.get(); + private List argvWithResume(List argv, String id) { if (id == null || id.isBlank()) { return argv; } @@ -380,31 +349,11 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { return afterScheme.contains("/") ? trimmed : trimmed + "/v1"; } - /** - * {@inheritDoc} - * - *

    adds this adapter's session-identity work around the base's spawn — as opencode cannot be - * told its session id at spawn (see {@link Capability#SESSION_RESUME} vs - * {@link Capability#SESSION_NAME}), identity is only ever adopted after the fact: - *

      - *
    • the request's {@code resumeSessionId} is remembered for {@link #buildLaunch} to turn - * into {@code -s }; and
    • - *
    • the returned handle is wrapped so its - * {@link dev.ltms.bridged.peer.PeerHandle#agentSessionId()} performs lazy session - * discovery against opencode's storage (see {@link OpenCodeSessionDiscovery}) — always - * non-blocking, {@code null} until opencode has persisted the session record.
    • - *
    - */ + /** Add lazy on-disk session discovery to the base handle. */ @Override public PeerHandle spawn(SpawnRequest req) { - resumeSessionId.set(req.resumeSessionId()); - try { - PeerHandle inner = super.spawn(req); - return new SessionAwareHandle(inner, discovery, effectiveCwd(req)); - } finally { - // Never let a paused/leftover resume id bleed into the next spawn on this thread. - resumeSessionId.remove(); - } + PeerHandle inner = super.spawn(req); + return new SessionAwareHandle(inner, discovery, effectiveCwd(req)); } /** diff --git a/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java index 1dfa0ba..049ca30 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/ClaudeCodeLauncherTest.java @@ -796,14 +796,18 @@ class ClaudeCodeLauncherTest { .toList(); } + private static BridgedConfig.Fleet fleet(Map charters, String tabLabel) { + return new BridgedConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(), charters, tabLabel); + } + /** A profile with no {@code tabLabel:} of its own — the fleet template decides. */ - private ClaudeCodeLauncher labelService(FakeHerdr herdr, Supplier fleetTemplate) { + private ClaudeCodeLauncher labelService(FakeHerdr herdr, Supplier fleet) { BridgedConfig.Profile cfg = new BridgedConfig.Profile( "sonnet", "http://gx00.gw:8000", "sonnet", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", null, null, null, null); return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), - _ -> null, 0, 0L, fleetTemplate); + _ -> null, 0, 0L, fleet); } /** @@ -814,7 +818,7 @@ class ClaudeCodeLauncherTest { @Test void theFleetTemplateNamesTheRoleTheMemberWasSpawnedFor() { FakeHerdr herdr = new FakeHerdr(); - ClaudeCodeLauncher svc = labelService(herdr, () -> "{role}: {profile} #{n}"); + ClaudeCodeLauncher svc = labelService(herdr, () -> fleet(null, "{role}: {profile} #{n}")); svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER)); @@ -825,7 +829,7 @@ class ClaudeCodeLauncherTest { @Test void theCounterRunsPerRoleAndProfileNotPerFleet() { FakeHerdr herdr = new FakeHerdr(); - ClaudeCodeLauncher svc = labelService(herdr, () -> "{role}: {profile} #{n}"); + ClaudeCodeLauncher svc = labelService(herdr, () -> fleet(null, "{role}: {profile} #{n}")); svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.DEV)); svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER)); @@ -839,7 +843,7 @@ class ClaudeCodeLauncherTest { @Test void aBlankFleetTemplateFallsBackToTheRoleFirstDefault() { FakeHerdr herdr = new FakeHerdr(); - labelService(herdr, () -> null).spawn( + labelService(herdr, () -> fleet(null, null)).spawn( new SpawnRequest("sonnet", null, null, null, null, MemberRole.ARCHITECT)); assertEquals(List.of("architect: sonnet #1"), tabLabels(herdr)); @@ -855,7 +859,7 @@ class ClaudeCodeLauncherTest { List.of("claude"), "tab", "bridged-workers", "pinned {profile}", null, null, null); new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), - _ -> null, 0, 0L, () -> "{role}: {profile} #{n}") + _ -> null, 0, 0L, () -> fleet(null, "{role}: {profile} #{n}")) .spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER)); assertEquals(List.of("pinned sonnet"), tabLabels(herdr)); @@ -870,7 +874,7 @@ class ClaudeCodeLauncherTest { void theTemplateIsReadOnEverySpawnSoAnEditTakesEffect() { FakeHerdr herdr = new FakeHerdr(); AtomicReference template = new AtomicReference<>("{role}: {profile} #{n}"); - ClaudeCodeLauncher svc = labelService(herdr, template::get); + ClaudeCodeLauncher svc = labelService(herdr, () -> fleet(null, template.get())); svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.DEV)); template.set("[{profile}] {role} {n}"); diff --git a/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java index d5791dc..f73ccf8 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java @@ -77,7 +77,7 @@ class CompositePeerLauncherTest { } @Override - protected Launch buildLaunch(BridgedConfig.Profile cfg) { + protected Launch buildLaunch(BridgedConfig.Profile cfg, LaunchSpec spec) { return new Launch(Map.of(), List.of()); } diff --git a/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java b/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java new file mode 100644 index 0000000..0da9740 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java @@ -0,0 +1,71 @@ +package dev.ltms.bridged.member; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.herdr.AgentControl; +import dev.ltms.bridged.herdr.FakeHerdr; +import dev.ltms.bridged.herdr.WorkspaceControl; +import dev.ltms.bridged.peer.Capability; +import dev.ltms.bridged.peer.MemberRole; +import dev.ltms.bridged.peer.SpawnRequest; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HerdrPeerLauncherCharterTest { + + @Test + void readsAndComposesTheFleetCharterForEachSpawn() { + AtomicReference fleet = new AtomicReference<>(fleet(Map.of())); + CapturingLauncher launcher = new CapturingLauncher(fleet::get); + + launcher.spawn(new SpawnRequest("mcp", null, null, null, null, MemberRole.DEV)); + fleet.set(fleet(Map.of("dev", "role charter"))); + launcher.spawn(new SpawnRequest("mcp", null, null, null, null, MemberRole.DEV)); + launcher.spawn(new SpawnRequest("no-mcp", null, null, null, null, MemberRole.DEV)); + + assertEquals(HerdrPeerLauncher.REPLY_CHARTER, launcher.specs.get(0).charter(), + "without a role charter, MCP profiles receive only the reply charter"); + assertEquals("role charter\n\n" + HerdrPeerLauncher.REPLY_CHARTER, launcher.specs.get(1).charter(), + "the changed supplier value is read for the next spawn and the reply rule is last"); + assertEquals("role charter", launcher.specs.get(2).charter(), + "a role charter does not depend on an MCP mount"); + } + + private static BridgedConfig.Fleet fleet(Map charters) { + return new BridgedConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(), charters, null); + } + + private static final class CapturingLauncher extends HerdrPeerLauncher { + private final List specs = new ArrayList<>(); + + CapturingLauncher(Supplier fleet) { + super("test", new AgentControl(new FakeHerdr()), new WorkspaceControl(new FakeHerdr()), + Map.of("mcp", profile("mcp", "http://bridge"), + "no-mcp", profile("no-mcp", null)), + "mcp", _ -> null, 0, () -> 0L, () -> { }, fleet); + } + + @Override + protected Launch buildLaunch(BridgedConfig.Profile cfg, LaunchSpec spec) { + specs.add(spec); + return new Launch(Map.of(), List.of("test")); + } + + @Override + public Set capabilities() { + return Set.of(); + } + + private static BridgedConfig.Profile profile(String name, String mcpUrl) { + return new BridgedConfig.Profile(name, "http://gx00.gw:8000", null, null, + "BRIDGED_WORKER_TOKEN", List.of("test"), "pane", null, null, mcpUrl, null, null); + } + } +}