From 6e3772238393463ea15bf3f9f02d438a16065d0c Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Wed, 22 Jul 2026 05:20:04 +0200 Subject: [PATCH] CB-402 Increment 2: kind: discriminator on Worker profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridged/bridged.example.yaml | 14 +++++ .../ltms/bridged/config/BridgedConfig.java | 45 ++++++++++++---- .../bridged/config/BridgedConfigTest.java | 52 +++++++++++++++++++ 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index ab6644a..28d5b8e 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -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 diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index 9845dad..4267d7e 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -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} only 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 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 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 argv, + String placement, String workspace, String tabLabel, String mcpUrl, + String cwd, List 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; diff --git a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java index 2ec3f90..4d3d61d 100644 --- a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java @@ -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"); + } }