CB-402 Increment 2: kind: discriminator on Worker profiles

Add a `kind` field to BridgedConfig.Worker — "claude-code" (default) or
"opencode" — the discriminator the CompositePeerLauncher will route spawn/reap
by so each adapter drives only its own peer kind. Normalised to lower-case;
blank/absent ⇒ claude-code, so every existing config and call site is
unchanged. argv now defaults to the kind's own binary (claude vs opencode)
rather than always `claude`, so an opencode profile never inherits the Claude
command.

kind is appended at the record tail; a new 14-arg back-compat constructor
(git fields, no kind) keeps the CB-302 call sites working, and the existing
12-arg constructor is untouched. Also drop the never-used Primary(String)
legacy constructor to keep the file warning-clean.

example.yaml documents the key and carries a commented opencode-gemini
profile. Tests cover default/normalisation/argv-defaulting. 245 tests green,
config files 0 IDE problems.
This commit is contained in:
Dai Ha
2026-07-22 05:20:04 +02:00
parent ffce30afa2
commit 6e37722383
3 changed files with 102 additions and 9 deletions
+14
View File
@@ -29,6 +29,7 @@ herdrSocket: ~/.config/herdr/herdr.sock
# Put `defaultMode: "auto"` in each ccs profile so the worker runs autonomously. # Put `defaultMode: "auto"` in each ccs profile so the worker runs autonomously.
workers: workers:
gx10: # ccs profile name (NOT a hostname) 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) baseUrl: http://gx01.gw:8000 # the vLLM host this profile targets (gx00.gw / gx01.gw)
model: coder model: coder
placement: tab placement: tab
@@ -44,6 +45,19 @@ workers:
tabLabel: "worker: {profile} #{n}" tabLabel: "worker: {profile} #{n}"
mcpUrl: http://127.0.0.1:8765/mcp mcpUrl: http://127.0.0.1:8765/mcp
argv: ["ccs", "ollama"] argv: ["ccs", "ollama"]
# CB-402: a second coding-agent kind, proving the PeerLauncher SPI is provider-neutral.
# opencode is provider-agnostic and uses NONE of Claude's private seams: no ANTHROPIC_BASE_URL /
# SubscriptionGuard (so it needs no `guard` host entry), no --mcp-config / --append-system-prompt.
# The bridge MCP + reply charter mount via a generated OPENCODE_CONFIG file, and the model is a
# `provider/model` selector. Placement, tabs, cwd, and the readiness gate are shared with Claude.
# opencode-gemini:
# kind: opencode
# model: google/gemini-2.5-pro # opencode `provider/model` selector, injected as `-m`
# placement: tab
# workspace: bridged-workers
# tabLabel: "opencode: {model} #{n}"
# mcpUrl: http://127.0.0.1:8765/mcp
# argv: ["opencode"]
defaultWorker: gx10 defaultWorker: gx10
# Subscription boundary. A worker's base_url host MUST be one of these; the primary # Subscription boundary. A worker's base_url host MUST be one of these; the primary
@@ -90,6 +90,12 @@ public record BridgedConfig(
* (minimal-grant default — push over SSH stays free, PR-create is opt-in) * (minimal-grant default — push over SSH stays free, PR-create is opt-in)
* @param gitHostEnv name of the host env var holding the forge host (default {@code GITEA_HOST}); * @param gitHostEnv name of the host env var holding the forge host (default {@code GITEA_HOST});
* injected as {@code GITEA_HOST} <em>only</em> when {@code gitTokenEnv} is set * injected as {@code GITEA_HOST} <em>only</em> when {@code gitTokenEnv} is set
* @param kind which peer launcher spawns this profile: {@code "claude-code"} (default —
* the {@link dev.ltms.bridged.worker.ClaudeCodeLauncher}) or {@code "opencode"}.
* The {@code CompositePeerLauncher} routes {@code spawn}/reap by this value, so
* each adapter drives only its own kind. Normalised to lower-case; blank ⇒ the
* default. It selects the adapter, not the transport — placement, tabs, cwd, and
* the readiness gate are kind-independent and stay in the shared base.
*/ */
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record Worker(String profile, String baseUrl, String model, public record Worker(String profile, String baseUrl, String model,
@@ -97,9 +103,23 @@ public record BridgedConfig(
String placement, String workspace, String tabLabel, String mcpUrl, String placement, String workspace, String tabLabel, String mcpUrl,
String cwd, String cwd,
List<String> parityOverlay, List<String> parityOverlay,
String gitTokenEnv, String gitHostEnv) { String gitTokenEnv, String gitHostEnv,
String kind) {
/** Peer kind spawned by {@link dev.ltms.bridged.worker.ClaudeCodeLauncher} (the default). */
public static final String KIND_CLAUDE_CODE = "claude-code";
/** Peer kind spawned by the opencode adapter (CB-402). */
public static final String KIND_OPENCODE = "opencode";
public Worker { public Worker {
argv = (argv == null || argv.isEmpty()) ? List.of("claude") : List.copyOf(argv); // 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.
String k = (kind == null || kind.isBlank()) ? KIND_CLAUDE_CODE : kind.toLowerCase();
argv = (argv == null || argv.isEmpty())
? (KIND_CLAUDE_CODE.equals(k) ? List.of("claude") : List.of(k))
: List.copyOf(argv);
kind = k;
tokenEnv = (tokenEnv == null || tokenEnv.isBlank()) ? "BRIDGED_WORKER_TOKEN" : tokenEnv; tokenEnv = (tokenEnv == null || tokenEnv.isBlank()) ? "BRIDGED_WORKER_TOKEN" : tokenEnv;
placement = (placement == null || placement.isBlank()) ? "tab" : placement.toLowerCase(); placement = (placement == null || placement.isBlank()) ? "tab" : placement.toLowerCase();
workspace = (workspace == null || workspace.isBlank()) ? "bridged-workers" : workspace; workspace = (workspace == null || workspace.isBlank()) ? "bridged-workers" : workspace;
@@ -122,13 +142,25 @@ public record BridgedConfig(
String placement, String workspace, String tabLabel, String mcpUrl, String placement, String workspace, String tabLabel, String mcpUrl,
String cwd, List<String> parityOverlay) { String cwd, List<String> parityOverlay) {
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
mcpUrl, cwd, parityOverlay, null, null); mcpUrl, cwd, parityOverlay, null, null, null);
}
/**
* 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,
String configDir, String tokenEnv, List<String> argv,
String placement, String workspace, String tabLabel, String mcpUrl,
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv) {
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, null);
} }
/** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */ /** A copy with {@code profile} set — used to default a profile to its {@code workers} key. */
public Worker withProfile(String p) { public Worker withProfile(String p) {
return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, return new Worker(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv); mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind);
} }
/** True when this profile's workers are granted a forge token to open their own PR (CB-302). */ /** True when this profile's workers are granted a forge token to open their own PR (CB-302). */
@@ -203,11 +235,6 @@ public record BridgedConfig(
*/ */
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record Primary(String terminal, Integer pushReminders, Integer pushBackoffMs) { public record Primary(String terminal, Integer pushReminders, Integer pushBackoffMs) {
/** Legacy constructor with just a terminal — both push knobs default. */
public Primary(String terminal) {
this(terminal, null, null);
}
/** @return configured reminder cap, or 5 */ /** @return configured reminder cap, or 5 */
public int remindersOrDefault() { public int remindersOrDefault() {
return pushReminders != null ? pushReminders : 5; return pushReminders != null ? pushReminders : 5;
@@ -160,4 +160,56 @@ class BridgedConfigTest {
assertTrue(cfg.primary().terminal() == null || cfg.primary().terminal().isBlank(), assertTrue(cfg.primary().terminal() == null || cfg.primary().terminal().isBlank(),
"a blank terminal in yaml should be treated as absent — null or empty are equivalent"); "a blank terminal in yaml should be treated as absent — null or empty are equivalent");
} }
// --- CB-402: peer kind discriminator -------------------------------------------------------
@Test
void workerKindDefaultsToClaudeCodeWhenOmitted(@TempDir Path dir) throws Exception {
Path f = dir.resolve("kind-absent.yaml");
Files.writeString(f, """
workers:
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(),
"a worker with no kind: is a claude-code worker (backward compatible)");
}
@Test
void opencodeKindIsNormalizedToLowerCase(@TempDir Path dir) throws Exception {
Path f = dir.resolve("kind-opencode.yaml");
Files.writeString(f, """
workers:
gemini:
kind: OpenCode
model: google/gemini-2.5-pro
argv: ["opencode"]
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(BridgedConfig.Worker.KIND_OPENCODE, cfg.workerProfiles().get("gemini").kind(),
"kind is normalised to lower-case so YAML casing does not matter");
}
@Test
void argvDefaultsToTheKindBinaryWhenUnset(@TempDir Path dir) throws Exception {
Path f = dir.resolve("kind-argv.yaml");
Files.writeString(f, """
workers:
claude:
baseUrl: http://gx10.gw:8000
gemini:
kind: opencode
model: google/gemini-2.5-pro
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(java.util.List.of("claude"), cfg.workerProfiles().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(),
"an opencode worker with no argv defaults to the opencode binary, never claude");
}
} }