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:
@@ -29,6 +29,7 @@ herdrSocket: ~/.config/herdr/herdr.sock
|
||||
# Put `defaultMode: "auto"` in each ccs profile so the worker runs autonomously.
|
||||
workers:
|
||||
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)
|
||||
model: coder
|
||||
placement: tab
|
||||
@@ -44,6 +45,19 @@ workers:
|
||||
tabLabel: "worker: {profile} #{n}"
|
||||
mcpUrl: http://127.0.0.1:8765/mcp
|
||||
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
|
||||
|
||||
# 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)
|
||||
* @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
|
||||
* @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)
|
||||
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 cwd,
|
||||
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 {
|
||||
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;
|
||||
placement = (placement == null || placement.isBlank()) ? "tab" : placement.toLowerCase();
|
||||
workspace = (workspace == null || workspace.isBlank()) ? "bridged-workers" : workspace;
|
||||
@@ -122,13 +142,25 @@ public record BridgedConfig(
|
||||
String placement, String workspace, String tabLabel, String mcpUrl,
|
||||
String cwd, List<String> parityOverlay) {
|
||||
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. */
|
||||
public Worker withProfile(String p) {
|
||||
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). */
|
||||
@@ -203,11 +235,6 @@ public record BridgedConfig(
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
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 */
|
||||
public int remindersOrDefault() {
|
||||
return pushReminders != null ? pushReminders : 5;
|
||||
|
||||
@@ -160,4 +160,56 @@ class BridgedConfigTest {
|
||||
assertTrue(cfg.primary().terminal() == null || cfg.primary().terminal().isBlank(),
|
||||
"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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user