From 07722a600772c9a9a5554a0d9e5e7e6b78d11e78 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Tue, 14 Jul 2026 16:40:25 +0200 Subject: [PATCH] CB-1xx: worker launch via ccs + inline bridge MCP mount (step 4) + port 8765 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawn a real worker with 'ccs ltms-local' (profile sets CLAUDE_CONFIG_DIR + off-sub base_url; auto mode preconfigured as defaultMode:auto). bridged appends the bridge MCP mount (--mcp-config, inline JSON) and the reply charter (--append-system-prompt) as launch FLAGS — non-invasive, nothing written to the worker's profile (safer than provisioning its config dir, which would clobber it). Identity is connection-based so the mount is shared. config: worker.mcpUrl. Default bind port 8080 -> 8765. The reply charter is guidance; the send timeout catches a non-cooperative worker. 60 tests green, IDE-clean. --- bridged/bridged.example.yaml | 8 ++- .../ltms/bridged/config/BridgedConfig.java | 12 ++++- .../ltms/bridged/worker/WorkerService.java | 52 ++++++++++++++++--- .../dev/ltms/bridged/rest/BridgedAppTest.java | 2 +- .../bridged/worker/WorkerServiceTest.java | 52 +++++++++++++++++++ 5 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml index 87b5748..e105ac2 100644 --- a/bridged/bridged.example.yaml +++ b/bridged/bridged.example.yaml @@ -6,7 +6,7 @@ # REST + MCP listen address. Keep it on loopback — bridged is same-host in Stage-1. bind: host: 127.0.0.1 - port: 8080 + port: 8765 # herdr Unix socket. Omit to use the client default # (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}). @@ -24,6 +24,12 @@ worker: placement: tab # tab | pane workspace: bridged-workers # the dedicated worker space (found-or-created, shared) tabLabel: "worker: {profile} #{n}" # {profile}/{model}/{n} substituted; {n} keeps sibling tabs distinct + # Step 4 — real worker: launch via ccs (profile sets CLAUDE_CONFIG_DIR + off-sub base_url; put + # `defaultMode: "auto"` in the profile so it runs autonomously). bridged appends the bridge MCP + # (--mcp-config, inline) + reply charter (--append-system-prompt) as flags — nothing is written + # to the profile. Omit mcpUrl (and use a bash argv) for a mechanics-only probe that never replies. + mcpUrl: http://127.0.0.1:8765/mcp + argv: ["ccs", "ltms-local"] # Subscription boundary. A worker's base_url host MUST be one of these; the primary # must carry none. Grounded in ltms-local's real endpoints. 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 fd064c3..4c8a417 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -32,7 +32,7 @@ public record BridgedConfig( public record Bind(String host, int port) { public Bind { if (host == null || host.isBlank()) host = "127.0.0.1"; - if (port <= 0) port = 8080; + if (port <= 0) port = 8765; } } @@ -53,11 +53,14 @@ public record BridgedConfig( * @param tabLabel template for a worker tab's label; {@code {profile}}/{@code {model}} * and {@code {n}} (per-worker number, to keep sibling tabs distinct) * are substituted (default {@code "worker: {profile} #{n}"}) + * @param mcpUrl bridge MCP URL to provision into the worker's {@code configDir} so it + * can call {@code bridge_reply} ({@code null}/blank → no provisioning; the + * worker won't reply, only the fallback/timeout resolves the send) */ @JsonIgnoreProperties(ignoreUnknown = true) public record Worker(String profile, String baseUrl, String model, String configDir, String tokenEnv, List argv, - String placement, String workspace, String tabLabel) { + String placement, String workspace, String tabLabel, String mcpUrl) { public Worker { argv = (argv == null || argv.isEmpty()) ? List.of("claude") : List.copyOf(argv); tokenEnv = (tokenEnv == null || tokenEnv.isBlank()) ? "BRIDGED_WORKER_TOKEN" : tokenEnv; @@ -71,6 +74,11 @@ public record BridgedConfig( return "tab".equals(placement); } + /** True when the bridge MCP should be mounted into a spawned worker (via launch flags). */ + public boolean hasMcp() { + return mcpUrl != null && !mcpUrl.isBlank(); + } + /** * Render {@link #tabLabel} for the {@code n}-th worker (substitutes * {@code {profile}}/{@code {model}}/{@code {n}}), so sibling worker tabs are distinct. diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java index 194c897..0839578 100644 --- a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java +++ b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java @@ -12,6 +12,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -46,6 +47,18 @@ public final class WorkerService { private final BridgedConfig.Worker cfg; private final Function env; // host env lookup (injectable for tests) private final AtomicLong nameSeq = new AtomicLong(); // per-worker counter (also the tab #) + + /** + * 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 a worker in the claude-bridge fleet. You receive one delegated task at a time " + + "as a prompt. When you finish the task, immediately call the bridge_reply MCP tool " + + "with `content` set to your answer — do not ask for confirmation; that reply is how " + + "the delegator receives your result."; + // Per-process token mixed into each worker name so a fresh process (nameSeq back at 0) // cannot collide with same-profile workers that outlived a restart. See startUniquelyNamed. private final String nameNonce = String.format("%06x", new SecureRandom().nextInt(1 << 24)); @@ -71,11 +84,34 @@ public final class WorkerService { String token = env.apply(cfg.tokenEnv()); putIfPresent(workerEnv, "ANTHROPIC_AUTH_TOKEN", token); - return cfg.tabPlacement() ? spawnInTab(workerEnv) : spawnAsPane(workerEnv); + // Mount the bridge MCP + reply charter as launch FLAGS (non-invasive: nothing written to + // the worker's profile/config dir). Identity is connection-based, so the mount is shared. + List argv = argvWithBridge(); + + return cfg.tabPlacement() ? spawnInTab(workerEnv, argv) : spawnAsPane(workerEnv, argv); + } + + /** + * The launch argv, plus — when {@code worker.mcpUrl} is set — inline {@code --mcp-config} for + * the bridge server and {@code --append-system-prompt} for the {@link #REPLY_CHARTER}. Neither + * touches the profile's config; both are pure command-line flags. + */ + private List argvWithBridge() { + if (!cfg.hasMcp()) { + return cfg.argv(); + } + String mcpJson = "{\"mcpServers\":{\"bridge\":{\"type\":\"http\",\"url\":\"" + + cfg.mcpUrl() + "\"}}}"; + List argv = new ArrayList<>(cfg.argv()); + argv.add("--mcp-config"); + argv.add(mcpJson); + argv.add("--append-system-prompt"); + argv.add(REPLY_CHARTER); + return argv; } /** Dedicated worker space → own tab → drop the placeholder shell so only the worker remains. */ - private Agent spawnInTab(Map workerEnv) { + private Agent spawnInTab(Map workerEnv, List argv) { Workspace space = spaces.ensureWorkspace(cfg.workspace()); Tab.Created tab = spaces.createTab(space.workspaceId()); log.info("spawning worker profile={} base_url={} space={} tab={}", @@ -83,7 +119,7 @@ public final class WorkerService { Started started; try { - started = startUniquelyNamed(workerEnv, tab.tab().tabId()); + started = startUniquelyNamed(workerEnv, argv, tab.tab().tabId()); } catch (RuntimeException e) { // The worker never started — don't leave the tab we just created orphaned. // Best-effort cleanup; never let it mask the real spawn failure. @@ -123,10 +159,10 @@ public final class WorkerService { } /** Legacy placement: herdr splits the currently-focused tab. */ - private Agent spawnAsPane(Map workerEnv) { + private Agent spawnAsPane(Map workerEnv, List argv) { log.info("spawning worker (pane placement) profile={} base_url={} argv={}", - cfg.profile(), cfg.baseUrl(), cfg.argv()); - Agent worker = startUniquelyNamed(workerEnv, null).agent(); + cfg.profile(), cfg.baseUrl(), argv); + Agent worker = startUniquelyNamed(workerEnv, argv, null).agent(); log.info("worker started pane={} terminal={}", worker.paneId(), worker.terminalId()); return worker; } @@ -145,13 +181,13 @@ public final class WorkerService { * retry is a belt-and-braces backstop for the astronomically unlikely nonce+seq clash; * the name is a label only — herdr detects kind and status from terminal output, not it. */ - private Started startUniquelyNamed(Map workerEnv, String tabId) { + private Started startUniquelyNamed(Map workerEnv, List argv, String tabId) { HerdrException last = null; for (int attempt = 0; attempt < NAME_RETRIES; attempt++) { long seq = nameSeq.incrementAndGet(); String name = "claude-" + cfg.profile() + "-" + nameNonce + "-" + seq; try { - return new Started(agents.start(name, cfg.argv(), workerEnv, tabId), seq); + return new Started(agents.start(name, argv, workerEnv, tabId), seq); } catch (HerdrException e) { if (!"agent_name_taken".equals(e.code())) throw e; log.debug("worker name '{}' taken, retrying", name); diff --git a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java index 01bbd27..b3f1ad2 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -52,7 +52,7 @@ class BridgedAppTest { private int start(FakeHerdr herdr, String workerBaseUrl, Set allow, String placement) { BridgedConfig.Worker wcfg = new BridgedConfig.Worker( "ltms-local", workerBaseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null, - placement, "bridged-workers", "worker: {profile} #{n}"); + placement, "bridged-workers", "worker: {profile} #{n}", null); AgentControl agents = new AgentControl(herdr); WorkerService workers = new WorkerService( agents, new WorkspaceControl(herdr), new SubscriptionGuard(allow), wcfg, diff --git a/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java new file mode 100644 index 0000000..b34703c --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/worker/WorkerServiceTest.java @@ -0,0 +1,52 @@ +package dev.ltms.bridged.worker; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.guard.SubscriptionGuard; +import dev.ltms.bridged.herdr.AgentControl; +import dev.ltms.bridged.herdr.FakeHerdr; +import dev.ltms.bridged.herdr.WorkspaceControl; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** The step-4 launch-flag injection: the bridge MCP + reply charter are appended to the argv. */ +class WorkerServiceTest { + + private WorkerService service(FakeHerdr herdr, List argv, String mcpUrl) { + BridgedConfig.Worker cfg = new BridgedConfig.Worker( + "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", + argv, "tab", "bridged-workers", "worker: {profile} #{n}", mcpUrl); + return new WorkerService(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), cfg, _ -> null); + } + + @SuppressWarnings("unchecked") + private List spawnedArgv(FakeHerdr herdr) { + return (List) ((Map) herdr.lastCall("agent.start").params()).get("argv"); + } + + @Test + void appendsBridgeMcpAndReplyCharterWhenMcpUrlSet() { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, List.of("ccs", "ltms-local"), "http://127.0.0.1:8765/mcp").spawn(); + + List argv = spawnedArgv(herdr); + assertEquals(List.of("ccs", "ltms-local"), argv.subList(0, 2), "base command preserved first"); + assertTrue(argv.contains("--mcp-config")); + assertTrue(argv.stream().anyMatch(a -> a.contains("\"bridge\"") && a.contains("http://127.0.0.1:8765/mcp")), + "inline bridge MCP config present"); + assertTrue(argv.contains("--append-system-prompt")); + assertTrue(argv.stream().anyMatch(a -> a.contains("bridge_reply")), "reply charter present"); + } + + @Test + void noBridgeFlagsWhenMcpUrlAbsent() { + FakeHerdr herdr = new FakeHerdr(); + service(herdr, List.of("bash", "-c", "sleep 1"), null).spawn(); + assertEquals(List.of("bash", "-c", "sleep 1"), spawnedArgv(herdr), "argv untouched without mcpUrl"); + } +}