Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eaa7aced3 | |||
| ef49835c4f | |||
| ef1e014b41 | |||
| e3c8393d1b | |||
| 379e03f9d0 | |||
| e13921aa8a | |||
| 8a549d8610 | |||
| 84081b2bd8 | |||
| defe3365c4 | |||
| 4e6201ecd1 | |||
| ccf50f950e | |||
| a7f0211e2f | |||
| 049ce4828c | |||
| c1173346ef | |||
| 7ace184fe6 | |||
| 54b314ace5 | |||
| 224b344445 | |||
| 0b28b4cb0f | |||
| 83129e165c |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "claude-bridge",
|
||||
"description": "Tooling for orchestrating a fleet of delegated coding agents through the bridged MCP gateway.",
|
||||
"owner": {
|
||||
"name": "LTMS"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-bridge",
|
||||
"source": "./plugin",
|
||||
"description": "Make a project bridge-ready: mount the bridged MCP gateway and apply standard Claude Code settings so a session can orchestrate delegated workers. Ships no credentials.",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "LTMS"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,11 +9,13 @@ The turn contract (one `bridge_reply`, `bridge_ask` for the lead's decisions, ho
|
||||
never merge, never commit `.mcp.json` or `wiki/`) is in **`CLAUDE.md` → Bridge communication →
|
||||
Worker** and already applies. This skill is only the *implement-and-hand-off procedure*.
|
||||
|
||||
You run in an **isolated git worktree on your own branch** — a full peer of the primary (same
|
||||
repo, `CLAUDE.md`, skills, MCP), differing in the model behind you and the branch you sit on.
|
||||
You run in an **isolated git worktree on your own branch** — a full peer of the primary (same repo,
|
||||
`CLAUDE.md`, skills), differing in the model behind you and the branch you sit on. Your MCP surface
|
||||
is **only what your launcher mounted** (the bridge): the primary's IDE and forge servers are not
|
||||
yours, and the worktree's `.mcp.json` is deliberately emptied so you cannot inherit them.
|
||||
The worktree model is documented in [`docs/Worker-Git-Workflow.md`](../../../docs/Worker-Git-Workflow.md).
|
||||
|
||||
## 1. Confirm where you are
|
||||
## 1. Confirm where you are — then never leave
|
||||
|
||||
Before touching anything:
|
||||
|
||||
@@ -26,13 +28,36 @@ git status # should be clean at the start
|
||||
Do **all** work here, on this branch. Never `git checkout main`, never rebase onto or push to
|
||||
`main`. The branch is your isolation — respect it.
|
||||
|
||||
**Every path you read, edit, or build is relative to that root.** Work from `$PWD`; if a tool, a
|
||||
brief, or your own memory hands you an absolute path, check it starts with your worktree root
|
||||
before you touch it, and stop if it doesn't. An absolute path pointing anywhere else is the
|
||||
primary's checkout — editing there while building here means **every build you run is of code that
|
||||
does not contain your changes**, and it passes while your work goes nowhere. This has happened:
|
||||
a worker made all 59 of its edits in the primary's tree and never noticed.
|
||||
|
||||
```bash
|
||||
test "$(git rev-parse --show-toplevel)" = "$PWD" || cd "$(git rev-parse --show-toplevel)"
|
||||
```
|
||||
|
||||
## 2. Implement
|
||||
|
||||
- Implement exactly the scope the lead named. Keep the diff focused; note anything out of scope
|
||||
in your reply instead of widening it.
|
||||
- Match the surrounding code's style, naming, and idioms.
|
||||
- Run whatever build/test you can — `mvn clean install` from the module root. Read its **full**
|
||||
output; a piped `mvn ... | tail` hides failures.
|
||||
|
||||
**Acceptance criterion — a green build, quoted.** Your work is not done until this passes *inside
|
||||
your worktree*:
|
||||
|
||||
```bash
|
||||
cd "$(git rev-parse --show-toplevel)/bridged" && mvn clean install
|
||||
echo "exit=$?"
|
||||
```
|
||||
|
||||
Read its **full** output — never pipe it through `tail`/`head`/`grep`, which hide a failure behind
|
||||
a zero exit. Then quote the real `Tests run: … Failures: … Errors: …` line and the
|
||||
`BUILD SUCCESS`/`FAILURE` verbatim in your reply. If it does not go green, say so with the actual
|
||||
error; a failing build honestly reported is a usable result, a claimed-green one is not. You have
|
||||
no IDE MCP tools, so `mvn` is your only verification — never claim a check you had no way to run.
|
||||
|
||||
## 3. Commit
|
||||
|
||||
@@ -41,7 +66,8 @@ git add <the files you changed> # explicitly — never `git add -A` / `git a
|
||||
git commit -m "<ticket>: <clear one-line summary>"
|
||||
```
|
||||
|
||||
`.mcp.json` will show as modified. Leave it — it is `--skip-worktree` and not yours to commit.
|
||||
`.mcp.json` is neutralized and `--skip-worktree` in your worktree — never `git add` it, and never
|
||||
"restore" it from the primary's copy. Same for `wiki/` (a submodule with its own remote).
|
||||
|
||||
## 4. Push
|
||||
|
||||
@@ -84,8 +110,9 @@ The reply is the entire handoff; the lead cannot see your terminal.
|
||||
```
|
||||
PR: <html_url from step 5, or "not created: <reason>" + branch name>
|
||||
branch: <your branch>
|
||||
files: <the files you changed>
|
||||
tests: <what you ran and its REAL result — or "not run: <why>">
|
||||
root: <git rev-parse --show-toplevel — proves you worked in your own worktree>
|
||||
files: <worktree-relative paths you changed>
|
||||
build: <the verbatim "Tests run: …" and BUILD SUCCESS/FAILURE lines — or "not run: <why>">
|
||||
summary: <2-3 lines: what you implemented and any caveat the reviewer needs>
|
||||
```
|
||||
|
||||
@@ -97,7 +124,8 @@ sequenceDiagram
|
||||
participant G as git / gitea
|
||||
|
||||
L->>I: delegated task (you are in a worktree on your branch)
|
||||
I->>I: implement + build/test here
|
||||
I->>I: "implement here — every path under $PWD"
|
||||
I->>I: "mvn clean install in this worktree, unpiped, until green"
|
||||
I->>G: git commit (never .mcp.json / wiki)
|
||||
I->>G: git push -u origin HEAD
|
||||
I->>G: POST /pulls (GITEA_TOKEN) — open PR to main
|
||||
|
||||
@@ -53,3 +53,55 @@ jobs:
|
||||
grep -qE "Failures: [1-9]|Errors: [1-9]" "$f" && { echo "===== $f ====="; cat "$f"; }
|
||||
done
|
||||
exit 0
|
||||
|
||||
# CB-521 — actually run the AMQP contract test in CI, against a REAL broker. The broker is a
|
||||
# RabbitMQ SERVICE CONTAINER, not Testcontainers-with-Docker: the runner image has no Docker, so
|
||||
# AmqpReplyInboxContractTest reads AMQP_URI (set below to the service's network alias) and binds
|
||||
# straight to it — no Docker, no skipped tests. This separation (build job hermetic and
|
||||
# Docker-free; contract job broker-provided) is deliberate — see the default-excludes/contract
|
||||
# profiles in bridged/pom.xml. `setup-java` provides the JDK only; Maven is installed separately,
|
||||
# exactly as in the build job above.
|
||||
contract:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.13 # same AMQP 0-9-1 engine the local Testcontainers fixture uses
|
||||
env:
|
||||
RABBITMQ_DEFAULT_USER: guest
|
||||
RABBITMQ_DEFAULT_PASS: guest
|
||||
ports:
|
||||
- 5672:5672
|
||||
env:
|
||||
# Service containers are reachable from the job by their network alias on their internal port.
|
||||
AMQP_URI: amqp://guest:guest@rabbitmq:5672
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
cache: maven
|
||||
|
||||
- name: Install Maven
|
||||
run: |
|
||||
apt-get update && apt-get install -y --no-install-recommends maven
|
||||
mvn -version
|
||||
|
||||
# The `contract` profile clears the default-excludes group, so the @Tag("contract") AMQP test
|
||||
# runs against the RabbitMQ service container (AMQP_URI). Pinned to the one contract test to
|
||||
# avoid re-running the unit suite already covered by the `build` job.
|
||||
- name: Contract tests
|
||||
working-directory: bridged
|
||||
run: mvn -B -Pcontract test -Dtest=AmqpReplyInboxContractTest
|
||||
|
||||
- name: Failing test output
|
||||
if: failure()
|
||||
working-directory: bridged
|
||||
run: |
|
||||
for f in target/surefire-reports/*.txt; do
|
||||
[ -f "$f" ] || continue
|
||||
grep -qE "Failures: [1-9]|Errors: [1-9]" "$f" && { echo "===== $f ====="; cat "$f"; }
|
||||
done
|
||||
exit 0
|
||||
|
||||
@@ -168,6 +168,15 @@ Before you call any work done, check the row that matches what you touched:
|
||||
| worktree provisioning or the parity overlay | the "both roles read this file" premise — it rests on the worker's worktree being a checkout of this repo |
|
||||
| `.claude/skills/**` | the addendum's skill list, and the "name the playbook" rule |
|
||||
| a new peer kind (non-Claude adapter) | what that peer can read — anything it must obey belongs in its charter, not in the block |
|
||||
| **anything an operator can use, configure, or observe** — an MCP tool, a `bridged.yaml` knob, an endpoint, a visible behaviour | **[Features](wiki/11-Features.md)** — one entry: what it does · the knob that turns it on · **why it exists** · the gotcha |
|
||||
|
||||
That last row is not bookkeeping. Chapters 1–10 answer *how is this built* and *why this way*;
|
||||
none of them has a home for *what can it do and how do I turn it on*, so for twenty tickets a
|
||||
shipped capability landed nowhere and the Roadmap went on claiming the stage was finished. The
|
||||
*why* line is the one that matters — without it a decision gets re-litigated from scratch a month
|
||||
later. Internal contract changes go to `wiki/9-Implementation.md` instead; test and coverage work
|
||||
is a Roadmap line. A change that touches none of the three earns no entry, and that is a normal
|
||||
outcome rather than an omission.
|
||||
|
||||
Then **propagate**: the block in this file and the template in the wiki
|
||||
([Use Cases](https://git.ltms.dev/lms/claude-bridge/wiki/7-Use-Cases) → *The portable `CLAUDE.md`
|
||||
|
||||
@@ -31,6 +31,17 @@ bind:
|
||||
# mode: token
|
||||
# tokenEnv: BRIDGED_API_TOKEN
|
||||
|
||||
# Optional pinned primary terminal (CB-307). Names the herdr pane the PRIMARY itself runs in:
|
||||
# a caller whose connection maps to this pane resolves as the primary (no credential needed —
|
||||
# the pane mapping is as unforgeable as a worker's), and reply nudges are pushed to it.
|
||||
# REQUIRED when the primary runs inside a herdr pane — without it the pane match reads the
|
||||
# primary as a worker and refuses spawn/send/stop. Get the id from bridge_whoami; re-pin if
|
||||
# the primary moves panes.
|
||||
# primary:
|
||||
# terminal: term_0123456789abcd
|
||||
# pushReminders: 5 # max nudges before giving up (default 5)
|
||||
# pushBackoffMs: 15000 # delay between nudges (default 15000)
|
||||
|
||||
# herdr Unix socket. Omit to use the client default
|
||||
# (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}).
|
||||
herdrSocket: ~/.config/herdr/herdr.sock
|
||||
@@ -53,7 +64,16 @@ herdrSocket: ~/.config/herdr/herdr.sock
|
||||
# skills/MCP/hooks. Omit to leave the worker on the host default.
|
||||
# parityOverlay → repo-relative paths copied primary→worktree so a worker in a provisioned
|
||||
# worktree sees the same local config (CB-301-ext). Omit for the default set:
|
||||
# [.mcp.json, .claude/settings.local.json, .env, .envrc].
|
||||
# [.claude/settings.local.json, .env, .envrc].
|
||||
#
|
||||
# Do NOT add .mcp.json (CB-525). A worker's tools are whatever its launcher
|
||||
# mounts — the bridge, and nothing else. Replicating the primary's MCP config
|
||||
# handed a worker the primary's IDE servers, which are bound to the primary's
|
||||
# checkout, so its navigation returned paths OUTSIDE its own worktree: one
|
||||
# worker made all 59 of its edits in the primary tree while compiling its
|
||||
# worktree, and every build it ran was of code that did not contain them.
|
||||
# bridged neutralizes a provisioned worktree's .mcp.json for this reason;
|
||||
# listing it here would copy the primary's back over that.
|
||||
# gitTokenEnv → host env var holding the git-forge API token. When set, its value is injected
|
||||
# as GITEA_TOKEN so the worker can open its OWN PR at checkpoint (CB-302).
|
||||
# Opt-in by design — omit and the worker gets no PR-create grant (push over
|
||||
@@ -89,18 +109,28 @@ workers:
|
||||
mcpUrl: http://127.0.0.1:8765/mcp
|
||||
tokenEnv: BRIDGED_WORKER_TOKEN
|
||||
argv: ["ccs", "gx10"]
|
||||
weight: 0.5 # relative selection weight for placement: weighted
|
||||
maxLoad: 2 # max live workers on this profile (omit for unlimited)
|
||||
# gitTokenEnv: GITEA_TOKEN # opt-in: let this profile's workers open their own PR (CB-302)
|
||||
# gitHostEnv: GITEA_HOST # defaults to GITEA_HOST; injected only with gitTokenEnv
|
||||
# configDir: /Users/me/.ccs/instances/gx10 # CLAUDE_CONFIG_DIR — inherit that profile's skills/MCP
|
||||
# cwd: /Users/me/src/myrepo # pin the working dir; omit to inherit the primary's
|
||||
# parityOverlay: [".mcp.json", ".claude/settings.local.json", ".env", ".envrc"]
|
||||
ollama:
|
||||
baseUrl: http://ollama.ltms.dev # local/self-hosted; usually no token
|
||||
# parityOverlay: [".claude/settings.local.json", ".env", ".envrc"] # never add .mcp.json — see above
|
||||
gx11: # a second backend, so `placement: weighted` has a choice
|
||||
baseUrl: http://gx01.gw:8000 # self-hosted; ccs handles the model + token
|
||||
placement: tab
|
||||
workspace: bridged-workers
|
||||
tabLabel: "worker: {profile} #{n}"
|
||||
mcpUrl: http://127.0.0.1:8765/mcp
|
||||
argv: ["ccs", "ollama"]
|
||||
argv: ["ccs", "gx11"]
|
||||
weight: 0.5
|
||||
maxLoad: 2
|
||||
# Pin an auto-compact window BELOW the served model's context ceiling. The global
|
||||
# ~/.claude/settings.json value is shared by every ccs instance and the primary, so the
|
||||
# per-profile override belongs here. Equal to the ceiling means auto-compact never fires
|
||||
# before the server rejects the prompt, which kills a worker mid-turn (CB-523).
|
||||
env:
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: "280000"
|
||||
# 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.
|
||||
@@ -144,6 +174,9 @@ workers:
|
||||
# tabLabel: "opencode: {profile} #{n}"
|
||||
# mcpUrl: http://127.0.0.1:8765/mcp
|
||||
# argv: ["opencode"]
|
||||
# How an unqualified spawn chooses a profile: fixed (default, reproduces pre-CB-518 behaviour),
|
||||
# round-robin, or weighted. Omitting this key is a strict no-op for existing configs.
|
||||
placement: weighted
|
||||
defaultWorker: gx10
|
||||
|
||||
# Subscription boundary. A worker's base_url host MUST be one of these; the primary
|
||||
@@ -152,7 +185,6 @@ guard:
|
||||
offSubscriptionHosts:
|
||||
- gx00.gw
|
||||
- gx01.gw
|
||||
- ollama.ltms.dev
|
||||
|
||||
# Spawn-readiness gate (CB-306). The launcher blocks until the worker's herdr status is
|
||||
# injectable (IDLE/BLOCKED/DONE) or the timeout elapses. 0 disables the gate.
|
||||
@@ -195,6 +227,15 @@ guard:
|
||||
# the first orchestration-side MCP call (the normal case). An off-host or
|
||||
# non-herdr primary leaves this unresolved → the loop is a no-op and delivery
|
||||
# degrades to pull; the reply is still never lost.
|
||||
#
|
||||
# REQUIRED (CB-522) if the primary itself runs inside a herdr pane. Caller
|
||||
# identity resolves a loopback PID to its herdr pane, and PaneLocator scans
|
||||
# EVERY pane — not just bridged-spawned ones — so such a primary is otherwise
|
||||
# classified as a WORKER and refused SPAWN/SEND/STOP. That failure is
|
||||
# self-locking: the learned terminal is populated by the very orchestration
|
||||
# calls being refused, so only this pinned value can break the cycle. Read the
|
||||
# id off bridge_whoami (it reports the current terminal even while
|
||||
# misclassified) and re-pin whenever the primary moves panes.
|
||||
# pushReminders → max nudges before giving up (default 5)
|
||||
# pushBackoffMs → delay between nudges in ms (default 15000)
|
||||
# primary:
|
||||
|
||||
+21
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>dev.ltms</groupId>
|
||||
<artifactId>bridged</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>bridged</name>
|
||||
@@ -236,6 +236,26 @@
|
||||
<profile>
|
||||
<id>contract</id>
|
||||
<properties><excludedGroups/></properties>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- Docker-engine compat (see "Running the contract tests" in
|
||||
docs/CB-307-Reliable-Delivery.md): Testcontainers 1.20.4's docker-java
|
||||
client defaults to Docker API 1.32 when no version is set, but modern
|
||||
engines (OrbStack on this dev host, min 1.40) reject that as too old —
|
||||
which surfaces as "Could not find a valid Docker environment". Pinning
|
||||
api.version=1.43 works on OrbStack and Docker 24+, and is overridable
|
||||
per-host via -Dapi.version. Only active under -Pcontract, so the
|
||||
default hermetic build never sets it. -->
|
||||
<systemPropertyVariables>
|
||||
<api.version>1.43</api.version>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
|
||||
@@ -33,6 +33,7 @@ import dev.ltms.bridged.session.SessionManager;
|
||||
import dev.ltms.bridged.peer.PeerLauncher;
|
||||
import dev.ltms.bridged.session.SessionReaper;
|
||||
import dev.ltms.bridged.worker.ClaudeCodeLauncher;
|
||||
import dev.ltms.bridged.placement.PlacementPolicies;
|
||||
import dev.ltms.bridged.worker.CompositePeerLauncher;
|
||||
import dev.ltms.bridged.worker.HerdrPeerLauncher;
|
||||
import dev.ltms.bridged.worker.OpenCodeLauncher;
|
||||
@@ -46,6 +47,8 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* {@code bridged} entry point. Wires the real herdr socket client to the REST app and
|
||||
@@ -111,7 +114,13 @@ public final class Bridged {
|
||||
opencodeProfiles, cfg.defaultProfile(), System::getenv,
|
||||
cfg.spawnReadyTimeoutMs(), cfg.spawnReadyPollMs()));
|
||||
}
|
||||
PeerLauncher workers = new CompositePeerLauncher(adapters, cfg.defaultProfile());
|
||||
AtomicReference<Function<String, Integer>> liveCountRef = new AtomicReference<>(name -> 0);
|
||||
PeerLauncher workers = new CompositePeerLauncher(
|
||||
adapters,
|
||||
cfg.defaultProfile(),
|
||||
cfg.workerProfiles(),
|
||||
PlacementPolicies.fromName(cfg.placement()),
|
||||
profileName -> liveCountRef.get().apply(profileName));
|
||||
// CB-504: under supervision (launchd/systemd) bridged can start before herdr's socket
|
||||
// exists. The client itself is lazy — it connects per call — but the orphan reap below is
|
||||
// the first thing that actually talks to herdr, so without this wait a boot-order race
|
||||
@@ -136,6 +145,9 @@ public final class Bridged {
|
||||
contextCap = cfg.lifecycle().contextCap();
|
||||
}
|
||||
SessionManager sessions = new SessionManager(workers, new GitWorktrees(cfg.worktreeRoot()), contextCap);
|
||||
liveCountRef.set(profileName -> (int) sessions.roster().stream()
|
||||
.filter(s -> profileName.equals(s.profile()))
|
||||
.count());
|
||||
|
||||
// CB-303 part 1: idle-ttl reaper — only when configured, defaults to disabled.
|
||||
final SessionReaper reaper;
|
||||
@@ -191,8 +203,10 @@ public final class Bridged {
|
||||
log.info("reply inbox: in-memory (soft-state)");
|
||||
}
|
||||
// CB-307: learn the primary's terminal from orchestration tool calls (or pin from config).
|
||||
PrimaryRegistry primaryRegistry = new PrimaryRegistry(
|
||||
cfg.primary() != null ? cfg.primary().terminal() : null);
|
||||
// The pin also feeds CallerResolver below: a primary running inside a herdr pane would
|
||||
// otherwise resolve as a worker and be refused every orchestration tool.
|
||||
String pinnedPrimaryTerminal = cfg.primary() != null ? cfg.primary().terminal() : null;
|
||||
PrimaryRegistry primaryRegistry = new PrimaryRegistry(pinnedPrimaryTerminal);
|
||||
|
||||
// CB-307: active push-to-primary loop — nudge the primary when replies land without an
|
||||
// open bridge_send. Uses its own lightweight scheduled executor, separate from the injector.
|
||||
@@ -209,11 +223,16 @@ public final class Bridged {
|
||||
MessageService messages = new MessageService(agents, injector, rendezvous, replyInbox,
|
||||
pushLoop, metrics);
|
||||
|
||||
// CB-520: the reply inbox only consumes for agents this gateway owns. own on acquire,
|
||||
// release on teardown. Do this before CB-516 so the inbox is owned before any reply can land.
|
||||
sessions.onAcquire(replyInbox::own);
|
||||
// CB-516: releasing a worker must fail whatever send was waiting on it. Without this a
|
||||
// torn-down delegation kept reporting PENDING until the 30-minute async timeout, and never
|
||||
// reached /metrics — the delegation was unresolvable and nothing said so.
|
||||
sessions.onRelease(terminal ->
|
||||
messages.abandon(terminal, "the worker session was released before it replied"));
|
||||
sessions.onRelease(terminal -> {
|
||||
messages.abandon(terminal, "the worker session was released before it replied");
|
||||
replyInbox.release(terminal);
|
||||
});
|
||||
|
||||
// MCP server face (CB-105): bridge_send/bridge_reply/bridge_status, mounted at /mcp.
|
||||
// Caller identity is resolved from the connection (peer PID → herdr pane), not arguments.
|
||||
@@ -229,11 +248,11 @@ public final class Bridged {
|
||||
throw new IllegalStateException("auth.mode=token but env var " + cfg.auth().tokenEnv()
|
||||
+ " is unset or empty — export it before starting bridged");
|
||||
}
|
||||
callers = new CallerResolver(identity, true, token);
|
||||
callers = new CallerResolver(identity, true, token, pinnedPrimaryTerminal);
|
||||
log.info("auth: token mode (bearer required for non-worker callers, env {})",
|
||||
cfg.auth().tokenEnv());
|
||||
} else {
|
||||
callers = new CallerResolver(identity);
|
||||
callers = new CallerResolver(identity, false, null, pinnedPrimaryTerminal);
|
||||
log.info("auth: loopback-trust (any loopback non-worker caller is the primary)");
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ import java.security.MessageDigest;
|
||||
*
|
||||
* <p><strong>Resolution order</strong> — connection identity first, token second, nothing third:
|
||||
* <ol>
|
||||
* <li>A loopback peer PID that maps to a herdr worker pane ⇒ {@link Role#WORKER}. This is
|
||||
* <li>A loopback peer PID that maps to the pinned {@code primary.terminal} pane (CB-307) ⇒
|
||||
* {@link Role#PRIMARY}. The pane mapping is as unforgeable as a worker's, and the config
|
||||
* explicitly names that pane as the primary's own — without this rule a primary running
|
||||
* <em>inside</em> a herdr pane is misread as a worker and locked out of orchestration.</li>
|
||||
* <li>A loopback peer PID that maps to any other herdr pane ⇒ {@link Role#WORKER}. This is
|
||||
* unforgeable (the OS reports the PID, herdr owns the PID→pane map) and is honoured
|
||||
* regardless of auth mode, so enabling auth never breaks the fleet.</li>
|
||||
* <li>Otherwise, under {@code token} mode, a valid bearer token ⇒ {@link Role#PRIMARY}.</li>
|
||||
@@ -29,18 +33,29 @@ public final class CallerResolver {
|
||||
private final ConnectionIdentity identity;
|
||||
private final boolean tokenMode;
|
||||
private final byte[] expectedToken; // null unless tokenMode
|
||||
private final String pinnedPrimaryTerminal; // null unless primary.terminal is configured
|
||||
|
||||
/** Loopback-trust resolver: no token required, historical behaviour. */
|
||||
public CallerResolver(ConnectionIdentity identity) {
|
||||
this(identity, false, null);
|
||||
this(identity, false, null, null);
|
||||
}
|
||||
|
||||
/** As {@link #CallerResolver(ConnectionIdentity, boolean, String, String)} with no pin. */
|
||||
public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token) {
|
||||
this(identity, tokenMode, token, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param identity connection-based worker identification
|
||||
* @param tokenMode when true, a non-worker caller must present a valid bearer token
|
||||
* @param token the expected bearer token; required (non-blank) when {@code tokenMode}
|
||||
* @param identity connection-based worker identification
|
||||
* @param tokenMode when true, a non-worker caller must present a valid bearer token
|
||||
* @param token the expected bearer token; required (non-blank) when
|
||||
* {@code tokenMode}
|
||||
* @param pinnedPrimaryTerminal the primary's own herdr {@code terminal_id} from
|
||||
* {@code primary.terminal} ({@code null}/blank = unpinned); a
|
||||
* caller resolving to this pane is the primary, not a worker
|
||||
*/
|
||||
public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token) {
|
||||
public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token,
|
||||
String pinnedPrimaryTerminal) {
|
||||
if (tokenMode && (token == null || token.isBlank())) {
|
||||
throw new IllegalArgumentException(
|
||||
"auth.mode=token requires a non-empty token; check that the env var named by "
|
||||
@@ -49,6 +64,9 @@ public final class CallerResolver {
|
||||
this.identity = identity;
|
||||
this.tokenMode = tokenMode;
|
||||
this.expectedToken = tokenMode ? token.getBytes(StandardCharsets.UTF_8) : null;
|
||||
this.pinnedPrimaryTerminal =
|
||||
pinnedPrimaryTerminal == null || pinnedPrimaryTerminal.isBlank()
|
||||
? null : pinnedPrimaryTerminal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +79,11 @@ public final class CallerResolver {
|
||||
public Principal resolve(String remoteAddr, int remotePort, String authorizationHeader) {
|
||||
ConnectionIdentity.Caller c = identity.resolve(remoteAddr, remotePort);
|
||||
if (c.terminal() != null) {
|
||||
if (c.terminal().equals(pinnedPrimaryTerminal)) {
|
||||
// The config names this pane as the primary's own. The pane mapping is exactly as
|
||||
// unforgeable as a worker's, so it outranks the token path — no credential needed.
|
||||
return Principal.primary(c.pid());
|
||||
}
|
||||
return Principal.worker(c.terminal(), c.pid()); // unforgeable; never token-gated
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -36,6 +37,8 @@ import java.util.Set;
|
||||
* @param primary optional pinned primary terminal config ({@code null} → derived from connection);
|
||||
* a non-blank {@code terminal} seeds {@code PrimaryRegistry} and prevents
|
||||
* connection-derived overrides, CB-307
|
||||
* @param placement how to choose a worker profile for an unqualified spawn:
|
||||
* {@code fixed} (default), {@code round-robin}, or {@code weighted}
|
||||
* @param auth API authentication mode ({@code null} → {@code loopback-trust}, the
|
||||
* historical behaviour), CB-501
|
||||
*/
|
||||
@@ -53,6 +56,7 @@ public record BridgedConfig(
|
||||
Integer spawnReadyPollMs,
|
||||
Broker broker,
|
||||
Primary primary,
|
||||
String placement,
|
||||
Auth auth) {
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -93,6 +97,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 weight relative selection weight for {@code placement: weighted}. Absent or
|
||||
* non-positive ⇒ 1.0. Weights are normalised by the policy, so they need
|
||||
* not sum to 1.0.
|
||||
* @param maxLoad max live workers allowed on this profile at one time; absent or
|
||||
* non-positive ⇒ unlimited. Live means any session the registry still owns
|
||||
* (acquired and not yet released), in any state.
|
||||
* @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
|
||||
@@ -108,12 +118,20 @@ public record BridgedConfig(
|
||||
List<String> parityOverlay,
|
||||
String gitTokenEnv, String gitHostEnv,
|
||||
String kind,
|
||||
Map<String, String> env) {
|
||||
Map<String, String> env,
|
||||
Float weight,
|
||||
Integer maxLoad) {
|
||||
|
||||
/** 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";
|
||||
/**
|
||||
* Peer kind spawned by the Codex adapter (CB-528). Like {@link #KIND_OPENCODE} it carries
|
||||
* its own argv and never inherits the Claude binary, and it sits outside the
|
||||
* {@code ANTHROPIC_BASE_URL} subscription guard because Codex has no such seam.
|
||||
*/
|
||||
public static final String KIND_CODEX = "codex";
|
||||
|
||||
public Worker {
|
||||
// A claude-code worker defaults its launch command to `claude`; other kinds carry their own
|
||||
@@ -128,13 +146,19 @@ public record BridgedConfig(
|
||||
placement = (placement == null || placement.isBlank()) ? "tab" : placement.toLowerCase();
|
||||
workspace = (workspace == null || workspace.isBlank()) ? "bridged-workers" : workspace;
|
||||
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? "worker: {profile} #{n}" : tabLabel;
|
||||
// CB-525: .mcp.json is deliberately NOT here. Replicating the primary's MCP config gave a
|
||||
// worker the primary's IDE servers, which are bound to the primary's checkout — so its
|
||||
// navigation returned paths outside its own worktree. GitWorktrees now neutralizes that
|
||||
// file instead; a worker's tools are whatever its launcher mounts.
|
||||
parityOverlay = (parityOverlay == null || parityOverlay.isEmpty())
|
||||
? List.of(".mcp.json", ".claude/settings.local.json", ".env", ".envrc")
|
||||
? List.of(".claude/settings.local.json", ".env", ".envrc")
|
||||
: List.copyOf(parityOverlay);
|
||||
// gitTokenEnv stays null when unset (opt-in). gitHostEnv defaults so operators enabling
|
||||
// checkpoints need only set gitTokenEnv; it is injected only alongside a resolved token.
|
||||
gitHostEnv = (gitHostEnv == null || gitHostEnv.isBlank()) ? "GITEA_HOST" : gitHostEnv;
|
||||
env = (env == null) ? Map.of() : Map.copyOf(env);
|
||||
weight = (weight == null || weight <= 0.0f) ? 1.0f : weight;
|
||||
maxLoad = (maxLoad == null || maxLoad <= 0) ? null : maxLoad;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,7 +171,7 @@ 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, null);
|
||||
mcpUrl, cwd, parityOverlay, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,7 +183,7 @@ public record BridgedConfig(
|
||||
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, null);
|
||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,13 +196,13 @@ public record BridgedConfig(
|
||||
String cwd, List<String> parityOverlay, String gitTokenEnv, String gitHostEnv,
|
||||
String kind) {
|
||||
this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel,
|
||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, null);
|
||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, null, null, 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, kind, env);
|
||||
mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad);
|
||||
}
|
||||
|
||||
/** True when this profile is served by the Claude Code adapter (the default kind). */
|
||||
@@ -254,8 +278,11 @@ public record BridgedConfig(
|
||||
/**
|
||||
* Optional pinned primary terminal config (CB-307). When present with a non-blank
|
||||
* {@code terminal}, the bridge uses this as the primary's herdr identity instead of
|
||||
* deriving it from the MCP connection. Useful when the primary runs off-host or in a
|
||||
* non-herdr terminal where connection-derived identity is unavailable.
|
||||
* deriving it from the MCP connection. It feeds two consumers: the push loop (where to nudge
|
||||
* when replies land), and caller resolution — a caller whose connection maps to this pane is
|
||||
* the primary, where the pane match would otherwise classify it as a worker. Pin it when the
|
||||
* primary runs <em>inside</em> a herdr pane; it also helps off-host or non-herdr primaries,
|
||||
* where connection-derived identity is unavailable and only the nudge target matters.
|
||||
*
|
||||
* @param terminal the primary's herdr {@code terminal_id} ({@code null}/blank → derive)
|
||||
* @param pushReminders max reminder nudges before giving up (default 5)
|
||||
@@ -338,7 +365,11 @@ public record BridgedConfig(
|
||||
Map<String, Worker> out = new LinkedHashMap<>();
|
||||
workers.forEach((name, w) -> out.put(name,
|
||||
(w.profile() == null || w.profile().isBlank()) ? w.withProfile(name) : w));
|
||||
return Map.copyOf(out);
|
||||
// Deliberately NOT Map.copyOf: its iteration order is salted per JVM run, which would
|
||||
// discard the YAML definition order built above. Placement tie-breaks on candidate
|
||||
// order (see WeightedRoundRobinPolicy), so losing it makes equal-weight placement
|
||||
// non-reproducible across restarts. Unmodifiable-wrap instead of copy-and-scramble.
|
||||
return Collections.unmodifiableMap(out);
|
||||
}
|
||||
if (worker != null) {
|
||||
String name = (worker.profile() == null || worker.profile().isBlank()) ? "default" : worker.profile();
|
||||
@@ -382,9 +413,10 @@ public record BridgedConfig(
|
||||
Integer timeout = (spawnReadyTimeoutMs != null) ? spawnReadyTimeoutMs : 20000;
|
||||
Integer pollMs = (spawnReadyPollMs != null) ? spawnReadyPollMs : 300;
|
||||
Auth a = auth != null ? auth : new Auth(null, null);
|
||||
String placementOrDefault = (placement != null && !placement.isBlank()) ? placement : "fixed";
|
||||
// broker is left as-is: null (or an empty/blank uri) keeps the in-memory soft-state inbox.
|
||||
// primary is left as-is: null defaults to connection-derived identity.
|
||||
return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g, worktreeRoot, l, timeout, pollMs, broker, primary, a);
|
||||
return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g, worktreeRoot, l, timeout, pollMs, broker, primary, placementOrDefault, a);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,93 +6,120 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Domain layer over herdr's native {@code agent.*} namespace — the worker south side.
|
||||
* Chosen in the CB-102 spike over the pane + {@code send_text} fallback because
|
||||
* {@code agent.start} takes a first-class {@code env} map (clean, guard-checked
|
||||
* subscription injection) and herdr tracks each worker's Claude session UUID itself.
|
||||
* Chosen in the CB-102 spike over the pane + {@code send_text} fallback because herdr
|
||||
* tracks each worker's Claude session UUID itself.
|
||||
*
|
||||
* <p>Ported to herdr protocol 19 (herdr 0.8.0, CB-521): {@code agent.start} now starts a
|
||||
* <em>supported</em> agent ({@code kind}) into an <em>existing</em> pane, so the worker's
|
||||
* {@code env}/{@code cwd} move to pane creation ({@code tab.create}/{@code pane.split} — see
|
||||
* {@link WorkspaceControl}), and {@code agent.send} is replaced by {@code agent.prompt}
|
||||
* (which submits in one call) plus {@code agent.send_keys} for the raw Enter nudge.
|
||||
*
|
||||
* <p>Every method is one herdr call through the injected {@link HerdrClient}, so this
|
||||
* layer is unit-testable with a fake and contract-tested against a live daemon.
|
||||
*/
|
||||
public final class AgentControl {
|
||||
|
||||
/**
|
||||
* The keystroke that submits a prompt in the Claude Code TUI: a carriage return (Enter).
|
||||
* It must be delivered as its <em>own</em> {@code agent.send} call — herdr delivers a message's
|
||||
* text as a bracketed paste, and a {@code "\r"} appended to that same text is swallowed as
|
||||
* literal newline content, not a submit. Sent as a separate keystroke event it lands outside
|
||||
* the paste and submits. (A bare {@code "\n"} inserts a newline either way.) Verified live
|
||||
* against Claude Code v2.1.210: an injected task stayed unsubmitted with {@code "text\r"} in
|
||||
* one call, and submitted the instant a standalone {@code "\r"} was sent.
|
||||
*/
|
||||
static final String SUBMIT_KEY = "\r";
|
||||
|
||||
private final HerdrClient herdr;
|
||||
|
||||
/**
|
||||
* Protocol 19 dropped {@code terminal_id} as an {@code agent.*} target — herdr now resolves
|
||||
* targets by pane id or agent name only, while the bridge keys every session on the terminal.
|
||||
* This caches the terminal→pane mapping (stable for a worker's lifetime) so callers keep
|
||||
* addressing agents by terminal; entries are invalidated on {@code agent_not_found}.
|
||||
*/
|
||||
private final Map<String, String> paneByTerminal = new ConcurrentHashMap<>();
|
||||
|
||||
public AgentControl(HerdrClient herdr) {
|
||||
this.herdr = herdr;
|
||||
}
|
||||
|
||||
/** One agent-targeted call, translating a terminal id to its pane id (retrying once fresh). */
|
||||
private JsonNode agentCall(String method, String target, Map<String, Object> extra) {
|
||||
String resolved = resolveTarget(target);
|
||||
try {
|
||||
return herdr.call(method, withTarget(resolved, extra));
|
||||
} catch (HerdrException e) {
|
||||
if (!"agent_not_found".equals(e.code()) || resolved.equals(target)) throw e;
|
||||
paneByTerminal.remove(target); // the cached pane went away — re-resolve once
|
||||
String fresh = resolveTarget(target);
|
||||
if (fresh.equals(resolved)) throw e;
|
||||
return herdr.call(method, withTarget(fresh, extra));
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> withTarget(String target, Map<String, Object> extra) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("target", target);
|
||||
m.putAll(extra);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** The pane id behind a terminal-id target, or the target verbatim for pane ids / names. */
|
||||
private String resolveTarget(String target) {
|
||||
if (target == null || !target.startsWith("term_")) {
|
||||
return target;
|
||||
}
|
||||
String cached = paneByTerminal.get(target);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
for (JsonNode a : herdr.call("agent.list").path("agents")) {
|
||||
if (target.equals(a.path("terminal_id").asText(null))) {
|
||||
String pane = a.path("pane_id").asText(null);
|
||||
if (pane != null) {
|
||||
paneByTerminal.put(target, pane);
|
||||
return pane;
|
||||
}
|
||||
}
|
||||
}
|
||||
return target; // unknown terminal — let herdr report it against the original target
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an agent. {@code env} is applied to the process environment verbatim — this
|
||||
* is where a worker's {@code ANTHROPIC_BASE_URL} lives, and the ONLY place it should.
|
||||
* Start an agent into {@code paneId}, which must be sitting at its interactive shell prompt —
|
||||
* the seed pane of a freshly-created worker tab, or a fresh split. The pane's shell already
|
||||
* carries the worker's env ({@code ANTHROPIC_BASE_URL}, token, …) and cwd from pane creation;
|
||||
* herdr resolves the executable from {@code kind} and waits (its default timeout) until the
|
||||
* agent is detected and ready for input.
|
||||
*
|
||||
* @param name label/kind for herdr status detection (e.g. {@code "claude"})
|
||||
* @param argv launch command, e.g. {@code ["claude"]}
|
||||
* @param env process environment additions ({@code ANTHROPIC_BASE_URL}, token, …)
|
||||
* @param name unique label for this agent ({@code <kind>-<profile>-<nonce>-<seq>})
|
||||
* @param kind supported agent kind and canonical executable, e.g. {@code "claude"},
|
||||
* {@code "opencode"}
|
||||
* @param args extra arguments after the executable, e.g. {@code --mcp-config …}
|
||||
* @param paneId the pane to start the agent in
|
||||
*/
|
||||
public Agent start(String name, List<String> argv, Map<String, String> env) {
|
||||
return start(name, argv, env, null);
|
||||
}
|
||||
|
||||
/** Spawn an agent into {@code tabId} at herdr's default cwd. */
|
||||
public Agent start(String name, List<String> argv, Map<String, String> env, String tabId) {
|
||||
return start(name, argv, env, tabId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an agent. With a non-null {@code tabId} the worker lands in that tab (the placement
|
||||
* policy's dedicated worker tab); with {@code null} herdr splits the currently-focused tab
|
||||
* (legacy pane placement). A non-blank {@code cwd} sets the worker process's working directory —
|
||||
* {@code agent.start} honours {@code cwd} directly (an agent pane does <em>not</em> inherit the
|
||||
* tab's or workspace's cwd, so this is the only way to root a worker in the primary's directory;
|
||||
* CB-112).
|
||||
*/
|
||||
public Agent start(String name, List<String> argv, Map<String, String> env, String tabId, String cwd) {
|
||||
public Agent start(String name, String kind, List<String> args, String paneId) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("name", name);
|
||||
params.put("argv", argv);
|
||||
params.put("env", env);
|
||||
if (tabId != null) {
|
||||
params.put("tab_id", tabId);
|
||||
}
|
||||
if (cwd != null && !cwd.isBlank()) {
|
||||
params.put("cwd", cwd);
|
||||
}
|
||||
params.put("kind", kind);
|
||||
params.put("pane_id", paneId);
|
||||
params.put("args", args);
|
||||
JsonNode result = herdr.call("agent.start", params);
|
||||
return Agent.from(result.get("agent"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver {@code text} to an agent as its next prompt <em>and submit it</em> — two keystroke
|
||||
* events: the message (a bracketed paste, so any embedded newlines are preserved verbatim),
|
||||
* then a standalone {@link #SUBMIT_KEY} (Enter) that actually submits it. Without the second
|
||||
* event the text just sits in the worker's input box, never processed (see {@link #SUBMIT_KEY}).
|
||||
* Deliver {@code text} to an agent as its next prompt <em>and submit it</em> — herdr's
|
||||
* {@code agent.prompt} pastes the text (embedded newlines preserved verbatim) and submits it
|
||||
* in the same call, replacing the pre-protocol-19 two-event {@code agent.send} dance.
|
||||
*/
|
||||
public void send(String target, String text) {
|
||||
herdr.call("agent.send", Map.of("target", target, "text", text));
|
||||
herdr.call("agent.send", Map.of("target", target, "text", SUBMIT_KEY));
|
||||
agentCall("agent.prompt", target, Map.of("text", text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-send the submit keystroke (Enter) to {@code target}. The Enter that accompanies a delivery
|
||||
* can race the paste — especially right as the worker's TUI becomes interactive — leaving the
|
||||
* text unsubmitted; the injector nudges it with this until the worker actually picks up (CB-113).
|
||||
* Re-send the submit keystroke (Enter) to {@code target}. The submit that accompanies a
|
||||
* delivery can race the paste — especially right as the worker's TUI becomes interactive —
|
||||
* leaving the text unsubmitted; the injector nudges it with this until the worker actually
|
||||
* picks up (CB-113).
|
||||
*/
|
||||
public void submit(String target) {
|
||||
herdr.call("agent.send", Map.of("target", target, "text", SUBMIT_KEY));
|
||||
agentCall("agent.send_keys", target, Map.of("keys", List.of("enter")));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,13 +128,13 @@ public final class AgentControl {
|
||||
* @param source one of {@code visible|recent|recent_unwrapped|detection}
|
||||
*/
|
||||
public String read(String target, String source) {
|
||||
JsonNode result = herdr.call("agent.read", Map.of("target", target, "source", source));
|
||||
JsonNode result = agentCall("agent.read", target, Map.of("source", source));
|
||||
return result.path("read").path("text").asText("");
|
||||
}
|
||||
|
||||
/** Current agent record (status, session UUID, pane). */
|
||||
public Agent get(String target) {
|
||||
return Agent.from(herdr.call("agent.get", Map.of("target", target)).get("agent"));
|
||||
return Agent.from(agentCall("agent.get", target, Map.of()).get("agent"));
|
||||
}
|
||||
|
||||
/** Just the lifecycle status — what the status-gated injector checks before send. */
|
||||
|
||||
@@ -23,9 +23,9 @@ public record Tab(String tabId, String workspaceId, String label, int paneCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* A freshly-created tab together with the placeholder shell pane herdr seeds it with.
|
||||
* The caller starts the worker into {@link #tab()} then closes {@link #rootPaneId()} so
|
||||
* only the worker pane remains.
|
||||
* A freshly-created tab together with the shell pane herdr seeds it with. Under protocol 19
|
||||
* the caller starts the worker <em>into</em> {@link #rootPaneId()} — the seed pane's shell
|
||||
* carries the worker's cwd and env from {@code tab.create}, and becomes the worker pane.
|
||||
*/
|
||||
public record Created(Tab tab, String rootPaneId) {
|
||||
/** Project a {@code tab_created} result ({@code {tab, root_pane}}). */
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -65,13 +66,37 @@ public final class WorkspaceControl {
|
||||
}
|
||||
|
||||
/**
|
||||
* A brand-new tab in {@code workspaceId} plus the placeholder shell pane herdr seeds it with.
|
||||
* Start the worker into the tab, then {@code pane.close} the root pane so the tab holds only the
|
||||
* worker. (The worker's own cwd is set on {@code agent.start}, not here — an {@code agent.start}
|
||||
* pane does not inherit the tab's cwd; see {@code AgentControl.start}.)
|
||||
* A brand-new tab in {@code workspaceId} plus the shell pane herdr seeds it with. Under
|
||||
* protocol 19 that seed pane is where the worker <em>starts</em>: its shell carries
|
||||
* {@code cwd} and {@code env} (the worker's {@code ANTHROPIC_BASE_URL} — this is the
|
||||
* subscription-injection seam now), and {@code agent.start} launches the agent into it.
|
||||
*/
|
||||
public Tab.Created createTab(String workspaceId) {
|
||||
return Tab.Created.from(herdr.call("tab.create", Map.of("workspace_id", workspaceId)));
|
||||
public Tab.Created createTab(String workspaceId, String cwd, Map<String, String> env) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("workspace_id", workspaceId);
|
||||
if (cwd != null && !cwd.isBlank()) {
|
||||
params.put("cwd", cwd);
|
||||
}
|
||||
if (env != null && !env.isEmpty()) {
|
||||
params.put("env", env);
|
||||
}
|
||||
return Tab.Created.from(herdr.call("tab.create", params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the currently-focused tab and return the new pane's id — the legacy pane placement's
|
||||
* seed pane, carrying {@code cwd} and {@code env} exactly as {@link #createTab}'s does.
|
||||
*/
|
||||
public String splitPane(String cwd, Map<String, String> env) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("direction", "right");
|
||||
if (cwd != null && !cwd.isBlank()) {
|
||||
params.put("cwd", cwd);
|
||||
}
|
||||
if (env != null && !env.isEmpty()) {
|
||||
params.put("env", env);
|
||||
}
|
||||
return herdr.call("pane.split", params).path("pane").path("pane_id").asText(null);
|
||||
}
|
||||
|
||||
/** Give a worker's tab a human label in the tab bar. */
|
||||
|
||||
@@ -575,15 +575,19 @@ public final class BridgeMcp {
|
||||
"default", workers.defaultProfile() == null ? "" : workers.defaultProfile())));
|
||||
}
|
||||
|
||||
/** {@code bridge_list}: bridge-owned roster merged with live herdr status by paneId. */
|
||||
/**
|
||||
* {@code bridge_list}: bridge-owned roster merged with live herdr status. CB-519 decoupled the
|
||||
* registry key (a host-unique id) from the herdr pane coordinate, so the join is on the
|
||||
* terminal id, which both the session and the live agent carry.
|
||||
*/
|
||||
static McpSchema.CallToolResult listWorkers(PeerLauncher workers, SessionManager sessions) {
|
||||
try {
|
||||
Map<String, Agent> live = workers.list().stream()
|
||||
.map(Agent.class::cast)
|
||||
.filter(a -> a.paneId() != null)
|
||||
.collect(Collectors.toMap(Agent::paneId, Function.identity(), (_, b) -> b));
|
||||
.filter(a -> a.terminalId() != null)
|
||||
.collect(Collectors.toMap(Agent::terminalId, Function.identity(), (_, b) -> b));
|
||||
List<Map<String, Object>> out = sessions.roster().stream()
|
||||
.map(s -> SessionManager.rosterView(s, live.get(s.paneId())))
|
||||
.map(s -> SessionManager.rosterView(s, live.get(s.terminalId())))
|
||||
.toList();
|
||||
return text(json(Map.of("workers", out)));
|
||||
} catch (HerdrException e) {
|
||||
|
||||
@@ -14,25 +14,30 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* AMQP-backed {@link ReplyInbox} (CB-307 Stage 2): genuine cross-restart durability behind the same
|
||||
* port {@link InMemoryReplyInbox} implements as soft state.
|
||||
*
|
||||
* <p><strong>Mapping — consume-and-hold with deferred manual ack.</strong> Each target owns a durable
|
||||
* queue {@code agent.<target>.inbox}. A manual-ack consumer pulls persistent messages off that queue
|
||||
* into an in-memory <em>held</em> map (keyed by {@code msgId}) but does <em>not</em> ack them.
|
||||
* {@link #peek} returns that snapshot; {@link #ack} acks the broker delivery-tag and drops the entry.
|
||||
* Because messages stay unacked until the primary actually drains them, a crash (or a {@code java -jar}
|
||||
* bounce) before caller-ack leaves them on the broker — it redelivers on reconnect. That is the
|
||||
* durability the in-memory adapter cannot give, with the port contract preserved.
|
||||
* <p><strong>Mapping — consume-and-hold with deferred manual ack.</strong> Each target has a durable
|
||||
* queue {@code agent.<target>.inbox}. The gateway that owns the target starts a manual-ack consumer
|
||||
* ({@link #own}) that pulls persistent messages off that queue into an in-memory <em>held</em> map
|
||||
* (keyed by {@code msgId}) but does <em>not</em> ack them. {@link #peek} returns that snapshot;
|
||||
* {@link #ack} acks the broker delivery-tag and drops the entry. Because messages stay unacked until
|
||||
* the owning gateway actually drains them, a crash (or a {@code java -jar} bounce) before caller-ack
|
||||
* leaves them on the broker — it redelivers on reconnect. That is the durability the in-memory
|
||||
* adapter cannot give, with the port contract preserved.
|
||||
*
|
||||
* <p><strong>Ownership is explicit.</strong> {@link #own} declares the queue and starts the consumer;
|
||||
* {@link #release} cancels it. {@link #publish} sends to the queue but does <em>not</em> imply ownership
|
||||
* and does not attach a consumer. This split is required by CB-308 federation, where one gateway may
|
||||
* publish to an agent owned by another gateway; in that case the publisher must not compete for
|
||||
* deliveries.
|
||||
*
|
||||
* <p><strong>Dedup.</strong> The consumer keys the held map by {@code msgId}; a redelivered duplicate
|
||||
* (at-least-once, or a producer double-publish) is acked-and-dropped on arrival, so it never
|
||||
* double-queues. {@link #publish} additionally short-circuits an already-held {@code msgId} — a
|
||||
* fast path; the consumer-side check is the real guarantee.
|
||||
* double-queues.
|
||||
*
|
||||
* <p><strong>Visibility.</strong> Unlike the in-memory adapter, publish → broker → consumer is
|
||||
* asynchronous, so a {@link #peek} immediately after {@link #publish} may not yet see the message
|
||||
@@ -51,12 +56,12 @@ public final class AmqpReplyInbox implements ReplyInbox, AutoCloseable {
|
||||
|
||||
private final Connection connection;
|
||||
private final Channel channel;
|
||||
/** All channel operations (publish/declare/ack) serialize on this — a Channel is not thread-safe. */
|
||||
/** All channel operations (publish/declare/ack/cancel) serialize on this — a Channel is not thread-safe. */
|
||||
private final Object channelLock = new Object();
|
||||
/** target → (msgId → held delivery). Per-target map is guarded by synchronizing on itself. */
|
||||
private final ConcurrentHashMap<String, LinkedHashMap<String, Held>> held = new ConcurrentHashMap<>();
|
||||
/** Targets whose queue is declared and consumer is running. */
|
||||
private final Set<String> consuming = ConcurrentHashMap.newKeySet();
|
||||
/** Targets whose queue is declared and consumer is running, mapped to their broker consumer tag. */
|
||||
private final ConcurrentHashMap<String, String> consumerTags = new ConcurrentHashMap<>();
|
||||
|
||||
/** A message pulled off the broker but not yet acked: its delivery-tag plus the port payload. */
|
||||
private record Held(long deliveryTag, InboxMessage message) {}
|
||||
@@ -103,16 +108,41 @@ public final class AmqpReplyInbox implements ReplyInbox, AutoCloseable {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(String target, String msgId, String content) {
|
||||
ensureConsuming(target);
|
||||
var perTarget = held.get(target);
|
||||
if (perTarget != null) {
|
||||
synchronized (perTarget) {
|
||||
if (perTarget.containsKey(msgId)) {
|
||||
return; // already held — producer-side fast dedup
|
||||
}
|
||||
public void own(String target) {
|
||||
synchronized (channelLock) {
|
||||
if (consumerTags.containsKey(target)) {
|
||||
return; // already owning this target
|
||||
}
|
||||
String queue = queueName(target);
|
||||
try {
|
||||
channel.queueDeclare(queue, true, false, false, null); // durable, non-exclusive, keep on idle
|
||||
String tag = channel.basicConsume(queue, false, deliverCallback(target), _ -> { });
|
||||
consumerTags.put(target, tag);
|
||||
log.debug("AMQP inbox owns queue {} for target {}", queue, target);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("cannot own queue " + queue, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release(String target) {
|
||||
synchronized (channelLock) {
|
||||
String tag = consumerTags.remove(target);
|
||||
held.remove(target); // stale delivery tags must not survive release
|
||||
if (tag == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
channel.basicCancel(tag);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("cannot cancel consumer for " + target, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(String target, String msgId, String content) {
|
||||
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
|
||||
.messageId(msgId)
|
||||
.deliveryMode(2) // persistent — survives a broker restart
|
||||
@@ -129,7 +159,6 @@ public final class AmqpReplyInbox implements ReplyInbox, AutoCloseable {
|
||||
|
||||
@Override
|
||||
public List<InboxMessage> peek(String target) {
|
||||
ensureConsuming(target);
|
||||
var perTarget = held.get(target);
|
||||
if (perTarget == null) {
|
||||
return List.of();
|
||||
@@ -166,26 +195,6 @@ public final class AmqpReplyInbox implements ReplyInbox, AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Declare the durable per-target queue and start its manual-ack consumer, once per target. */
|
||||
private void ensureConsuming(String target) {
|
||||
if (consuming.contains(target)) {
|
||||
return;
|
||||
}
|
||||
synchronized (channelLock) {
|
||||
if (!consuming.add(target)) {
|
||||
return; // another thread just set it up
|
||||
}
|
||||
String queue = queueName(target);
|
||||
try {
|
||||
channel.queueDeclare(queue, true, false, false, null); // durable, non-exclusive, keep on idle
|
||||
channel.basicConsume(queue, false, deliverCallback(target), _ -> { });
|
||||
} catch (IOException e) {
|
||||
consuming.remove(target);
|
||||
throw new IllegalStateException("cannot consume queue " + queue, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DeliverCallback deliverCallback(String target) {
|
||||
return (_, delivery) -> {
|
||||
String msgId = delivery.getProperties().getMessageId();
|
||||
|
||||
@@ -2,6 +2,7 @@ package dev.ltms.bridged.msg;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
@@ -9,12 +10,29 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* Per-target FIFO ordering (insertion order via {@link LinkedHashMap}). Dedup by {@code msgId}
|
||||
* within a target. Thread-safe for concurrent publish vs. drain.
|
||||
*
|
||||
* <p><strong>Ownership is explicit.</strong> {@link #own} marks a target as locally owned so that
|
||||
* {@link #peek} and {@link #ack} operate on it; {@link #publish} works whether or not the target is
|
||||
* owned. {@link #release} clears the local snapshot. This mirrors the AMQP adapter's contract so the
|
||||
* non-broker path stays interchangeable.
|
||||
*
|
||||
* <p><strong>This is soft-state, NOT persistence.</strong> Lost on a {@code java -jar} bounce — that
|
||||
* is correct and consistent with "bridged stays soft-state." The Stage-2 AMQP adapter replaces this.
|
||||
*/
|
||||
public final class InMemoryReplyInbox implements ReplyInbox {
|
||||
|
||||
private final ConcurrentHashMap<String, LinkedHashMap<String, InboxMessage>> store = new ConcurrentHashMap<>();
|
||||
private final Set<String> owned = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@Override
|
||||
public void own(String target) {
|
||||
owned.add(target);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release(String target) {
|
||||
owned.remove(target);
|
||||
store.remove(target);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(String target, String msgId, String content) {
|
||||
@@ -27,6 +45,9 @@ public final class InMemoryReplyInbox implements ReplyInbox {
|
||||
|
||||
@Override
|
||||
public List<InboxMessage> peek(String target) {
|
||||
if (!owned.contains(target)) {
|
||||
return List.of();
|
||||
}
|
||||
var perTarget = store.get(target);
|
||||
if (perTarget == null) {
|
||||
return List.of();
|
||||
@@ -39,6 +60,9 @@ public final class InMemoryReplyInbox implements ReplyInbox {
|
||||
|
||||
@Override
|
||||
public void ack(String target, String msgId) {
|
||||
if (!owned.contains(target)) {
|
||||
return;
|
||||
}
|
||||
var perTarget = store.get(target);
|
||||
if (perTarget != null) {
|
||||
//noinspection SynchronizationOnLocalVariableOrMethodParameter
|
||||
|
||||
@@ -10,16 +10,36 @@ import java.util.List;
|
||||
* <p><strong>This interface is the port.</strong> {@link InMemoryReplyInbox} is the Stage-1 adapter;
|
||||
* an AMQP-backed adapter (Stage 2) must implement the same contract (idempotent publish, FIFO peek,
|
||||
* at-least-once ack).
|
||||
*
|
||||
* <p><strong>Ownership is explicit.</strong> A gateway {@link #own owns} the inbox for each agent it
|
||||
* spawned; only the owner consumes and drains it. {@link #publish} sends a reply to the target's
|
||||
* inbox but does <em>not</em> imply ownership or start a consumer. This separation is required by
|
||||
* CB-308 federation, where one gateway may publish to an agent owned by another gateway.
|
||||
*/
|
||||
public interface ReplyInbox {
|
||||
|
||||
/** A queued reply: an idempotency id, the worker session it came from, and the reply text. */
|
||||
record InboxMessage(String msgId, String target, String content) {}
|
||||
|
||||
/**
|
||||
* Start owning (consuming) the inbox for {@code target}. Idempotent: multiple calls for the same
|
||||
* target are no-ops. The owner is the only gateway that may {@link #peek} and {@link #ack} replies
|
||||
* for this target.
|
||||
*/
|
||||
void own(String target);
|
||||
|
||||
/**
|
||||
* Stop owning (consuming) the inbox for {@code target}. Idempotent. Any replies held locally but
|
||||
* not yet acked are dropped from the local snapshot; the underlying durable queue keeps
|
||||
* unacked messages for redelivery when the target is re-owned.
|
||||
*/
|
||||
void release(String target);
|
||||
|
||||
/**
|
||||
* Queue {@code content} from worker {@code target} under {@code msgId}. Idempotent: publishing an
|
||||
* already-present {@code msgId} for {@code target} is a no-op (dedup), so an at-least-once Stage-2
|
||||
* redelivery cannot double-queue.
|
||||
* redelivery cannot double-queue. Publishing does <em>not</em> imply ownership and must not start a
|
||||
* consumer.
|
||||
*/
|
||||
void publish(String target, String msgId, String content);
|
||||
|
||||
|
||||
@@ -12,9 +12,13 @@ package dev.ltms.bridged.peer;
|
||||
public interface PeerHandle {
|
||||
|
||||
/**
|
||||
* The registry/routing key — an opaque, launcher-assigned identifier. For the herdr-backed
|
||||
* launcher this is the herdr pane id; for other launchers it is whatever their transport
|
||||
* uses. Guaranteed to be non-null and unique among live peers within a single daemon process.
|
||||
* The registry/routing key — an opaque, launcher-assigned identifier (CB-519). Multiple
|
||||
* daemon processes may run on one host, so the contract is <em>host-unique</em>, not merely
|
||||
* process-unique: the herdr-backed launcher mints a fresh UUID per spawn, and a non-herdr
|
||||
* launcher is likewise expected to return an identifier that cannot collide across processes
|
||||
* on the same host. This id is the routing key and is deliberately decoupled from any launcher
|
||||
* transport coordinate (e.g. a herdr pane id), which stays launcher-private. Guaranteed to be
|
||||
* non-null and unique among live peers on the host.
|
||||
*/
|
||||
String id();
|
||||
|
||||
@@ -26,4 +30,15 @@ public interface PeerHandle {
|
||||
default String terminalId() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker profile that spawned this peer, if the launcher resolved one. A launcher that
|
||||
* performs dynamic profile selection (e.g. CB-518 weighted placement) sets this so the
|
||||
* session registry records the actual profile rather than the requested/default one.
|
||||
*
|
||||
* @return the profile name, or {@code null} when the launcher leaves it unspecified
|
||||
*/
|
||||
default String profile() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.ltms.bridged.placement;
|
||||
|
||||
/**
|
||||
* Backward-compatible placement: an unqualified spawn always resolves to the configured default
|
||||
* profile, exactly as {@code CompositePeerLauncher} did before CB-518. This ignores caps and
|
||||
* reachability so that a pre-existing config behaves identically after upgrade.
|
||||
*/
|
||||
final class FixedPlacementPolicy implements PlacementPolicy {
|
||||
|
||||
@Override
|
||||
public PlacementCandidate select(PlacementContext ctx) {
|
||||
String d = ctx.defaultProfile();
|
||||
if (d != null && !d.isBlank()) {
|
||||
return new PlacementCandidate(d, null, 1.0f, null);
|
||||
}
|
||||
if (!ctx.candidates().isEmpty()) {
|
||||
PlacementCandidate first = ctx.candidates().getFirst();
|
||||
return new PlacementCandidate(first.profile(), null, first.weight(), first.maxLoad());
|
||||
}
|
||||
throw new PlacementException("no worker profiles configured");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.ltms.bridged.placement;
|
||||
|
||||
/**
|
||||
* A profile (and, in CB-308, a host) that can be chosen by a {@link PlacementPolicy}.
|
||||
*
|
||||
* <p>Keeping this as a small descriptor rather than a bare profile name lets CB-308 widen
|
||||
* selection to {@code (host, profile)} pairs without changing the policy interface.
|
||||
*/
|
||||
public record PlacementCandidate(String profile, String host, float weight, Integer maxLoad) {
|
||||
|
||||
/** A candidate with no explicit host (the single-host default) and the given weight/cap. */
|
||||
public static PlacementCandidate profile(String profile, float weight, Integer maxLoad) {
|
||||
return new PlacementCandidate(profile, null, weight, maxLoad);
|
||||
}
|
||||
|
||||
/** A candidate with no explicit host, unit weight, and no cap. */
|
||||
public static PlacementCandidate profile(String profile) {
|
||||
return new PlacementCandidate(profile, null, 1.0f, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.ltms.bridged.placement;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Everything a {@link PlacementPolicy} needs to make one selection.
|
||||
*
|
||||
* @param defaultProfile profile a {@code fixed} policy should return (may be {@code null})
|
||||
* @param candidates every configured candidate; the policy filters out those at cap or unreachable
|
||||
* @param liveCount current live worker count per profile (from the session registry)
|
||||
* @param unreachable profiles already known to have failed in this spawn attempt
|
||||
*/
|
||||
public record PlacementContext(String defaultProfile,
|
||||
List<PlacementCandidate> candidates,
|
||||
Function<String, Integer> liveCount,
|
||||
Set<String> unreachable) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.ltms.bridged.placement;
|
||||
|
||||
/**
|
||||
* Thrown when a {@link PlacementPolicy} has no candidate available. Kept as a distinct type so
|
||||
* callers can distinguish "no capacity" from a spawn-time transport failure.
|
||||
*/
|
||||
public final class PlacementException extends IllegalStateException {
|
||||
|
||||
public PlacementException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package dev.ltms.bridged.placement;
|
||||
|
||||
/**
|
||||
* Factory for the built-in placement policies.
|
||||
*/
|
||||
public final class PlacementPolicies {
|
||||
|
||||
private PlacementPolicies() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a policy name from config. Absent/blank values and {@code "fixed"} return the
|
||||
* backward-compatible fixed policy; unknown names throw.
|
||||
*/
|
||||
public static PlacementPolicy fromName(String name) {
|
||||
String n = (name == null) ? "" : name.toLowerCase();
|
||||
if (n.isBlank() || "fixed".equals(n)) {
|
||||
return fixed();
|
||||
}
|
||||
if ("weighted".equals(n)) {
|
||||
return weighted();
|
||||
}
|
||||
if ("round-robin".equals(n)) {
|
||||
return roundRobin();
|
||||
}
|
||||
throw new IllegalArgumentException("unknown placement policy '" + name
|
||||
+ "' — must be one of: fixed, round-robin, weighted");
|
||||
}
|
||||
|
||||
public static PlacementPolicy fixed() {
|
||||
return new FixedPlacementPolicy();
|
||||
}
|
||||
|
||||
public static PlacementPolicy weighted() {
|
||||
return new WeightedRoundRobinPolicy();
|
||||
}
|
||||
|
||||
public static PlacementPolicy roundRobin() {
|
||||
return new RoundRobinPlacementPolicy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.ltms.bridged.placement;
|
||||
|
||||
/**
|
||||
* How {@code bridged} chooses a worker profile when a spawn names none. Implementations are
|
||||
* deterministic and unit-testable; the caller (the composite launcher) handles failover retries.
|
||||
*/
|
||||
public interface PlacementPolicy {
|
||||
|
||||
/**
|
||||
* Pick one candidate from the configured set.
|
||||
*
|
||||
* @throws java.lang.IllegalStateException when no candidate is available, with a message naming
|
||||
* whether every profile is at capacity or unreachable
|
||||
*/
|
||||
PlacementCandidate select(PlacementContext ctx);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dev.ltms.bridged.placement;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Shared filtering and empty-set reporting used by the built-in placement policies.
|
||||
*/
|
||||
final class PlacementPolicyUtil {
|
||||
|
||||
private PlacementPolicyUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidates that are not known-unreachable and have not reached their maxLoad.
|
||||
* A {@code null} maxLoad means unlimited.
|
||||
*/
|
||||
static List<PlacementCandidate> available(PlacementContext ctx) {
|
||||
List<PlacementCandidate> out = new ArrayList<>();
|
||||
for (PlacementCandidate c : ctx.candidates()) {
|
||||
if (ctx.unreachable().contains(c.profile())) {
|
||||
continue;
|
||||
}
|
||||
Integer cap = c.maxLoad();
|
||||
if (cap != null) {
|
||||
int live = ctx.liveCount().apply(c.profile());
|
||||
if (live >= cap) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.add(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clear exception describing why every candidate was dropped: all at capacity,
|
||||
* all unreachable, or a mix.
|
||||
*/
|
||||
static PlacementException emptyException(PlacementContext ctx) {
|
||||
int atCap = 0;
|
||||
int unreachable = 0;
|
||||
for (PlacementCandidate c : ctx.candidates()) {
|
||||
Integer cap = c.maxLoad();
|
||||
if (ctx.unreachable().contains(c.profile())) {
|
||||
unreachable++;
|
||||
} else if (cap != null && ctx.liveCount().apply(c.profile()) >= cap) {
|
||||
atCap++;
|
||||
}
|
||||
}
|
||||
|
||||
int total = ctx.candidates().size();
|
||||
if (total == 0) {
|
||||
return new PlacementException("no worker profiles configured");
|
||||
}
|
||||
if (atCap == total) {
|
||||
return new PlacementException("all worker profiles are at maxLoad");
|
||||
}
|
||||
if (unreachable == total) {
|
||||
return new PlacementException("all worker profiles are unreachable");
|
||||
}
|
||||
return new PlacementException("no worker profile available: " + atCap + " at maxLoad, "
|
||||
+ unreachable + " unreachable, " + (total - atCap - unreachable) + " remaining");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.ltms.bridged.placement;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Deterministic round-robin over the profiles that still have capacity and are not known to be
|
||||
* unreachable. The index advances only on successful selections so the distribution stays even
|
||||
* across spawns.
|
||||
*/
|
||||
final class RoundRobinPlacementPolicy implements PlacementPolicy {
|
||||
|
||||
private final AtomicInteger index = new AtomicInteger(0);
|
||||
|
||||
@Override
|
||||
public synchronized PlacementCandidate select(PlacementContext ctx) {
|
||||
List<PlacementCandidate> available = PlacementPolicyUtil.available(ctx);
|
||||
if (available.isEmpty()) {
|
||||
throw PlacementPolicyUtil.emptyException(ctx);
|
||||
}
|
||||
return available.get(index.getAndIncrement() % available.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package dev.ltms.bridged.placement;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Smooth weighted round-robin (nginx-style): for each selection, add the candidate's weight to
|
||||
* its current score, pick the highest score, then subtract the total weight of all available
|
||||
* candidates from the winner. Weights are not required to sum to 1.0; only their ratios matter.
|
||||
*
|
||||
* <p>The state is per-policy instance and protected by {@code synchronized} so concurrent spawns
|
||||
* see a consistent, deterministic sequence rather than interleaving updates.
|
||||
*/
|
||||
final class WeightedRoundRobinPolicy implements PlacementPolicy {
|
||||
|
||||
private final Map<String, Double> current = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public synchronized PlacementCandidate select(PlacementContext ctx) {
|
||||
List<PlacementCandidate> available = PlacementPolicyUtil.available(ctx);
|
||||
if (available.isEmpty()) {
|
||||
throw PlacementPolicyUtil.emptyException(ctx);
|
||||
}
|
||||
|
||||
double total = 0.0;
|
||||
for (PlacementCandidate c : available) {
|
||||
total += c.weight();
|
||||
}
|
||||
if (total <= 0.0) {
|
||||
throw new PlacementException("all available profiles have non-positive weight");
|
||||
}
|
||||
|
||||
PlacementCandidate best = null;
|
||||
double bestScore = Double.NEGATIVE_INFINITY;
|
||||
for (PlacementCandidate c : available) {
|
||||
double score = current.merge(c.profile(), (double) c.weight(), (old, add) -> old + add);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
if (best == null) {
|
||||
throw new PlacementException("no placement candidate could be selected");
|
||||
}
|
||||
|
||||
current.put(best.profile(), current.get(best.profile()) - total);
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -225,12 +225,13 @@ public final class BridgedApp {
|
||||
if (!allow(ctx, Authz.Action.READ, null)) {
|
||||
return;
|
||||
}
|
||||
// CB-519: the registry key is a host-unique id, not the pane coordinate — join on terminal.
|
||||
Map<String, Agent> live = workers.list().stream()
|
||||
.map(Agent.class::cast)
|
||||
.filter(a -> a.paneId() != null)
|
||||
.collect(Collectors.toMap(Agent::paneId, Function.identity(), (_, b) -> b));
|
||||
.filter(a -> a.terminalId() != null)
|
||||
.collect(Collectors.toMap(Agent::terminalId, Function.identity(), (_, b) -> b));
|
||||
List<Map<String, Object>> out = sessions.roster().stream()
|
||||
.map(s -> SessionManager.rosterView(s, live.get(s.paneId())))
|
||||
.map(s -> SessionManager.rosterView(s, live.get(s.terminalId())))
|
||||
.toList();
|
||||
ctx.status(200).json(Map.of("workers", out));
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ public final class GitWorktrees implements Worktrees {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GitWorktrees.class);
|
||||
|
||||
/** Project-level MCP config. Present in the repo, so every worktree checks the primary's out. */
|
||||
private static final String MCP_CONFIG = ".mcp.json";
|
||||
|
||||
/** What {@link #isolateToolSurface} writes: a valid, explicitly empty server map. */
|
||||
private static final String NEUTRAL_MCP_CONFIG = "{\n \"mcpServers\": {}\n}\n";
|
||||
|
||||
private final String configuredRoot;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
private final AtomicLong seq = new AtomicLong();
|
||||
@@ -55,9 +61,41 @@ public final class GitWorktrees implements Worktrees {
|
||||
String wt = path.toAbsolutePath().toString();
|
||||
log.info("adding worktree branch={} path={} base={}", branch, wt, base);
|
||||
exec("git", "-C", repoRoot, "worktree", "add", wt, "-b", branch, base);
|
||||
isolateToolSurface(wt);
|
||||
return wt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralize the worktree's project MCP config so a worker inherits only the tools its launcher
|
||||
* mounts (the bridge, via {@code --mcp-config}) — never the primary's.
|
||||
*
|
||||
* <p>This is unconditional, and it is not the same job as the parity overlay. The repo's own
|
||||
* committed {@code .mcp.json} declares the primary's IDE servers, so a fresh checkout mounts them
|
||||
* whether or not the overlay copies anything; a worker that inherits them navigates and edits
|
||||
* through tools bound to the <em>primary's</em> IntelliJ project, which silently hands it absolute
|
||||
* paths outside its own worktree. That is not hypothetical: a CB-523 worker made all 59 of its
|
||||
* edits in the primary checkout while compiling its worktree, so every build it ran was of code
|
||||
* that did not contain its changes.
|
||||
*
|
||||
* <p>Writing an empty server map (rather than deleting the file) keeps a project-level
|
||||
* {@code .mcp.json} present and explicit, and the {@code --skip-worktree} bit keeps the
|
||||
* neutralized copy from ever showing up as a local modification the worker might commit.
|
||||
*/
|
||||
private void isolateToolSurface(String worktreePath) {
|
||||
Path root = Path.of(worktreePath).toAbsolutePath().normalize();
|
||||
Path mcp = root.resolve(MCP_CONFIG);
|
||||
try {
|
||||
Files.writeString(mcp, NEUTRAL_MCP_CONFIG);
|
||||
} catch (IOException e) {
|
||||
throw new WorktreeException("cannot neutralize " + MCP_CONFIG + " in the worktree: "
|
||||
+ e.getMessage(), e);
|
||||
}
|
||||
if (isTracked(root, MCP_CONFIG)) {
|
||||
exec("git", "-C", worktreePath, "update-index", "--skip-worktree", MCP_CONFIG);
|
||||
}
|
||||
log.debug("neutralized {} — worker tool surface is launcher-mounted only", MCP_CONFIG);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String repoRoot, String worktreePath) {
|
||||
Path p = Path.of(worktreePath);
|
||||
|
||||
@@ -48,8 +48,10 @@ public final class SessionManager implements TurnListener {
|
||||
private final LongSupplier nowNanos;
|
||||
private final int contextCap;
|
||||
|
||||
/** CB-520: notified with a terminalId on every acquire; no-op until wired. */
|
||||
private final List<Consumer<String>> acquireListeners = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
/** CB-516: notified with a terminalId on every release; no-op until wired. */
|
||||
private volatile Consumer<String> releaseListener = _ -> { };
|
||||
private final List<Consumer<String>> releaseListeners = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
|
||||
/** Backward-compatible constructor: shared-tree sessions, production git seam. */
|
||||
public SessionManager(PeerLauncher launcher) {
|
||||
@@ -110,9 +112,8 @@ public final class SessionManager implements TurnListener {
|
||||
if (wt == null) {
|
||||
SpawnRequest req = new SpawnRequest(profile, requestedCwd, callerCwd);
|
||||
PeerHandle handle = launcher.spawn(req);
|
||||
String resolvedProfile = (profile == null || profile.isBlank())
|
||||
? launcher.defaultProfile() : profile;
|
||||
String cwd = launcher.effectiveCwd(req);
|
||||
String resolvedProfile = resolveProfile(handle, profile);
|
||||
String cwd = launcher.effectiveCwd(new SpawnRequest(resolvedProfile, requestedCwd, callerCwd));
|
||||
long now = nowNanos.getAsLong();
|
||||
WorkerSession session = new WorkerSession(
|
||||
handle.id(),
|
||||
@@ -129,6 +130,7 @@ public final class SessionManager implements TurnListener {
|
||||
registry.put(handle.id(), session);
|
||||
log.debug("acquired session id={} terminal={} profile={} owner={}",
|
||||
handle.id(), handle.terminalId(), session.profile(), session.ownerTerminal());
|
||||
notifyAcquired(session.terminalId());
|
||||
return session;
|
||||
}
|
||||
return acquireWithWorktree(profile, requestedCwd, callerCwd, ownerTerminal, wt);
|
||||
@@ -151,18 +153,44 @@ public final class SessionManager implements TurnListener {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback invoked with a session's {@code terminalId} whenever it is acquired
|
||||
* (CB-520). This is the hook that lets the reply inbox {@code own} a target's queue.
|
||||
*/
|
||||
public void onAcquire(Consumer<String> listener) {
|
||||
if (listener != null) {
|
||||
acquireListeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback invoked with a session's {@code terminalId} whenever it is released
|
||||
* (CB-516). Every teardown path funnels through {@link #release}, so one hook covers the REST
|
||||
* and MCP stop tools, the idle-TTL reaper, {@code recycle}, and shutdown drain alike.
|
||||
*
|
||||
* <p>Set rather than injected because {@code MessageService} — the intended listener — is
|
||||
* <p>Added rather than injected because {@code MessageService} — one intended listener — is
|
||||
* constructed after this manager (it needs the injector and rendezvous, which need the session
|
||||
* presence view this manager exposes). Wiring it at construction would require breaking that
|
||||
* cycle for one callback.
|
||||
*/
|
||||
public void onRelease(Consumer<String> listener) {
|
||||
this.releaseListener = (listener == null) ? _ -> { } : listener;
|
||||
if (listener != null) {
|
||||
releaseListeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/** A listener failure must never prevent the acquisition it is reacting to. */
|
||||
private void notifyAcquired(String terminalId) {
|
||||
if (terminalId == null) {
|
||||
return;
|
||||
}
|
||||
for (Consumer<String> listener : acquireListeners) {
|
||||
try {
|
||||
listener.accept(terminalId);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("acquire listener failed for terminal {}: {}", terminalId, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A listener failure must never prevent the teardown it is reacting to. */
|
||||
@@ -170,16 +198,18 @@ public final class SessionManager implements TurnListener {
|
||||
if (terminalId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
releaseListener.accept(terminalId);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("release listener failed for terminal {}: {}", terminalId, e.toString());
|
||||
for (Consumer<String> listener : releaseListeners) {
|
||||
try {
|
||||
listener.accept(terminalId);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("release listener failed for terminal {}: {}", terminalId, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private WorkerSession acquireWithWorktree(String profile, String requestedCwd, String callerCwd,
|
||||
String ownerTerminal, WorktreeRequest wt) {
|
||||
String resolvedProfile = (profile == null || profile.isBlank())
|
||||
String preResolvedProfile = (profile == null || profile.isBlank())
|
||||
? launcher.defaultProfile() : profile;
|
||||
// CB-507: resolve through the launcher's CB-112 chain (requested → profile cwd → caller →
|
||||
// daemon cwd → "."), never the raw args. A plain REST spawn supplies neither a requested
|
||||
@@ -187,13 +217,13 @@ public final class SessionManager implements TurnListener {
|
||||
// `git -C null` on the command line — an NPE out of ProcessBuilder, surfacing as HTTP 500.
|
||||
// The non-worktree path always used this chain; only this branch was missed.
|
||||
String repoRoot = worktrees.repoRoot(
|
||||
launcher.effectiveCwd(new SpawnRequest(resolvedProfile, requestedCwd, callerCwd)));
|
||||
launcher.effectiveCwd(new SpawnRequest(preResolvedProfile, requestedCwd, callerCwd)));
|
||||
String branch = "worker/" + slug(wt.ticketSlug()) + "-" + nonce();
|
||||
String path = null;
|
||||
PeerHandle handle;
|
||||
try {
|
||||
path = worktrees.add(repoRoot, branch, wt.baseRef());
|
||||
worktrees.overlayParity(repoRoot, path, launcher.parityOverlay(resolvedProfile));
|
||||
worktrees.overlayParity(repoRoot, path, launcher.parityOverlay(preResolvedProfile));
|
||||
handle = launcher.spawn(new SpawnRequest(profile, path, callerCwd));
|
||||
} catch (RuntimeException e) {
|
||||
if (path != null) {
|
||||
@@ -205,12 +235,14 @@ public final class SessionManager implements TurnListener {
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
String resolvedProfile = resolveProfile(handle, profile);
|
||||
String cwd = launcher.effectiveCwd(new SpawnRequest(resolvedProfile, path, callerCwd));
|
||||
long now = nowNanos.getAsLong();
|
||||
WorkerSession session = new WorkerSession(
|
||||
handle.id(),
|
||||
handle.terminalId(),
|
||||
resolvedProfile,
|
||||
resolveCwd(path, profile, callerCwd),
|
||||
cwd,
|
||||
ownerTerminal,
|
||||
now,
|
||||
now,
|
||||
@@ -221,6 +253,7 @@ public final class SessionManager implements TurnListener {
|
||||
registry.put(handle.id(), session);
|
||||
log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}",
|
||||
handle.id(), handle.terminalId(), session.profile(), session.branch(), session.worktree());
|
||||
notifyAcquired(session.terminalId());
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -232,6 +265,22 @@ public final class SessionManager implements TurnListener {
|
||||
return String.format("%06x", nonceRandom.nextInt(1 << 24)) + "-" + nonceSeq.incrementAndGet();
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile to record for a session. A launcher that performed dynamic selection tells us
|
||||
* the actual profile via {@link PeerHandle#profile()}; otherwise fall back to what the caller
|
||||
* requested (or the launcher's default for a no-profile spawn).
|
||||
*/
|
||||
private String resolveProfile(PeerHandle handle, String requestedProfile) {
|
||||
String fromHandle = handle.profile();
|
||||
if (fromHandle != null && !fromHandle.isBlank()) {
|
||||
return fromHandle;
|
||||
}
|
||||
if (requestedProfile != null && !requestedProfile.isBlank()) {
|
||||
return requestedProfile;
|
||||
}
|
||||
return launcher.defaultProfile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the old session and acquire a fresh one with the same profile and working directory.
|
||||
* The new session is guaranteed to have a pane id distinct from the old one (no-reuse invariant).
|
||||
@@ -401,7 +450,19 @@ public final class SessionManager implements TurnListener {
|
||||
return registry.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* The registered session owning {@code terminalId}, or {@code null} if none does.
|
||||
*
|
||||
* <p>A null {@code terminalId} is a normal input, not a caller bug: every lifecycle hook here is
|
||||
* fed from the MCP transport, where the <em>primary</em> resolves to a {@link
|
||||
* dev.ltms.bridged.auth.Principal} with no terminal. {@code BridgeMcp} documents that contact as
|
||||
* a no-op, and {@link dev.ltms.bridged.inject.WorkerPresence#markPresent} honours it — but
|
||||
* {@code PresenceBridge} then forwards the same null here. Matching on a null id can never
|
||||
* succeed anyway (a registered session always has a terminal), so answer "no match" rather than
|
||||
* throwing: an NPE on this path takes down an unrelated tool call for the primary.
|
||||
*/
|
||||
private WorkerSession findByTerminal(String terminalId) {
|
||||
if (terminalId == null) return null;
|
||||
for (WorkerSession s : registry.values()) {
|
||||
if (terminalId.equals(s.terminalId())) return s;
|
||||
}
|
||||
@@ -423,10 +484,6 @@ public final class SessionManager implements TurnListener {
|
||||
return registry.replace(expected.paneId(), expected, updated);
|
||||
}
|
||||
|
||||
private String resolveCwd(String requestedCwd, String profileName, String callerCwd) {
|
||||
return launcher.effectiveCwd(new SpawnRequest(profileName, requestedCwd, callerCwd));
|
||||
}
|
||||
|
||||
/** WorkerPresence bridge that also drives the manager's READY transition. */
|
||||
private static final class PresenceBridge extends WorkerPresence {
|
||||
private final SessionManager sessions;
|
||||
@@ -437,6 +494,9 @@ public final class SessionManager implements TurnListener {
|
||||
|
||||
@Override
|
||||
public void markPresent(String terminal) {
|
||||
if (terminal == null || terminal.isBlank()) {
|
||||
return; // the primary's contact carries no worker terminal — not a readiness signal
|
||||
}
|
||||
super.markPresent(terminal);
|
||||
sessions.onReady(terminal);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ package dev.ltms.bridged.session;
|
||||
* process spawned. Immutable; state transitions are performed by replacing the record in
|
||||
* {@link SessionManager}'s registry.
|
||||
*
|
||||
* @param paneId herdr pane handle — the registry key and the argument to teardown
|
||||
* @param paneId the host-unique opaque id (CB-519) — the registry key and the argument to
|
||||
* teardown. Despite the historical name this is the {@link
|
||||
* dev.ltms.bridged.peer.PeerHandle#id()}, a UUID, and is distinct from the
|
||||
* launcher-private herdr pane coordinate.
|
||||
* @param terminalId herdr terminal handle — the {@code target} for send/read/status
|
||||
* @param profile the worker profile name that spawned this session
|
||||
* @param cwd the resolved working directory the worker started in
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package dev.ltms.bridged.worker;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Provisions an isolated {@code CODEX_HOME} for one Codex peer (CB-528).
|
||||
*
|
||||
* <p>Codex reads <em>everything</em> from {@code CODEX_HOME} — its config, credentials, sessions,
|
||||
* skills, plugins, and state. Pointing a peer at the operator's own {@code ~/.codex} would give it
|
||||
* the operator's tool surface and let it write into the operator's session history, which is the
|
||||
* same class of failure CB-525 exists to prevent on the Claude side. So every peer gets its own
|
||||
* directory, and this is the seam that builds it.
|
||||
*
|
||||
* <p>It is an interface rather than a method on the launcher for two reasons: provisioning is
|
||||
* filesystem work with its own failure modes (a missing credential is the most common, and it
|
||||
* surfaces as an opaque {@code 401} from Codex rather than a spawn error), and keeping it separate
|
||||
* lets the launcher be tested without touching a real home directory.
|
||||
*
|
||||
* <p>Three things the implementation must put in the home, because Codex has no launch flag for
|
||||
* any of them:
|
||||
* <ul>
|
||||
* <li>the bridge MCP server, as {@code [mcp_servers.*]} in {@code config.toml};</li>
|
||||
* <li>the reply charter, as {@code AGENTS.md} — Codex has no {@code --append-system-prompt},
|
||||
* so the standing instruction has to reach it as a file;</li>
|
||||
* <li>credentials, since a freshly created home has none and Codex fails closed.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public interface CodexHome {
|
||||
|
||||
/**
|
||||
* Build a fresh, isolated home for a peer launching under {@code cfg} and return its path,
|
||||
* suitable for the {@code CODEX_HOME} environment variable.
|
||||
*
|
||||
* @param cfg the profile being launched; supplies the MCP URL and any bearer-token variable
|
||||
* @return the provisioned directory
|
||||
* @throws RuntimeException if the home cannot be provisioned — including when no credential is
|
||||
* available, which must fail loudly here rather than as a 401 later
|
||||
*/
|
||||
Path provision(BridgedConfig.Worker cfg);
|
||||
|
||||
/**
|
||||
* Remove a home previously returned by {@link #provision}. Idempotent: releasing an already
|
||||
* released or never provisioned path is not an error, because teardown races teardown.
|
||||
*/
|
||||
void release(Path home);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package dev.ltms.bridged.worker;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
import dev.ltms.bridged.herdr.Agent;
|
||||
import dev.ltms.bridged.herdr.AgentControl;
|
||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||
import dev.ltms.bridged.peer.Capability;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/**
|
||||
* The {@link HerdrPeerLauncher} adapter for <strong>OpenAI's {@code codex}</strong> CLI — the third
|
||||
* peer kind behind the bridge (after Claude Code and opencode). Like {@link OpenCodeLauncher} it
|
||||
* extends {@link HerdrPeerLauncher} and reuses every line of shared transport (tab/pane placement,
|
||||
* the CB-306 readiness gate, unique naming + CB-117 reap, teardown, listing, cwd), overriding only
|
||||
* the launch seams.
|
||||
*
|
||||
* <p>The divergences, all confined to {@link #buildLaunch}:
|
||||
* <ul>
|
||||
* <li><strong>No subscription boundary.</strong> Codex authenticates through its own
|
||||
* {@code CODEX_HOME} credentials and has no {@code ANTHROPIC_BASE_URL} seam, so there is no
|
||||
* {@link dev.ltms.bridged.guard.SubscriptionGuard} — the guard is a Claude-private concern,
|
||||
* not part of the SPI. This asymmetry is deliberate and safe for the same reason opencode's is:
|
||||
* the guard exists to stop a worker borrowing the primary's Anthropic subscription, and a
|
||||
* codex process has no Anthropic credential path at all, so nothing can leak the subscription.
|
||||
* The bridge injects no {@code ANTHROPIC_*}/{@code CLAUDE_*} variable and reads none.</li>
|
||||
* <li><strong>Isolated home.</strong> Codex reads everything from {@code CODEX_HOME}. The
|
||||
* {@link CodexHome} seam provisions a fresh, isolated directory (config, reply charter,
|
||||
* credentials — none of which Codex can take as a launch flag) and the worker is pointed at it
|
||||
* with {@code CODEX_HOME}, so a peer never inherits the operator's own {@code ~/.codex}.</li>
|
||||
* <li><strong>{@code --approve-for-me}</strong> is mandatory: without it, every MCP tool call from
|
||||
* the peer returns "user cancelled MCP tool call" and the peer can never call
|
||||
* {@code bridge_reply}. It routes approvals through automatic review while keeping the sandbox
|
||||
* on — deliberately not {@code --dangerously-bypass-approvals-and-sandbox}.</li>
|
||||
* <li><strong>Flags, not env/files.</strong> the model ({@code -m}), the bridge MCP override
|
||||
* ({@code -c mcp_servers.bridged.url=...}), and the bridge bearer-token variable
|
||||
* ({@code --bearer-token-env-var}) are all command-line.</li>
|
||||
* <li><strong>{@code codex} name prefix</strong> so reap matches {@code codex-*} panes and never
|
||||
* another adapter's.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class CodexLauncher extends HerdrPeerLauncher {
|
||||
|
||||
/** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */
|
||||
private static final String NAME_PREFIX = "codex";
|
||||
|
||||
/** Provisions the isolated {@code CODEX_HOME} each peer runs against (CB-528). */
|
||||
private final CodexHome codexHome;
|
||||
|
||||
/**
|
||||
* Production constructor — disables the spawn-ready gate ({@code spawnReadyTimeoutMs == 0}) so it
|
||||
* matches the legacy non-blocking spawn semantics.
|
||||
*/
|
||||
public CodexLauncher(AgentControl agents, WorkspaceControl spaces, CodexHome codexHome,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Function<String, String> env) {
|
||||
this(agents, spaces, codexHome, profiles, defaultProfile, env, 0,
|
||||
System::currentTimeMillis, () -> sleepUninterruptibly(300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Production constructor with the spawn-ready gate enabled. Polls {@code agents.status()} until
|
||||
* the pane reports an injectable state or {@code spawnReadyTimeoutMs} elapses.
|
||||
*/
|
||||
public CodexLauncher(AgentControl agents, WorkspaceControl spaces, CodexHome codexHome,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Function<String, String> env,
|
||||
long spawnReadyTimeoutMs, long spawnReadyPollMs) {
|
||||
this(agents, spaces, codexHome, profiles, defaultProfile, env,
|
||||
spawnReadyTimeoutMs, System::currentTimeMillis,
|
||||
() -> sleepUninterruptibly(spawnReadyPollMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Full testability constructor. Every injectable collaborator is explicit so unit tests supply a
|
||||
* fake clock ({@code nowMillis}), poll-loop wait ({@code sleeper}), and a stub {@link CodexHome}
|
||||
* whose returned path they inspect as {@code CODEX_HOME}.
|
||||
*
|
||||
* @param agents herdr agent control (start, status, close)
|
||||
* @param spaces workspace / tab control (ensure, create, close)
|
||||
* @param codexHome seam that provisions the isolated {@code CODEX_HOME} (CB-528)
|
||||
* @param profiles configured worker profiles
|
||||
* @param defaultProfile profile a no-argument spawn uses (nullable)
|
||||
* @param env host env lookup (injectable for tests)
|
||||
* @param spawnReadyTimeoutMs max ms to wait for injectable state (0 disables the gate)
|
||||
* @param nowMillis monotonic clock source (e.g. {@code System::currentTimeMillis})
|
||||
* @param sleeper sleep/wait hook (never called when the gate is disabled)
|
||||
*/
|
||||
public CodexLauncher(AgentControl agents, WorkspaceControl spaces, CodexHome codexHome,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Function<String, String> env,
|
||||
long spawnReadyTimeoutMs,
|
||||
LongSupplier nowMillis, Runnable sleeper) {
|
||||
super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env,
|
||||
spawnReadyTimeoutMs, nowMillis, sleeper);
|
||||
this.codexHome = codexHome;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>Builds the codex launch: no {@code ANTHROPIC_*} and no guard (codex reads its own
|
||||
* {@code CODEX_HOME} credentials); provision an isolated home via {@link #codexHome} and point
|
||||
* the worker at it with {@code CODEX_HOME}; carry the parity-neutral git-forge grant; and pass
|
||||
* the model, bridge MCP override, and bearer-token variable as flags — with mandatory
|
||||
* {@code --approve-for-me}.
|
||||
*/
|
||||
@Override
|
||||
protected Launch buildLaunch(BridgedConfig.Worker cfg) {
|
||||
Map<String, String> workerEnv = baseEnv(cfg);
|
||||
workerEnv.put("CODEX_HOME", codexHome.provision(cfg).toString());
|
||||
applyGitToken(workerEnv, cfg);
|
||||
return new Launch(workerEnv, argvFor(cfg));
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch argv: {@code cfg.argv()} as the base command (the profile may override the
|
||||
* executable), then mandatory {@code --approve-for-me}, then the optional model / bridge-MCP /
|
||||
* bearer-token flags.
|
||||
*
|
||||
* <p>{@code --approve-for-me} is not optional polish — without it Codex auto-rejects every MCP
|
||||
* tool call ("user cancelled MCP tool call") and the peer can never answer via
|
||||
* {@code bridge_reply}. It routes approvals through automatic review while keeping the sandbox
|
||||
* on; it is deliberately not the escape-hatch bypass flag.
|
||||
*/
|
||||
private List<String> argvFor(BridgedConfig.Worker cfg) {
|
||||
List<String> argv = mutableArgv(cfg.argv());
|
||||
argv.add("--approve-for-me");
|
||||
if (cfg.model() != null && !cfg.model().isBlank()) {
|
||||
argv.add("-m");
|
||||
argv.add(cfg.model());
|
||||
}
|
||||
if (cfg.hasMcp()) {
|
||||
argv.add("-c");
|
||||
argv.add("mcp_servers.bridged.url=\"" + cfg.mcpUrl() + "\"");
|
||||
}
|
||||
if (cfg.tokenEnv() != null && !cfg.tokenEnv().isBlank()) {
|
||||
argv.add("--bearer-token-env-var");
|
||||
argv.add(cfg.tokenEnv());
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
|
||||
// --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) ---
|
||||
|
||||
/** Spawn a worker for the default profile in the resolved default cwd. */
|
||||
public Agent spawn() {
|
||||
return spawnInternal(null, null, null);
|
||||
}
|
||||
|
||||
/** Spawn a worker for a named profile (null → default) in the resolved default cwd. */
|
||||
public Agent spawn(String profileName) {
|
||||
return spawnInternal(profileName, null, null);
|
||||
}
|
||||
|
||||
/** Spawn a worker for a named profile with an explicit requested/caller cwd (CB-112). */
|
||||
public Agent spawn(String profileName, String requestedCwd, String callerCwd) {
|
||||
return spawnInternal(profileName, requestedCwd, callerCwd);
|
||||
}
|
||||
|
||||
// --- capabilities --------------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public Set<Capability> capabilities() {
|
||||
Set<Capability> caps = EnumSet.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP);
|
||||
if (hasGitTokenProfile()) {
|
||||
caps.add(Capability.SELF_PR);
|
||||
}
|
||||
return Set.copyOf(caps);
|
||||
}
|
||||
|
||||
/** Whether any configured profile opts into a git-forge token (required for {@link Capability#SELF_PR}). */
|
||||
private boolean hasGitTokenProfile() {
|
||||
return profileConfigs().stream().anyMatch(BridgedConfig.Worker::hasGitToken);
|
||||
}
|
||||
|
||||
// --- CB-117 reap predicate (codex prefix), kept for direct unit testing --------------------
|
||||
|
||||
/**
|
||||
* Whether {@code name} is a codex bridge worker started by a <em>different</em> process than
|
||||
* {@code currentNonce}. A thin {@code codex}-prefix binding of
|
||||
* {@link HerdrPeerLauncher#isForeignWorker(String, String, String)}.
|
||||
*/
|
||||
static boolean isForeignWorker(String name, String currentNonce) {
|
||||
return HerdrPeerLauncher.isForeignWorker(NAME_PREFIX, name, currentNonce);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
package dev.ltms.bridged.worker;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
import dev.ltms.bridged.herdr.Agent;
|
||||
import dev.ltms.bridged.peer.Capability;
|
||||
import dev.ltms.bridged.peer.PeerHandle;
|
||||
import dev.ltms.bridged.peer.PeerLauncher;
|
||||
import dev.ltms.bridged.peer.PeerUnreachableException;
|
||||
import dev.ltms.bridged.peer.SpawnRequest;
|
||||
import dev.ltms.bridged.placement.PlacementCandidate;
|
||||
import dev.ltms.bridged.placement.PlacementContext;
|
||||
import dev.ltms.bridged.placement.PlacementPolicies;
|
||||
import dev.ltms.bridged.placement.PlacementPolicy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The {@link PeerLauncher} the core actually holds when more than one adapter is configured — a thin
|
||||
@@ -35,6 +45,12 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* and combine. {@link #list} is deduplicated by pane id because every herdr-backed delegate
|
||||
* shares one herdr connection and so reports the same global agent set.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>CB-518: an unqualified spawn is routed through a {@link PlacementPolicy}. The default
|
||||
* {@code fixed} policy reproduces the historical default-profile behaviour; {@code weighted} uses
|
||||
* smooth weighted round-robin with {@code maxLoad} gating. If a chosen profile fails with
|
||||
* {@link PeerUnreachableException}, the composite advances to the next available candidate and
|
||||
* retries, bounded by the number of candidates.
|
||||
*/
|
||||
public final class CompositePeerLauncher implements PeerLauncher {
|
||||
|
||||
@@ -47,18 +63,49 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
/** paneId → the delegate that spawned it, so {@link #stop} tears down through the right adapter. */
|
||||
private final Map<String, HerdrPeerLauncher> spawnedBy = new ConcurrentHashMap<>();
|
||||
|
||||
private final Map<String, BridgedConfig.Worker> profileConfigs;
|
||||
private final PlacementPolicy placementPolicy;
|
||||
private final Function<String, Integer> liveCount;
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor: fixed placement, no live-counting. Use this for tests and
|
||||
* simple wiring; it preserves the pre-CB-518 behaviour exactly.
|
||||
*
|
||||
* @param delegates one adapter per configured peer kind; must be non-empty and declare
|
||||
* disjoint profile-name sets
|
||||
* @param defaultProfile the profile a no-argument spawn resolves to (may be null)
|
||||
* @throws IllegalArgumentException if {@code delegates} is empty or two adapters claim one profile
|
||||
*/
|
||||
public CompositePeerLauncher(List<HerdrPeerLauncher> delegates, String defaultProfile) {
|
||||
this(delegates, defaultProfile, Map.of(), PlacementPolicies.fixed(), name -> 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Production constructor with a placement policy and live-worker counter.
|
||||
*
|
||||
* @param delegates one adapter per configured peer kind; must be non-empty and declare
|
||||
* disjoint profile-name sets
|
||||
* @param defaultProfile the profile a no-argument spawn resolves to under {@code fixed} policy
|
||||
* @param profileConfigs all configured worker profiles (used for candidate weights/caps)
|
||||
* @param placementPolicy which policy governs unqualified spawns
|
||||
* @param liveCount live worker count per profile (must never return {@code null})
|
||||
*/
|
||||
public CompositePeerLauncher(List<HerdrPeerLauncher> delegates,
|
||||
String defaultProfile,
|
||||
Map<String, BridgedConfig.Worker> profileConfigs,
|
||||
PlacementPolicy placementPolicy,
|
||||
Function<String, Integer> liveCount) {
|
||||
if (delegates.isEmpty()) {
|
||||
throw new IllegalArgumentException("at least one peer adapter must be configured");
|
||||
}
|
||||
this.delegates = List.copyOf(delegates);
|
||||
this.defaultProfile = defaultProfile;
|
||||
// LinkedHashMap, not Map.copyOf: candidates() promises definition order and the weighted
|
||||
// policy breaks exact-weight ties on it, so a salted iteration order would make placement
|
||||
// differ from one JVM run to the next.
|
||||
this.profileConfigs = Collections.unmodifiableMap(new LinkedHashMap<>(profileConfigs));
|
||||
this.placementPolicy = placementPolicy;
|
||||
this.liveCount = liveCount;
|
||||
Map<String, HerdrPeerLauncher> index = new LinkedHashMap<>();
|
||||
for (HerdrPeerLauncher d : this.delegates) {
|
||||
for (String profile : d.profiles()) {
|
||||
@@ -69,7 +116,8 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.byProfile = Map.copyOf(index);
|
||||
// Order-preserving for the same reason, and because profiles() is user-visible (bridge_profiles).
|
||||
this.byProfile = Collections.unmodifiableMap(index);
|
||||
}
|
||||
|
||||
/** The adapter owning {@code profileName} (null/blank → the default). Throws on an unknown profile. */
|
||||
@@ -89,10 +137,64 @@ public final class CompositePeerLauncher implements PeerLauncher {
|
||||
|
||||
@Override
|
||||
public PeerHandle spawn(SpawnRequest req) {
|
||||
HerdrPeerLauncher d = route(req.profileName());
|
||||
PeerHandle handle = d.spawn(req);
|
||||
spawnedBy.put(handle.id(), d);
|
||||
return handle;
|
||||
String requestedProfile = req.profileName();
|
||||
if (requestedProfile != null && !requestedProfile.isBlank()) {
|
||||
// An explicit profile bypasses the policy entirely.
|
||||
HerdrPeerLauncher d = route(requestedProfile);
|
||||
PeerHandle handle = d.spawn(req);
|
||||
spawnedBy.put(handle.id(), d);
|
||||
return handle;
|
||||
}
|
||||
|
||||
List<PlacementCandidate> candidates = candidates();
|
||||
Set<String> unreachable = new HashSet<>();
|
||||
PlacementContext ctx = new PlacementContext(defaultProfile, candidates, liveCount, unreachable);
|
||||
|
||||
int maxAttempts = candidates.isEmpty() ? 1 : candidates.size();
|
||||
for (int attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
PlacementCandidate chosen;
|
||||
try {
|
||||
chosen = placementPolicy.select(ctx);
|
||||
} catch (RuntimeException e) {
|
||||
// No candidate left (all at cap or all unreachable). The policy already threw a clear
|
||||
// message; do not wrap it in a generic PeerUnreachableException.
|
||||
throw e;
|
||||
}
|
||||
|
||||
HerdrPeerLauncher d = byProfile.get(chosen.profile());
|
||||
if (d == null) {
|
||||
// A configured profile with no adapter is a wiring bug; fail fast.
|
||||
unreachable.add(chosen.profile());
|
||||
continue;
|
||||
}
|
||||
|
||||
SpawnRequest routedReq = new SpawnRequest(chosen.profile(), req.requestedCwd(), req.callerCwd());
|
||||
try {
|
||||
PeerHandle handle = d.spawn(routedReq);
|
||||
spawnedBy.put(handle.id(), d);
|
||||
return handle;
|
||||
} catch (PeerUnreachableException e) {
|
||||
log.warn("spawn on profile {} unreachable, will retry next candidate if any: {}",
|
||||
chosen.profile(), e.getMessage());
|
||||
unreachable.add(chosen.profile());
|
||||
// Update the context for the next selection so the policy excludes this profile.
|
||||
ctx = new PlacementContext(defaultProfile, candidates, liveCount, unreachable);
|
||||
}
|
||||
}
|
||||
|
||||
throw new PeerUnreachableException(
|
||||
"no reachable worker profile available after trying " + unreachable.size()
|
||||
+ " candidate(s): " + String.join(", ", unreachable));
|
||||
}
|
||||
|
||||
/** Build the candidate list from the configured profiles, in definition order. */
|
||||
private List<PlacementCandidate> candidates() {
|
||||
List<PlacementCandidate> out = new ArrayList<>();
|
||||
for (Map.Entry<String, BridgedConfig.Worker> e : profileConfigs.entrySet()) {
|
||||
BridgedConfig.Worker w = e.getValue();
|
||||
out.add(new PlacementCandidate(e.getKey(), null, w.weight(), w.maxLoad()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -21,6 +21,9 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.LongSupplier;
|
||||
@@ -55,6 +58,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
/** herdr rejects a duplicate agent {@code name}; we retry a bumped name this many times. */
|
||||
private static final int NAME_RETRIES = 8;
|
||||
|
||||
/**
|
||||
* Retries for {@code agent.start} against a seed pane whose shell has not reached its prompt
|
||||
* yet — {@code tab.create}/{@code pane.split} return as soon as the pane exists, and herdr
|
||||
* refuses to start an agent in a pane that is not "an available shell" ({@code agent_pane_busy}).
|
||||
*/
|
||||
private static final int SHELL_READY_RETRIES = 20;
|
||||
|
||||
private final String namePrefix; // label prefix: naming + reap scheme
|
||||
private final AgentControl agents;
|
||||
private final WorkspaceControl spaces;
|
||||
@@ -74,6 +84,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
// collide with same-profile peers that outlived a restart. See startUniquelyNamed.
|
||||
private final String nameNonce = String.format("%06x", new SecureRandom().nextInt(1 << 24));
|
||||
|
||||
// CB-519: PeerHandle.id() is a host-unique opaque UUID, decoupled from the herdr pane id. The
|
||||
// routing/registry key is the UUID; the herdr pane id is a launcher-private placement/teardown
|
||||
// coordinate. This map bridges the two so stop(id) can resolve a host-unique key back to the
|
||||
// exact pane it must tear down. The pane id is launcher-private (never the routing key) — see
|
||||
// PeerHandle.id().
|
||||
private final ConcurrentMap<String, String> paneByAgentId = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* @param namePrefix label prefix for this peer kind (drives naming and reap)
|
||||
* @param agents herdr agent control (start, status, close)
|
||||
@@ -182,10 +199,13 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>Delegates to {@link #spawnInternal} and wraps the resulting herdr {@link Agent} in a
|
||||
* {@link WorkerHandle} whose {@link PeerHandle#id()} equals the agent's paneId. When
|
||||
* {@code spawnReadyTimeoutMs > 0}, blocks until the peer's herdr status is injectable or the
|
||||
* timeout elapses; on timeout the pane is closed (no orphan) and a
|
||||
* {@link PeerUnreachableException} is thrown.
|
||||
* {@link WorkerHandle} whose {@link PeerHandle#id()} is a fresh <em>host-unique</em> opaque
|
||||
* UUID (CB-519), deliberately decoupled from the herdr pane id: the id is the registry/routing
|
||||
* key and must never collide across daemon processes on the same host, while the herdr pane id
|
||||
* stays a launcher-private placement/teardown coordinate, remembered here so {@link #stop}
|
||||
* can resolve the host-unique key back to its pane. When {@code spawnReadyTimeoutMs > 0},
|
||||
* blocks until the peer's herdr status is injectable or the timeout elapses; on timeout the
|
||||
* pane is closed (no orphan) and a {@link PeerUnreachableException} is thrown.
|
||||
*/
|
||||
@Override
|
||||
public PeerHandle spawn(SpawnRequest req) {
|
||||
@@ -194,7 +214,10 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
if (spawnReadyTimeoutMs > 0) {
|
||||
waitUntilInjectableOrThrow(paneId);
|
||||
}
|
||||
return new WorkerHandle(paneId, agent.terminalId());
|
||||
// CB-519: the handle id is a host-unique UUID; the herdr pane it maps to stays internal.
|
||||
String id = UUID.randomUUID().toString();
|
||||
paneByAgentId.put(id, paneId);
|
||||
return new WorkerHandle(id, agent.terminalId(), requireProfile(req.profileName()).profile());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -227,17 +250,23 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Dedicated worker space → own tab → start the peer (rooted at {@code cwd}) → drop the shell. */
|
||||
/** Dedicated worker space → own tab (carrying cwd+env) → start the peer into the seed pane. */
|
||||
private Agent spawnInTab(BridgedConfig.Worker cfg, Map<String, String> workerEnv,
|
||||
List<String> argv, String cwd) {
|
||||
Workspace space = spaces.ensureWorkspace(cfg.workspace());
|
||||
Tab.Created tab = spaces.createTab(space.workspaceId());
|
||||
Tab.Created tab = spaces.createTab(space.workspaceId(), cwd, workerEnv);
|
||||
log.info("spawning {} profile={} space={} tab={} cwd={}",
|
||||
namePrefix, cfg.profile(), space.workspaceId(), tab.tab().tabId(), cwd);
|
||||
|
||||
Started started;
|
||||
try {
|
||||
started = startUniquelyNamed(cfg, workerEnv, argv, tab.tab().tabId(), cwd);
|
||||
if (tab.rootPaneId() == null) {
|
||||
// Protocol 19 starts the agent INTO the seed pane — without one there is nowhere
|
||||
// to start, and a partial tab would be left behind.
|
||||
throw new IllegalStateException("tab " + tab.tab().tabId()
|
||||
+ " had no seed pane in the create response — cannot start a peer in it");
|
||||
}
|
||||
started = startUniquelyNamed(cfg, argv, tab.rootPaneId());
|
||||
} catch (RuntimeException e) {
|
||||
// The peer never started — don't leave the tab we just created orphaned.
|
||||
// Best-effort cleanup; never let it mask the real spawn failure.
|
||||
@@ -250,16 +279,9 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// The peer is LIVE now. The remaining steps are cosmetic (drop herdr's seed shell so the
|
||||
// tab holds only the peer; label the tab). They must not fail the spawn or orphan the
|
||||
// running peer — on error we log and still return it so the caller gets its paneId and can
|
||||
// tear it down.
|
||||
if (tab.rootPaneId() != null) {
|
||||
tidy("close seed pane " + tab.rootPaneId(), () -> agents.close(tab.rootPaneId()));
|
||||
} else {
|
||||
log.warn("tab {} had no seed pane in the create response; peer tab may hold an extra pane",
|
||||
tab.tab().tabId());
|
||||
}
|
||||
// The peer is LIVE now, in the seed pane itself (no shell pane to drop — protocol 19).
|
||||
// Labelling is cosmetic: it must not fail the spawn or orphan the running peer — on error
|
||||
// we log and still return it so the caller gets its paneId and can tear it down.
|
||||
tidy("label tab " + tab.tab().tabId(),
|
||||
() -> spaces.renameTab(tab.tab().tabId(), cfg.renderTabLabel(started.seq())));
|
||||
log.info("{} started pane={} tab={} terminal={}",
|
||||
@@ -276,12 +298,16 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy placement: herdr splits the currently-focused tab; the peer still starts in {@code cwd}. */
|
||||
/** Legacy placement: split the currently-focused tab; the peer still starts in {@code cwd}. */
|
||||
private Agent spawnAsPane(BridgedConfig.Worker cfg, Map<String, String> workerEnv,
|
||||
List<String> argv, String cwd) {
|
||||
log.info("spawning {} (pane placement) profile={} cwd={} argv={}",
|
||||
namePrefix, cfg.profile(), cwd, argv);
|
||||
Agent peer = startUniquelyNamed(cfg, workerEnv, argv, null, cwd).agent();
|
||||
String paneId = spaces.splitPane(cwd, workerEnv);
|
||||
if (paneId == null) {
|
||||
throw new IllegalStateException("pane.split returned no pane — cannot start a peer");
|
||||
}
|
||||
Agent peer = startUniquelyNamed(cfg, argv, paneId).agent();
|
||||
log.info("{} started pane={} terminal={}", namePrefix, peer.paneId(), peer.terminalId());
|
||||
return peer;
|
||||
}
|
||||
@@ -300,14 +326,16 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
* backstop for the astronomically unlikely nonce+seq clash; the name is a label only — herdr
|
||||
* detects kind and status from terminal output, not from it.
|
||||
*/
|
||||
private Started startUniquelyNamed(BridgedConfig.Worker cfg, Map<String, String> workerEnv,
|
||||
List<String> argv, String tabId, String cwd) {
|
||||
private Started startUniquelyNamed(BridgedConfig.Worker cfg, List<String> argv, String paneId) {
|
||||
// Protocol 19 resolves the executable from the agent kind (== namePrefix here), so
|
||||
// argv[0] — the configured executable — is dropped and only the extra args are passed.
|
||||
List<String> args = argv.isEmpty() ? argv : argv.subList(1, argv.size());
|
||||
HerdrException last = null;
|
||||
for (int attempt = 0; attempt < NAME_RETRIES; attempt++) {
|
||||
long seq = nameSeq.incrementAndGet();
|
||||
String name = namePrefix + "-" + cfg.profile() + "-" + nameNonce + "-" + seq;
|
||||
try {
|
||||
return new Started(agents.start(name, argv, workerEnv, tabId, cwd), seq);
|
||||
return new Started(startAwaitingShellPrompt(name, args, paneId), seq);
|
||||
} catch (HerdrException e) {
|
||||
if (!"agent_name_taken".equals(e.code())) throw e;
|
||||
log.debug("peer name '{}' taken, retrying", name);
|
||||
@@ -317,6 +345,22 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
throw last;
|
||||
}
|
||||
|
||||
/** Start the agent into {@code paneId}, waiting out the seed shell's boot with the sleeper. */
|
||||
private Agent startAwaitingShellPrompt(String name, List<String> args, String paneId) {
|
||||
HerdrException busy = null;
|
||||
for (int attempt = 0; attempt < SHELL_READY_RETRIES; attempt++) {
|
||||
try {
|
||||
return agents.start(name, namePrefix, args, paneId);
|
||||
} catch (HerdrException e) {
|
||||
if (!"agent_pane_busy".equals(e.code())) throw e;
|
||||
log.debug("pane {} not at its shell prompt yet, retrying agent.start", paneId);
|
||||
busy = e;
|
||||
sleeper.run();
|
||||
}
|
||||
}
|
||||
throw busy;
|
||||
}
|
||||
|
||||
// --- discovery + reap ----------------------------------------------------------------------
|
||||
|
||||
/** All herdr-tracked agents — discovery for "what peers exist". */
|
||||
@@ -398,21 +442,31 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
// --- teardown ------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tear a peer down by pane id: close the pane, and close its tab <em>only</em> when the peer is
|
||||
* that tab's sole occupant. The single-pane check is what makes this safe regardless of how the
|
||||
* peer was placed (or a placement-config change across a restart): a pane-placement peer sitting
|
||||
* in one of the user's shared tabs has siblings, so its tab is never closed — we only ever
|
||||
* remove a tab we created to hold one peer.
|
||||
* Tear a peer down: close the pane, and close its tab <em>only</em> when the peer is that tab's
|
||||
* sole occupant. The single-pane check is what makes this safe regardless of how the peer was
|
||||
* placed (or a placement-config change across a restart): a pane-placement peer sitting in one
|
||||
* of the user's shared tabs has siblings, so its tab is never closed — we only ever remove a
|
||||
* tab we created to hold one peer.
|
||||
*
|
||||
* <p>{@code idOrPane} is the {@link PeerHandle#id()} of a peer this launcher spawned (CB-519's
|
||||
* host-unique opaque UUID), resolved through {@link #paneByAgentId} to the pane it must tear
|
||||
* down. An argument that is not one of our ids is treated as a raw herdr pane id — the
|
||||
* {@link #reapOrphanWorkers() orphan-reap} and spawn-gate-timeout paths, plus any caller that
|
||||
* passes a pane directly, keep working without an owning id.
|
||||
*
|
||||
* <p>Resolves the tab from the pane <em>before</em> closing it. An already-gone pane/tab
|
||||
* (repeated DELETE, crashed peer) is treated as success; any other failure propagates so a
|
||||
* genuinely failed teardown is not reported as done.
|
||||
*/
|
||||
@Override
|
||||
public void stop(String paneId) {
|
||||
// Teardown knows only the paneId, not which profile spawned it. Attempt tab cleanup when any
|
||||
public void stop(String idOrPane) {
|
||||
// Teardown knows only the pane, not which profile spawned it. Attempt tab cleanup when any
|
||||
// profile uses tab placement (so the bridge may have created a dedicated peer tab); the
|
||||
// single-occupant check below is what actually protects the user's shared tabs.
|
||||
String paneId = paneByAgentId.remove(idOrPane);
|
||||
if (paneId == null) {
|
||||
paneId = idOrPane; // raw-pane fallback (reap, gate timeout, pane-addressed callers)
|
||||
}
|
||||
WorkspaceControl.PaneLocation loc = usesTabPlacement() ? spaces.locatePane(paneId) : null;
|
||||
try {
|
||||
agents.close(paneId);
|
||||
@@ -460,8 +514,8 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
||||
+ spawnReadyTimeoutMs + "ms");
|
||||
}
|
||||
|
||||
/** A concrete {@link PeerHandle} wrapping herdr agent coordinates. */
|
||||
private record WorkerHandle(String id, String terminalId) implements PeerHandle {
|
||||
/** A concrete {@link PeerHandle} wrapping herdr agent coordinates and the profile that spawned it. */
|
||||
private record WorkerHandle(String id, String terminalId, String profile) implements PeerHandle {
|
||||
}
|
||||
|
||||
// --- shared helpers ------------------------------------------------------------------------
|
||||
|
||||
@@ -44,6 +44,43 @@ class CallerResolverTest {
|
||||
assertEquals("term_a", underToken.terminal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPinnedPrimaryTerminalResolvesToPrimaryNotWorker() {
|
||||
// The primary's own session lives in a herdr pane (term_a here). Without the pin the pane
|
||||
// match wins and the primary is locked out of spawn/send/stop as a misread worker.
|
||||
Principal p = new CallerResolver(workerIdentity(), false, null, "term_a")
|
||||
.resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertEquals(Role.PRIMARY, p.role());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPinnedPrimaryTerminalNeedsNoTokenEvenInTokenMode() {
|
||||
Principal p = new CallerResolver(workerIdentity(), true, "s3cret", "term_a")
|
||||
.resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertEquals(Role.PRIMARY, p.role(),
|
||||
"the pane mapping is as unforgeable as a worker's — the pin outranks the token path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherPanesRemainWorkersWhenAPinIsSet() {
|
||||
Principal p = new CallerResolver(workerIdentity(), false, null, "term_someone_else")
|
||||
.resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertEquals(Role.WORKER, p.role());
|
||||
assertEquals("term_a", p.terminal());
|
||||
}
|
||||
|
||||
/** The pin is optional config, so an absent or whitespace one must change nothing at all. */
|
||||
@Test
|
||||
void aBlankPinLeavesWorkerResolutionUntouched() {
|
||||
assertEquals(Role.WORKER,
|
||||
new CallerResolver(workerIdentity(), false, null, " ").resolve("127.0.0.1", 42, null).role());
|
||||
assertEquals(Role.WORKER,
|
||||
new CallerResolver(workerIdentity(), false, null, null).resolve("127.0.0.1", 42, null).role());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loopbackTrustTreatsANonWorkerLoopbackCallerAsThePrimary() {
|
||||
Principal p = new CallerResolver(nonWorkerIdentity()).resolve("127.0.0.1", 99, null);
|
||||
|
||||
@@ -79,6 +79,11 @@ class BridgedConfigTest {
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(Set.of("gx10", "ollama"), cfg.workerProfiles().keySet());
|
||||
// Order, not just membership: placement breaks an exact-weight tie on definition order, so a
|
||||
// hash-ordered map here would make equal-weight placement differ from one restart to the next.
|
||||
assertEquals(java.util.List.of("gx10", "ollama"),
|
||||
java.util.List.copyOf(cfg.workerProfiles().keySet()),
|
||||
"workerProfiles must preserve YAML definition order");
|
||||
assertEquals("gx10", cfg.defaultProfile());
|
||||
assertEquals("ollama", cfg.workerProfiles().get("ollama").profile(), "profile defaults to its map key");
|
||||
assertEquals("http://gx10.gw:8000", cfg.workerProfiles().get("gx10").baseUrl());
|
||||
@@ -333,6 +338,9 @@ class BridgedConfigTest {
|
||||
parityOverlay: [".mcp.json", ".env"]
|
||||
gitTokenEnv: GITEA_TOKEN
|
||||
gitHostEnv: GITEA_HOST
|
||||
weight: 0.5
|
||||
maxLoad: 2
|
||||
placement: weighted
|
||||
lifecycle:
|
||||
idleTtlSeconds: 300
|
||||
contextCap: 10
|
||||
@@ -356,7 +364,10 @@ class BridgedConfigTest {
|
||||
assertEquals(java.util.List.of(".mcp.json", ".env"), w.parityOverlay());
|
||||
assertTrue(w.hasGitToken(), "gitTokenEnv binds and enables the CB-302 PR grant");
|
||||
assertEquals("GITEA_HOST", w.gitHostEnv());
|
||||
assertEquals(0.5f, w.weight(), 0.0001f, "weight binds as a float");
|
||||
assertEquals(2, w.maxLoad(), "maxLoad binds as an integer");
|
||||
|
||||
assertEquals("weighted", cfg.placement(), "placement binds at the top level");
|
||||
assertEquals(300, cfg.lifecycle().idleTtlSeconds());
|
||||
assertEquals(10, cfg.lifecycle().contextCap());
|
||||
assertEquals(5, cfg.lifecycle().drainTimeoutSeconds());
|
||||
@@ -365,4 +376,36 @@ class BridgedConfigTest {
|
||||
assertEquals(5, cfg.primary().remindersOrDefault());
|
||||
assertEquals(15000L, cfg.primary().backoffMsOrDefault());
|
||||
}
|
||||
|
||||
@Test
|
||||
void placementDefaultsToFixedForExistingConfigs(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("no-placement.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals("fixed", cfg.placement(), "omitted placement must default to fixed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void workerWeightAndMaxLoadDefaultSanely(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("no-weight.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
BridgedConfig.Worker w = cfg.workerProfiles().get("gx10");
|
||||
assertEquals(1.0f, w.weight(), 0.0001f, "absent weight defaults to 1.0");
|
||||
assertNull(w.maxLoad(), "absent maxLoad defaults to unlimited (null)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Contract test for the {@code agent.*} south side against a REAL herdr, locking in
|
||||
* the CB-102 spike findings. It spawns a HARMLESS probe command (never {@code claude},
|
||||
* so no subscription/token involvement), proves the {@code env} map reaches the process
|
||||
* environment, exercises status/read, and always tears the pane down.
|
||||
* Contract test for the worker env seam against a REAL herdr. Under protocol 19 (CB-521) the
|
||||
* env map is injected at PANE CREATION ({@code tab.create}), not {@code agent.start} — and
|
||||
* {@code agent.start} now only launches supported agent kinds, so this probes the seed pane's
|
||||
* SHELL directly (never {@code claude}, so no subscription/token involvement) and always tears
|
||||
* the throwaway space down.
|
||||
*
|
||||
* <p>Tagged {@code contract}; run with {@code mvn test -Pcontract}.
|
||||
*/
|
||||
@@ -26,38 +27,30 @@ class AgentControlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void startInjectsEnvThenReadAndClose() throws Exception {
|
||||
void tabCreateInjectsEnvIntoTheSeedShell() throws Exception {
|
||||
assumeTrue(!noSocket(), "no herdr socket — skipping");
|
||||
try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) {
|
||||
AgentControl agents = new AgentControl(herdr);
|
||||
|
||||
Agent probe = agents.start(
|
||||
"__contract__",
|
||||
List.of("bash", "-c", "printf 'PROBE_BASE=[%s]\\n' \"$ANTHROPIC_BASE_URL\"; sleep 20"),
|
||||
WorkspaceControl spaces = new WorkspaceControl(herdr);
|
||||
Workspace space = spaces.ensureWorkspace("__bridged_env_contract__");
|
||||
Tab.Created tab = spaces.createTab(space.workspaceId(), null,
|
||||
Map.of("ANTHROPIC_BASE_URL", "http://gx00.gw:8000"));
|
||||
|
||||
assertNotNull(probe.terminalId());
|
||||
assertNotNull(probe.paneId());
|
||||
try {
|
||||
// Give the shell a moment to print, then confirm env reached the process.
|
||||
assertNotNull(tab.rootPaneId(), "tab.create must return the seed pane");
|
||||
Thread.sleep(1000); // let the seed shell reach its prompt
|
||||
herdr.call("pane.send_input", Map.of(
|
||||
"pane_id", tab.rootPaneId(),
|
||||
"text", "printf 'PROBE_BASE=[%s]\\n' \"$ANTHROPIC_BASE_URL\"",
|
||||
"keys", List.of("enter")));
|
||||
Thread.sleep(800);
|
||||
String visible = agents.read(probe.terminalId(), "visible");
|
||||
String visible = herdr.call("pane.read",
|
||||
Map.of("pane_id", tab.rootPaneId(), "source", "visible"))
|
||||
.path("read").path("text").asText("");
|
||||
assertTrue(visible.contains("PROBE_BASE=[http://gx00.gw:8000]"),
|
||||
"env map must reach the process; saw: " + visible);
|
||||
|
||||
// Status is queryable; the probe appears in the agent list.
|
||||
assertNotNull(agents.status(probe.terminalId()));
|
||||
assertTrue(agents.list().stream()
|
||||
.anyMatch(a -> probe.terminalId().equals(a.terminalId())),
|
||||
"spawned probe should appear in agent.list");
|
||||
"env map must reach the seed shell; saw: " + visible);
|
||||
} finally {
|
||||
agents.close(probe.paneId());
|
||||
spaces.closeTab(tab.tab().tabId());
|
||||
herdr.call("workspace.close", Map.of("workspace_id", space.workspaceId()));
|
||||
}
|
||||
|
||||
// After close the pane is gone.
|
||||
assertFalse(agents.list().stream()
|
||||
.anyMatch(a -> probe.terminalId().equals(a.terminalId())),
|
||||
"closed probe should no longer be listed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,34 +7,68 @@ import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/** Unit-level behaviour of {@link AgentControl} over a fake herdr. */
|
||||
/** Unit-level behaviour of {@link AgentControl} over a fake herdr (protocol 19). */
|
||||
class AgentControlTest {
|
||||
|
||||
/** The {@code text} of every agent.send, in call order. */
|
||||
/** The {@code text} of every agent.prompt, in call order. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<String> sendTexts(FakeHerdr herdr) {
|
||||
private static List<String> promptTexts(FakeHerdr herdr) {
|
||||
return herdr.calls.stream()
|
||||
.filter(c -> c.method().equals("agent.send"))
|
||||
.filter(c -> c.method().equals("agent.prompt"))
|
||||
.map(c -> ((Map<String, Object>) c.params()).get("text").toString())
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendDeliversThePayloadThenAStandaloneSubmitKey() {
|
||||
void sendDeliversThePayloadAsOnePromptThatSubmitsItself() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
new AgentControl(herdr).send("term_x", "do the thing");
|
||||
|
||||
// The Enter must be its own event — appended to the paste it would be swallowed as text.
|
||||
assertEquals(List.of("do the thing", "\r"), sendTexts(herdr),
|
||||
"payload paste first, then a separate carriage-return keystroke to submit it");
|
||||
// agent.prompt pastes AND submits in one call — no separate Enter event to assert.
|
||||
assertEquals(List.of("do the thing"), promptTexts(herdr),
|
||||
"exactly one agent.prompt carrying the payload");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendPreservesEmbeddedNewlinesAndSubmitsOnlyOnce() {
|
||||
void sendPreservesEmbeddedNewlinesVerbatim() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
new AgentControl(herdr).send("term_x", "line1\nline2");
|
||||
|
||||
assertEquals(List.of("line1\nline2", "\r"), sendTexts(herdr),
|
||||
"multiline content is delivered verbatim; a single trailing Enter submits it");
|
||||
assertEquals(List.of("line1\nline2"), promptTexts(herdr),
|
||||
"multiline content is delivered verbatim in the single prompt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitNudgesWithAStandaloneEnterKeystroke() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
new AgentControl(herdr).submit("term_x");
|
||||
|
||||
FakeHerdr.Call keys = herdr.lastCall("agent.send_keys");
|
||||
assertEquals(Map.of("target", "term_x", "keys", List.of("enter")), keys.params(),
|
||||
"the raced-Enter nudge is a raw send_keys, not a second prompt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void aTerminalIdTargetIsTranslatedToItsPaneId() {
|
||||
// Protocol 19 rejects terminal_id as an agent.* target; the fake's agent.list maps
|
||||
// term_a to pane w2:p7, and the control layer must address herdr by that pane.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
new AgentControl(herdr).send("term_a", "hello");
|
||||
|
||||
Map<String, Object> prompt = (Map<String, Object>) herdr.lastCall("agent.prompt").params();
|
||||
assertEquals("w2:p7", prompt.get("target"), "terminal target resolved to the agent's pane id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void theTerminalToPaneMappingIsCachedAcrossCalls() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
AgentControl agents = new AgentControl(herdr);
|
||||
agents.send("term_a", "one");
|
||||
agents.send("term_a", "two");
|
||||
|
||||
long lists = herdr.calls.stream().filter(c -> c.method().equals("agent.list")).count();
|
||||
assertEquals(1, lists, "one agent.list resolution serves every later call to the same terminal");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* Recording fake {@link HerdrClient} for unit/acceptance tests. Returns canned frames
|
||||
* captured from the real herdr 0.7.0 daemon and records every call so tests can assert
|
||||
* both behaviour and that guard-blocked paths never reached herdr.
|
||||
* matching the real herdr 0.8.0 daemon (protocol 19) and records every call so tests can
|
||||
* assert both behaviour and that guard-blocked paths never reached herdr.
|
||||
*/
|
||||
public final class FakeHerdr implements HerdrClient {
|
||||
|
||||
@@ -25,11 +25,15 @@ public final class FakeHerdr implements HerdrClient {
|
||||
private final List<String> extraWorkspaces = new ArrayList<>();
|
||||
private final List<String> extraAgents = new ArrayList<>();
|
||||
private int agentNameTakenFor = 0;
|
||||
private int agentPaneBusyFor = 0;
|
||||
private int workerTabPaneCount = 1;
|
||||
private String paneCloseErrorCode = null;
|
||||
private String agentSendErrorCode = null;
|
||||
private volatile String agentStatus = "idle"; // steady-state agent.get status
|
||||
private volatile String readText = "worker transcript tail"; // canned agent.read output
|
||||
private int pinnedStarts = 0; // how many upcoming agent.start calls report a fixed pane
|
||||
private String pinnedStartTerminal;
|
||||
private String pinnedStartPane;
|
||||
|
||||
public FakeHerdr healthy(boolean h) {
|
||||
this.healthy = h;
|
||||
@@ -42,6 +46,12 @@ public final class FakeHerdr implements HerdrClient {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Reject the first {@code n} {@code agent.start} calls with {@code agent_pane_busy}. */
|
||||
public FakeHerdr agentPaneBusyTimes(int n) {
|
||||
this.agentPaneBusyFor = n;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Make the worker tab (w9:t2) report this many panes in {@code tab.list} (default 1). */
|
||||
public FakeHerdr withWorkerTabPaneCount(int n) {
|
||||
this.workerTabPaneCount = n;
|
||||
@@ -66,12 +76,25 @@ public final class FakeHerdr implements HerdrClient {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Make {@code agent.send} fail with this herdr error code. */
|
||||
/** Make delivery ({@code agent.prompt} / {@code agent.send_keys}) fail with this error code. */
|
||||
public FakeHerdr agentSendFailsWith(String code) {
|
||||
this.agentSendErrorCode = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the next {@code n} {@code agent.start} calls to report this terminal/pane coordinate,
|
||||
* instead of the fake's usual incrementing {@code term_new_n}/{@code w9:pRoot_n}. Lets a test make
|
||||
* two spawns report the <em>same</em> herdr pane, to prove the host-unique id (CB-519) never
|
||||
* collides on that coordinate.
|
||||
*/
|
||||
public FakeHerdr pinNextStarts(int n, String terminalId, String paneId) {
|
||||
this.pinnedStarts = n;
|
||||
this.pinnedStartTerminal = terminalId;
|
||||
this.pinnedStartPane = paneId;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Seed a named agent into {@code agent.list} (e.g. an orphaned worker for CB-117 reaper tests).
|
||||
@@ -110,7 +133,7 @@ public final class FakeHerdr implements HerdrClient {
|
||||
try {
|
||||
return switch (method) {
|
||||
case "ping" -> mapper.readTree(
|
||||
"{\"type\":\"pong\",\"version\":\"0.7.0\",\"protocol\":14}");
|
||||
"{\"type\":\"pong\",\"version\":\"0.8.0\",\"protocol\":19}");
|
||||
case "workspace.list" -> mapper.readTree(("""
|
||||
{"type":"workspace_list","workspaces":[
|
||||
{"workspace_id":"w1","label":"dev-mgnl","focused":true,"pane_count":7,"agent_status":"unknown"},
|
||||
@@ -122,9 +145,19 @@ public final class FakeHerdr implements HerdrClient {
|
||||
"agent_session":{"kind":"id","value":"sess-1111"},
|
||||
"workspace_id":"w2","tab_id":"w2:t7","pane_id":"w2:p7"}%s]}""")
|
||||
.formatted(extraAgents.isEmpty() ? "" : "," + String.join(",", extraAgents)));
|
||||
case "agent.send" -> {
|
||||
case "agent.prompt" -> {
|
||||
if (agentSendErrorCode != null) {
|
||||
throw new HerdrException("herdr error [" + agentSendErrorCode + "]: agent.send failed",
|
||||
throw new HerdrException("herdr error [" + agentSendErrorCode + "]: agent.prompt failed",
|
||||
agentSendErrorCode, null);
|
||||
}
|
||||
yield mapper.readTree(("""
|
||||
{"type":"agent_prompted","agent":{"terminal_id":"term_a","agent":"claude",
|
||||
"agent_status":"%s","workspace_id":"w2","tab_id":"w2:t7","pane_id":"w2:p7"}}""")
|
||||
.formatted(agentStatus));
|
||||
}
|
||||
case "agent.send_keys" -> {
|
||||
if (agentSendErrorCode != null) {
|
||||
throw new HerdrException("herdr error [" + agentSendErrorCode + "]: agent.send_keys failed",
|
||||
agentSendErrorCode, null);
|
||||
}
|
||||
yield mapper.readTree("{\"type\":\"ok\"}");
|
||||
@@ -136,29 +169,62 @@ public final class FakeHerdr implements HerdrClient {
|
||||
case "agent.read" -> mapper.readTree(mapper.writeValueAsString(
|
||||
java.util.Map.of("type", "agent_read", "read", java.util.Map.of("text", readText))));
|
||||
case "agent.start" -> {
|
||||
// Protocol 19: kind and pane_id are required — reject like the real daemon.
|
||||
java.util.Map<?, ?> p = params instanceof java.util.Map<?, ?> m ? m : java.util.Map.of();
|
||||
for (String required : new String[]{"kind", "pane_id"}) {
|
||||
if (p.get(required) == null) {
|
||||
throw new HerdrException(
|
||||
"herdr error [invalid_request]: invalid request: missing field `"
|
||||
+ required + "`", "invalid_request", null);
|
||||
}
|
||||
}
|
||||
long starts = calls.stream().filter(c -> c.method().equals("agent.start")).count();
|
||||
if (starts <= agentNameTakenFor) {
|
||||
if (starts <= agentPaneBusyFor) {
|
||||
throw new HerdrException(
|
||||
"herdr error [agent_pane_busy]: agent target pane is not an available shell",
|
||||
"agent_pane_busy", null);
|
||||
}
|
||||
long busyAdjusted = starts - agentPaneBusyFor;
|
||||
if (busyAdjusted <= agentNameTakenFor) {
|
||||
throw new HerdrException(
|
||||
"herdr error [agent_name_taken]: agent name already used",
|
||||
"agent_name_taken", null);
|
||||
}
|
||||
long n = starts - agentNameTakenFor;
|
||||
long n = busyAdjusted - agentNameTakenFor;
|
||||
boolean pinned = pinnedStarts > 0;
|
||||
if (pinned) {
|
||||
pinnedStarts--;
|
||||
}
|
||||
// Protocol 19: the agent starts INTO the requested pane, so its pane_id normally
|
||||
// echoes the param. A pin overrides both coordinates, which is the only way to
|
||||
// make two spawns report one pane — what CB-519's collision test needs.
|
||||
String terminal = pinned ? pinnedStartTerminal : ("term_new_" + n);
|
||||
Object pane = pinned ? pinnedStartPane : p.get("pane_id");
|
||||
yield mapper.readTree(("""
|
||||
{"type":"agent_started","agent":{
|
||||
"terminal_id":"term_new_%d","name":"claude","agent_status":"unknown",
|
||||
"workspace_id":"w9","tab_id":"w9:t2","pane_id":"w9:pW_%d"}}""")
|
||||
.formatted(n, n));
|
||||
"terminal_id":"%s","name":"claude","agent_status":"unknown",
|
||||
"workspace_id":"w9","tab_id":"w9:t2","pane_id":"%s"}}""")
|
||||
.formatted(terminal, pane));
|
||||
}
|
||||
case "pane.split" -> mapper.readTree("""
|
||||
{"type":"pane_info","pane":{"pane_id":"w1:pSplit","workspace_id":"w1",
|
||||
"tab_id":"w1:t1"}}""");
|
||||
case "workspace.create" -> mapper.readTree("""
|
||||
{"type":"workspace_created",
|
||||
"workspace":{"workspace_id":"w9","label":"bridged-workers","focused":false,
|
||||
"pane_count":1,"tab_count":1,"active_tab_id":"w9:t1","agent_status":"unknown"},
|
||||
"tab":{"tab_id":"w9:t1","workspace_id":"w9","label":"1","pane_count":1},
|
||||
"root_pane":{"pane_id":"w9:p1","workspace_id":"w9","tab_id":"w9:t1"}}""");
|
||||
case "tab.create" -> mapper.readTree("""
|
||||
case "tab.create" -> {
|
||||
// Each tab gets its own seed pane — under protocol 19 that pane becomes the
|
||||
// worker pane, so distinct spawns must yield distinct pane ids.
|
||||
long tabs = calls.stream().filter(c -> c.method().equals("tab.create")).count();
|
||||
yield mapper.readTree(("""
|
||||
{"type":"tab_created",
|
||||
"tab":{"tab_id":"w9:t2","workspace_id":"w9","label":"2","pane_count":1},
|
||||
"root_pane":{"pane_id":"w9:pRoot","workspace_id":"w9","tab_id":"w9:t2"}}""");
|
||||
"root_pane":{"pane_id":"w9:pRoot_%d","workspace_id":"w9","tab_id":"w9:t2"}}""")
|
||||
.formatted(tabs));
|
||||
}
|
||||
case "tab.rename" -> mapper.readTree("""
|
||||
{"type":"tab_info","tab":{"tab_id":"w9:t2","workspace_id":"w9",
|
||||
"label":"worker: ltms-local","pane_count":1}}""");
|
||||
|
||||
@@ -5,16 +5,15 @@ import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Contract test for the herdr half of connection-based identity against a REAL herdr: spawn a
|
||||
* harmless probe, read its actual {@code shell_pid} from {@code pane.process_info}, and confirm
|
||||
* {@link PaneLocator} resolves that PID back to the probe's own {@code terminal_id}.
|
||||
* Contract test for {@link PaneLocator} against a REAL herdr: the PID→pane mapping that
|
||||
* connection identity rests on. Uses a throwaway tab's seed shell as the probe process
|
||||
* (protocol 19 removed arbitrary-command agents), and always tears the space down.
|
||||
*
|
||||
* <p>Tagged {@code contract}; run with {@code mvn test -Pcontract}.
|
||||
*/
|
||||
@@ -25,18 +24,24 @@ class PaneLocatorContractTest {
|
||||
void resolvesTheTerminalOwningARealProcessPid() throws Exception {
|
||||
assumeTrue(Files.exists(UnixSocketHerdrClient.defaultSocketPath()), "no herdr socket — skipping");
|
||||
try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) {
|
||||
AgentControl agents = new AgentControl(herdr);
|
||||
Agent probe = agents.start("__pidprobe__", List.of("bash", "-c", "sleep 20"), Map.of());
|
||||
WorkspaceControl spaces = new WorkspaceControl(herdr);
|
||||
Workspace space = spaces.ensureWorkspace("__bridged_pid_contract__");
|
||||
Tab.Created tab = spaces.createTab(space.workspaceId(), null, Map.of());
|
||||
try {
|
||||
JsonNode info = herdr.call("pane.process_info", Map.of("pane_id", probe.paneId()))
|
||||
JsonNode info = herdr.call("pane.process_info", Map.of("pane_id", tab.rootPaneId()))
|
||||
.path("process_info");
|
||||
long shellPid = info.path("shell_pid").asLong(-1);
|
||||
assertTrue(shellPid > 0, "probe pane should report a shell pid");
|
||||
assertTrue(shellPid > 0, "seed pane should report a shell pid");
|
||||
|
||||
assertEquals(probe.terminalId(), new PaneLocator(herdr).terminalForPid(shellPid),
|
||||
String terminalId = herdr.call("pane.get", Map.of("pane_id", tab.rootPaneId()))
|
||||
.path("pane").path("terminal_id").asText(null);
|
||||
assertNotNull(terminalId, "seed pane should carry a terminal_id");
|
||||
|
||||
assertEquals(terminalId, new PaneLocator(herdr).terminalForPid(shellPid),
|
||||
"a real PID must resolve back to its own pane's terminal_id");
|
||||
} finally {
|
||||
agents.close(probe.paneId());
|
||||
spaces.closeTab(tab.tab().tabId());
|
||||
herdr.call("workspace.close", Map.of("workspace_id", space.workspaceId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -13,9 +12,9 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Contract test for the placement layer ({@code workspace.*}/{@code tab.*}) against a
|
||||
* REAL herdr, locking in the "one clean tab per worker" recipe: find-or-create a worker
|
||||
* space, give the worker its own tab, drop herdr's seed shell so the tab holds only the
|
||||
* worker, and tear it all down. Uses a HARMLESS probe (never {@code claude}) in a
|
||||
* REAL herdr, locking in the "one clean tab per worker" recipe under protocol 19: find-or-create
|
||||
* a worker space, give the worker its own tab, and the SEED pane is where the worker starts —
|
||||
* the tab holds exactly that one pane from creation. Uses no agent (never {@code claude}) in a
|
||||
* throwaway space that is fully removed at the end.
|
||||
*
|
||||
* <p>Tagged {@code contract}; run with {@code mvn test -Pcontract}.
|
||||
@@ -41,7 +40,6 @@ class WorkspacePlacementContractTest {
|
||||
void workerGetsOwnCleanTabAndTearsDownCompletely() throws Exception {
|
||||
assumeTrue(!noSocket(), "no herdr socket — skipping");
|
||||
try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) {
|
||||
AgentControl agents = new AgentControl(herdr);
|
||||
WorkspaceControl spaces = new WorkspaceControl(herdr);
|
||||
|
||||
Workspace space = spaces.ensureWorkspace(LABEL);
|
||||
@@ -49,36 +47,27 @@ class WorkspacePlacementContractTest {
|
||||
// Idempotent: a second ensure finds the same space, never creates a duplicate.
|
||||
assertEquals(space.workspaceId(), spaces.ensureWorkspace(LABEL).workspaceId());
|
||||
|
||||
Tab.Created tab = spaces.createTab(space.workspaceId());
|
||||
Agent worker = agents.start(
|
||||
"__contract__",
|
||||
List.of("bash", "-c", "sleep 20"),
|
||||
Map.of(),
|
||||
tab.tab().tabId());
|
||||
Tab.Created tab = spaces.createTab(space.workspaceId(), null, Map.of());
|
||||
try {
|
||||
// The worker landed in its dedicated tab in the worker space.
|
||||
assertEquals(tab.tab().tabId(), worker.tabId());
|
||||
assertEquals(space.workspaceId(), worker.workspaceId());
|
||||
assertNotNull(tab.rootPaneId(), "tab.create must return the seed pane");
|
||||
|
||||
// Drop the seed shell; the tab now holds exactly the worker pane.
|
||||
agents.close(tab.rootPaneId());
|
||||
// Protocol 19: the seed pane IS the worker pane — the tab holds exactly it.
|
||||
spaces.renameTab(tab.tab().tabId(), "worker: contract");
|
||||
assertEquals(1, paneCount(herdr, space.workspaceId(), tab.tab().tabId()),
|
||||
"worker tab must hold only the worker pane after the seed shell is dropped");
|
||||
"worker tab must hold exactly the seed/worker pane");
|
||||
|
||||
// Teardown resolves the tab from the pane, and sees it holds exactly one pane.
|
||||
WorkspaceControl.PaneLocation loc = spaces.locatePane(worker.paneId());
|
||||
WorkspaceControl.PaneLocation loc = spaces.locatePane(tab.rootPaneId());
|
||||
assertNotNull(loc);
|
||||
assertEquals(tab.tab().tabId(), loc.tabId());
|
||||
assertEquals(1, loc.tabPaneCount(), "worker is the tab's sole occupant");
|
||||
assertEquals(1, loc.tabPaneCount(), "worker pane is the tab's sole occupant");
|
||||
} finally {
|
||||
agents.close(worker.paneId());
|
||||
spaces.closeTab(tab.tab().tabId());
|
||||
}
|
||||
|
||||
// Tolerant teardown: closing an already-gone tab / reading a gone pane is a no-op.
|
||||
spaces.closeTab(tab.tab().tabId());
|
||||
assertNull(spaces.locatePane(worker.paneId()), "closed worker pane must be gone");
|
||||
assertNull(spaces.locatePane(tab.rootPaneId()), "closed worker pane must be gone");
|
||||
|
||||
// Remove the throwaway space entirely so the test leaves no residue.
|
||||
herdr.call("workspace.close", Map.of("workspace_id", space.workspaceId()));
|
||||
|
||||
@@ -28,17 +28,15 @@ class InjectorTest {
|
||||
private final Injector injector = new Injector(new AgentControl(herdr));
|
||||
|
||||
/**
|
||||
* The logical messages delivered, in order. AgentControl.send emits each delivery as two
|
||||
* agent.send calls — the payload, then a standalone Enter keystroke ({@code "\r"}) to submit
|
||||
* it; these tests assert delivery ordering/gating, not the submit event, so drop the bare
|
||||
* carriage returns.
|
||||
* The logical messages delivered, in order. Under protocol 19 each delivery is one
|
||||
* {@code agent.prompt} carrying the payload (it submits itself); the Enter nudge is a
|
||||
* separate {@code agent.send_keys} and never appears here.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> sent() {
|
||||
return herdr.calls.stream()
|
||||
.filter(c -> c.method().equals("agent.send"))
|
||||
.filter(c -> c.method().equals("agent.prompt"))
|
||||
.map(c -> ((Map<String, Object>) c.params()).get("text").toString())
|
||||
.filter(t -> !t.equals("\r"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -66,11 +64,9 @@ class InjectorTest {
|
||||
assertEquals(List.of("task"), sent(), "delivers once the worker is available");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private long enterKeystrokes() {
|
||||
return herdr.calls.stream()
|
||||
.filter(c -> c.method().equals("agent.send"))
|
||||
.filter(c -> "\r".equals(((Map<String, Object>) c.params()).get("text")))
|
||||
.filter(c -> c.method().equals("agent.send_keys"))
|
||||
.count();
|
||||
}
|
||||
|
||||
@@ -374,13 +370,12 @@ class InjectorTest {
|
||||
poller.stop();
|
||||
}
|
||||
assertEquals(List.of("via-poller"), idle.calls.stream()
|
||||
.filter(c -> c.method().equals("agent.send"))
|
||||
.filter(c -> c.method().equals("agent.prompt"))
|
||||
.map(c -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> p = (Map<String, Object>) c.params();
|
||||
return p.get("text").toString();
|
||||
})
|
||||
.filter(t -> !t.equals("\r")) // drop the standalone submit keystroke
|
||||
.toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import dev.ltms.bridged.session.WorkerSession;
|
||||
import dev.ltms.bridged.session.WorktreeRequest;
|
||||
import dev.ltms.bridged.worker.ClaudeCodeLauncher;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import dev.ltms.bridged.msg.InMemoryReplyInbox;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -31,10 +33,19 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
*/
|
||||
class BridgeMcpTest {
|
||||
|
||||
private static final String T = "term_a";
|
||||
|
||||
private final FakeHerdr herdr = new FakeHerdr();
|
||||
private final AgentControl agents = new AgentControl(herdr);
|
||||
private final Rendezvous rendezvous = new Rendezvous();
|
||||
private final MessageService messages = new MessageService(agents, new Injector(agents), rendezvous);
|
||||
private final InMemoryReplyInbox inbox = new InMemoryReplyInbox();
|
||||
private final MessageService messages = new MessageService(agents, new Injector(agents), rendezvous, inbox);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// CB-520: the inbox only peeks/acks targets it owns.
|
||||
inbox.own(T);
|
||||
}
|
||||
|
||||
private static String textOf(McpSchema.CallToolResult r) {
|
||||
return ((McpSchema.TextContent) r.content().getFirst()).text();
|
||||
@@ -216,12 +227,15 @@ class BridgeMcpTest {
|
||||
@Test
|
||||
void spawnReturnsTheNewWorkersSessionAndPane() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
McpSchema.CallToolResult res = BridgeMcp.spawn(
|
||||
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null);
|
||||
SessionManager sm = sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw"));
|
||||
McpSchema.CallToolResult res = BridgeMcp.spawn(sm, null);
|
||||
assertNotEquals(Boolean.TRUE, res.isError());
|
||||
String out = textOf(res);
|
||||
assertTrue(out.contains("\"sessionId\":\"term_new_1\""), out);
|
||||
assertTrue(out.contains("\"paneId\":\"w9:pW_1\""), out);
|
||||
// CB-519: the "paneId" wire field now carries the host-unique opaque id, not the herdr pane.
|
||||
WorkerSession s = sm.roster().getFirst();
|
||||
assertTrue(out.contains("\"paneId\":\"" + s.paneId() + "\""), out);
|
||||
assertNotEquals("w9:pRoot_1", s.paneId(), "the id is decoupled from the herdr pane coordinate");
|
||||
assertTrue(out.contains("\"status\":\"spawning\""), out);
|
||||
}
|
||||
|
||||
@@ -250,9 +264,10 @@ class BridgeMcpTest {
|
||||
McpSchema.CallToolResult res = BridgeMcp.spawn(
|
||||
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")), null, "/req/dir", null, null, null);
|
||||
assertNotEquals(Boolean.TRUE, res.isError());
|
||||
// Protocol 19: the requested cwd roots the worker's pane at creation (tab.create).
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> start = (Map<String, Object>) h.lastCall("agent.start").params();
|
||||
assertEquals("/req/dir", start.get("cwd"));
|
||||
Map<String, Object> create = (Map<String, Object>) h.lastCall("tab.create").params();
|
||||
assertEquals("/req/dir", create.get("cwd"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package dev.ltms.bridged.msg;
|
||||
|
||||
import com.rabbitmq.client.AMQP;
|
||||
import com.rabbitmq.client.Channel;
|
||||
import com.rabbitmq.client.Connection;
|
||||
import com.rabbitmq.client.ConnectionFactory;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.RabbitMQContainer;
|
||||
@@ -19,19 +24,46 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
* excluded from {@code mvn test}/{@code mvn clean install} (which stay hermetic and need no Docker);
|
||||
* run it with Docker present via {@code mvn test -Pcontract}.
|
||||
*
|
||||
* <p>Two broker modes:
|
||||
* <ul>
|
||||
* <li><b>Locally</b> ({@code AMQP_URI} unset): Testcontainers spins a RabbitMQ container. Requires
|
||||
* a working Docker engine; see the "Running the contract tests" note in
|
||||
* {@code docs/CB-307-Reliable-Delivery.md} for the {@code api.version} engine-compat pin.</li>
|
||||
* <li><b>In CI</b> ({@code AMQP_URI} set): a RabbitMQ service container provisions the broker and
|
||||
* {@code AMQP_URI} points at it, so the contract job needs <em>no</em> Docker on the runner.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>It proves the port contract on genuine infrastructure: eventual visibility of a published reply,
|
||||
* ack removal, msgId dedup, and — the reason Stage 2 exists — cross-restart durability: an unacked
|
||||
* reply survives closing the inbox and is redelivered to a fresh connection.
|
||||
* ack removal, msgId dedup, explicit ownership, and — the reason Stage 2 exists — cross-restart
|
||||
* durability: an unacked reply survives closing the inbox and is redelivered to a fresh connection.
|
||||
*/
|
||||
@Tag("contract")
|
||||
@Testcontainers
|
||||
// disabledWithoutDocker=false on purpose: on the CI path (AMQP_URI set) no container is started and
|
||||
// the class must still RUN against the external broker even though the runner has no Docker — a
|
||||
// disabled-without-docker check would silently skip the whole contract suite there.
|
||||
@Testcontainers(disabledWithoutDocker = false)
|
||||
class AmqpReplyInboxContractTest {
|
||||
|
||||
@Container
|
||||
static final RabbitMQContainer BROKER =
|
||||
// When a broker is provisioned out-of-band (CI service container), AMQP_URI takes us straight to
|
||||
// it and we never touch Testcontainers. Unset locally → Testcontainers starts the container below.
|
||||
private static final String EXTERNAL_URI = System.getenv("AMQP_URI");
|
||||
|
||||
private static final RabbitMQContainer BROKER =
|
||||
new RabbitMQContainer(DockerImageName.parse("rabbitmq:3.13-management"));
|
||||
|
||||
// No @Container on BROKER: the JUnit 5 extension would force-start it even when AMQP_URI is set.
|
||||
// Start it manually only on the local (no-external-broker) path; Ryuk reaps it on JVM exit.
|
||||
@BeforeAll
|
||||
static void startBrokerUnlessExternal() {
|
||||
if (EXTERNAL_URI == null) {
|
||||
BROKER.start();
|
||||
}
|
||||
}
|
||||
|
||||
private static String uri() {
|
||||
if (EXTERNAL_URI != null) {
|
||||
return EXTERNAL_URI;
|
||||
}
|
||||
// guest/guest against the mapped AMQP port. No trailing slash: an empty path is vhost "",
|
||||
// which does not exist — omitting it selects the default vhost "/".
|
||||
return "amqp://guest:guest@" + BROKER.getHost() + ":" + BROKER.getAmqpPort();
|
||||
@@ -41,6 +73,7 @@ class AmqpReplyInboxContractTest {
|
||||
void publishThenPeekThenAck() throws Exception {
|
||||
String target = "worker-pub-" + System.nanoTime();
|
||||
try (AmqpReplyInbox inbox = AmqpReplyInbox.open(uri())) {
|
||||
inbox.own(target);
|
||||
inbox.publish(target, "m1", "hello primary");
|
||||
|
||||
List<ReplyInbox.InboxMessage> got = awaitPeek(inbox, target);
|
||||
@@ -58,6 +91,7 @@ class AmqpReplyInboxContractTest {
|
||||
void duplicateMsgIdIsNotDoubleQueued() throws Exception {
|
||||
String target = "worker-dedup-" + System.nanoTime();
|
||||
try (AmqpReplyInbox inbox = AmqpReplyInbox.open(uri())) {
|
||||
inbox.own(target);
|
||||
inbox.publish(target, "dup", "first");
|
||||
awaitPeek(inbox, target);
|
||||
inbox.publish(target, "dup", "second"); // same msgId — must be a no-op
|
||||
@@ -74,8 +108,9 @@ class AmqpReplyInboxContractTest {
|
||||
void unackedReplySurvivesRestartAndIsRedelivered() throws Exception {
|
||||
String target = "worker-durable-" + System.nanoTime();
|
||||
|
||||
// First "process life": publish, see it held, but crash before acking.
|
||||
// First "process life": own, publish, see it held, but crash before acking.
|
||||
try (AmqpReplyInbox first = AmqpReplyInbox.open(uri())) {
|
||||
first.own(target);
|
||||
first.publish(target, "persist-1", "survive me");
|
||||
assertEquals(1, awaitPeek(first, target).size());
|
||||
// no ack — simulate a java -jar bounce with the reply still pending
|
||||
@@ -83,6 +118,7 @@ class AmqpReplyInboxContractTest {
|
||||
|
||||
// Second "process life": a fresh connection to the same broker must be redelivered the reply.
|
||||
try (AmqpReplyInbox second = AmqpReplyInbox.open(uri())) {
|
||||
second.own(target);
|
||||
List<ReplyInbox.InboxMessage> got = awaitPeek(second, target);
|
||||
assertEquals(1, got.size(), "an unacked persistent reply is redelivered after restart");
|
||||
assertEquals("persist-1", got.getFirst().msgId());
|
||||
@@ -93,11 +129,43 @@ class AmqpReplyInboxContractTest {
|
||||
|
||||
// Third life: once acked, it is gone for good — durability is not endless replay.
|
||||
try (AmqpReplyInbox third = AmqpReplyInbox.open(uri())) {
|
||||
third.own(target);
|
||||
Thread.sleep(500);
|
||||
assertTrue(third.peek(target).isEmpty(), "an acked reply does not come back on the next restart");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishDoesNotAttachAConsumer() throws Exception {
|
||||
String target = "worker-pub-no-consumer-" + System.nanoTime();
|
||||
try (AmqpReplyInbox inbox = AmqpReplyInbox.open(uri());
|
||||
Connection inspect = newConnection()) {
|
||||
inbox.own(target);
|
||||
inbox.publish(target, "m1", "published");
|
||||
awaitPeek(inbox, target); // ensure the owner's consumer received it
|
||||
|
||||
try (Channel ch = inspect.createChannel()) {
|
||||
AMQP.Queue.DeclareOk ok = ch.queueDeclare(queueName(target), true, false, false, null);
|
||||
assertEquals(1, ok.getConsumerCount(),
|
||||
"publish must not attach a consumer; only the owner's consumer should exist");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseCancelsConsumerAndClearsHeld() throws Exception {
|
||||
String target = "worker-release-" + System.nanoTime();
|
||||
try (AmqpReplyInbox inbox = AmqpReplyInbox.open(uri())) {
|
||||
inbox.own(target);
|
||||
inbox.publish(target, "m1", "release me");
|
||||
assertEquals(1, awaitPeek(inbox, target).size(), "owned target holds the reply");
|
||||
|
||||
inbox.release(target);
|
||||
assertTrue(inbox.peek(target).isEmpty(),
|
||||
"release clears the local held snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll peek (broker delivery is async) until a reply for {@code target} appears or ~10s elapse. */
|
||||
@SuppressWarnings("BusyWait") // deliberate poll for async broker delivery, bounded by the deadline
|
||||
private static List<ReplyInbox.InboxMessage> awaitPeek(AmqpReplyInbox inbox, String target)
|
||||
@@ -110,4 +178,15 @@ class AmqpReplyInboxContractTest {
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
/** A separate broker connection for inspecting queue state without disturbing the inbox. */
|
||||
private static Connection newConnection() throws Exception {
|
||||
ConnectionFactory factory = new ConnectionFactory();
|
||||
factory.setUri(uri());
|
||||
return factory.newConnection("contract-inspector");
|
||||
}
|
||||
|
||||
private static String queueName(String target) {
|
||||
return "agent." + target + ".inbox";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.ltms.bridged.msg;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
@@ -10,13 +11,18 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link InMemoryReplyInbox}: publish, peek, ack, dedup, FIFO ordering, and thread
|
||||
* safety under concurrent publish vs. drain.
|
||||
* Unit tests for {@link InMemoryReplyInbox}: publish, peek, ack, dedup, FIFO ordering, thread
|
||||
* safety under concurrent publish vs. drain, and explicit ownership.
|
||||
*/
|
||||
class InMemoryReplyInboxTest {
|
||||
|
||||
private final ReplyInbox inbox = new InMemoryReplyInbox();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
inbox.own("term_a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishThenPeekReturnsTheMessage() {
|
||||
inbox.publish("term_a", "m1", "hello");
|
||||
@@ -72,6 +78,7 @@ class InMemoryReplyInboxTest {
|
||||
|
||||
@Test
|
||||
void perTargetIsolation() {
|
||||
inbox.own("term_b");
|
||||
inbox.publish("term_a", "m1", "for-a");
|
||||
inbox.publish("term_b", "m2", "for-b");
|
||||
assertEquals(1, inbox.peek("term_a").size());
|
||||
@@ -142,4 +149,40 @@ class InMemoryReplyInboxTest {
|
||||
exec.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishWithoutOwnDoesNotClaimOwnership() {
|
||||
String unowned = "term_unowned";
|
||||
inbox.publish(unowned, "m1", "hello");
|
||||
// Without an owner, peek returns nothing — publish did not imply consume.
|
||||
assertTrue(inbox.peek(unowned).isEmpty(),
|
||||
"publishing to an unowned target must not make it peekable");
|
||||
// Owning afterwards makes the already-published message visible.
|
||||
inbox.own(unowned);
|
||||
var msgs = inbox.peek(unowned);
|
||||
assertEquals(1, msgs.size());
|
||||
assertEquals("m1", msgs.getFirst().msgId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekAndAckAreNoOpsForUnownedTarget() {
|
||||
assertTrue(inbox.peek("term_not_owned").isEmpty());
|
||||
inbox.ack("term_not_owned", "m1"); // no-op, should not throw
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseStopsConsumingAndClearsHeld() {
|
||||
inbox.publish("term_a", "m1", "hello");
|
||||
assertEquals(1, inbox.peek("term_a").size());
|
||||
inbox.release("term_a");
|
||||
assertTrue(inbox.peek("term_a").isEmpty(),
|
||||
"after release, the local snapshot is cleared");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownIsIdempotent() {
|
||||
inbox.own("term_a");
|
||||
inbox.publish("term_a", "m1", "hello");
|
||||
assertEquals(1, inbox.peek("term_a").size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import dev.ltms.bridged.herdr.FakeHerdr;
|
||||
import dev.ltms.bridged.herdr.HerdrException;
|
||||
import dev.ltms.bridged.inject.CompletionResolver;
|
||||
import dev.ltms.bridged.inject.Injector;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -30,7 +31,14 @@ class MessageServiceTest {
|
||||
private final Rendezvous rendezvous = new Rendezvous();
|
||||
private final CompletionResolver completion = new CompletionResolver(agents, rendezvous);
|
||||
private final Injector injector = new Injector(agents, completion);
|
||||
private final MessageService messages = new MessageService(agents, injector, rendezvous);
|
||||
private final InMemoryReplyInbox inbox = new InMemoryReplyInbox();
|
||||
private final MessageService messages = new MessageService(agents, injector, rendezvous, inbox);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// CB-520: the inbox only peeks/acks targets it owns.
|
||||
inbox.own(T);
|
||||
}
|
||||
|
||||
/** Run {@code send} on a background thread; the current thread drives the worker's turn. */
|
||||
private CompletableFuture<MessageService.Reply> sendAsync() {
|
||||
|
||||
@@ -45,6 +45,7 @@ class ReplyPushLoopTest {
|
||||
void setUp() {
|
||||
registry = new PrimaryRegistry(PRIMARY);
|
||||
inbox = new InMemoryReplyInbox();
|
||||
inbox.own(WORKER); // CB-520: the inbox only peeks/acks targets it owns
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
}
|
||||
|
||||
@@ -133,10 +134,10 @@ class ReplyPushLoopTest {
|
||||
loop(1, 50).onReplyQueued(WORKER);
|
||||
|
||||
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS),
|
||||
"one nudge (2 agent.send calls) should have been sent");
|
||||
"one nudge (1 agent.prompt call) should have been sent");
|
||||
|
||||
// Exactly one nudge = exactly 2 agent.send calls (text + submit)
|
||||
assertEquals(2, rec.sendCount());
|
||||
// Exactly one nudge = exactly 1 agent.prompt call (it submits itself)
|
||||
assertEquals(1, rec.sendCount());
|
||||
assertTrue(rec.sentParams().stream()
|
||||
.anyMatch(e -> e.getValue().toString().contains("bridge_poll")),
|
||||
"nudge text should contain bridge_poll");
|
||||
@@ -153,9 +154,9 @@ class ReplyPushLoopTest {
|
||||
loop.onReplyQueued(WORKER); // second call — should be a no-op
|
||||
|
||||
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS),
|
||||
"expected exactly one nudge (2 sends)");
|
||||
"expected exactly one nudge (1 prompt)");
|
||||
Thread.sleep(200);
|
||||
assertEquals(2, rec.sendCount(),
|
||||
assertEquals(1, rec.sendCount(),
|
||||
"second onReplyQueued must not trigger another nudge");
|
||||
}
|
||||
|
||||
@@ -165,15 +166,15 @@ class ReplyPushLoopTest {
|
||||
var rec = recordingClient();
|
||||
agents = new AgentControl(rec);
|
||||
inbox.publish(WORKER, "m1", "hello");
|
||||
rec.sendLatch = new CountDownLatch(cap * 2);
|
||||
rec.sendLatch = new CountDownLatch(cap);
|
||||
|
||||
loop(cap, 50).onReplyQueued(WORKER);
|
||||
|
||||
assertTrue(rec.sendLatch.await(5, TimeUnit.SECONDS),
|
||||
cap + " nudges (" + (cap * 2) + " sends) should have fired");
|
||||
cap + " nudges (" + cap + " prompts) should have fired");
|
||||
Thread.sleep(300);
|
||||
assertEquals(cap * 2, rec.sendCount(),
|
||||
"exactly " + (cap * 2) + " agent.send calls (cap=" + cap + ")");
|
||||
assertEquals(cap, rec.sendCount(),
|
||||
"exactly " + cap + " agent.prompt calls (cap=" + cap + ")");
|
||||
}
|
||||
|
||||
// --- nudge format --------------------------------------------------------------------------
|
||||
@@ -197,7 +198,7 @@ class ReplyPushLoopTest {
|
||||
loop(1, 50, metrics).onReplyQueued(WORKER);
|
||||
|
||||
assertTrue(rec.sendLatch.await(3, TimeUnit.SECONDS),
|
||||
"one nudge (2 agent.send calls) should have been sent");
|
||||
"one nudge (1 agent.prompt call) should have been sent");
|
||||
// The delivered count is bumped on the scheduler thread right after the send that releases
|
||||
// the latch — settle briefly so the counter is published before we read it.
|
||||
Thread.sleep(200);
|
||||
@@ -261,13 +262,13 @@ class ReplyPushLoopTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Thread-safe recording fake that counts agent.send calls. Uses synchronized access
|
||||
* so the scheduler thread and test thread never race.
|
||||
* Thread-safe recording fake that counts agent.prompt calls (protocol 19: one nudge = one
|
||||
* prompt). Uses synchronized access so the scheduler thread and test thread never race.
|
||||
*/
|
||||
private static final class RecordingHerdrClient implements HerdrClient {
|
||||
private final List<Map.Entry<String, Object>> calls =
|
||||
Collections.synchronizedList(new ArrayList<>());
|
||||
volatile CountDownLatch sendLatch = new CountDownLatch(2);
|
||||
volatile CountDownLatch sendLatch = new CountDownLatch(1);
|
||||
|
||||
@Override
|
||||
public JsonNode call(String method, Object params) {
|
||||
@@ -277,7 +278,7 @@ class ReplyPushLoopTest {
|
||||
.put("terminal_id", PRIMARY)
|
||||
.put("agent_status", "idle")); // recording double is always injectable
|
||||
}
|
||||
if ("agent.send".equals(method)) {
|
||||
if ("agent.prompt".equals(method)) {
|
||||
calls.add(Map.entry(method, params));
|
||||
sendLatch.countDown();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package dev.ltms.bridged.placement;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for the placement policies. They run with no herdr and no launcher — pure selection
|
||||
* logic exercised through the descriptor type so CB-308 host expansion will not need to rewrite
|
||||
* these assertions.
|
||||
*/
|
||||
class PlacementPolicyTest {
|
||||
|
||||
private static Function<String, Integer> noSessions() {
|
||||
return name -> 0;
|
||||
}
|
||||
|
||||
private static PlacementContext ctx(List<PlacementCandidate> candidates,
|
||||
Function<String, Integer> liveCount,
|
||||
Set<String> unreachable) {
|
||||
return new PlacementContext("b", candidates, liveCount, unreachable);
|
||||
}
|
||||
|
||||
private static PlacementContext ctx(List<PlacementCandidate> candidates,
|
||||
Function<String, Integer> liveCount) {
|
||||
return ctx(candidates, liveCount, Set.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixedReturnsDefaultEvenIfOtherProfilesExist() {
|
||||
PlacementPolicy policy = PlacementPolicies.fixed();
|
||||
PlacementContext ctx = ctx(List.of(
|
||||
PlacementCandidate.profile("a"),
|
||||
PlacementCandidate.profile("b")), noSessions());
|
||||
assertEquals("b", policy.select(ctx).profile());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixedFallsBackToFirstCandidateWhenNoDefault() {
|
||||
PlacementPolicy policy = PlacementPolicies.fixed();
|
||||
PlacementContext ctx = new PlacementContext(null,
|
||||
List.of(PlacementCandidate.profile("a"), PlacementCandidate.profile("b")),
|
||||
noSessions(), Set.of());
|
||||
assertEquals("a", policy.select(ctx).profile());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixedThrowsWhenNoProfilesAndNoDefault() {
|
||||
PlacementPolicy policy = PlacementPolicies.fixed();
|
||||
PlacementContext ctx = new PlacementContext(null, List.of(), noSessions(), Set.of());
|
||||
assertThrows(PlacementException.class, () -> policy.select(ctx));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundRobinCyclesThroughAvailableProfiles() {
|
||||
PlacementPolicy policy = PlacementPolicies.roundRobin();
|
||||
List<PlacementCandidate> candidates = List.of(
|
||||
PlacementCandidate.profile("a"),
|
||||
PlacementCandidate.profile("b"),
|
||||
PlacementCandidate.profile("c"));
|
||||
assertEquals("a", policy.select(ctx(candidates, noSessions())).profile());
|
||||
assertEquals("b", policy.select(ctx(candidates, noSessions())).profile());
|
||||
assertEquals("c", policy.select(ctx(candidates, noSessions())).profile());
|
||||
assertEquals("a", policy.select(ctx(candidates, noSessions())).profile());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundRobinSkipsProfilesAtMaxLoad() {
|
||||
PlacementPolicy policy = PlacementPolicies.roundRobin();
|
||||
List<PlacementCandidate> candidates = List.of(
|
||||
PlacementCandidate.profile("a", 1.0f, 2),
|
||||
PlacementCandidate.profile("b", 1.0f, null));
|
||||
Function<String, Integer> liveCount = Map.of("a", 2)::get;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertEquals("b", policy.select(ctx(candidates, liveCount)).profile());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundRobinThrowsWhenAllAtMaxLoad() {
|
||||
PlacementPolicy policy = PlacementPolicies.roundRobin();
|
||||
List<PlacementCandidate> candidates = List.of(
|
||||
PlacementCandidate.profile("a", 1.0f, 1),
|
||||
PlacementCandidate.profile("b", 1.0f, 1));
|
||||
Function<String, Integer> liveCount = Map.of("a", 1, "b", 1)::get;
|
||||
PlacementException e = assertThrows(PlacementException.class,
|
||||
() -> policy.select(ctx(candidates, liveCount)));
|
||||
assertTrue(e.getMessage().contains("maxLoad"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightedAlternatesEvenlyWithEqualWeights() {
|
||||
PlacementPolicy policy = PlacementPolicies.weighted();
|
||||
List<PlacementCandidate> candidates = List.of(
|
||||
PlacementCandidate.profile("a", 0.5f, null),
|
||||
PlacementCandidate.profile("b", 0.5f, null));
|
||||
int a = 0, b = 0;
|
||||
for (int i = 0; i < 100; i++) {
|
||||
String p = policy.select(ctx(candidates, noSessions())).profile();
|
||||
if ("a".equals(p)) a++;
|
||||
else if ("b".equals(p)) b++;
|
||||
}
|
||||
assertEquals(50, a, "equal weights should split 50/50");
|
||||
assertEquals(50, b);
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightedHoldsThreeToOneRatio() {
|
||||
PlacementPolicy policy = PlacementPolicies.weighted();
|
||||
List<PlacementCandidate> candidates = List.of(
|
||||
PlacementCandidate.profile("a", 0.75f, null),
|
||||
PlacementCandidate.profile("b", 0.25f, null));
|
||||
int a = 0, b = 0;
|
||||
for (int i = 0; i < 40; i++) {
|
||||
String p = policy.select(ctx(candidates, noSessions())).profile();
|
||||
if ("a".equals(p)) a++;
|
||||
else if ("b".equals(p)) b++;
|
||||
}
|
||||
assertEquals(30, a, "0.75/0.25 should yield a 3:1 ratio over a multiple of 4");
|
||||
assertEquals(10, b);
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightedSkipsProfileAtMaxLoad() {
|
||||
PlacementPolicy policy = PlacementPolicies.weighted();
|
||||
List<PlacementCandidate> candidates = List.of(
|
||||
PlacementCandidate.profile("a", 1.0f, 1),
|
||||
PlacementCandidate.profile("b", 1.0f, null));
|
||||
Function<String, Integer> liveCount = name -> "a".equals(name) ? 1 : 0;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertEquals("b", policy.select(ctx(candidates, liveCount)).profile());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightedThrowsWhenAllAtMaxLoad() {
|
||||
PlacementPolicy policy = PlacementPolicies.weighted();
|
||||
List<PlacementCandidate> candidates = List.of(
|
||||
PlacementCandidate.profile("a", 1.0f, 1),
|
||||
PlacementCandidate.profile("b", 1.0f, 1));
|
||||
Function<String, Integer> liveCount = name -> 1;
|
||||
PlacementException e = assertThrows(PlacementException.class,
|
||||
() -> policy.select(ctx(candidates, liveCount)));
|
||||
assertTrue(e.getMessage().contains("maxLoad"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightedThrowsWhenAllUnreachable() {
|
||||
PlacementPolicy policy = PlacementPolicies.weighted();
|
||||
List<PlacementCandidate> candidates = List.of(
|
||||
PlacementCandidate.profile("a"),
|
||||
PlacementCandidate.profile("b"));
|
||||
PlacementException e = assertThrows(PlacementException.class,
|
||||
() -> policy.select(ctx(candidates, noSessions(), Set.of("a", "b"))));
|
||||
assertTrue(e.getMessage().contains("unreachable"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedExclusionMessageNamesBothReasons() {
|
||||
PlacementPolicy policy = PlacementPolicies.weighted();
|
||||
List<PlacementCandidate> candidates = List.of(
|
||||
PlacementCandidate.profile("a", 1.0f, 1),
|
||||
PlacementCandidate.profile("b", 1.0f, null));
|
||||
Function<String, Integer> liveCount = name -> "a".equals(name) ? 1 : 0;
|
||||
Set<String> unreachable = new HashSet<>();
|
||||
unreachable.add("b");
|
||||
PlacementException e = assertThrows(PlacementException.class,
|
||||
() -> policy.select(ctx(candidates, liveCount, unreachable)));
|
||||
assertTrue(e.getMessage().contains("1 at maxLoad"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("1 unreachable"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPolicyNameThrows() {
|
||||
assertThrows(IllegalArgumentException.class, () -> PlacementPolicies.fromName("random"));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||
import dev.ltms.bridged.inject.Injector;
|
||||
import dev.ltms.bridged.inject.StatusPoller;
|
||||
import dev.ltms.bridged.inject.WorkerPresence;
|
||||
import dev.ltms.bridged.msg.InMemoryReplyInbox;
|
||||
import dev.ltms.bridged.msg.MessageService;
|
||||
import dev.ltms.bridged.msg.Rendezvous;
|
||||
import dev.ltms.bridged.session.FakeWorktrees;
|
||||
@@ -28,6 +29,7 @@ import java.net.http.HttpResponse;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -74,7 +76,12 @@ class BridgedAppTest {
|
||||
poller = new StatusPoller(agents, injector, 5); // delivers when the fake reports idle
|
||||
poller.start();
|
||||
Rendezvous rendezvous = new Rendezvous();
|
||||
MessageService messages = new MessageService(agents, injector, rendezvous);
|
||||
InMemoryReplyInbox inbox = new InMemoryReplyInbox();
|
||||
sessions.onAcquire(inbox::own);
|
||||
// The static REST tests address "term_a" without acquiring it through SessionManager, so own
|
||||
// it directly so the inbox contract holds for those endpoints.
|
||||
inbox.own("term_a");
|
||||
MessageService messages = new MessageService(agents, injector, rendezvous, inbox);
|
||||
app = new BridgedApp(herdr, workers, sessions, messages, this.presence, null)
|
||||
.build().start("127.0.0.1", 0);
|
||||
return app.port();
|
||||
@@ -118,7 +125,7 @@ class BridgedAppTest {
|
||||
assertEquals(200, res.statusCode());
|
||||
JsonNode body = mapper.readTree(res.body());
|
||||
assertEquals("ok", body.get("status").asText());
|
||||
assertEquals(14, body.get("herdr").get("protocol").asInt());
|
||||
assertEquals(19, body.get("herdr").get("protocol").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -156,22 +163,27 @@ class BridgedAppTest {
|
||||
HttpResponse<String> res = req(port, "POST", "/workers");
|
||||
assertEquals(201, res.statusCode());
|
||||
JsonNode body = mapper.readTree(res.body());
|
||||
assertEquals("w9:pW_1", body.get("paneId").asText());
|
||||
// CB-519: the responded paneId is a host-unique opaque UUID, not the herdr pane coordinate.
|
||||
String id = body.get("paneId").asText();
|
||||
assertNotEquals("w9:pRoot_1", id, "paneId is the host-unique id, not the herdr pane");
|
||||
assertDoesNotThrow(() -> UUID.fromString(id), "paneId must be a UUID: " + id);
|
||||
assertEquals("spawning", body.get("state").asText());
|
||||
|
||||
// Subscription boundary: agent.start carried base_url + token in its env map.
|
||||
Map<String, Object> start = params(herdr, "agent.start");
|
||||
// Subscription boundary (protocol 19): tab.create carried base_url + token in its env
|
||||
// map — the seed shell the agent starts into is what inherits them.
|
||||
Map<String, Object> create = params(herdr, "tab.create");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> env = (Map<String, String>) start.get("env");
|
||||
Map<String, String> env = (Map<String, String>) create.get("env");
|
||||
assertEquals("http://gx00.gw:8000", env.get("ANTHROPIC_BASE_URL"));
|
||||
assertEquals("tok-abc", env.get("ANTHROPIC_AUTH_TOKEN"));
|
||||
assertEquals(List.of("claude"), start.get("argv"));
|
||||
|
||||
// Placement: worker space ensured, worker started INTO its own tab, seed shell
|
||||
// dropped, and the tab given a friendly label.
|
||||
// Placement: worker space ensured, worker started INTO its tab's seed pane (which
|
||||
// becomes the worker pane — nothing is dropped), and the tab given a friendly label.
|
||||
Map<String, Object> start = params(herdr, "agent.start");
|
||||
assertTrue(herdr.called("workspace.create"), "worker space must be found-or-created");
|
||||
assertEquals("w9:t2", start.get("tab_id"), "worker must start into its dedicated tab");
|
||||
assertEquals("w9:pRoot", params(herdr, "pane.close").get("pane_id"), "seed shell pane dropped");
|
||||
assertEquals("claude", start.get("kind"), "herdr resolves the executable from kind");
|
||||
assertEquals("w9:pRoot_1", start.get("pane_id"), "worker must start into its tab's seed pane");
|
||||
assertFalse(herdr.called("pane.close"), "the seed pane IS the worker pane — never dropped");
|
||||
assertEquals("worker: ltms-local #1", params(herdr, "tab.rename").get("label"),
|
||||
"tab label carries the worker number so siblings stay distinct");
|
||||
}
|
||||
@@ -215,7 +227,7 @@ class BridgedAppTest {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"));
|
||||
assertEquals(201, req(port, "POST", "/workers?cwd=/tmp/proj").statusCode());
|
||||
assertEquals("/tmp/proj", params(herdr, "agent.start").get("cwd"), "the worker starts in cwd");
|
||||
assertEquals("/tmp/proj", params(herdr, "tab.create").get("cwd"), "the worker starts in cwd");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -322,7 +334,7 @@ class BridgedAppTest {
|
||||
HttpResponse<String> res = send.get(6, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertEquals(200, res.statusCode());
|
||||
assertEquals("LGTM ship it", mapper.readTree(res.body()).get("reply").asText());
|
||||
// (injection via agent.send is covered deterministically by the timeout-working test)
|
||||
// (injection via agent.prompt is covered deterministically by the timeout-working test)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -394,7 +406,7 @@ class BridgedAppTest {
|
||||
HttpResponse<String> res = postMessage(port, "{\"content\":\"hi\",\"timeoutMs\":150}");
|
||||
assertEquals(202, res.statusCode());
|
||||
assertEquals("queued", mapper.readTree(res.body()).get("status").asText());
|
||||
assertFalse(herdr.called("agent.send"), "no injection while the worker is mid-turn");
|
||||
assertFalse(herdr.called("agent.prompt"), "no injection while the worker is mid-turn");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -405,7 +417,7 @@ class BridgedAppTest {
|
||||
HttpResponse<String> res = postMessage(port, "{\"content\":\"hi\",\"timeoutMs\":250}");
|
||||
assertEquals(202, res.statusCode());
|
||||
assertEquals("working", mapper.readTree(res.body()).get("status").asText());
|
||||
assertTrue(herdr.called("agent.send"), "message was injected");
|
||||
assertTrue(herdr.called("agent.prompt"), "message was injected");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package dev.ltms.bridged.session;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* CB-525 acceptance test for tool-surface isolation. This is one of the few tests that drives real
|
||||
* {@code git} — the behaviour under test is precisely what {@link GitWorktrees} does to a checkout,
|
||||
* so a fake would assert nothing. Everything happens inside a {@link TempDir} throwaway repo.
|
||||
*/
|
||||
class GitWorktreesTest {
|
||||
|
||||
/** A project MCP config with servers in it — what this repo actually commits. */
|
||||
private static final String WITH_SERVERS = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"jetbrains": { "type": "sse", "url": "http://localhost:64342/sse" }
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private static Path initRepo(Path dir) throws Exception {
|
||||
Files.createDirectories(dir);
|
||||
git(dir, "init", "-q", "-b", "main");
|
||||
git(dir, "config", "user.email", "test@example.invalid");
|
||||
git(dir, "config", "user.name", "Test");
|
||||
Files.writeString(dir.resolve(".mcp.json"), WITH_SERVERS);
|
||||
Files.writeString(dir.resolve("README.md"), "seed\n");
|
||||
git(dir, "add", ".mcp.json", "README.md");
|
||||
git(dir, "commit", "-q", "-m", "seed");
|
||||
return dir;
|
||||
}
|
||||
|
||||
private static void git(Path cwd, String... args) throws Exception {
|
||||
List<String> cmd = new java.util.ArrayList<>(List.of("git"));
|
||||
cmd.addAll(List.of(args));
|
||||
Process p = new ProcessBuilder(cmd).directory(cwd.toFile()).redirectErrorStream(true).start();
|
||||
String out = new String(p.getInputStream().readAllBytes());
|
||||
assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git timed out: " + String.join(" ", cmd));
|
||||
assertEquals(0, p.exitValue(), "git " + String.join(" ", args) + " failed:\n" + out);
|
||||
}
|
||||
|
||||
/** Pending changes to {@code .mcp.json} in {@code cwd}, empty when git considers it unmodified. */
|
||||
private static String mcpStatus(Path cwd) throws Exception {
|
||||
Process p = new ProcessBuilder("git", "status", "--porcelain", "--", ".mcp.json")
|
||||
.directory(cwd.toFile()).redirectErrorStream(true).start();
|
||||
String out = new String(p.getInputStream().readAllBytes());
|
||||
assertTrue(p.waitFor(30, TimeUnit.SECONDS), "git status timed out");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heart of CB-525: a provisioned worktree must not inherit the primary's MCP servers. Without
|
||||
* the isolation step the checked-out {@code .mcp.json} carries them in, and a worker navigating
|
||||
* through the primary's IDE servers edits the primary's tree while building its own.
|
||||
*/
|
||||
@Test
|
||||
void aProvisionedWorktreeInheritsNoMcpServers(@TempDir Path tmp) throws Exception {
|
||||
Path repo = initRepo(tmp.resolve("repo"));
|
||||
String wt = new GitWorktrees(tmp.resolve("wts").toString())
|
||||
.add(repo.toString(), "cb-525-a", "HEAD");
|
||||
|
||||
Path mcp = Path.of(wt).resolve(".mcp.json");
|
||||
assertTrue(Files.exists(mcp), ".mcp.json must still exist — present and explicitly empty");
|
||||
String body = Files.readString(mcp);
|
||||
assertFalse(body.contains("jetbrains"), "worktree inherited the primary's MCP servers:\n" + body);
|
||||
assertTrue(body.replaceAll("\\s+", "").contains("\"mcpServers\":{}"),
|
||||
"expected an explicitly empty server map, got:\n" + body);
|
||||
}
|
||||
|
||||
/** Neutralizing must not look like work in progress, or a worker would commit it into its PR. */
|
||||
@Test
|
||||
void theNeutralizedConfigIsNotAPendingLocalModification(@TempDir Path tmp) throws Exception {
|
||||
Path repo = initRepo(tmp.resolve("repo"));
|
||||
String wt = new GitWorktrees(tmp.resolve("wts").toString())
|
||||
.add(repo.toString(), "cb-525-b", "HEAD");
|
||||
|
||||
assertEquals("", mcpStatus(Path.of(wt)),
|
||||
"the neutralized .mcp.json shows as modified — --skip-worktree did not take");
|
||||
}
|
||||
|
||||
/** Isolation is the worktree's business only; the primary's own checkout must be untouched. */
|
||||
@Test
|
||||
void thePrimaryCheckoutIsLeftAlone(@TempDir Path tmp) throws Exception {
|
||||
Path repo = initRepo(tmp.resolve("repo"));
|
||||
new GitWorktrees(tmp.resolve("wts").toString()).add(repo.toString(), "cb-525-c", "HEAD");
|
||||
|
||||
assertEquals(WITH_SERVERS, Files.readString(repo.resolve(".mcp.json")),
|
||||
"the primary's .mcp.json was rewritten — isolation reached out of the worktree");
|
||||
}
|
||||
|
||||
/** A repo that commits no {@code .mcp.json} still gets one, so nothing can be inherited later. */
|
||||
@Test
|
||||
void aRepoWithoutAnMcpConfigStillGetsANeutralOne(@TempDir Path tmp) throws Exception {
|
||||
Path repo = tmp.resolve("repo");
|
||||
Files.createDirectories(repo);
|
||||
git(repo, "init", "-q", "-b", "main");
|
||||
git(repo, "config", "user.email", "test@example.invalid");
|
||||
git(repo, "config", "user.name", "Test");
|
||||
Files.writeString(repo.resolve("README.md"), "seed\n");
|
||||
git(repo, "add", "README.md");
|
||||
git(repo, "commit", "-q", "-m", "seed");
|
||||
|
||||
String wt = new GitWorktrees(tmp.resolve("wts").toString())
|
||||
.add(repo.toString(), "cb-525-d", "HEAD");
|
||||
|
||||
// Untracked is the normal case here, so the --skip-worktree branch must be skipped rather
|
||||
// than run and fail: `update-index --skip-worktree` on an unknown path exits non-zero.
|
||||
String body = Files.readString(Path.of(wt).resolve(".mcp.json"));
|
||||
assertTrue(body.replaceAll("\\s+", "").contains("\"mcpServers\":{}"), body);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,17 @@ class SessionManagerTest {
|
||||
return new SessionManager(workers, new GitWorktrees(), clock, contextCap);
|
||||
}
|
||||
|
||||
@Test
|
||||
void primaryContactWithNoTerminalIsNotAReadinessSignal() {
|
||||
// The MCP context extractor calls presence.markPresent(p.terminal()) on EVERY request,
|
||||
// and the primary's terminal is null — the presence bridge must treat that as a no-op,
|
||||
// not feed it into the READY transition (which NPEd on the first real primary contact).
|
||||
SessionManager sessions = sessionManager(new FakeHerdr());
|
||||
|
||||
assertDoesNotThrow(() -> sessions.asPresence().markPresent(null));
|
||||
assertDoesNotThrow(() -> sessions.asPresence().markPresent(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acquireRegistersSpawningSessionWithDistinctPaneId() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
@@ -69,6 +80,25 @@ class SessionManagerTest {
|
||||
assertEquals(2, sessions.roster().size(), "both sessions are registered");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNullTerminalFromThePrimaryIsANoOpEvenWithSessionsRegistered() {
|
||||
// The primary resolves to a Principal with no terminal, and BridgeMcp's context extractor
|
||||
// forwards that null into markPresent on EVERY MCP call. It only reached the registry scan
|
||||
// once a session existed, so this NPE'd the primary's second spawn while the first passed.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
SessionManager sessions = sessionManager(herdr);
|
||||
WorkerSession session = sessions.acquire("ltms-local", null, "/caller", "term_primary");
|
||||
|
||||
assertDoesNotThrow(() -> sessions.asPresence().markPresent(null),
|
||||
"the primary's null terminal must not blow up an unrelated tool call");
|
||||
assertDoesNotThrow(() -> sessions.onDelivered(null));
|
||||
assertDoesNotThrow(() -> sessions.onTurnComplete(null));
|
||||
assertDoesNotThrow(() -> sessions.onTurnFailed(null));
|
||||
|
||||
assertEquals(WorkerSession.State.SPAWNING, sessions.get(session.paneId()).orElseThrow().state(),
|
||||
"and must not transition any registered session");
|
||||
}
|
||||
|
||||
@Test
|
||||
void presenceMovesSpawningToReadyAndDeliveredTurnMovesToDone() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
@@ -142,9 +172,11 @@ class SessionManagerTest {
|
||||
assertEquals(1, sessions.roster().size(), "only the fresh session remains");
|
||||
assertEquals(fresh.paneId(), sessions.roster().getFirst().paneId());
|
||||
|
||||
// The old session was the first spawn → pane w9:pRoot_1 (CB-519: the registry key is the
|
||||
// uuid id, so teardown is asserted on the real pane coordinate).
|
||||
long paneCloseCount = herdr.calls.stream()
|
||||
.filter(c -> "pane.close".equals(c.method()))
|
||||
.filter(c -> oldPane.equals(((Map<?, ?>) c.params()).get("pane_id")))
|
||||
.filter(c -> "w9:pRoot_1".equals(((Map<?, ?>) c.params()).get("pane_id")))
|
||||
.count();
|
||||
assertEquals(1, paneCloseCount, "the old worker was torn down");
|
||||
}
|
||||
@@ -277,7 +309,7 @@ class SessionManagerTest {
|
||||
WorkerSession updated = sessions.get(session.paneId()).orElseThrow();
|
||||
assertEquals(WorkerSession.State.DONE, updated.state(), "session finishes second turn");
|
||||
assertEquals(2, updated.turnCount(), "turn count tracks both deliveries");
|
||||
long releaseCloseCount = paneCloseCallsFor(herdr, session.paneId());
|
||||
long releaseCloseCount = paneCloseCallsFor(herdr, "w9:pRoot_1"); // the real pane coordinate
|
||||
assertEquals(0, releaseCloseCount, "cap disabled — no forced release of the worker pane");
|
||||
}
|
||||
|
||||
@@ -300,7 +332,7 @@ class SessionManagerTest {
|
||||
|
||||
assertTrue(sessions.get(session.paneId()).isEmpty(), "session released after cap reached");
|
||||
assertTrue(sessions.roster().isEmpty(), "released session leaves roster");
|
||||
assertEquals(1, paneCloseCallsFor(herdr, session.paneId()),
|
||||
assertEquals(1, paneCloseCallsFor(herdr, "w9:pRoot_1"),
|
||||
"forced release tears the worker pane down exactly once");
|
||||
}
|
||||
|
||||
@@ -321,9 +353,10 @@ class SessionManagerTest {
|
||||
assertTrue(sessions.roster().isEmpty(), "drain clears the roster");
|
||||
assertTrue(sessions.get(ready.paneId()).isEmpty(), "ready session is released");
|
||||
assertTrue(sessions.get(busy.paneId()).isEmpty(), "busy session is released after timeout");
|
||||
assertEquals(1, paneCloseCallsFor(herdr, ready.paneId()),
|
||||
// ready is the first spawn → pane w9:pRoot_1, busy the second → w9:pRoot_2 (FakeHerdr order).
|
||||
assertEquals(1, paneCloseCallsFor(herdr, "w9:pRoot_1"),
|
||||
"ready worker pane is torn down");
|
||||
assertEquals(1, paneCloseCallsFor(herdr, busy.paneId()),
|
||||
assertEquals(1, paneCloseCallsFor(herdr, "w9:pRoot_2"),
|
||||
"busy worker pane is torn down");
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ class WorktreeSessionManagerTest {
|
||||
|
||||
private static String startCwd(FakeHerdr herdr) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> start = (Map<String, Object>) herdr.lastCall("agent.start").params();
|
||||
// Protocol 19: the worker's cwd rides on pane creation (tab.create), not agent.start.
|
||||
Map<String, Object> start = (Map<String, Object>) herdr.lastCall("tab.create").params();
|
||||
Object cwd = start.get("cwd");
|
||||
return cwd == null ? null : cwd.toString();
|
||||
}
|
||||
@@ -81,7 +82,7 @@ class WorktreeSessionManagerTest {
|
||||
void worktreeAcquireRunsParityOverlayWithProfileDefaults() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeWorktrees worktrees = new FakeWorktrees().withRepoRoot("/repo").withPrefix("/wt")
|
||||
.track(".mcp.json")
|
||||
.track(".envrc")
|
||||
.exists(".claude/settings.local.json");
|
||||
SessionManager sessions = new SessionManager(workerService(herdr), worktrees);
|
||||
|
||||
@@ -92,11 +93,14 @@ class WorktreeSessionManagerTest {
|
||||
FakeWorktrees.OverlayCall overlay = worktrees.lastOverlay();
|
||||
assertNotNull(overlay);
|
||||
assertEquals("/repo", overlay.repoRoot());
|
||||
assertEquals(List.of(".mcp.json", ".claude/settings.local.json", ".env", ".envrc"),
|
||||
assertEquals(List.of(".claude/settings.local.json", ".env", ".envrc"),
|
||||
overlay.requested(), "default parity overlay is used when unset");
|
||||
assertEquals(List.of(".mcp.json", ".claude/settings.local.json"), overlay.copied(),
|
||||
assertFalse(overlay.requested().contains(".mcp.json"),
|
||||
"CB-525: replicating the primary's MCP config gives a worker the primary's IDE "
|
||||
+ "servers, which navigate its edits out of its own worktree");
|
||||
assertEquals(List.of(".claude/settings.local.json", ".envrc"), overlay.copied(),
|
||||
"existing paths are copied; missing paths are skipped");
|
||||
assertEquals(List.of(".mcp.json"), overlay.skipWorktree(),
|
||||
assertEquals(List.of(".envrc"), overlay.skipWorktree(),
|
||||
"tracked copied paths are --skip-worktree'd");
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -29,52 +30,81 @@ class ClaudeCodeLauncherTest {
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
|
||||
}
|
||||
|
||||
/** The {@code args} of the last agent.start — protocol 19: everything after the executable. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> spawnedArgv(FakeHerdr herdr) {
|
||||
return (List<String>) ((Map<String, Object>) herdr.lastCall("agent.start").params()).get("argv");
|
||||
private List<String> spawnedArgs(FakeHerdr herdr) {
|
||||
return (List<String>) ((Map<String, Object>) herdr.lastCall("agent.start").params()).get("args");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsBridgeMcpAndReplyCharterWhenMcpUrlSet() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, List.of("ccs", "ltms-local"), "http://127.0.0.1:8765/mcp").spawn();
|
||||
service(herdr, List.of("claude"), "http://127.0.0.1:8765/mcp").spawn();
|
||||
|
||||
List<String> 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")),
|
||||
List<String> args = spawnedArgs(herdr);
|
||||
assertTrue(args.contains("--mcp-config"));
|
||||
assertTrue(args.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");
|
||||
assertTrue(args.contains("--append-system-prompt"));
|
||||
assertTrue(args.stream().anyMatch(a -> a.contains("bridge_reply")), "reply charter present");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startRetriesWhileTheSeedShellBoots() {
|
||||
// tab.create returns before the seed shell reaches its prompt; herdr refuses agent.start
|
||||
// into a not-ready pane with agent_pane_busy. The launcher must wait it out, not fail.
|
||||
FakeHerdr herdr = new FakeHerdr().agentPaneBusyTimes(2);
|
||||
long[] clock = {0};
|
||||
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(
|
||||
new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")),
|
||||
Map.of("ltms-local", new BridgedConfig.Worker(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null)),
|
||||
"ltms-local", _ -> null,
|
||||
0, () -> clock[0], () -> clock[0] += 50);
|
||||
|
||||
PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null));
|
||||
|
||||
assertNotNull(handle, "spawn succeeds once the shell is ready");
|
||||
assertEquals(3, herdr.calls.stream().filter(c -> c.method().equals("agent.start")).count(),
|
||||
"two busy rejections, then the successful start");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startResolvesTheExecutableFromKindAndDropsArgvZero() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, List.of("claude"), null).spawn();
|
||||
|
||||
Map<?, ?> start = (Map<?, ?>) herdr.lastCall("agent.start").params();
|
||||
assertEquals("claude", start.get("kind"), "herdr launches the canonical executable by kind");
|
||||
assertEquals(List.of(), start.get("args"), "the configured executable is not repeated in args");
|
||||
}
|
||||
|
||||
@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");
|
||||
service(herdr, List.of("claude", "--verbose"), null).spawn();
|
||||
assertEquals(List.of("--verbose"), spawnedArgs(herdr), "extra args untouched without mcpUrl");
|
||||
}
|
||||
|
||||
private ClaudeCodeLauncher multiProfile(FakeHerdr herdr) {
|
||||
BridgedConfig.Worker gx10 = new BridgedConfig.Worker("gx10", "http://gx10.gw:8000", "coder",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "gx10"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||
BridgedConfig.Worker ollama = new BridgedConfig.Worker("ollama", "http://ollama.ltms.dev", null,
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("ccs", "ollama"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
|
||||
return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx10.gw", "ollama.ltms.dev")),
|
||||
Map.of("gx10", gx10, "ollama", ollama), "gx10", _ -> "tok");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void spawnPicksTheNamedProfilesBaseUrlAndArgv() {
|
||||
void spawnPicksTheNamedProfilesBaseUrl() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
multiProfile(herdr).spawn("ollama");
|
||||
|
||||
Map<String, Object> start = (Map<String, Object>) herdr.lastCall("agent.start").params();
|
||||
Map<String, String> env = (Map<String, String>) start.get("env");
|
||||
assertEquals("http://ollama.ltms.dev", env.get("ANTHROPIC_BASE_URL"), "the named profile's base_url");
|
||||
assertEquals(List.of("ccs", "ollama"), start.get("argv"), "the named profile's launch command");
|
||||
assertEquals("http://ollama.ltms.dev", startEnv(herdr).get("ANTHROPIC_BASE_URL"),
|
||||
"the named profile's base_url");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -86,8 +116,9 @@ class ClaudeCodeLauncherTest {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static String startCwd(FakeHerdr herdr) {
|
||||
// The worker's cwd is set on agent.start (an agent pane does not inherit the tab's cwd).
|
||||
Object v = ((Map<String, Object>) herdr.lastCall("agent.start").params()).get("cwd");
|
||||
// Protocol 19: the worker's cwd is set at pane creation (tab.create), where the seed
|
||||
// shell — which the agent starts into — is rooted.
|
||||
Object v = ((Map<String, Object>) herdr.lastCall("tab.create").params()).get("cwd");
|
||||
return v == null ? null : v.toString();
|
||||
}
|
||||
|
||||
@@ -119,9 +150,10 @@ class ClaudeCodeLauncherTest {
|
||||
|
||||
// --- CB-302 git-forge token injection (worker checkpoint grant) ------------
|
||||
|
||||
/** Protocol 19: the worker's env is injected at pane creation (tab.create), not agent.start. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, String> startEnv(FakeHerdr herdr) {
|
||||
return (Map<String, String>) ((Map<String, Object>) herdr.lastCall("agent.start").params()).get("env");
|
||||
return (Map<String, String>) ((Map<String, Object>) herdr.lastCall("tab.create").params()).get("env");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -227,14 +259,18 @@ class ClaudeCodeLauncherTest {
|
||||
// --- PeerHandle indirection ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void spawnReturnsPeerHandleWithIdEqualToPaneId() {
|
||||
void spawnReturnsPeerHandleWithHostUniqueOpaqueId() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null);
|
||||
|
||||
PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null));
|
||||
|
||||
assertNotNull(handle, "spawn must return a non-null handle");
|
||||
assertEquals("w9:pW_1", handle.id(), "handle.id() must equal the agent's paneId");
|
||||
// CB-519: id() is a host-unique opaque UUID, decoupled from the herdr pane coordinate.
|
||||
assertNotEquals("w9:pRoot_1", handle.id(),
|
||||
"handle.id() must NOT be the herdr pane id");
|
||||
assertDoesNotThrow(() -> UUID.fromString(handle.id()),
|
||||
"handle.id() must be a UUID: " + handle.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -320,6 +356,40 @@ class ClaudeCodeLauncherTest {
|
||||
assertTrue(herdr.called("pane.close"), "stop via handle.id() must close the pane");
|
||||
}
|
||||
|
||||
// --- CB-519: host-unique id, decoupled from the pane coordinate ------------------------------
|
||||
|
||||
@Test
|
||||
void twoSpawnsOnTheSamePaneNeverCollideOnHostUniqueId() {
|
||||
// Two spawns may be placed on the same herdr pane coordinate (e.g. a pane that was reused
|
||||
// or re-reported after a restart); the host-unique id must not collide even then.
|
||||
FakeHerdr herdr = new FakeHerdr().pinNextStarts(2, "term_shared", "w9:pShared");
|
||||
ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null);
|
||||
|
||||
PeerHandle a = svc.spawn(new SpawnRequest(null, null, null));
|
||||
PeerHandle b = svc.spawn(new SpawnRequest(null, null, null));
|
||||
|
||||
assertNotEquals(a.id(), b.id(),
|
||||
"two spawns on the same pane coordinate get distinct host-unique ids");
|
||||
assertNotEquals("w9:pShared", a.id(), "id is not the pane coordinate");
|
||||
assertNotEquals("w9:pShared", b.id(), "id is not the pane coordinate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopResolvesTheHostUniqueIdToThePaneThatSpawnedIt() {
|
||||
// CB-519: id() != paneId, so stop(id) must tear down the exact pane the id names — and no
|
||||
// other live peer's pane.
|
||||
FakeHerdr herdr = new FakeHerdr(); // deterministic panes w9:pRoot_1, w9:pRoot_2 per spawn
|
||||
ClaudeCodeLauncher svc = service(herdr, List.of("ccs", "ltms-local"), null);
|
||||
|
||||
PeerHandle a = svc.spawn(new SpawnRequest(null, null, null));
|
||||
PeerHandle b = svc.spawn(new SpawnRequest(null, null, null));
|
||||
|
||||
svc.stop(b.id());
|
||||
|
||||
assertEquals(1, paneCloseCount(herdr, "w9:pRoot_2"), "stop(b.id()) closes only b's pane");
|
||||
assertEquals(0, paneCloseCount(herdr, "w9:pRoot_1"), "a's pane is untouched");
|
||||
}
|
||||
|
||||
// --- CB-306 spawn-readiness gate -----------------------------------------------------------
|
||||
|
||||
private static Map<String, BridgedConfig.Worker> workerConfigMap(String profile, String mcpUrl) {
|
||||
@@ -355,8 +425,9 @@ class ClaudeCodeLauncherTest {
|
||||
PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null));
|
||||
|
||||
assertNotNull(handle, "spawn returns a handle when worker becomes injectable");
|
||||
assertEquals("w9:pW_1", handle.id(), "handle id matches the started pane");
|
||||
assertEquals(0, paneCloseCount(herdr, "w9:pW_1"),
|
||||
assertNotEquals("w9:pRoot_1", handle.id(),
|
||||
"handle id is a host-unique opaque id, not the started pane");
|
||||
assertEquals(0, paneCloseCount(herdr, "w9:pRoot_1"),
|
||||
"no pane.close when worker becomes injectable before timeout");
|
||||
}
|
||||
|
||||
@@ -376,12 +447,12 @@ class ClaudeCodeLauncherTest {
|
||||
PeerUnreachableException.class,
|
||||
() -> svc.spawn(new SpawnRequest(null, null, null)));
|
||||
|
||||
assertTrue(ex.getMessage().contains("w9:pW_1"),
|
||||
assertTrue(ex.getMessage().contains("w9:pRoot_1"),
|
||||
"exception message references the paneId: " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("1000"),
|
||||
"exception message references the timeout: " + ex.getMessage());
|
||||
assertTrue(clock[0] >= 1000, "fake clock advanced past the timeout: " + clock[0]);
|
||||
assertEquals(1, paneCloseCount(herdr, "w9:pW_1"),
|
||||
assertEquals(1, paneCloseCount(herdr, "w9:pRoot_1"),
|
||||
"pane was closed on timeout (no orphan left behind)");
|
||||
}
|
||||
|
||||
@@ -414,8 +485,9 @@ class ClaudeCodeLauncherTest {
|
||||
PeerHandle handle = svc.spawn(new SpawnRequest(null, null, null));
|
||||
|
||||
assertNotNull(handle, "spawn still succeeds with zero timeout");
|
||||
assertEquals(0, paneCloseCount(herdr, handle.id()),
|
||||
assertEquals(0, paneCloseCount(herdr, "w9:pRoot_1"),
|
||||
"no orphan pane close from the gate path");
|
||||
assertDoesNotThrow(() -> UUID.fromString(handle.id()));
|
||||
}
|
||||
|
||||
// --- CB-511: worker environment seeding -----------------------------------------------------
|
||||
@@ -440,7 +512,7 @@ class ClaudeCodeLauncherTest {
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null,
|
||||
null, Map.of("JAVA_HOME", "/opt/jdk", "PATH", "/profile/bin"));
|
||||
null, Map.of("JAVA_HOME", "/opt/jdk", "PATH", "/profile/bin"), null, null);
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
|
||||
k -> "PATH".equals(k) ? "/daemon/bin" : null).spawn();
|
||||
@@ -461,7 +533,7 @@ class ClaudeCodeLauncherTest {
|
||||
BridgedConfig.Worker cfg = new BridgedConfig.Worker(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN",
|
||||
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null,
|
||||
null, Map.of("ANTHROPIC_BASE_URL", "http://evil.example.com"));
|
||||
null, Map.of("ANTHROPIC_BASE_URL", "http://evil.example.com"), null, null);
|
||||
new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
|
||||
_ -> null).spawn();
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package dev.ltms.bridged.worker;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* The Codex adapter's launch build (CB-528): an isolated {@code CODEX_HOME} provisioned through the
|
||||
* {@link CodexHome} seam, mandatory {@code --approve-for-me}, the {@code -m}/{@code -c}/
|
||||
* {@code --bearer-token-env-var} flags, and — like opencode — no {@code ANTHROPIC_*} and no
|
||||
* subscription guard (Codex authenticates via its own home credentials). Plus the shared base
|
||||
* transport (herdr kind, capabilities, reap).
|
||||
*/
|
||||
class CodexLauncherTest {
|
||||
|
||||
/** A codex profile. {@code null} argv → the kind default {@code ["codex"]}. */
|
||||
private static BridgedConfig.Worker codexCfg(String model, String mcpUrl,
|
||||
String tokenEnv, String gitTokenEnv) {
|
||||
return new BridgedConfig.Worker("codex-peer", null, model, null, tokenEnv,
|
||||
null, "tab", "bridged-workers", "codex: {model} #{n}", mcpUrl,
|
||||
null, null, gitTokenEnv, null, BridgedConfig.Worker.KIND_CODEX);
|
||||
}
|
||||
|
||||
/** A stub {@link CodexHome} returning {@code path} from {@code provision} (records calls). */
|
||||
private static final class FakeCodexHome implements CodexHome {
|
||||
private final Path path;
|
||||
int provisions;
|
||||
FakeCodexHome(Path path) {
|
||||
this.path = path;
|
||||
}
|
||||
@Override
|
||||
public Path provision(BridgedConfig.Worker cfg) {
|
||||
provisions++;
|
||||
return path;
|
||||
}
|
||||
@Override
|
||||
public void release(Path home) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Gate-disabled launcher with a stub home and an env that resolves the forge token. */
|
||||
private CodexLauncher service(FakeHerdr herdr, FakeCodexHome home, BridgedConfig.Worker cfg) {
|
||||
return new CodexLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), home,
|
||||
Map.of(cfg.profile(), cfg), cfg.profile(),
|
||||
k -> "GITEA_ACCESS_TOKEN".equals(k) ? "tok" : null,
|
||||
0, System::currentTimeMillis, () -> { });
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> lastStart(FakeHerdr herdr) {
|
||||
return (Map<String, Object>) herdr.lastCall("agent.start").params();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, String> startEnv(FakeHerdr herdr) {
|
||||
Map<String, String> env =
|
||||
(Map<String, String>) ((Map<String, Object>) herdr.lastCall("tab.create").params()).get("env");
|
||||
return env == null ? Map.of() : env;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<String> startArgs(FakeHerdr herdr) {
|
||||
return (List<String>) lastStart(herdr).get("args");
|
||||
}
|
||||
|
||||
@Test
|
||||
void approveForMeIsAlwaysPresent(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeCodexHome home = new FakeCodexHome(root.resolve("codex-home"));
|
||||
service(herdr, home, codexCfg(null, null, null, null)).spawn();
|
||||
|
||||
List<String> args = startArgs(herdr);
|
||||
assertTrue(args.contains("--approve-for-me"),
|
||||
"--approve-for-me is mandatory — without it MCP tool calls are user-cancelled");
|
||||
assertEquals("--approve-for-me", args.getFirst(),
|
||||
"--approve-for-me is the first launch flag, right after the executable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void argvHasAllFlagsWhenModelMcpAndTokenSet(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, new FakeCodexHome(root.resolve("h")),
|
||||
codexCfg("gpt-5.2", "http://127.0.0.1:8765/mcp", "CODEX_TOKEN", null)).spawn();
|
||||
|
||||
assertEquals(List.of(
|
||||
"--approve-for-me",
|
||||
"-m", "gpt-5.2",
|
||||
"-c", "mcp_servers.bridged.url=\"http://127.0.0.1:8765/mcp\"",
|
||||
"--bearer-token-env-var", "CODEX_TOKEN"),
|
||||
startArgs(herdr),
|
||||
"all three optional flags follow --approve-for-me when configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void argvOmitsModelAndMcpWhenUnsetButKeepsApproveForMe(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
// tokenEnv defaults to BRIDGED_WORKER_TOKEN (never null/blank after record normalization).
|
||||
service(herdr, new FakeCodexHome(root.resolve("h")),
|
||||
codexCfg(null, null, null, null)).spawn();
|
||||
|
||||
List<String> args = startArgs(herdr);
|
||||
assertFalse(args.contains("-m"), "no model → no -m flag");
|
||||
assertFalse(args.contains("-c"), "no mcp url → no -c override");
|
||||
assertTrue(args.contains("--approve-for-me"), "--approve-for-me is never dropped");
|
||||
assertEquals(List.of("--approve-for-me", "--bearer-token-env-var", "BRIDGED_WORKER_TOKEN"), args,
|
||||
"only the mandatory flag and the default bearer-token var remain");
|
||||
}
|
||||
|
||||
@Test
|
||||
void codexHomeIsSetToExactlyWhatTheSeamReturned(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeCodexHome home = new FakeCodexHome(root.resolve("codex-home"));
|
||||
service(herdr, home, codexCfg(null, null, null, null)).spawn();
|
||||
|
||||
assertEquals(root.resolve("codex-home").toString(), startEnv(herdr).get("CODEX_HOME"),
|
||||
"CODEX_HOME is the provisioned home, verbatim from the CodexHome seam");
|
||||
assertEquals(1, home.provisions, "provision is called exactly once per spawn");
|
||||
}
|
||||
|
||||
@Test
|
||||
void codexHomeIsSetEvenWithoutMcp(@TempDir Path root) {
|
||||
// Codex reads everything from CODEX_HOME, so a peer must never inherit ~/.codex even when no
|
||||
// bridge MCP is mounted — this asserts provision is unconditional, not gated on hasMcp().
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeCodexHome home = new FakeCodexHome(root.resolve("home"));
|
||||
service(herdr, home, codexCfg(null, null, null, null)).spawn();
|
||||
assertEquals(root.resolve("home").toString(), startEnv(herdr).get("CODEX_HOME"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noAnthropicOrClaudeVarsInWorkerEnv(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, new FakeCodexHome(root.resolve("h")),
|
||||
codexCfg(null, null, null, null)).spawn();
|
||||
|
||||
// Assert on the whole env map (a prefix match, not named keys) so the next variable someone
|
||||
// adds to this boundary is caught too.
|
||||
startEnv(herdr).keySet().forEach(k -> {
|
||||
String up = k.toUpperCase();
|
||||
assertFalse(up.startsWith("ANTHROPIC_"), "worker env must not carry " + k
|
||||
+ " — Codex has no Anthropic seam and must not borrow the subscription");
|
||||
assertFalse(up.startsWith("CLAUDE_"), "worker env must not carry " + k
|
||||
+ " — the Claude config dir is a Claude-private concern");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructionNeedsNoSubscriptionGuard(@TempDir Path root) {
|
||||
// Codex authenticates through its own CODEX_HOME credentials, so the launcher takes a
|
||||
// CodexHome, not a SubscriptionGuard, and spawns without consulting one.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeCodexHome home = new FakeCodexHome(root.resolve("h"));
|
||||
CodexLauncher launcher = new CodexLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
home, Map.of("codex-peer", codexCfg(null, null, null, null)), "codex-peer", _ -> null);
|
||||
|
||||
launcher.spawn();
|
||||
assertTrue(noAnthropicOrClaude(startEnv(herdr)), "the production constructor sets no ANTHROPIC_*");
|
||||
|
||||
// The gate-enabled constructor builds the same way (sanity access to profiles).
|
||||
CodexLauncher gated = new CodexLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
home, Map.of("codex-peer", codexCfg(null, null, null, null)), "codex-peer",
|
||||
_ -> null, 5000, 100);
|
||||
assertEquals("codex-peer", gated.defaultProfile());
|
||||
}
|
||||
|
||||
private static boolean noAnthropicOrClaude(Map<String, String> env) {
|
||||
return env.keySet().stream()
|
||||
.noneMatch(k -> k.toUpperCase().startsWith("ANTHROPIC_")
|
||||
|| k.toUpperCase().startsWith("CLAUDE_"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void herdrAgentKindIsCodex(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, new FakeCodexHome(root.resolve("h")),
|
||||
codexCfg(null, null, null, null)).spawn();
|
||||
|
||||
assertEquals("codex", lastStart(herdr).get("kind"),
|
||||
"herdr detects and status-tracks the pane natively under the codex kind");
|
||||
}
|
||||
|
||||
@Test
|
||||
void capabilitiesDeclareOrphanReapAndMcpAskAndConditionalSelfPr(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeCodexHome home = new FakeCodexHome(root.resolve("h"));
|
||||
assertEquals(Set.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP),
|
||||
service(herdr, home, codexCfg(null, null, null, null)).capabilities(),
|
||||
"no git token → no SELF_PR");
|
||||
assertTrue(service(herdr, home, codexCfg(null, null, null, "GITEA_ACCESS_TOKEN"))
|
||||
.capabilities().contains(Capability.SELF_PR),
|
||||
"a git-token profile adds SELF_PR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void injectsForgeTokenWhenProfileGrantsIt(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, new FakeCodexHome(root.resolve("h")),
|
||||
codexCfg(null, null, null, "GITEA_ACCESS_TOKEN")).spawn();
|
||||
assertEquals("tok", startEnv(herdr).get("GITEA_TOKEN"),
|
||||
"a git-token profile gets the peer-neutral GITEA_TOKEN grant, same as Claude/opencode");
|
||||
}
|
||||
|
||||
@Test
|
||||
void foreignWorkerMatchesCodexPrefixButNotClaude() {
|
||||
String nonce = "abc123";
|
||||
assertTrue(CodexLauncher.isForeignWorker("codex-codex-peer-def456-1", nonce),
|
||||
"a codex pane from another process is foreign");
|
||||
assertFalse(CodexLauncher.isForeignWorker("codex-codex-peer-" + nonce + "-1", nonce),
|
||||
"our own codex pane (same nonce) is not foreign");
|
||||
assertFalse(CodexLauncher.isForeignWorker("claude-ltms-local-def456-1", nonce),
|
||||
"a claude pane is never reaped by the codex adapter");
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,27 @@ package dev.ltms.bridged.worker;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
import dev.ltms.bridged.guard.SubscriptionGuard;
|
||||
import dev.ltms.bridged.herdr.Agent;
|
||||
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.PeerHandle;
|
||||
import dev.ltms.bridged.peer.PeerLauncher;
|
||||
import dev.ltms.bridged.peer.PeerUnreachableException;
|
||||
import dev.ltms.bridged.peer.SpawnRequest;
|
||||
import dev.ltms.bridged.placement.PlacementException;
|
||||
import dev.ltms.bridged.placement.PlacementPolicies;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -46,6 +56,87 @@ class CompositePeerLauncherTest {
|
||||
List.of(claudeAdapter(herdr), opencodeAdapter(herdr)), "claude");
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal concrete HerdrPeerLauncher for policy tests. It either returns a fake handle for the
|
||||
* requested profile or throws, depending on {@code failProfiles}. buildLaunch is a stub; only
|
||||
* spawn/stop/list/caps/reap are exercised by the composite.
|
||||
*/
|
||||
private static final class StubLauncher extends HerdrPeerLauncher {
|
||||
private final Set<String> failProfiles;
|
||||
private final Map<String, Integer> spawnCounts = new HashMap<>();
|
||||
|
||||
StubLauncher(String prefix, FakeHerdr herdr,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Set<String> failProfiles) {
|
||||
super(prefix, new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
profiles, defaultProfile, _ -> null, 0L, System::currentTimeMillis, () -> { });
|
||||
this.failProfiles = Set.copyOf(failProfiles);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Launch buildLaunch(BridgedConfig.Worker cfg) {
|
||||
return new Launch(Map.of(), List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PeerHandle spawn(SpawnRequest req) {
|
||||
String p = (req.profileName() == null || req.profileName().isBlank())
|
||||
? defaultProfile() : req.profileName();
|
||||
spawnCounts.merge(p, 1, Integer::sum);
|
||||
if (failProfiles.contains(p)) {
|
||||
throw new PeerUnreachableException(p + " is down");
|
||||
}
|
||||
return new PeerHandle() {
|
||||
@Override public String id() { return "pane-" + p; }
|
||||
@Override public String terminalId() { return "term-" + p; }
|
||||
@Override public String profile() { return p; }
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop(String id) { }
|
||||
|
||||
@Override
|
||||
public List<Agent> list() { return List.of(); }
|
||||
|
||||
@Override
|
||||
public Set<Capability> capabilities() { return EnumSet.noneOf(Capability.class); }
|
||||
|
||||
@Override
|
||||
public int reapOrphanWorkers() { return 0; }
|
||||
|
||||
int spawnCount(String profile) {
|
||||
return spawnCounts.getOrDefault(profile, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static BridgedConfig.Worker stubWorker(String profile) {
|
||||
return new BridgedConfig.Worker(profile, "http://gx00.gw:8000", "coder",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers",
|
||||
"w #{n}", null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static BridgedConfig.Worker stubWorker(String profile, float weight, Integer maxLoad) {
|
||||
return new BridgedConfig.Worker(profile, "http://gx00.gw:8000", "coder",
|
||||
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers",
|
||||
"w #{n}", null, null, null, null, null, null, null,
|
||||
weight, maxLoad);
|
||||
}
|
||||
|
||||
/**
|
||||
* An <em>order-preserving</em> profile map. Never {@code Map.of} here: its iteration order is
|
||||
* salted per JVM run, and the weighted policy breaks an exact-weight tie on candidate order —
|
||||
* so a {@code Map.of} would make "which profile is tried first" a coin flip per run and any
|
||||
* assertion about the first attempt intermittently false.
|
||||
*/
|
||||
private static Map<String, BridgedConfig.Worker> ordered(String first, BridgedConfig.Worker a,
|
||||
String second, BridgedConfig.Worker b) {
|
||||
Map<String, BridgedConfig.Worker> m = new LinkedHashMap<>();
|
||||
m.put(first, a);
|
||||
m.put(second, b);
|
||||
return m;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static String startedName(FakeHerdr herdr) {
|
||||
return (String) ((Map<String, Object>) herdr.lastCall("agent.start").params()).get("name");
|
||||
@@ -127,15 +218,18 @@ class CompositePeerLauncherTest {
|
||||
|
||||
@Test
|
||||
void stopTearsDownAPaneSpawnedThroughTheComposite() {
|
||||
// CB-519: handle.id() is a host-unique opaque UUID, not the herdr pane — stop(id) must
|
||||
// resolve it through the owning adapter down to the actual pane coordinate it spawned.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
PeerLauncher composite = composite(herdr);
|
||||
PeerHandle handle = composite.spawn(new SpawnRequest("gemini", null, null));
|
||||
assertNotEquals("w9:pRoot_1", handle.id(), "the id is decoupled from the pane coordinate");
|
||||
|
||||
composite.stop(handle.id());
|
||||
assertTrue(herdr.calls.stream()
|
||||
.anyMatch(c -> c.method().equals("pane.close")
|
||||
&& handle.id().equals(((Map<?, ?>) c.params()).get("pane_id"))),
|
||||
"stop routes to the spawning adapter and closes that worker's pane");
|
||||
&& "w9:pRoot_1".equals(((Map<?, ?>) c.params()).get("pane_id"))),
|
||||
"stop routes to the spawning adapter and closes exactly that worker's pane");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -155,4 +249,119 @@ class CompositePeerLauncherTest {
|
||||
() -> new CompositePeerLauncher(List.of(), "claude"),
|
||||
"at least one adapter must be configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixedDefaultIsNoOpForUnqualifiedSpawns() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
CompositePeerLauncher composite = composite(herdr);
|
||||
|
||||
PeerHandle h = composite.spawn(new SpawnRequest(null, null, null));
|
||||
assertTrue(startedName(herdr).startsWith("claude-"),
|
||||
"fixed placement still routes an unqualified spawn to the default profile");
|
||||
assertEquals("claude", h.profile(), "the returned handle carries the resolved default profile");
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightedPolicyGatesProfileAtMaxLoad() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
"a", stubWorker("a", 1.0f, 1),
|
||||
"b", stubWorker("b", 1.0f, null));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
|
||||
CompositePeerLauncher composite = new CompositePeerLauncher(
|
||||
List.of(adapter), "a", profiles, PlacementPolicies.weighted(), name -> "a".equals(name) ? 1 : 0);
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
PeerHandle h = composite.spawn(new SpawnRequest(null, null, null));
|
||||
assertEquals("b", h.profile(), "profile a is at maxLoad, so every spawn must land on b");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightedPolicyDistributesAccordingToWeightRatio() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
"a", stubWorker("a", 0.75f, null),
|
||||
"b", stubWorker("b", 0.25f, null));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
|
||||
CompositePeerLauncher composite = new CompositePeerLauncher(
|
||||
List.of(adapter), "a", profiles, PlacementPolicies.weighted(), name -> 0);
|
||||
|
||||
int a = 0, b = 0;
|
||||
for (int i = 0; i < 40; i++) {
|
||||
String p = composite.spawn(new SpawnRequest(null, null, null)).profile();
|
||||
if ("a".equals(p)) a++;
|
||||
else if ("b".equals(p)) b++;
|
||||
}
|
||||
assertEquals(30, a, "weighted distribution should hold the 3:1 ratio");
|
||||
assertEquals(10, b);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failoverRetriesNextCandidateWhenProfileIsUnreachable() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
"a", stubWorker("a"),
|
||||
"b", stubWorker("b"));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("a"));
|
||||
CompositePeerLauncher composite = new CompositePeerLauncher(
|
||||
List.of(adapter), "a", profiles, PlacementPolicies.weighted(), name -> 0);
|
||||
|
||||
PeerHandle h = composite.spawn(new SpawnRequest(null, null, null));
|
||||
assertEquals("b", h.profile(), "the spawn must fail over from unreachable a to b");
|
||||
assertEquals(1, adapter.spawnCount("a"), "a was tried once and failed");
|
||||
assertEquals(1, adapter.spawnCount("b"), "b was tried once and succeeded");
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition order — not hash order — decides an exact-weight tie. Paired with the test above
|
||||
* (same two profiles, opposite declaration order, opposite expected first attempt) this pins the
|
||||
* ordering contract from both sides: under a salted map one of the two must fail on every run.
|
||||
*/
|
||||
@Test
|
||||
void reversingDefinitionOrderReversesWhichProfileIsTriedFirst() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
"b", stubWorker("b"),
|
||||
"a", stubWorker("a"));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("b"));
|
||||
CompositePeerLauncher composite = new CompositePeerLauncher(
|
||||
List.of(adapter), "a", profiles, PlacementPolicies.weighted(), _ -> 0);
|
||||
|
||||
PeerHandle h = composite.spawn(new SpawnRequest(null, null, null));
|
||||
assertEquals("a", h.profile(), "b is declared first and unreachable, so the spawn lands on a");
|
||||
assertEquals(1, adapter.spawnCount("b"), "b, declared first, is the one tried first");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failoverBoundedByCandidateCount() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
"a", stubWorker("a"),
|
||||
"b", stubWorker("b"));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of("a", "b"));
|
||||
CompositePeerLauncher composite = new CompositePeerLauncher(
|
||||
List.of(adapter), "a", profiles, PlacementPolicies.weighted(), name -> 0);
|
||||
|
||||
PeerUnreachableException e = assertThrows(PeerUnreachableException.class,
|
||||
() -> composite.spawn(new SpawnRequest(null, null, null)));
|
||||
assertTrue(e.getMessage().contains("no reachable worker profile"), e.getMessage());
|
||||
assertEquals(1, adapter.spawnCount("a"));
|
||||
assertEquals(1, adapter.spawnCount("b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCandidateSetThrowsClearException() {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
Map<String, BridgedConfig.Worker> profiles = ordered(
|
||||
"a", stubWorker("a", 1.0f, 1),
|
||||
"b", stubWorker("b", 1.0f, 1));
|
||||
StubLauncher adapter = new StubLauncher("claude", herdr, profiles, "a", Set.of());
|
||||
CompositePeerLauncher composite = new CompositePeerLauncher(
|
||||
List.of(adapter), "a", profiles, PlacementPolicies.weighted(), name -> 1);
|
||||
|
||||
PlacementException e = assertThrows(PlacementException.class,
|
||||
() -> composite.spawn(new SpawnRequest(null, null, null)));
|
||||
assertTrue(e.getMessage().contains("maxLoad"), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,14 +45,18 @@ class OpenCodeLauncherTest {
|
||||
return (Map<String, Object>) herdr.lastCall("agent.start").params();
|
||||
}
|
||||
|
||||
/** Protocol 19: the worker's env is injected at pane creation (tab.create), not agent.start. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, String> startEnv(FakeHerdr herdr) {
|
||||
return (Map<String, String>) lastStart(herdr).get("env");
|
||||
Map<String, String> env =
|
||||
(Map<String, String>) ((Map<String, Object>) herdr.lastCall("tab.create").params()).get("env");
|
||||
return env == null ? Map.of() : env;
|
||||
}
|
||||
|
||||
/** Protocol 19: agent.start carries only the args after the kind-resolved executable. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<String> startArgv(FakeHerdr herdr) {
|
||||
return (List<String>) lastStart(herdr).get("argv");
|
||||
private static List<String> startArgs(FakeHerdr herdr) {
|
||||
return (List<String>) lastStart(herdr).get("args");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -99,18 +103,17 @@ class OpenCodeLauncherTest {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, root, opencodeCfg("google/gemini-2.5-pro", null, null)).spawn();
|
||||
|
||||
List<String> argv = startArgv(herdr);
|
||||
assertEquals("opencode", argv.getFirst(), "base opencode command preserved first");
|
||||
int m = argv.indexOf("-m");
|
||||
List<String> args = startArgs(herdr);
|
||||
int m = args.indexOf("-m");
|
||||
assertTrue(m >= 0, "model is selected with -m");
|
||||
assertEquals("google/gemini-2.5-pro", argv.get(m + 1), "the provider/model selector follows -m");
|
||||
assertEquals("google/gemini-2.5-pro", args.get(m + 1), "the provider/model selector follows -m");
|
||||
}
|
||||
|
||||
@Test
|
||||
void noModelFlagWhenModelBlank(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, root, opencodeCfg(null, null, null)).spawn();
|
||||
assertEquals(List.of("opencode"), startArgv(herdr), "no model → argv is the bare opencode command");
|
||||
assertEquals(List.of(), startArgs(herdr), "no model → no extra args beyond the executable");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -170,7 +173,7 @@ class OpenCodeLauncherTest {
|
||||
assertTrue(clock[0] >= 1000, "the fake clock advanced past the timeout: " + clock[0]);
|
||||
long closes = herdr.calls.stream()
|
||||
.filter(c -> c.method().equals("pane.close"))
|
||||
.filter(c -> "w9:pW_1".equals(((Map<?, ?>) c.params()).get("pane_id")))
|
||||
.filter(c -> "w9:pRoot_1".equals(((Map<?, ?>) c.params()).get("pane_id")))
|
||||
.count();
|
||||
assertEquals(1, closes, "the worker pane was reaped on timeout (no orphan)");
|
||||
assertNotNull(ex.getMessage());
|
||||
|
||||
@@ -162,3 +162,40 @@ Stage-2 AMQP adapter: absent → in-memory, present → AMQP).
|
||||
Commit on your feature branch and reply with: the commit SHA, the surefire total (run/failures/errors), a
|
||||
one-line note on the drain-surface decision (§2.4) you shipped, and confirmation that `.mcp.json`/`wiki/`
|
||||
were untouched. The primary re-gates and integrates.
|
||||
|
||||
## 8. Running the contract tests (CB-521)
|
||||
|
||||
`AmqpReplyInboxContractTest` is the real-broker proof of the `ReplyInbox` port (eventual visibility, ack
|
||||
removal, msgId dedup, cross-restart redelivery). It is `@Tag("contract")`, so the default
|
||||
`mvn test` / `mvn clean install` **skip it** — that hermetic, Docker-free default is deliberate and
|
||||
untouched. Run it explicitly when Docker (or a broker) is available:
|
||||
|
||||
```bash
|
||||
cd bridged
|
||||
mvn -Pcontract test -Dtest=AmqpReplyInboxContractTest # local: spins a RabbitMQ Testcontainers fixture
|
||||
```
|
||||
|
||||
### Two broker modes
|
||||
|
||||
| Mode | Trigger | Broker | Needs Docker? |
|
||||
|---|---|---|---|
|
||||
| Local | `AMQP_URI` unset | Testcontainers starts `rabbitmq:3.13-management` | Yes |
|
||||
| CI / external | `AMQP_URI` set | the broker at that URI (CI RabbitMQ service container) | **No** — binds straight to the URI, never touches Testcontainers |
|
||||
|
||||
In CI the broker is provided as a RabbitMQ **service container** and `AMQP_URI` points at it, so the
|
||||
contract job runs the same assertions with no Docker on the runner and no skipped test
|
||||
(see `.gitea/workflows/ci.yml` → `contract`). The `build` job stays hermetic and Docker-free — keep
|
||||
that separation.
|
||||
|
||||
### Docker-engine discovery (why the contract profile pins `api.version`)
|
||||
|
||||
Out of the box, Testcontainers 1.20.4's docker-java client defaults to Docker API **1.32** when no
|
||||
version is requested. Modern engines reject that as too old — on this host's OrbStack (`min API 1.40`)
|
||||
testcontainers fails with *"Could not find a valid Docker environment … client version 1.32 is too
|
||||
old"* even though the `docker` CLI works (the CLI negotiates a newer API).
|
||||
|
||||
The `contract` Maven profile sets `api.version=1.43` in surefire, which works on OrbStack and Docker
|
||||
24+, and is overridable per host: `mvn -Pcontract -Dapi.version=1.54 test …`. It only applies under
|
||||
`-Pcontract`, so the default build is unaffected. If your engine differs, set `-Dapi.version` to a
|
||||
version ≥ your engine's minimum API (e.g. `docker version` shows `API version`).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CB-308 — Multi-Host Federation (Stage 5)
|
||||
|
||||
**Status:** design note (proposal)
|
||||
**Status:** design note (proposal) — core design decisions resolved 2026-08-10 (§7)
|
||||
**Depends on:** CB-307 (broker-based reliable delivery) — CB-308 is the multi-host layer built *on*
|
||||
CB-307's broker fabric.
|
||||
**Relates to:** CB-401 (`PeerHandle` opaque id), CB-304 (`rosterView`), CB-306 (spawn-readiness),
|
||||
@@ -143,6 +143,8 @@ extending the same broker from "worker→primary reliability" to "gateway↔gate
|
||||
5. **Trust** — the broker connection is now the security boundary. A gateway injects env/tokens at
|
||||
daemon privilege (the CB-401 Stage-C concern), so a **remote-triggered spawn/send** needs
|
||||
authn/authz: who may act on which host, and which control channels a gateway will honour.
|
||||
*Authenticity* is resolved — signed messages, §7.1; *authorization* (who may do what) remains
|
||||
open — §8.
|
||||
|
||||
## 5. The one thing the broker does NOT dissolve
|
||||
|
||||
@@ -191,15 +193,111 @@ useful), and build CB-308's items on top once the broker fabric exists. Choose C
|
||||
channel naming **multi-host-ready** now (per-agent routing keys, a `roster.*` topic namespace) so
|
||||
CB-308 doesn't have to repaint the topology.
|
||||
|
||||
## 7. Open questions
|
||||
## 7. Resolved design decisions (2026-08-10)
|
||||
|
||||
Settled in a design review of this note + wiki chapter 10. The broker-level operational rules
|
||||
(inbox caps, TLS + private broker, schema versioning, trace id, exclusive consumers, U8 broadcast)
|
||||
are recorded in wiki 10 §10; the CB-308-side decisions are below. Entries 1–6 are the first-pass
|
||||
decisions; 7–10 came out of the adversarial second-pass review (same day) and supersede 1–6 where
|
||||
they overlap (notably: the envelope is no longer optional, and dedup is split by path).
|
||||
|
||||
1. **Sender authenticity — sign every message.** Each gateway holds its own signing key and signs
|
||||
what it publishes (sender gid, `msgId`, timestamp). The receiving gateway verifies the
|
||||
signature **and** checks against the roster that the claimed sender lives on the signing
|
||||
gateway's host. This extends the single-host invariant — *identity comes from the connection,
|
||||
never an argument* — across the broker: cross-host, identity comes from the key. Complements
|
||||
(not replaces) per-gateway broker logins over TLS.
|
||||
2. **Profiles are owned by the worker's host.** `bridge_spawn(profile, host)` resolves the name in
|
||||
the *target* gateway's `bridged.yaml`. Gateways advertise their profile names in presence
|
||||
heartbeats, so a leader sees what each host offers before spawning; an unknown name is a clear
|
||||
error from the target. Secrets (base URLs, tokens) never leave the host that uses them.
|
||||
3. **Repo provisioning — clone from the forge, pinned.** A cross-host spawn names the repo URL and
|
||||
the exact commit. The target gateway clones from the forge into a local cache (first spawn
|
||||
only), then cuts a per-worker worktree — the CB-301-ext flow with a clone step in front,
|
||||
covered by the same repo-scoped forge token (CB-302). Git stays the only channel code moves
|
||||
through.
|
||||
4. **Asks are live-only, with expiry.** `ASK`/`ANSWER` (U2) traverse the broker as short-lived
|
||||
(TTL'd) messages carrying the `turn_id`, and are never held durably — the single-host rule
|
||||
kept. An answer arriving after its turn ended is **not** injected; it is dropped and the leader
|
||||
gets a `TOO_LATE` notice, so the one failure case is loud rather than weird. Only terminal
|
||||
replies are durable. Walkthrough: wiki 10 §7.4.
|
||||
5. **Spawn dedup — a spawn id, remembered on the target.** The control queue redelivers like any
|
||||
queue; a replayed `SpawnRequest` must not double-spawn. Requests carry a unique spawn id; the
|
||||
target gateway keeps a short memory of handled ids and answers a redelivery with the existing
|
||||
`PeerHandle`. CB-117's orphan reap stays as the backstop.
|
||||
6. **Broker down — local unaffected, remote fails fast.** The routing fork (§3.2) means same-host
|
||||
traffic never touches the broker; that is now a written promise. A send to a remote agent while
|
||||
the broker is unreachable **fails immediately** with a clear error — the gateway never buffers
|
||||
on the broker's behalf (it stays soft-state, so a crash cannot lose messages it claimed to
|
||||
deliver). Gateways auto-reconnect; remote hosts read as unknown in the roster meanwhile. Broker
|
||||
HA is a later ops choice, not a design requirement.
|
||||
|
||||
7. **Turn state — split by where the signals are.** The *worker's* gateway owns the turn record
|
||||
(turnId minting, ask coalescing, STALE_TURN, the completion/failure fallbacks, CB-516 abandon):
|
||||
every input to those decisions — pane status, injection, teardown — is local to it. The
|
||||
*sender's* gateway owns only the waiter. The two are stitched by terminal-outcome envelope
|
||||
kinds (`REPLY` / `FAILED` / `ABANDONED`) published to the sender's inbox: a worker dying on B
|
||||
fails A's waiter fast because gateway B sees the death synchronously and says so.
|
||||
**`ABANDONED` is belt-and-braces over the waiter's own timeout and roster expiry, never a
|
||||
replacement** — the case where the waiter hangs longest is gateway B itself dying, which is
|
||||
exactly when B can publish nothing.
|
||||
8. **Dual ack model + spawn idempotence by construction.** Forward path (a brief into a worker):
|
||||
ack **before** the inject — at-most-once, duplicates structurally impossible; the loss window
|
||||
is closed by an `INJECTED` confirmation published after the inject lands (no `INJECTED` within
|
||||
a bound = loud fast failure at the sender, not a silent send-timeout). Reply/pull path keeps
|
||||
ack-after-drain — a duplicate reply is benign, deduped by `msgId`. Spawn: the requester mints
|
||||
**spawn id = the new worker's gid**; the target checks it against the **live pane registry**,
|
||||
and the gid is **stored in the herdr pane itself** (label/env, readable back), so a restarted
|
||||
gateway rebuilds gid↔pane from herdr and the check survives restarts with *no persisted
|
||||
ledger* — this storage point is the load-bearing detail of the no-ledger position. An
|
||||
**in-flight reservation set**, entered before the launcher call, absorbs a redelivery arriving
|
||||
while the first spawn is still inside CB-306's readiness gate; a crash mid-spawn leaves a
|
||||
half-built pane, which is exactly what CB-117 reaps.
|
||||
9. **Publish is enforced, not fire-and-forget.** Publisher confirms + the `mandatory` flag + a
|
||||
return listener, on a **publish channel separate from the consume/ack channel** — synchronous
|
||||
confirms on the single shared channel would hold its lock across a broker round trip and
|
||||
serialize acks fleet-wide. Ordering caveat: a *return* (unroutable) arrives **before** the
|
||||
confirm, so "confirmed" ≠ "routed"; the sender checks the returned-set at confirm time.
|
||||
`mandatory` is false only for `BROADCAST`, where an empty group is legal silence.
|
||||
10. **Queue lifecycle is session lifecycle.** `bridge_stop`/reap deletes the worker's inbox queue
|
||||
(its `broadcast.*` bindings die with it — no broadcasts to the dead); `x-expires` collects
|
||||
queues orphaned by a crashed gateway (long for main/orchestrator inboxes, short for workers).
|
||||
Queue names carry a version suffix (`.v2`): AMQP refuses to redeclare an existing durable
|
||||
queue with new arguments (`PRECONDITION_FAILED` — a crash loop on an in-place upgrade from
|
||||
v1.0.0), and the suffix keeps old sender-keyed and new recipient-keyed queues apart during
|
||||
the keying migration (wiki 10 §3 footnote).
|
||||
|
||||
## 8. Still open
|
||||
|
||||
- **Directory ground-truth:** pure soft-state presence (heartbeats) vs. also treating broker queue
|
||||
existence as authoritative. Lean soft-state to preserve the persistence boundary; revisit if
|
||||
split-brain roster views cause mis-routing.
|
||||
- **Global id scheme:** `<host>/<paneId>` (human-legible, leaks host) vs. opaque UUID (clean, needs
|
||||
the directory to resolve host). Probably UUID in the protocol, host as directory metadata.
|
||||
- **Gateway discovery:** how gateways find the broker and each other (static config vs. discovery).
|
||||
- **Trust model shape:** per-host shared secret vs. mTLS on the broker vs. a capability token per
|
||||
control action — ties into CB-401 Stage-C.
|
||||
- **Failure semantics:** a host/gateway dies mid-turn — how the federated roster reaps it (missed
|
||||
heartbeat) and whether in-flight primary-bound messages survive (broker durability = yes).
|
||||
- **Control authorization — THE GATE ON U4.** Signing (§7.1) settles *who sent it*; authorization
|
||||
is *who may do what*. **Cross-host spawn must not land before the minimal version exists**: a
|
||||
per-host allowlist in `bridged.yaml` — beside the peer public keys — of gateway ids permitted to
|
||||
publish control to this host, checked against the verified signature. A few lines of config and
|
||||
check; without them, any principal holding broker credentials can start processes on every host
|
||||
in the fleet.
|
||||
- **Key distribution & rotation:** static config (host → public key in each `bridged.yaml`) is
|
||||
fine at the current 2–3 host scale; rotation is manual. A refinement, not a blocker.
|
||||
- **Gateway death mid-turn:** the roster reaps it by missed heartbeat, and in-flight primary-bound
|
||||
messages survive by broker durability; still open is reconciling *worker* state when the dead
|
||||
gateway's host comes back (orphaned panes vs. still-valid sessions).
|
||||
|
||||
*(Resolved and moved up: the global id scheme — an opaque UUID minted by the spawn requester as
|
||||
the spawn id, host carried as roster metadata; §7.8.)*
|
||||
|
||||
## 9. Implementation order (each step verifiable single-host)
|
||||
|
||||
1. **As-built fixes, independent of CB-308** (v1.0.x tickets): `basicQos` prefetch on the AMQP
|
||||
consumer (today the queue drains into gateway heap, so any cap would guard an empty queue);
|
||||
publisher confirms + `mandatory` (§7.9); the `drainReplies` javadoc that claims "the ack is
|
||||
local" — false for the AMQP adapter.
|
||||
2. Envelope + signing (wiki 10 §2.1) — testable against the single-host broker.
|
||||
3. Recipient-keyed queue migration (`.v2` names, drain-by-`from`, `ReplyPushLoop` rekeyed).
|
||||
4. Global id + queue lifecycle (§7.8, §7.10).
|
||||
5. Roster: host-level heartbeat + signed presence; then the routing fork (§3.2).
|
||||
6. U2 cross-host with the terminal-outcome kinds (§7.4, §7.7).
|
||||
7. U4 cross-host spawn — **gated on the control allowlist (§8)**.
|
||||
8. U8 broadcast **last** — it is the feature that punishes an unfinished queue lifecycle.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# v1.0.0 — One leader, one host, complete
|
||||
|
||||
This is the first release of **`bridged`**.
|
||||
|
||||
`bridged` lets one main Claude Code session (the **leader**, on your Pro/Max subscription)
|
||||
run a team of **workers** — extra Claude Code sessions on a cheaper or local model, and
|
||||
non-Claude agents too. The leader's own session is never touched: it stays on subscription,
|
||||
with a clean environment.
|
||||
|
||||
**This release finishes a full scope — it does not stop halfway.** The scope is *one leader
|
||||
on one machine*, running many workers. Everything that setup needs is now built, tested, and
|
||||
used daily: who-is-who, the subscription line, messages in both directions, worker start and
|
||||
stop, security, and monitoring. Nothing on the single-machine path is left as a known gap.
|
||||
|
||||
This is also how the code is shaped: `PrimaryRegistry` holds exactly **one** leader. Running
|
||||
across many machines is the next big step (see *What comes next* below) — not a missing piece
|
||||
of this one.
|
||||
|
||||
## One gateway for all messages
|
||||
|
||||
- **Everyone talks through the same door.** The leader and every worker connect to the same
|
||||
MCP server and use only its tools: `bridge_whoami` · `bridge_profiles` · `bridge_spawn` ·
|
||||
`bridge_list` · `bridge_status` · `bridge_send` · `bridge_reply` · `bridge_ask` ·
|
||||
`bridge_poll` · `bridge_ack` · `bridge_stop`.
|
||||
- **You are who your connection says you are.** The bridge finds out who is calling from the
|
||||
connection itself, never from a name the caller sends. So a worker cannot pretend to be
|
||||
someone else, and `bridge_whoami` tells each agent its own role — no guessing.
|
||||
- **The subscription line cannot be crossed.** Only a spawned worker gets
|
||||
`ANTHROPIC_BASE_URL`; the leader never does. Each worker profile has a list of allowed
|
||||
model hosts, checked before anything starts.
|
||||
- **Messages wait for the right moment.** The bridge sends one message per turn, only when
|
||||
the other side is ready — no hammering a busy agent.
|
||||
|
||||
## Worker lifecycle
|
||||
|
||||
- **Start → work → stop.** Each worker gets its own git worktree (its own copy of the repo)
|
||||
with the same setup as the leader — `CLAUDE.md`, skills, hooks — so it commits on its own
|
||||
branch and opens its own PR. Ready-made playbooks ship in the repo:
|
||||
`.claude/skills/implementer` and `.claude/skills/reviewer`.
|
||||
- **Fast failure, not a silent hang.** Starting a worker waits until it is really connected.
|
||||
If it never connects, you get a clear error (`PeerUnreachableException`) instead of a stuck
|
||||
send.
|
||||
- **Workers don't live forever.** Idle workers are cleaned up (`idle_ttl`), long sessions have
|
||||
a turn limit (`context_cap`), shutdown drains work first, and workers left behind by an old
|
||||
daemon are found and removed at startup.
|
||||
- **Predictable placement.** Each worker gets its own tab in a shared worker space, in the
|
||||
same order every time.
|
||||
|
||||
## No reply gets lost
|
||||
|
||||
MCP only lets the client call the server, so the bridge could push to a worker but the leader
|
||||
had to ask for its replies. That gap is now closed on a single machine:
|
||||
|
||||
- **Replies are kept, never dropped.** If a reply arrives and nobody is waiting, the bridge
|
||||
holds it until the leader picks it up.
|
||||
- **Replies can survive a restart.** With a broker (LavinMQ or RabbitMQ) set up, held replies
|
||||
live on the broker, so a daemon restart does not lose them — they come back, and repeats are
|
||||
filtered out by `msgId`. No `broker:` in the config → replies are held in memory instead.
|
||||
- **The leader gets a tap on the shoulder.** When a reply lands, the bridge nudges the
|
||||
leader's own pane — only when the leader is free, and only a few times. If the leader is on
|
||||
another machine, this quietly falls back to pick-up mode; the reply still waits.
|
||||
- **Workers can ask questions.** With `bridge_ask`, a worker can pause mid-task, ask the
|
||||
leader something, and continue the *same* task with the answer.
|
||||
|
||||
## More than one kind of worker
|
||||
|
||||
Workers are started through a small plug-in interface (`PeerLauncher`). Two plug-ins ship:
|
||||
one for Claude Code and one for **opencode** (tested live against opencode 1.18.5). The
|
||||
opencode one proves the interface is neutral — it uses nothing Claude-specific. Each worker
|
||||
only sees the tools its own launcher gives it.
|
||||
|
||||
## Security & operations
|
||||
|
||||
- **Auth.** Default is `loopback-trust`: only same-machine callers are trusted. Or set a
|
||||
bearer `token`. Unknown callers count as `ANONYMOUS` — nobody is trusted by accident. If
|
||||
the config would expose the daemon to the network without a token, it **refuses to start**.
|
||||
Workers never need the token, so turning auth on cannot lock them out.
|
||||
- **Rules + audit log.** The role rules are checked on both doors (REST and MCP). A worker
|
||||
may only act as itself. The audit log is JSON and never contains message text.
|
||||
- **Monitoring.** `/healthz` for liveness, `/metrics` for Prometheus — no extra libraries.
|
||||
- **Runs as a service.** launchd (macOS) and systemd (Linux) files are included. If herdr
|
||||
isn't up yet at boot, the daemon waits up to 30 seconds and then runs in a reduced mode
|
||||
instead of crash-looping.
|
||||
- **CI.** Every push builds and tests on the Gitea runner, including the broker test against
|
||||
a real broker. Only the live-herdr test stays local (`-Pcontract`).
|
||||
|
||||
## What you need
|
||||
|
||||
Java 25 · Maven · **herdr 0.8.0 (protocol 19)** · optionally LavinMQ or RabbitMQ for
|
||||
restart-proof replies · macOS (launchd) or Linux (systemd). For TLS, put a reverse proxy in
|
||||
front — the daemon does not do TLS itself, by design.
|
||||
|
||||
## Tested
|
||||
|
||||
`mvn clean install` is green at `84081b2`: **399 tests**, coverage **75.6%** of instructions /
|
||||
**64.5%** of branches. Live end-to-end runs under `e2e/`: a worker asking the leader a
|
||||
question, one leader running several workers at once on a bug hunt, and a 30-turn
|
||||
back-and-forth conversation. The bridge is used on itself — worker-run code reviews have led
|
||||
to real committed fixes in this repo.
|
||||
|
||||
## What comes next
|
||||
|
||||
The single-machine story is done. The next big step stretches the same rules across machines:
|
||||
|
||||
- **Many machines (CB-308).** A leader on machine A with workers on machines B and C — built
|
||||
on this release's broker layer, with one gateway per machine. The design is written
|
||||
(`docs/CB-308-Multi-Host-Federation.md`); the open question is trust between machines.
|
||||
- **Many leaders.** Today the bridge holds exactly one leader. The next idea is a small
|
||||
council — for example one Claude and one Codex — that can discuss a problem together,
|
||||
compare answers, and agree on a decision before the work is handed to workers. This needs
|
||||
leader-to-leader messages and a simple way to settle disagreement, neither of which exists
|
||||
yet.
|
||||
- **Third-party launcher plug-ins.** Loading launcher plug-ins from outside the project needs
|
||||
a trust model first, because a launcher runs with daemon rights and can hand secrets to
|
||||
workers.
|
||||
|
||||
One thing we chose **not** to build, so it isn't read as a gap: the originally planned heavy
|
||||
message envelope (CB-201). Connection identity already routes every reply to the right place,
|
||||
so only a small `QUESTION` message kind and a `turn_id` were added.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "claude-bridge",
|
||||
"description": "Make a project bridge-ready: mount the bridged MCP gateway and set up standard Claude Code settings so this session can orchestrate a fleet of delegated workers. Ships no credentials.",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "LTMS"
|
||||
},
|
||||
"homepage": "https://git.ltms.dev/lms/claude-bridge",
|
||||
"repository": "https://git.ltms.dev/lms/claude-bridge",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"orchestration",
|
||||
"multi-agent",
|
||||
"delegation",
|
||||
"codex"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"bridged": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8765/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# claude-bridge (Claude Code plugin)
|
||||
|
||||
Makes a project **bridge-ready**: mounts the `bridged` MCP gateway and applies standard Claude Code
|
||||
settings, so the session can orchestrate a fleet of delegated workers.
|
||||
|
||||
**This plugin ships no credentials.** Every secret is referenced by environment-variable *name*;
|
||||
the values stay with the user. Nothing the plugin writes is unsafe to commit.
|
||||
|
||||
## What it is not
|
||||
|
||||
The plugin is the **client-side setup**, not the bridge. `bridged` is a separate daemon and `herdr`
|
||||
is a separate PTY multiplexer, each with its own lifecycle and install. The plugin mounts an
|
||||
already-running daemon and tells you what is missing when one isn't there — it deliberately does
|
||||
not try to install system services on your behalf.
|
||||
|
||||
## Install
|
||||
|
||||
```shell
|
||||
/plugin marketplace add ltms/claude-bridge
|
||||
/plugin install claude-bridge@claude-bridge
|
||||
```
|
||||
|
||||
Then, in the project you want to onboard:
|
||||
|
||||
```shell
|
||||
/claude-bridge:setup
|
||||
```
|
||||
|
||||
## What you get
|
||||
|
||||
| Component | Effect |
|
||||
|---|---|
|
||||
| `.mcp.json` | mounts `bridged` at `http://127.0.0.1:8765/mcp` for any session with the plugin enabled |
|
||||
| `skills/setup` | `/claude-bridge:setup` — preflight, project settings, credential guidance, and verification |
|
||||
|
||||
Because the plugin carries its own `.mcp.json`, an installed plugin needs no project-level MCP
|
||||
file at all. The setup skill writes one only when you want the mount to work *without* the plugin —
|
||||
for teammates who haven't installed it, or for CI.
|
||||
|
||||
## Verifying a setup
|
||||
|
||||
The setup skill ends by requiring a **real spawn**, not a health check. `/healthz` only reports
|
||||
that the daemon can reach herdr; a protocol mismatch between the daemon's adapter and the herdr
|
||||
binary leaves health green while every spawn fails. Only a spawn that reaches `ready` proves the
|
||||
fleet.
|
||||
|
||||
## Local development
|
||||
|
||||
```shell
|
||||
claude --plugin-dir ./plugin
|
||||
claude plugin validate ./plugin
|
||||
```
|
||||
|
||||
`/reload-plugins` picks up edits without restarting the session.
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
name: setup
|
||||
description: Make the current project bridge-ready — check the prerequisites, mount the bridged MCP gateway into the project's .mcp.json, apply standard Claude Code settings, and verify this session resolves as the primary. Writes no credentials. Load this when asked to set up, install, configure, or onboard a project onto claude-bridge, or when bridge_* tools are expected but absent.
|
||||
---
|
||||
|
||||
# Bridge setup — make this project bridge-ready
|
||||
|
||||
This skill configures **the project you are currently in** so that this Claude Code session can
|
||||
orchestrate a fleet of delegated workers through `bridged`.
|
||||
|
||||
**It writes no credentials, ever.** Every secret is referenced by environment-variable *name*, and
|
||||
the user exports the value themselves. Nothing this skill creates is unsafe to commit. If you are
|
||||
ever about to write a token, key, or password into a file, you have misread this skill — stop.
|
||||
|
||||
Work through the steps in order. Each one has a check; **report what actually happened**, including
|
||||
failures. A setup that half-worked and was reported as done is worse than one that failed loudly.
|
||||
|
||||
## 0. Establish where you are
|
||||
|
||||
```bash
|
||||
pwd
|
||||
git rev-parse --show-toplevel 2>/dev/null || echo "(not a git repo)"
|
||||
ls -a | head -30
|
||||
```
|
||||
|
||||
Everything below is written into **this** project root. If the user meant a different directory,
|
||||
confirm before writing anything.
|
||||
|
||||
## 1. Preflight — what must already exist
|
||||
|
||||
The bridge is three moving parts, and the plugin is only one of them. Check all of it before
|
||||
changing any file, so you can tell the user the whole story at once instead of failing one step at
|
||||
a time.
|
||||
|
||||
```bash
|
||||
command -v herdr && herdr --version 2>&1 | head -1 || echo "MISSING: herdr"
|
||||
command -v ccs && ccs version 2>&1 | head -1 || echo "MISSING: ccs (needed for worker profiles)"
|
||||
command -v codex && codex --version 2>&1 | head -1 || echo "absent: codex (optional)"
|
||||
curl -s -m 5 http://127.0.0.1:8765/healthz || echo "MISSING: bridged daemon is not reachable"
|
||||
```
|
||||
|
||||
A healthy daemon answers with its status **and the herdr protocol it negotiated**:
|
||||
|
||||
```json
|
||||
{"status":"ok","herdr":{"version":"0.8.0","protocol":19}}
|
||||
```
|
||||
|
||||
| Missing | What to tell the user |
|
||||
|---|---|
|
||||
| `herdr` | The PTY multiplexer that owns worker terminals. Install it first; nothing else works without it. |
|
||||
| `bridged` | The daemon. It is a separate service, not part of this plugin — the plugin only *mounts* it. Point the user at the project's own install instructions. |
|
||||
| `ccs` | Only needed to launch worker profiles. The bridge itself will still start. |
|
||||
| `codex` | Optional. Only needed if this fleet will run Codex peers. |
|
||||
|
||||
**Do not attempt to install these yourself.** They are system services with their own lifecycles;
|
||||
guessing at an install is how you end up with two daemons on one socket. Report what is missing and
|
||||
let the user install it.
|
||||
|
||||
## 2. Mount the bridge MCP — merge, never overwrite
|
||||
|
||||
The project's `.mcp.json` may already declare servers. **Read it first and merge**; clobbering
|
||||
someone's existing MCP config is not a recoverable mistake.
|
||||
|
||||
```bash
|
||||
cat .mcp.json 2>/dev/null || echo "(no .mcp.json yet)"
|
||||
```
|
||||
|
||||
The entry to add, exactly:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bridged": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8765/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `.mcp.json` already exists, add only the `bridged` key and leave every other server untouched.
|
||||
If a `bridged` entry is already there with a different URL, **ask** rather than assuming yours is
|
||||
right — a non-default port usually means a deliberate second daemon.
|
||||
|
||||
> **If this plugin is installed, you can skip this step entirely.** The plugin ships its own
|
||||
> `.mcp.json`, so `bridged` is already mounted for any session with the plugin enabled. Write the
|
||||
> project-level file only when the user wants the mount to work *without* the plugin — for
|
||||
> teammates who have not installed it, or for CI.
|
||||
|
||||
**Before writing it, settle whether `.mcp.json` is committed here:**
|
||||
|
||||
```bash
|
||||
git ls-files --error-unmatch .mcp.json 2>/dev/null && echo "TRACKED" || echo "untracked"
|
||||
```
|
||||
|
||||
A tracked `.mcp.json` is inherited by every checkout of this repo — including git worktrees the
|
||||
bridge provisions for workers. Servers bound to *your* machine (an IDE index, a local language
|
||||
server) will then be mounted by workers too, and every path they return points into **your**
|
||||
checkout rather than the worker's. That failure is silent and expensive: it has produced a worker
|
||||
that made all of its edits in the wrong tree while its builds passed, because it was building the
|
||||
tree it was not editing. Keep machine-local servers out of a tracked `.mcp.json`, or keep the file
|
||||
untracked.
|
||||
|
||||
## 3. Standard project settings
|
||||
|
||||
Create or merge `.claude/settings.json`. These are defaults, not requirements — keep anything the
|
||||
project already set.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__bridged__bridge_whoami",
|
||||
"mcp__bridged__bridge_list",
|
||||
"mcp__bridged__bridge_status",
|
||||
"mcp__bridged__bridge_profiles",
|
||||
"mcp__bridged__bridge_poll"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only the **read-only** bridge verbs are pre-allowed. `bridge_spawn`, `bridge_send`, and
|
||||
`bridge_stop` start processes, deliver work, and tear down terminals — those stay behind a prompt
|
||||
on purpose. Do not "helpfully" add them.
|
||||
|
||||
Never write `settings.local.json` on the user's behalf; that file is personal and usually
|
||||
gitignored.
|
||||
|
||||
## 4. Credentials — by reference only
|
||||
|
||||
The bridge takes every secret from the **environment**, and the daemon's config names the variable
|
||||
rather than holding the value. Your job is to tell the user which variables to export, not to
|
||||
collect or store them.
|
||||
|
||||
| Variable | Needed for | Notes |
|
||||
|---|---|---|
|
||||
| `BRIDGED_WORKER_TOKEN` | authenticating a worker to the daemon | only when the daemon is configured with `tokenEnv` |
|
||||
| `GITEA_TOKEN` / equivalent | letting a worker open its own PR | **minimal `write:repository` scope** — see below |
|
||||
| `GITEA_HOST` | the forge base URL | no secret; safe anywhere |
|
||||
|
||||
Two rules to state plainly to the user:
|
||||
|
||||
- **The PR token must not be able to merge.** A worker opens a PR; the primary is the gate. A token
|
||||
that can merge makes the gate decorative. Mint a narrow, repo-scoped token — never reuse a
|
||||
personal admin token.
|
||||
- **Never set `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN`** in this project, this shell, or any
|
||||
settings file. The primary stays on subscription; only the daemon moves a *worker* off it, at
|
||||
spawn. Mounting the bridge must never move a session across that boundary — if setup appears to
|
||||
need this, something is wrong and you should stop and say so.
|
||||
|
||||
Write none of these into any file. Show the user the `export` lines to run themselves.
|
||||
|
||||
## 5. Verify — and do not trust a green health check
|
||||
|
||||
Reconnect MCP if needed (`/mcp`), then confirm the tools are live and this session is the primary:
|
||||
|
||||
```
|
||||
bridge_whoami
|
||||
```
|
||||
|
||||
- `{"role":"primary"}` — correct, you are done with this step.
|
||||
- `{"role":"worker", …}` — **this is the trap.** If the primary runs inside a herdr pane, the
|
||||
daemon resolves it to a terminal and classifies it as a worker, refusing `spawn`/`send`/`stop`:
|
||||
every verb an orchestrator exists to call. It is **self-locking**, because the daemon can only
|
||||
*learn* the primary's terminal from those same refused calls. The only way out is an
|
||||
operator-set pin in the daemon's config:
|
||||
|
||||
```yaml
|
||||
primary:
|
||||
terminal: term_xxxxxxxxxxxx # the terminalId bridge_whoami just reported
|
||||
```
|
||||
|
||||
The daemon reads this **at boot**, so it needs a restart. Re-pin whenever the primary moves
|
||||
panes — a stale pin fails exactly as silently as no pin.
|
||||
|
||||
Then prove the fleet actually works, with a real spawn:
|
||||
|
||||
```
|
||||
bridge_profiles → the configured backends
|
||||
bridge_spawn{profile: "<one of them>"} → must reach state "ready"
|
||||
bridge_stop{paneId: "<from spawn>"}
|
||||
```
|
||||
|
||||
**`/healthz` reporting `ok` is not evidence that spawning works.** It reports that the daemon can
|
||||
reach herdr — nothing more. A version mismatch between the daemon's adapter and the herdr binary
|
||||
leaves health green while every single spawn fails. Only a real spawn proves the fleet. Do this
|
||||
even when everything above looked fine.
|
||||
|
||||
## 6. Optional — Codex parity
|
||||
|
||||
Only if the user wants Codex and Claude Code to share instructions, skills, and MCP config:
|
||||
|
||||
```bash
|
||||
npm install -g ai-config-sync-manager
|
||||
ai-config-sync connect
|
||||
ai-config-sync status # compare both hosts
|
||||
ai-config-sync sync --dry-run # preview — always look before applying
|
||||
ai-config-sync sync --apply
|
||||
```
|
||||
|
||||
It maps `~/.claude/CLAUDE.md` ↔ `~/.codex/AGENTS.md`, `~/.claude/skills/` ↔ `~/.codex/skills/`, and
|
||||
Claude's MCP servers ↔ `[mcp_servers.*]` in `~/.codex/config.toml`.
|
||||
|
||||
**Raise the boundary before running it.** That sync is *user-level* and bidirectional, while the
|
||||
bridge deliberately isolates each worker's tool surface (§2). Syncing your MCP servers into
|
||||
`~/.codex/config.toml` gives every Codex session your machine-local servers — the same
|
||||
wrong-tree failure as §2, in a different runtime. Use the sync for the two CLIs *you* drive
|
||||
interactively; leave anything the bridge spawns isolated. Always `--dry-run` first.
|
||||
|
||||
## 7. Report
|
||||
|
||||
State plainly:
|
||||
|
||||
```
|
||||
prereqs: herdr <version> · bridged <protocol> · ccs <version> · codex <version|absent>
|
||||
written: <files created or merged, or "none">
|
||||
role: <bridge_whoami result — and the pin, if one was needed>
|
||||
spawn: <real spawn result: profile, state reached, torn down>
|
||||
env: <variables the USER still needs to export — names only, never values>
|
||||
skipped: <anything not done, and why>
|
||||
```
|
||||
|
||||
Never report a step as done that you did not verify. If the daemon was unreachable, say so and stop
|
||||
— the remaining steps cannot be checked, and guessing at them is how a broken setup gets called
|
||||
finished.
|
||||
+1
-1
Submodule wiki updated: 0c896eb49b...4320c1ca52
Reference in New Issue
Block a user