wiki: Java stack + REST-as-test-surface + verified herdr 0.7.0 API
- Tech stack Go -> Java 21 (virtual threads, GraalVM native-image); MCP Java SDK; Javalin REST; UnixDomainSocketAddress; Jackson YAML; Lettuce; JUnit. Interface sketch rewritten in Java (2-Message-Server + 8-Roadmap). - Testability: REST API is the contract surface — every feature = an endpoint = an acceptance test (no Claude/MCP in loop); MCP verified by parity. New Roadmap section + feature/endpoint/test map + diagram; Stage-1 tickets (CB-104/105) reframed around REST endpoints; CB-106 Jackson/SLF4J. - Spike correction: verified against RUNNING herdr 0.7.0 (protocol 14). No session.snapshot (fixed 5 refs -> workspace.list/pane.list/ping). Documented the native agent.* namespace as a south-side opportunity; CB-102 now spikes agent.start first. pane.wait_for_output used. - Envelope open-question already resolved -> Use Cases. All 21 mermaid blocks validated.
+1
-1
@@ -238,7 +238,7 @@ deliberately:
|
||||
|
||||
| What dies | Effect | Recovery |
|
||||
|---|---|---|
|
||||
| **`bridged`** | **All** agent comms stop — sync *and* async — since it is the only gateway; in-flight blocking calls error out. | herdr + workers keep running (state on disk / queue). systemd restarts `bridged`; it re-attaches to existing panes via `session.snapshot` and drains its queue. This restart path is load-bearing — harden it. |
|
||||
| **`bridged`** | **All** agent comms stop — sync *and* async — since it is the only gateway; in-flight blocking calls error out. | herdr + workers keep running (state on disk / queue). systemd restarts `bridged`; it re-attaches to existing panes via `workspace.list`/`pane.list` and drains its queue. This restart path is load-bearing — harden it. |
|
||||
| **herdr** | No pane control; all delivery (sync + async injection) dead. | PTYs die with the *server* (only client detach survives). Respawn workers from persisted state (Ralph loop); replay unacked queue items. |
|
||||
| **Broker / queue** (internal) | Durability + cross-host async degrade; **same-host async still works** (idle-injection needs no queue). | `bridged` delivers locally without it; ack + visibility timeout re-deliver on recovery. Nothing silently dropped. |
|
||||
| **Model endpoint** | Workers stall or error mid-turn. | herdr status shows `working` stuck / `blocked`; `bridged` times out the blocking call and surfaces the error. |
|
||||
|
||||
+78
-68
@@ -219,7 +219,20 @@ primary out of herdr — see [Deployment model](#deployment-model).)*
|
||||
|
||||
## The herdr control contract (what `bridged` drives)
|
||||
|
||||
Everything `bridged` needs is in herdr's socket API (verified against `herdr.dev/docs/socket-api`):
|
||||
> **Verified against the running herdr 0.7.0 (protocol 14), not just docs.** A spike hit the
|
||||
> live socket. `ping` returns `{version:"0.7.0", protocol:14, capabilities:{live_handoff:true}}`;
|
||||
> `workspace.list` / `pane.list` return the pane inventory (with `agent_status`); `events.subscribe`
|
||||
> exists. **There is no `session.snapshot`** (the earlier draft was wrong — use
|
||||
> `workspace.list`/`pane.list` to enumerate/re-attach). Crucially, herdr 0.7.0 exposes a
|
||||
> **native `agent.*` namespace** — `agent.start`, `agent.send`, `agent.read`, `agent.list`,
|
||||
> `agent.get`, `agent.focus`, plus `server.agent_manifests` and `pane.report_agent` — so herdr
|
||||
> already models "agents", not just panes. **Open opportunity:** `bridged`'s south side may use
|
||||
> `agent.start`/`agent.send`/`agent.list` directly instead of the "create pane + `send_text` a
|
||||
> launch line" workaround below. Spike this in **Stage 1 (CB-102)** and prefer it if it carries
|
||||
> env/model cleanly; the pane-based path stays the documented fallback. (Also available:
|
||||
> `worktree.*` for git-isolated workers.)
|
||||
|
||||
The pane-based control path (the fallback, and what the examples below use):
|
||||
|
||||
```jsonc
|
||||
// spawn: create a pane, then launch the worker in its shell (env stays worker-only)
|
||||
@@ -289,8 +302,8 @@ then calls `send_text`. That read-then-send is a **TOCTOU window**: the pane cou
|
||||
the per-pane FIFO queue serializes deliveries so two turns never interleave. This removes
|
||||
injector-vs-injector races, not injector-vs-agent ones.
|
||||
- **Serialize send within the event loop.** Do the status check and the `send_text`/`send_keys`
|
||||
pair as one non-preemptible unit on the same goroutine that consumes events, so a status
|
||||
change can't be processed mid-send.
|
||||
pair as one non-preemptible unit on the same event-loop virtual thread that consumes events,
|
||||
so a status change can't be processed mid-send.
|
||||
- **Initial readiness needs prompt detection, not just status.** On spawn there may be no
|
||||
`agent_status` event until the first turn. Gate the *first* injection on an
|
||||
`output_matched`/`pane.read {source:"detection"}` prompt-ready signal (the `Ready` state
|
||||
@@ -433,12 +446,14 @@ The invariant is unchanged from [Architecture](1-Architecture) — **anything th
|
||||
|
||||
## API surface (SERVER face)
|
||||
|
||||
**Two faces over one core.** Claude sessions use the **MCP tools**
|
||||
([above](#the-client-contract--mcp-unified-for-primary--workers)); the **REST/SSE** routes
|
||||
below are the same operations for *non-Claude* clients (webhooks, dashboards, a human CLI)
|
||||
and are kept AgentAPI-shaped so existing clients migrate without rewrites. The MCP tools are
|
||||
thin wrappers over these routes — e.g. `bridge_send` → `POST /sessions/{id}/message`,
|
||||
`bridge_status` → `GET /sessions/{id}/status`, `bridge_reply` → the reply-rendezvous callback.
|
||||
**Two faces over one core — and REST is the testability surface.** Every feature is implemented
|
||||
as a **REST route**; the **MCP tools are thin adapters over those routes** — e.g. `bridge_send`
|
||||
→ `POST /sessions/{id}/message`, `bridge_status` → `GET /sessions/{id}/status`, `bridge_reply` →
|
||||
`POST /sessions/{id}/reply` (rendezvous). The REST routes are also the surface non-Claude clients
|
||||
use (webhooks, dashboards, a human CLI), kept AgentAPI-shaped for drop-in migration. Because the
|
||||
logic lives in REST, **each feature is acceptance-tested by an HTTP call with no Claude/MCP in
|
||||
the loop**, and MCP is verified by a **parity test** (tool result == REST result). See
|
||||
[Roadmap → Testability](8-Roadmap#testability--the-rest-api-is-the-contract-surface).
|
||||
|
||||
| Method + path | Purpose |
|
||||
|---|---|
|
||||
@@ -533,71 +548,66 @@ turns. Until then, treat one `bridged` as one trust domain.
|
||||
|
||||
| Layer | Choice | Why | Alternative |
|
||||
|---|---|---|---|
|
||||
| **Server core** | **Go** | Single static binary → trivial `scp`/systemd deploy to the worker host; goroutines fit the socket + HTTP + broker + SSE fan-in; mirrors `coder/agentapi` (can reuse its `msgfmt` reply parser). | Rust (matches herdr, slower to build); **TypeScript/Node** if the driver is the Agent SDK and you want shared types; Python for a quick spike. |
|
||||
| **herdr transport** | Unix domain socket, **NDJSON**, `id`-correlated request/response + a persistent events stream | Native herdr contract | — |
|
||||
| **SERVER API — Claude** | **MCP server**, streamable-HTTP transport (`mcp-go` / official Go SDK) | The unified contract both primary and workers mount; native to Claude Code, no shell/`curl` step, subscription-safe by construction | stdio MCP adapter (per-session subprocess) if a long-lived HTTP endpoint is undesirable |
|
||||
| **SERVER API — others** | **REST + SSE**, OpenAPI-generated | Drop-in for AgentAPI-shaped/non-Claude clients; SSE streams status cheaply | gRPC (if callers are all code); WebSocket (bidi UI) |
|
||||
| **Internal queue** *(optional)* | **Redis Streams** (consumer groups, `XACK`, visibility timeout) — `bridged`-owned, below the gateway | Durability + cross-host for async; satisfies the guardrails in [Architecture](1-Architecture). Same-host can start with an in-process queue and add this only when durability/cross-host is needed | NATS JetStream for multi-host scale / replay; embedded (BadgerDB/SQLite) for a single host |
|
||||
| **Config** | Env + YAML (`koanf`) | 12-factor; secrets via env only | — |
|
||||
| **Observability** | `slog` + Prometheus `/metrics` + `/healthz` | Ops from day one | OpenTelemetry traces |
|
||||
| **Process supervision** | **systemd** unit (or Docker Compose) colocating herdr + `bridged` | Restart-on-crash; ordered start (herdr before `bridged`) | k8s (overkill for one host) |
|
||||
| **Testing** | **Mock herdr socket** server + golden transcripts | Deterministic CI without a real TTY | Dockerized herdr for e2e |
|
||||
| **Server core** | **Java 21+ (virtual threads)** | Loom virtual threads fit the socket + MCP + queue + SSE **blocking fan-in** as cleanly as goroutines — one blocking thread per pane/call, no callback soup; mature libraries; the official MCP Java SDK exists. | **Kotlin** (same JVM, terser); Go (single static binary, smaller RSS); Rust (matches herdr, slower to build). |
|
||||
| **Deploy artifact** | Runnable **JAR** on a JRE, or **GraalVM `native-image`** | native-image restores the "single binary → `scp` + systemd, fast cold start, small RSS" story the JVM otherwise gives up. | plain JRE + fat JAR (simplest); jlink custom runtime. |
|
||||
| **herdr transport** | JDK **`UnixDomainSocketAddress` + `SocketChannel`** (native UDS, no dep), **NDJSON** via Jackson, `id`-correlated + a persistent events stream | Native herdr contract; a dedicated **virtual thread** blocks on the event stream. | — |
|
||||
| **SERVER API — Claude** | **Official MCP Java SDK** (streamable-HTTP transport) on an embedded server (Jetty / Spring Boot) | The unified contract both primary and workers mount; native to Claude Code, no shell/`curl`, subscription-safe by construction | stdio MCP adapter (per-session subprocess) if a long-lived HTTP endpoint is undesirable |
|
||||
| **SERVER API — others** | **REST + SSE** via **Javalin** (light) or Spring MVC | Drop-in for AgentAPI-shaped/non-Claude clients; SSE streams status cheaply | JAX-RS (Helidon/Quarkus); gRPC if callers are all code |
|
||||
| **Internal queue** *(optional)* | **Redis Streams** via **Lettuce** (consumer groups, `XACK`, visibility timeout) — `bridged`-owned, below the gateway | Durability + cross-host for async; satisfies the guardrails in [Architecture](1-Architecture). Same-host can start with an in-JVM queue and add this only when durability/cross-host is needed | NATS JetStream for multi-host scale; embedded H2/SQLite for a single host |
|
||||
| **Config** | **YAML via Jackson** (`jackson-dataformat-yaml`) + env overrides | 12-factor; secrets via env only | MicroProfile Config; Spring config if on Spring Boot |
|
||||
| **Observability** | **SLF4J + Logback**; **Micrometer** → Prometheus `/metrics`; `/healthz` | Ops from day one | OpenTelemetry traces |
|
||||
| **Process supervision** | **systemd** unit (`java -jar` or the native-image binary), ordered after herdr | Restart-on-crash; ordered start (herdr before `bridged`) | Docker Compose colocating herdr + `bridged`; k8s (overkill for one host) |
|
||||
| **Testing** | **JUnit 5** + a **mock UDS socket** server + golden transcripts; fake `ccs`/`claude` stubs | Deterministic CI without a real TTY | Testcontainers (Redis) + a real herdr for e2e |
|
||||
|
||||
**Recommendation: Go.** It gives the smallest operational footprint on the worker host, the
|
||||
cleanest concurrency story for this exact fan-in shape, and direct code reuse from
|
||||
`agentapi`. Pick **TypeScript** instead only if `bridged`'s primary *driver* is an Agent-SDK
|
||||
program and shared types outweigh the deploy simplicity.
|
||||
**Recommendation: Java 21+ with virtual threads.** The daemon is almost entirely
|
||||
blocking-I/O fan-in (herdr socket, MCP calls, queue, SSE) — the exact shape Loom makes trivial:
|
||||
one blocking virtual thread per pane and per in-flight call, no reactive plumbing. Ship a
|
||||
**GraalVM `native-image`** build to recover Go's small-footprint/fast-start deploy. Use
|
||||
**Kotlin** instead only if the team prefers it — same JVM, same libraries. (`agentapi`'s Go
|
||||
`msgfmt` reply parser is trivial to reimplement; it isn't a reason to stay on Go.)
|
||||
|
||||
### Interface sketch (Go)
|
||||
### Interface sketch (Java 21)
|
||||
|
||||
```go
|
||||
// herdr socket client — one method, id-correlated; events on a separate stream.
|
||||
type Herdr interface {
|
||||
Call(ctx context.Context, method string, params any) (json.RawMessage, error)
|
||||
Subscribe(ctx context.Context, subs []Sub) (<-chan Event, error)
|
||||
```java
|
||||
// herdr socket client — one call method, id-correlated; events on a separate virtual thread.
|
||||
interface Herdr {
|
||||
JsonNode call(String method, Object params) throws IOException; // blocking, id-correlated
|
||||
BlockingQueue<Event> subscribe(List<Sub> subs) throws IOException; // fed by an event-loop vthread
|
||||
}
|
||||
|
||||
// Injector: deliver ONLY when the pane is safe to type into.
|
||||
func (i *Injector) Deliver(ctx context.Context, pane string, text string) error {
|
||||
if st := i.status[pane]; st != Idle && st != Blocked {
|
||||
i.queue[pane] = append(i.queue[pane], text) // hold until next idle event
|
||||
return nil
|
||||
}
|
||||
if _, err := i.h.Call(ctx, "pane.send_text", P{"pane_id": pane, "text": text}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := i.h.Call(ctx, "pane.send_keys", P{"pane_id": pane, "keys": "enter"})
|
||||
return err
|
||||
}
|
||||
|
||||
// Subscription guard: the boundary, in code.
|
||||
// NOTE: a substring check is not enough — validate the RESOLVED host against an
|
||||
// off-subscription allowlist, and confirm egress post-spawn via pane.process_info.
|
||||
var offSubHosts = map[string]bool{"ollama.ltms.dev": true /* + GX10 vLLM host */}
|
||||
|
||||
func (g *Guard) AssertWorker(baseURL string) error {
|
||||
if baseURL == "" {
|
||||
return fmt.Errorf("refusing to spawn worker without ANTHROPIC_BASE_URL")
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("worker ANTHROPIC_BASE_URL unparseable: %w", err)
|
||||
}
|
||||
if !offSubHosts[u.Hostname()] { // api.anthropic.com etc. must be rejected here
|
||||
return fmt.Errorf("worker base_url %q is not an approved off-subscription host", u.Hostname())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only meaningful for a primary bridged itself hosts (single-host). A remote/Mac
|
||||
// primary is a process bridged never sees — its cleanliness is the operator's.
|
||||
func (g *Guard) AssertLocalPrimaryClean(env []string) error {
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, "ANTHROPIC_BASE_URL=") {
|
||||
return fmt.Errorf("primary env is tainted — this is the subscription line")
|
||||
// Injector: deliver ONLY when the pane is safe to type into (status-gated, single writer per pane).
|
||||
final class Injector {
|
||||
void deliver(String pane, String text) throws IOException {
|
||||
Status st = status.get(pane);
|
||||
if (st != Status.IDLE && st != Status.BLOCKED) {
|
||||
queue.computeIfAbsent(pane, k -> new ArrayDeque<>()).add(text); // hold until next idle event
|
||||
return;
|
||||
}
|
||||
herdr.call("pane.send_text", Map.of("pane_id", pane, "text", text));
|
||||
herdr.call("pane.send_keys", Map.of("pane_id", pane, "keys", "enter"));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription guard: the boundary, in code. The base_url comes from `ccs env <profile>`
|
||||
// (see Use Cases → ccs spawn), NOT a hand-built env — validate the RESOLVED host against an
|
||||
// off-subscription allowlist, then confirm egress post-spawn via pane.process_info.
|
||||
final class Guard {
|
||||
private static final Set<String> OFF_SUB_HOSTS = Set.of("ollama.ltms.dev" /* + GX10 vLLM host */);
|
||||
|
||||
void assertWorker(String baseUrl) {
|
||||
if (baseUrl == null || baseUrl.isBlank())
|
||||
throw new GuardException("refusing to spawn worker without ANTHROPIC_BASE_URL");
|
||||
String host = URI.create(baseUrl).getHost(); // api.anthropic.com must be rejected
|
||||
if (host == null || !OFF_SUB_HOSTS.contains(host))
|
||||
throw new GuardException("worker base_url host %s is not an approved off-subscription host".formatted(host));
|
||||
}
|
||||
|
||||
// Only meaningful for a primary bridged itself hosts (single-host). A remote/Mac primary is a
|
||||
// process bridged never sees — its cleanliness is the operator's.
|
||||
void assertLocalPrimaryClean(Map<String,String> env) {
|
||||
if (env.containsKey("ANTHROPIC_BASE_URL"))
|
||||
throw new GuardException("primary env is tainted — this is the subscription line");
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
@@ -622,7 +632,7 @@ func (g *Guard) AssertLocalPrimaryClean(env []string) error {
|
||||
| **Mid-run interrupt still unsolved** | Same as AgentAPI. Injection gates on status; `ctrl+c` via `pane.send_input` is the only (disruptive) interrupt. |
|
||||
| **Spawn-with-env uncertainty in socket API** | Launch via `send_text` of the env-prefixed command → env is provably worker-only; verify native spawn in the CLI reference and prefer it if present. |
|
||||
| **Reply-scrape fragility (fallback path)** | Prefer the structured **envelope** path; scrape `recent-unwrapped` only as a last resort. |
|
||||
| **herdr socket API is unversioned + single-dev churn** | Pin the herdr version in the systemd/Compose unit; keep the socket client behind the `Herdr` interface; probe `session.snapshot` shape on startup and fail fast on an unexpected schema. Don't build against `UNCERTAIN` primitives (e.g. native spawn-with-env) until confirmed in the running CLI. |
|
||||
| **herdr socket API is unversioned + single-dev churn** | Pin the herdr version in the systemd/Compose unit; keep the socket client behind the `Herdr` interface; probe `ping` (assert `protocol: 14`) on connect and enumerate via `workspace.list`/`pane.list`, failing fast on an unexpected schema. Don't build against `UNCERTAIN` primitives (e.g. native spawn-with-env) until confirmed in the running CLI. |
|
||||
| **SPOF (bridged / herdr / queue)** | Documented in [Architecture](1-Architecture) → *Failure modes*. Key property: the **primary is never downstream** of a bridge component, so a total outage costs workers only, never the subscription session. |
|
||||
| **Injection TOCTOU / shared pane** | Single-writer injector + serialized send; worker panes are bridged-owned. Residual collision corrupts a turn (recoverable), never the subscription boundary. See *Delivery gating & races*. |
|
||||
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@ This page will cover standing up the bridge on an off-subscription worker host.
|
||||
`claude` that inherits your `CLAUDE.md`/hooks/skills/MCP; a reachable worker model
|
||||
(`ollama.ltms.dev` or GX10 vLLM) with a bearer token.
|
||||
2. **herdr** — start the headless server; confirm the socket at
|
||||
`~/.config/herdr/herdr.sock` (or `HERDR_SOCKET_PATH`); verify with `session.snapshot`.
|
||||
`~/.config/herdr/herdr.sock` (or `HERDR_SOCKET_PATH`); verify with `ping` (expect
|
||||
`protocol: 14` on herdr 0.7.0) and `workspace.list`.
|
||||
3. **`bridged`** — deploy the binary, `bridged.yaml` (worker model, `base_url` allowlist,
|
||||
auth token, bind address), and a **systemd** unit ordered *after* herdr.
|
||||
4. **Worker session** — create the first worker pane with the env-prefixed launch line
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ Day-2 runbook for a running bridge.
|
||||
`agent_status` per session via `GET /sessions`, and confirming the **MCP endpoint** is
|
||||
reachable from both the primary and the workers (`claude mcp list` shows `bridge` connected).
|
||||
- **Restart & recovery** — ordered restart (herdr before `bridged`); how `bridged`
|
||||
re-attaches to existing panes via `session.snapshot`; internal broker/queue replay of unacked items. See
|
||||
re-attaches to existing panes via `workspace.list`/`pane.list`; internal broker/queue replay of unacked items. See
|
||||
[Architecture](1-Architecture) → *Failure modes & single points of failure* for what each outage costs.
|
||||
- **Model swaps** — repoint a worker to a different `base_url`/model by recycling its pane
|
||||
(Ralph loop); the subscription guard re-validates the new host against the allowlist.
|
||||
|
||||
+101
-34
@@ -40,16 +40,73 @@ Consolidated from [Message Server](2-Message-Server#proposed-tech-stack); the cc
|
||||
|
||||
| Concern | Choice | Note |
|
||||
|---|---|---|
|
||||
| **Language** | **Go** | Single static binary → `scp`/systemd; goroutines fit socket + MCP + queue fan-in; reuse `agentapi` `msgfmt`. |
|
||||
| **MCP server** | `mcp-go` / official Go SDK, streamable-HTTP | The SERVER-face contract both sides mount. |
|
||||
| **herdr client** | Unix-socket NDJSON, id-correlated + event stream | Native herdr contract. |
|
||||
| **Language** | **Java 21+ (virtual threads)** | Loom fits the blocking socket + MCP + queue + SSE fan-in; **GraalVM `native-image`** recovers the `scp`+systemd single-binary deploy. Kotlin OK (same JVM). |
|
||||
| **REST API core** | **Javalin** (or Spring MVC) + SSE | **The implementation & testability surface** — every feature is a REST endpoint (see [Testability](#testability--the-rest-api-is-the-contract-surface)). |
|
||||
| **MCP server** | **Official MCP Java SDK**, streamable-HTTP (Jetty/Spring) | A **thin adapter over the REST core** — the Claude-facing face of the same features. |
|
||||
| **herdr client** | JDK `UnixDomainSocketAddress` + `SocketChannel`, NDJSON (Jackson) | Native UDS, no dep; event stream on a virtual thread. Pinned to herdr **0.7.0 / protocol 14** ([verified API](2-Message-Server#the-herdr-control-contract-what-bridged-drives)). |
|
||||
| **Worker spawn** | **`ccs <profile> claude`** into a herdr pane | Profile = worker identity; no env-prefix. |
|
||||
| **Subscription guard** | **`ccs env <profile>`** → resolved `base_url` host allowlist | Boundary check in ccs terms. |
|
||||
| **Config** | YAML (`koanf`) + env | `bridged.yaml`: worker profiles, allowlist, bind addr, lifecycle knobs. |
|
||||
| **Internal queue** *(Stage 4)* | Redis Streams (ack + visibility) | Below the gateway; embedded queue OK single-host. |
|
||||
| **Observability** | `slog` + Prometheus `/metrics` + `/healthz` | Stage 5. |
|
||||
| **Supervision** | systemd unit, ordered after herdr | Stage 5. |
|
||||
| **Testing** | Mock herdr socket + golden transcripts; fake `ccs`/`claude` stubs | Deterministic CI, no TTY. |
|
||||
| **Config** | **YAML via Jackson** + env | `bridged.yaml`: worker profiles, allowlist, bind addr, lifecycle knobs. |
|
||||
| **Internal queue** *(Stage 4)* | **Redis Streams via Lettuce** (ack + visibility) | Below the gateway; in-JVM queue OK single-host. |
|
||||
| **Observability** | **SLF4J+Logback**; **Micrometer**→Prometheus `/metrics`; `/healthz` | Stage 5. |
|
||||
| **Supervision** | systemd unit (`java -jar` or native-image), ordered after herdr | Stage 5. |
|
||||
| **Testing** | **JUnit 5**; **mock UDS herdr** server; **REST contract tests per feature**; **contract test vs real herdr 0.7.0**; fake `ccs`/`claude` stubs | See [Testability](#testability--the-rest-api-is-the-contract-surface). |
|
||||
|
||||
## Testability — the REST API is the contract surface
|
||||
|
||||
Every feature is implemented as a **REST endpoint first**; the **MCP tools are thin adapters
|
||||
over those endpoints** (already the design in [Message Server](2-Message-Server#api-surface-server-face)).
|
||||
That inversion is what makes the system testable *against expectations*:
|
||||
|
||||
- **Each feature = one endpoint = one acceptance test.** You verify behaviour by calling REST
|
||||
with an input and asserting the response — **no Claude session, no MCP client, no TTY in the
|
||||
loop.** Deterministic and CI-friendly.
|
||||
- **MCP is validated by parity.** For each tool, one test asserts the MCP call and the REST call
|
||||
return the same result for the same input. If REST is green and parity holds, MCP is correct by
|
||||
construction — we don't re-test business logic through the MCP layer.
|
||||
- **Expectations are pinned to reality, not docs.** herdr behaviour is asserted by a **contract
|
||||
test against the running herdr 0.7.0** (`ping.protocol == 14`, `workspace.list`/`pane.list`
|
||||
shapes), so the socket client can't silently drift from the actual server.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph tests["Test layers (all hit the REST surface or below)"]
|
||||
FT["feature/acceptance tests<br/>HTTP → REST endpoint"]
|
||||
PT["parity tests<br/>MCP tool == REST route"]
|
||||
UT["unit tests<br/>mock UDS herdr · fake ccs/claude"]
|
||||
CT["contract test<br/>vs real herdr 0.7.0"]
|
||||
end
|
||||
MCPC["MCP client (Claude)"] -->|"thin adapter"| REST["REST API (feature core)"]
|
||||
REST --> CORE["session mgr · injector · guard · rendezvous"]
|
||||
CORE --> HERDR["herdr socket client"]
|
||||
FT --> REST
|
||||
PT --> MCPC
|
||||
PT --> REST
|
||||
UT --> CORE
|
||||
CT --> HERDR
|
||||
classDef core fill:#2f855a,stroke:#22543d,color:#ffffff;
|
||||
classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
|
||||
class REST,CORE,HERDR core
|
||||
class MCPC ext
|
||||
```
|
||||
|
||||
*Figure: features live in the REST core; MCP wraps it; tests target REST (feature), MCP-vs-REST
|
||||
(parity), the core with a mocked socket (unit), and the real herdr (contract).*
|
||||
|
||||
### Feature ⇄ endpoint ⇄ test map (the acceptance surface)
|
||||
|
||||
| Feature | REST endpoint | MCP tool | Acceptance test |
|
||||
|---|---|---|---|
|
||||
| Deliver a turn (blocking) | `POST /sessions/{id}/message` | `bridge_send` | reply returned; `working→done` unblocks; timeout → 202 |
|
||||
| Worker status | `GET /sessions/{id}/status` | `bridge_status` | matches herdr `agent_status` |
|
||||
| Async ticket + poll | `POST …/message?mode=async` · `GET /tickets/{t}` | `bridge_send`(async) · `bridge_poll` | ticket issued; reply retrievable |
|
||||
| Worker reply | `POST /sessions/{id}/reply` | `bridge_reply` | resolves the awaiting request by `corr` |
|
||||
| Worker question | `POST /sessions/{id}/ask` | `bridge_ask` | surfaces to primary; parks worker |
|
||||
| Discovery | `GET /sessions` | `bridge_sessions` | roster + live match config/herdr |
|
||||
| Spawn (guarded) | `POST /sessions` | *(internal)* | rejects on-subscription profile; accepts allowlisted |
|
||||
| Health | `GET /healthz` · `GET /metrics` | — | liveness + Prometheus |
|
||||
|
||||
Every ticket's **Acceptance** below is written to be executed against these endpoints.
|
||||
|
||||
## Tickets by stage (2–5)
|
||||
|
||||
@@ -73,51 +130,61 @@ pane scrape (envelope comes in Stage 2). This is the thinnest end-to-end vertica
|
||||
---
|
||||
|
||||
### CB-101 — herdr socket client
|
||||
**Scope.** Connect to `~/.config/herdr/herdr.sock` (`HERDR_SOCKET_PATH`); implement
|
||||
`Call(method, params) → result` (NDJSON, `id`-correlated) and `Subscribe(subs) → <-chan Event`
|
||||
for `pane.agent_status_changed`. Probe `session.snapshot` on connect; fail fast on schema
|
||||
mismatch.
|
||||
**Acceptance.** Unit test against a **mock socket** replays a `workspace.create` round-trip and
|
||||
one event; `session.snapshot` shape asserted.
|
||||
**Scope.** Connect to `~/.config/herdr/herdr.sock` (`HERDR_SOCKET_PATH`) via
|
||||
`UnixDomainSocketAddress`; implement `call(method, params) → JsonNode` (NDJSON, `id`-correlated)
|
||||
and `subscribe(subs) → BlockingQueue<Event>` (fed by an event-loop virtual thread) for
|
||||
`pane.agent_status_changed`. Probe `ping` on connect (assert `protocol: 14`); enumerate via
|
||||
`workspace.list`/`pane.list`; fail fast on schema mismatch. **Build against the
|
||||
[verified 0.7.0 API](2-Message-Server#the-herdr-control-contract-what-bridged-drives), not the docs.**
|
||||
**Acceptance.** JUnit test against a **mock UDS socket** replays a `workspace.create` round-trip
|
||||
and one event; a **contract test vs the real herdr** asserts `ping.protocol == 14` and the
|
||||
`workspace.list`/`pane.list` shapes.
|
||||
**Deps.** none.
|
||||
|
||||
### CB-102 — spawn a worker via ccs profile
|
||||
**Scope.** Given a profile name, open a herdr pane and `send_text` `ccs <profile> claude`
|
||||
(+ workspace `cwd`); detect **Ready** via `output_matched` / `pane.read {source:"detection"}`
|
||||
(prompt-ready), not an absent status.
|
||||
**Scope.** First **spike herdr's native `agent.start`/`agent.send`/`agent.list`** (0.7.0 has
|
||||
them) — if they carry the `ccs <profile> claude` command + env/model cleanly, use them. Else
|
||||
fall back to the documented path: open a herdr pane and `send_text` `ccs <profile> claude`
|
||||
(+ workspace `cwd`), detecting **Ready** via `pane.wait_for_output` / `pane.read
|
||||
{source:"detection"}` (prompt-ready), not an absent status.
|
||||
**Acceptance.** Against a fake `ccs`+`claude` stub in a real herdr pane, the worker reaches
|
||||
`Ready`; the resolved command is exactly `ccs gx00-vllm claude` (asserted from a spawn log).
|
||||
**Deps.** CB-101.
|
||||
|
||||
### CB-103 — status-gated injector
|
||||
**Scope.** Per-pane FIFO; `Deliver(pane, text)` sends `send_text` + `send_keys "enter"` **only**
|
||||
**Scope.** Per-pane FIFO; `deliver(pane, text)` sends `send_text` + `send_keys "enter"` **only**
|
||||
when `agent_status ∈ {idle, blocked}`, else queues until the next idle event. Single writer per
|
||||
pane; status-check + send serialized on the event goroutine (close the TOCTOU window).
|
||||
pane; status-check + send serialized on the event-loop virtual thread (close the TOCTOU window).
|
||||
**Acceptance.** A delivery issued while the pane is `working` lands only after the `idle` event;
|
||||
two rapid deliveries never interleave (golden transcript).
|
||||
**Deps.** CB-101.
|
||||
|
||||
### CB-104 — blocking `bridge_send` + reply capture
|
||||
**Scope.** `bridge_send(to, body, {mode:"block"})` → resolve `to` to the (single, hardcoded)
|
||||
worker pane → inject the request text → wait for `agent_status=done` → return
|
||||
`pane.read {source:"recent-unwrapped"}` of the last assistant block as the tool result. (No
|
||||
envelope yet; scrape is acceptable for Stage 1.)
|
||||
**Acceptance.** A canned request produces a non-empty reply string derived from the pane; a
|
||||
`working→done` transition unblocks the call; a timeout returns a typed "still working" error.
|
||||
### CB-104 — blocking `bridge_send` as a **REST endpoint** + reply capture
|
||||
**Scope.** Implement the feature as **`POST /sessions/{id}/message`** (`{content}`, blocking) →
|
||||
resolve to the (single, hardcoded) worker pane → inject → wait for `agent_status=done` → return
|
||||
`pane.read {source:"recent-unwrapped"}` of the last assistant block in the response body. (No
|
||||
envelope yet; scrape is acceptable for Stage 1.) This REST route is the feature; MCP wraps it in
|
||||
CB-105.
|
||||
**Acceptance.** A **REST contract test** (`POST /sessions/{id}/message`, **no Claude in the
|
||||
loop**) returns a non-empty reply from the pane; a `working→done` transition unblocks it; a
|
||||
timeout returns a typed "still working" (HTTP 202-style) response.
|
||||
**Deps.** CB-102, CB-103.
|
||||
|
||||
### CB-105 — MCP server (SERVER face)
|
||||
**Scope.** Streamable-HTTP MCP server exposing `bridge_send` and `bridge_status`; bind
|
||||
`127.0.0.1:8080`. Provide the one-line mount: `claude mcp add --transport http bridge
|
||||
### CB-105 — MCP adapter over the REST core (SERVER face)
|
||||
**Scope.** Streamable-HTTP MCP server exposing `bridge_send`/`bridge_status` as **thin adapters
|
||||
over the CB-104 REST routes** (`POST /sessions/{id}/message`, `GET /sessions/{id}/status`); bind
|
||||
`127.0.0.1:8080`. One-line mount: `claude mcp add --transport http bridge
|
||||
http://127.0.0.1:8080/mcp`.
|
||||
**Acceptance.** `claude mcp list` shows `bridge` connected from the primary; calling
|
||||
`bridge_status` returns the worker's live `agent_status`.
|
||||
**Acceptance.** **Parity test** — `bridge_send` via MCP and `POST …/message` via REST produce
|
||||
identical results for the same input; `claude mcp list` shows `bridge` connected; `bridge_status`
|
||||
returns the live `agent_status`.
|
||||
**Deps.** CB-104.
|
||||
|
||||
### CB-106 — config + wiring
|
||||
**Scope.** `bridged.yaml` load (`koanf`): one worker profile (`gx00-vllm` → ccs profile, model,
|
||||
`base_url` host), bind address, workspace root. `slog` startup line logging the resolved worker
|
||||
command (secrets redacted). Run as a foreground process (systemd deferred to Stage 5).
|
||||
**Scope.** `bridged.yaml` load (**Jackson YAML**): one worker profile (`gx00-vllm` → ccs profile,
|
||||
model, `base_url` host), bind address, workspace root. **SLF4J/Logback** startup line logging the
|
||||
resolved worker command (secrets redacted). Run as a foreground process (systemd deferred to
|
||||
Stage 5).
|
||||
**Acceptance.** Bad/missing profile fails fast with a clear message; a valid config boots and
|
||||
logs the resolved (redacted) spawn command.
|
||||
**Deps.** none (parallel with CB-101).
|
||||
|
||||
Reference in New Issue
Block a user