bc13b8e92cfbecfe5f533fee045b315fee86d615
133 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bc13b8e92c |
CB-530..536, CB-538 groundwork: leads as peers, and a fleet that can find itself
Two leads now work as peers rather than one primary plus workers. The arc:
CB-530/531 lead identity: `leaders:` names panes, `leadScan:` discovers them by
tab label (LeadTabScanner, TTL-cached, worker spaces excluded).
CB-532 leads can message each other AND be answered. Principal.leader now
carries its terminal, so ownsSession() can be true for a lead; the
"and you must be a worker" conjunct beside it protected nothing.
Retires `primary:` — reply nudges follow the delegating lead, a
binding recorded at bridge_send where both halves are known.
CB-533 ClaudeCodeLauncher passes --model. argv is usually a wrapper
(`ccs <profile>`) that re-exports its own model family, so
ANTHROPIC_MODEL alone was silently overruled.
CB-534 a lead is deliverable. The CB-113 readiness gate only opened for
terminals in WorkerPresence, which only workers ever enter, so every
lead->lead send waited out the ~60s grace and failed having never
been typed. The gate guards a *spawned* peer's boot window; a lead
is never spawned.
CB-535 bridge_list returns `leads` alongside `workers`, with `self` on the
caller's row. An empty worker roster no longer reads as "no peers".
CB-536 CLAUDE.md: lead<->lead is coordinate-only, never sideways delegation.
Propagated byte-identically to wiki/7-Use-Cases.md.
MIXED PROVENANCE — recorded deliberately rather than hidden. This tree also carries
in-progress CB-537 (context separation) authored by the peer lead gpt-sol-5.6 and
its worker: Capability.CONTEXT_RESET, SessionManager.clearAfterTurn, and the
Injector/TurnListener/CompletionResolver/launcher changes around it. That work was
done in this shared working tree rather than a worktree, and is entangled with the
above in BridgedConfig.java, Bridged.java and ClaudeCodeLauncher.java, so neither
lead could stage its own half without sweeping in the other's. Committing the whole
green state is the honest resolution; the peer branches from here.
Note for whoever picks CB-537 up: the design in this commit is SUPERSEDED. Both
leads agreed to replace the global `clearAfterTurn` boolean with per-delivery
policy (inherit|fresh|thread) applied PRE-delivery, because a post-turn reset races
by construction — Injector.onStatus clears awaitingCompletion and dequeues the next
message in the same tick. `fresh` is also a correctness guarantee, so an adapter
without a reset capability must refuse it rather than log a no-op.
mvn clean install: Tests run: 464, Failures: 0, Errors: 0, Skipped: 0. BUILD SUCCESS.
|
||
|
|
ef1e014b41 |
CB-527: ship the bridge as an installable Claude Code plugin
The orchestration contract had no distributable form. Every consuming project hand-copied a block of CLAUDE.md and hand-wrote an .mcp.json, and we keep a script whose only job is to notice those copies drifting apart. A plugin is versioned, installed once, and updates in place. Ships no credentials, deliberately: every secret is referenced by environment variable NAME and the value never enters a file, which is what makes the artifact safe to publish. The setup skill states the two rules that are easy to get wrong for the right-sounding reasons — the PR token must not be able to merge (a worker opens, the primary gates), and ANTHROPIC_BASE_URL must never be set by setup, because mounting the bridge must not move a session off subscription. The plugin root is plugin/, not the repo root. An installed plugin's .mcp.json is a committed file, while this repo's root .mcp.json is local-only and --skip-worktree; rooting the plugin at the repo would commit the primary's IDE servers and hand them to every worker — the exact failure CB-525 exists to prevent. Scope is client-side setup only. herdr and bridged stay separate services with their own lifecycles, and the skill refuses to install them rather than guess. It also refuses to accept /healthz as proof: health reports only that the daemon can reach herdr, and CB-521 showed it staying green while every spawn failed, so verification ends with a real spawn. Both manifests pass `claude plugin validate --strict`. |
||
|
|
e3c8393d1b |
CB-527: retire the ollama profile from the worker choice
The ollama backend is decommissioned, so the example config stops pointing readers at a dead host and the guard allowlist stops carrying an entry with no profile behind it — a stale entry there is dead permission, and that list is the only thing keeping a worker off the primary's subscription. The second illustrative profile survives as gx11: the example exists to show `placement: weighted` having something to choose between, and a one-profile example would quietly stop demonstrating that. It also moves the CB-523 auto-compact override onto the surviving profile. That guard had been attached to `ollama` alone, so retiring the profile would have removed the fleet's only protection against the failure it was written for — a worker whose prompt is rejected before auto-compact ever fires. The window belongs on every profile, not on whichever one happened to hit it. |
||
|
|
379e03f9d0 |
CB-308: fold in the adversarial review; bump wiki to 4320c1c
Four new resolved decisions (§7.7-7.10): turn state split by where the signals are, with ABANDONED explicitly belt-and-braces over the waiter timeout; the dual ack model with spawn idempotence by construction (gid stored IN the herdr pane — the load-bearing detail of the no-ledger position — plus an in-flight reservation for redelivery during a slow spawn); enforced publish semantics (confirms + mandatory on a separate channel, return-before-confirm caveat); queue lifecycle = session lifecycle with .v2 names for the redeclare hazard. §8 reworked: global id scheme resolved and moved up; control authorization sharpened into the hard gate on U4 (per-host allowlist beside the peer keys); key distribution/rotation added. New §9: implementation order, each step verifiable single-host, U4 gated, U8 last. Wiki pointer bumped to 4320c1c (chapter 10 same-pass changes). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ZGgxLQ2VpwZhEYoru8rkf |
||
|
|
e13921aa8a |
CB-308: resolve the cross-host design review; bump wiki to 36bb865
Six decisions recorded in §7, replacing the matching open questions: signed messages (identity from the key, extending identity-from-connection across the broker), target-host-owned profiles advertised via presence, repo provisioning by pinned forge clone, live-only asks with TTL + TOO_LATE notice, spawn-id dedup on the target, and broker-outage semantics (local unaffected, remote fails fast, gateway stays soft-state). §8 keeps what is genuinely still open, with control *authorization* now separated from the resolved *authenticity*. The broker-level half (U8 broadcast, exclusive consumers, inbox caps, TLS, schema version, trace id) lands in wiki chapter 10 §10 — pointer bumped (also picks up 1710a77, chapter 11 Features). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ZGgxLQ2VpwZhEYoru8rkf |
||
|
|
8a549d8610 |
Release 1.0.0 — one leader, one host, complete
Bump bridged to 1.0.0 and add the release notes: the single-leader, single-host scope is closed — gateway, lifecycle, two-way delivery, pluggable peers, auth/authz/audit, supervision, CI. Cross-host federation (CB-308) is the next major line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ZGgxLQ2VpwZhEYoru8rkfv1.0.0 |
||
|
|
84081b2bd8 |
CB-526: make a shipped capability undocumentable-by-accident
CLAUDE.md already carries a mandatory before-done checklist ("the prompt is part
of the product"). It covered the instruction surface but not the operator-facing
one, and the result was measurable: CB-506 through CB-525 shipped without a
single wiki mention, while the Roadmap went on claiming Stage 5 was finished.
Discipline is what already failed, so this rides the existing gate rather than
adding a new habit to remember: one more row, firing when a change touches
anything an operator can use, configure, or observe. The row names where the
other two kinds of change go too (contracts to Implementation, coverage to the
Roadmap), so "nothing to document" is a decision the table makes rather than a
default you fall into.
Addendum-only — the canonical block is untouched and still byte-identical to the
wiki template (verified).
|
||
|
|
defe3365c4 |
CB-519: re-point pane assertions at the protocol-19 coordinate
Rebase integration only, no behaviour change. CB-519's tests named the pane the pre-protocol-19 fake produced (w9:pW_n); upstream's herdr 0.8.0 port creates the pane through tab.create and starts the agent into it, so the fake now reports w9:pRoot_n. Five assertions were therefore counting closes of a pane that never existed and reading 0. mvn clean install: Tests run: 399, Failures: 0, Errors: 0 — BUILD SUCCESS |
||
|
|
4e6201ecd1 |
CB-525: isolate a worker's tool surface to what its launcher mounts
A worker in a provisioned worktree was inheriting the primary's MCP servers by two independent routes: the repo commits a .mcp.json declaring the IDE servers, so a fresh checkout mounts them, and the default parity overlay then copied the primary's own copy over the top. Those servers are bound to the primary's IntelliJ project, so every path they hand back points into the primary's checkout. A CB-523 worker made all 59 of its edits there while running `mvn -f bridged/pom.xml` against its worktree — every build it ran was of code that did not contain its changes, and it passed. The worker's own `ls` of the file it had "edited" returned "No such file". GitWorktrees now neutralizes .mcp.json at provisioning: an explicitly empty server map, --skip-worktree'd when tracked so it never reads as pending work a worker might commit. Unconditional, because the overlay was only half the leak. The bridge itself is unaffected — it reaches a worker through the launcher's --mcp-config flag, not the project file, so bridge_reply still works. - BridgedConfig: .mcp.json out of the default parity overlay - GitWorktrees: isolateToolSurface() on add(), with the rationale in javadoc - GitWorktreesTest: 4 real-git acceptance tests (2 fail if the call is removed) - implementer skill: work from $PWD, and quote a green unpiped `mvn clean install` from the worktree as the acceptance criterion mvn clean install: Tests run: 392, Failures: 0, Errors: 0 — BUILD SUCCESS |
||
|
|
ccf50f950e |
CB-524: make worker placement order deterministic across JVM runs
The weighted policy breaks an exact-weight tie on candidate list order (WeightedRoundRobinPolicy picks the first candidate with a strictly greater score), and that list comes from CompositePeerLauncher.candidates(), which iterates profileConfigs. Both that map and BridgedConfig.workerProfiles() were built with Map.copyOf, whose iteration order is salted per JVM run — so the "in definition order" contract candidates() documents was not held. Two consequences. In production, a config with equal weights (ollama 0.5 / gx10 0.5) placed its first worker on a profile chosen at random on every daemon restart. In the suite, CompositePeerLauncherTest.failoverRetriesNextCandidate- WhenProfileIsUnreachable failed roughly one run in four, because whether profile "a" was tried first depended on the salt. Preserve definition order at every layer: unmodifiable LinkedHashMap for workerProfiles(), profileConfigs, and byProfile (which also feeds the user-visible bridge_profiles listing). The tests build profile maps with an ordered helper rather than Map.of, which is salted for the same reason. Guarded by a pair of tests declaring the same two profiles in opposite order and asserting opposite first attempts, so any order-scrambling implementation must fail one of them. Verified by mutation: reverting profileConfigs to Map.copyOf fails 8/8 runs (6 caught by the original test, 2 only by the new reversed-order one); with the fix, 10/10 fresh JVMs pass, 388 tests green. |
||
|
|
a7f0211e2f | CB-518: weighted placement policy for worker spawns | ||
|
|
049ce4828c | CB-520: split ReplyInbox into explicit own/release and publish halves | ||
|
|
c1173346ef | CB-521: make the AMQP contract test runnable locally and in CI | ||
|
|
7ace184fe6 | CB-519: make PeerHandle.id() a host-unique opaque UUID, decoupled from the herdr pane id | ||
|
|
54b314ace5 |
CB-522: let the primary run inside a herdr pane
Caller identity resolved any loopback PID that mapped to a herdr pane as a WORKER, and PaneLocator scans every pane -- not just bridged-spawned ones. A primary running inside a herdr pane therefore classified itself as a worker and was refused SPAWN/SEND/STOP, i.e. every orchestration verb it exists to call. The failure is self-locking: PrimaryRegistry only learns the primary's terminal from bridge_send/bridge_spawn, the exact calls being refused, so the learned value can never bootstrap. Only an operator-set pin breaks the cycle. CallerResolver now consults primary.terminal from config *before* the pane lookup. Deliberately the pinned value only, never the learned one -- the learned terminal is populated by the callers this method is itself classifying, so trusting it would be circular. Config is operator input, never network input, so this widens no attack surface; bridge_whoami and the authz gate still share one resolution. Fixing that exposed a second, older bug. BridgeMcp's context extractor forwards the caller's terminal into markPresent on every MCP call, documented as "no-op for the primary (null terminal)". WorkerPresence.markPresent honours that, but PresenceBridge overrides it and forwards the same null into SessionManager. onReady -> transitionByTerminal -> findByTerminal, which called terminalId.equals(...) unguarded. It only reached the scan once the registry was non-empty, so the primary's first spawn succeeded and every later call NPE'd with an HTTP 500 -- and it would have fired for ANY primary not living in a herdr pane, pinned or not. findByTerminal is now total. That covers onReady, onDelivered, onTurnComplete and onTurnFailed at once; a null id could never match a registered session anyway, so "no match" is the honest answer rather than taking down an unrelated tool call. Also drops two dead pass-throughs on CallerResolver (cwdForPid, tokenMode) that IDE inspections flagged -- callers use ConnectionIdentity and BridgedConfig.Auth directly. The example config now states that primary.terminal is REQUIRED, not just a push-loop optimisation, when the primary shares a herdr pane. mvn clean install: 360 tests, 0 failures. Verified live: daemon restarted on this jar, bridge_whoami reports primary, and four concurrent worktree spawns -- the exact shape that NPE'd -- now all succeed. |
||
|
|
224b344445 |
CB-522: resolve the pinned primary.terminal pane as the primary, not a worker
CI / build (push) Successful in 2m54s
A primary running INSIDE a herdr pane was resolved as a worker by the pane-match rule and refused every orchestration tool — the exact lockout bridge_whoami surfaced on this deployment. The CB-307 primary.terminal pin always claimed to replace connection-derived identity but only fed the push loop; it now short-circuits CallerResolver ahead of the pane→worker rule (the pane mapping is as unforgeable as a worker's, so no credential needed, even in token mode). bridged.example.yaml documents the block. Also guard the presence bridge against the primary's null terminal: the MCP context extractor marks presence on every request, and the first genuine primary contact NPEd into the SPAWNING→READY transition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SUTLvxtRPr2iT5u5g45BEs |
||
|
|
0b28b4cb0f |
CB-521: port the herdr adapter to protocol 19 (herdr 0.8.0)
herdr 0.8.0 redesigned the agent API out from under the daemon: agent.start now launches a supported kind INTO an existing pane, env/cwd move to pane creation (tab.create / pane.split — the subscription-boundary seam now), agent.send is replaced by agent.prompt (self-submitting) plus agent.send_keys for the Enter nudge, and terminal ids are no longer valid agent.* targets. - AgentControl: start(name, kind, args, paneId); prompt/send_keys delivery; cached terminal→pane target translation (invalidated on agent_not_found). - WorkspaceControl: tab.create carries cwd+env; pane.split for legacy placement. - HerdrPeerLauncher: the seed pane IS the worker pane (no drop step); retry agent.start while the seed shell boots (agent_pane_busy). - FakeHerdr and the test suite model protocol 19 (unique seed panes, required kind/pane_id, prompt-based delivery); contract tests probe the seed shell instead of arbitrary-command agents, which protocol 19 removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SUTLvxtRPr2iT5u5g45BEs |
||
|
|
83129e165c |
Merge CB-518: state the primary's orchestration as an explicit, ordered flow
CI / build (push) Successful in 1m19s
Turns the primary's half of the bridge charter from a bullet list of policies into a numbered 0-8 procedure, and splits delegated review out of the merge step it used to sit beside. Wiki template kept byte-identical by splicing; pointer bumped to 0c896eb in the same commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Kw1FosEt3Noix5GG9wJ2r |
||
|
|
871b595954 |
CB-518: state the primary's orchestration as an explicit, ordered flow
CI / build (pull_request) Successful in 1m23s
CB-517 moved orchestration policy into CLAUDE.md but left it as a bullet list, so the procedure was implicit: the order of operations had to be reconstructed from a parallelisation bullet, and nothing said when to review or when to tear down. A policy you have to reassemble on each task is one you will reassemble differently each task. Restate the primary's half as a numbered 0-8 flow — role check, split, gate, spawn all, send all, collect, verify, review, adjudicate — so that following it is checkable against the tool calls rather than a matter of recall. Two steps carry the load. Spawn and send are separate on purpose: folding them into one loop is what silently serialises work that was meant to fan out. And review is now its own step ahead of the merge rather than a clause inside it, because the two have opposite owners — reviewers fan out over the diff (never the implementer of the scope they review, and briefed from the diff rather than the author's rationale, which carries the same blind spot), while adjudication, the merge and teardown stay with the primary. Merging on a reviewer's word is delegating the gate by proxy, so the step says so outright. Nothing is dropped. The six bullets that trailed the tool table are relocated into the step that owns each — profile explicitness into spawn, playbook naming and self-containment into send, claim verification into its own step, the ~60s blocking-send cap into a note beneath the flow — and the table stays as the intent→tool lookup. The wiki pointer moves with it. The block is canonical only if its template matches byte for byte, so the template was produced by splicing the block out of CLAUDE.md rather than by editing it in parallel, and the sync check the repo documents passes. Bumping the pointer in the same commit keeps charter and template versioned together, as CB-517 did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Kw1FosEt3Noix5GG9wJ2r |
||
|
|
b67b1585c2 |
CB-517: deploy LavinMQ as a pinned, durable, self-restarting broker
CI / build (push) Successful in 1m23s
The CB-307 durable ReplyInbox needs an AMQP broker, but the one behind it
was run ad hoc and had simply vanished from the host — which takes the
whole daemon with it, since AmqpReplyInbox.open throws and Bridged.java:187
does not guard it. A missing broker is a hard startup failure, not a
degraded mode, so 'how the broker runs' is part of the system, not a local
detail.
Pinned to 2.9.1 (:latest would move the broker under a running daemon),
data on a named volume (held-but-unacked replies are the entire point of
Stage 2 — a plain 'compose down' would discard exactly what durability
protects), and restart: unless-stopped so it comes back after a reboot
instead of disappearing again.
Ports are bound to 127.0.0.1 deliberately: LavinMQ ships a default
guest/guest account, which is only acceptable while nothing off-host can
reach it.
Verified by driving the production AmqpReplyInbox against this deployment
(publish/peek/dedup/FIFO/ack, then reconnect): 8/8 including redelivery of
the unacked message. That pairing had never been exercised — the
@Tag("contract") test runs against a RabbitMQ container, and is excluded
from the default build, so mvn clean install covers the broker path zero
times.
|
||
|
|
979b2b5632 |
CB-517: add bridge_whoami and make the bridge prompt a portable charter
CI / build (push) Successful in 1m25s
The communication rules lived only in two opt-in skills, so nothing always-on told the primary how to orchestrate and nothing guaranteed a worker loaded its playbook. Move protocol and policy into CLAUDE.md, which a worker inherits for free (its worktree is a checkout of this repo), and leave the skills as pure per-job procedure. bridge_whoami closes the load-bearing gap: every tool already consumed the caller identity ConnectionIdentity resolves from the connection, but none reported it, so an agent had to infer its own role from side channels the daemon does not control. Guessing fails asymmetrically — a primary acting as a worker is refused by the authz gate and learns at once, while a worker acting as the primary ends its turn without bridge_reply and the sender silently receives nothing. The tool reuses the same Principal the gate is built on, so the two cannot disagree; the primary gets role only (handing it a sessionId it does not own would invite the forged reply Authz refuses), and a worker missing from the registry still gets role + sessionId rather than 'unknown'. The CLAUDE.md block is written to be copied as-is into any project that mounts the bridge: repo-local details (Authz paths, the .mcp.json/wiki exclusions, the skill names) moved below it into a project addendum, and every role-inference fallback is stated one-way — the mount-name signal only holds for mcp__bridge__* (the launcher fixes it), not for the primary's mount, which each project names itself. The wiki carries the block verbatim as the template, with a sync check. Because this repo IS the bridge, that block is shipped surface, not documentation: the addendum adds a mandatory checklist mapping each part of the code to the part of the prompt it can invalidate. Also: delegate-by-default policy for the primary — the test is not 'could I do this faster myself' but 'can I write a brief good enough for a worker'. mvn clean install: 356 tests green (353 + 3 for whoami); ide_diagnostics clean on both changed files. |
||
|
|
cf4ad186ab |
CB-516: fail a delegation when its worker session is released
CI / build (push) Successful in 1m30s
Tearing a worker down left the send that was waiting on it stranded. The
rendezvous waiter stayed open, so a blocking bridge_send kept blocking and an
async one kept reporting PENDING until ASYNC_TIMEOUT_MS — thirty minutes —
even though the worker provably no longer existed and the delegation could
never complete.
Observed repeatedly this session: a task sitting at
{"phase":"pending","detail":"worker unknown"} long after I had personally
deleted the pane. The maddening part is that poll() already HAD the evidence —
it calls liveStatus() to build that detail string, gets back "unknown", and
reports PENDING anyway. It also never reached /metrics under any outcome, so a
stalled delegation was invisible to both the task view and the dashboard. The
only way I ever diagnosed one was reading the worker's pane over the herdr
socket by hand.
Fixed at the choke point rather than by guessing from status strings:
SessionManager.release() is the single path every teardown funnels through
(REST stop, MCP stop, idle-TTL reaper, recycle, shutdown drain), so it now
notifies a release listener with the terminalId, and Bridged.main wires that to
MessageService.abandon(). Abandon fails the open waiter with a real reason.
Deliberately NOT done by inferring "gone" from liveStatus(): that method
collapses a vanished pane, a wedged worker and a herdr hiccup into the same
"unknown" string, so acting on it would fail live delegations during a
transient blip. An explicit lifecycle signal cannot be ambiguous.
Notified before launcher.stop() so a blocked caller fails fast, and wrapped so
a listener failure can never prevent the teardown it is reacting to.
Resolving as a failure rather than letting it time out also means the outcome
is counted — a torn-down delegation now shows up as
sends_total{outcome="failed"} instead of nothing at all.
353 tests (was 346): abandon fails a waiting send / is a no-op with no waiter /
never clobbers a send the worker already answered, an abandoned async task
polls FAILED rather than PENDING, release notifies with the right terminal,
releasing an unknown pane notifies nobody, and a throwing listener does not
block teardown.
Verified live on the running daemon, reproducing the original scenario:
async send -> {"phase":"pending","detail":"worker working"}
DELETE the worker -> 204
poll -> {"phase":"failed","detail":"the worker session was
released before it replied"}
/metrics -> bridged_sends_total{outcome="failed"} 1
Previously that poll returned PENDING for thirty minutes and the counter stayed
empty.
NOT addressed here, and worth deciding separately: a worker that simply never
replies (rather than being released) still rides out the full 30-minute
ASYNC_TIMEOUT_MS. That is a policy question about long-running delegations, not
a correctness bug.
|
||
|
|
5100f215cf |
CB-515: regression-protect the turn-attribution guards
CI / build (push) Successful in 1m33s
Five tests pinning the invariants that decide WHICH turn a reply belongs to. These protect against a silent correctness bug — a reply attributed to the wrong turn — not against a crash, which is why they were worth picking over higher-percentage coverage gaps. Chosen by blast radius, not by uncovered-line count. Both guards are compound conditions with a side that never executed, i.e. exactly the shape where a clause can be deleted as "redundant" and every existing test still passes. CompletionResolver: - The CB-115 misattribution guard suppresses a completion when the scrape is byte-identical to the pane at delivery. Its !scrapeFailed clause was unexercised: delete it and a FAILED read is misread as "no output change", so the send is suppressed and hangs to the caller's timeout instead of resolving. The new test sets the baseline to "" so the empty tail from a failed read would byte-match and wrongly suppress — built to die precisely when that clause dies. - The fail() guard leaves an already-resolved waiter alone. The new test also asserts agent.read is never called, so the worker is not scraped for a send nobody is waiting on. Rendezvous: a second resolution of an already-completed waiter returns false and does not overwrite the first value, for both resolveCompletion and resolveFailure. Verified by sabotage, one guard at a time: removing !scrapeFailed reds resolvesWhenTheScrapeItselfFailsEvenWithABaselinePresent; removing the isDone() clause reds failLeavesAnAlreadyResolvedWaiterUntouchedAndSkipsTheScrape. (The first attempt at the second sabotage reported a false pass — the patch hit an identically-worded guard earlier in the file. Line-targeted and re-run.) 346 tests, was 341. Worker-implemented on the local-vLLM profile; it noticed three of the eight cases I asked for already existed and said so with names rather than duplicating them. Also of note: the first delegation of this ticket wedged the worker — the pane showed a zsh parse error and it went idle with an untouched worktree, task stuck pending. The retry differed only in phrasing the same requirements as prose instead of quoting Java boolean expressions. Filed as a bridge robustness concern: injected content shares a channel with control, and a wedged turn is invisible in both the task view and /metrics. |
||
|
|
f129e9b7cd |
Merge CB-514: MessageService coverage for timeout, answer, poll and lock edges
CI / build (push) Successful in 1m29s
Six tests on the delivery core, the most load-bearing class in the project, which sat at 74% with its hardest paths unexercised: answer() (the CB-205 ask-answer resolution) had 11 of 23 lines uncovered, send()'s timeout branches 7 of 21, poll() 7 of 18, and tryLock 3 of 4. Covered: TIMED_OUT_QUEUED vs TIMED_OUT_WORKING (undelivered vs delivered-but- silent), an answered worker that never sends its follow-up reply, an unknown ticket, a completed async ticket reporting its reply and replySource, and a second concurrent send to the same session returning BUSY rather than hanging. Worker-implemented on the local-vLLM profile, self-verified: it reported 'Tests run: 341, Failures: 0' and an independent run of its branch agrees exactly. Additive only — 101 lines in one test file, no main/ source touched. Quality is good on its own terms, not just green: the async-completion test polls to a deadline instead of sleeping and hoping, the contention test releases the blocked send so the test thread is not left pinned, and every assertion is on a specific Outcome rather than 'nothing threw' — the failure mode an earlier worker produced in CB-510. Branch pushed by the worker; PR left to the primary since GITEA_TOKEN is not granted to that profile by design. |
||
|
|
4aed45de19 | CB-514: add MessageService coverage for timeout, answer, poll, and lock edges | ||
|
|
1e6daa5c73 |
CB-513: test the MCP-side authorization gate (BridgeMcp 27.4% -> 57.4%)
CI / build (push) Successful in 1m13s
CB-505 claimed authorization is "enforced on both entry paths". It is — but only REST was ever tested. Coverage showed BridgeMcp.deny(), principal(), callerTerminal(), worktreeRequest() and every tool-registration lambda at ZERO executed lines: no test had ever constructed a BridgeMcp, because the existing BridgeMcpTest calls only the static handler methods. So the MCP half of the security control had ten REST tests' worth of nothing behind it. An unexercised security control is a claim, not a control. Made testable by separating policy from plumbing rather than by reaching for a mocking library the project does not use: - denyFor(Principal, Action, target) is the decision — testable directly. - deny(exchange, ...) shrinks to pulling the caller out of the SDK exchange. - principalFrom(role, terminal, pid) extracts identity reconstruction from McpSyncServerExchange, an SDK type with no fake available. Moved the `authz == null` enforcement switch OUT of the exchange-facing wrapper and INTO denyFor. Found by a failing test: as written, any future tool calling denyFor directly would have silently skipped the gate. The switch now lives with the decision it governs. New BridgeMcpAuthzTest constructs a real BridgeMcp — which is why coverage moved so far, since that also runs the constructor and all the tool wiring — and pins the table on this path: primary orchestrates, worker cannot; worker replies only as itself; the primary cannot forge a worker reply; anonymous gets nothing; and 401-shaped vs 403-shaped refusals are counted apart. Verified as real controls, not decoration: with the gate forced open, 5 of the 9 fail. 335 tests (was 326). |
||
|
|
4c015d76b7 |
Merge CB-512: wire bridged_push_nudges_total (worker-implemented, self-verified)
CI / build (push) Successful in 1m30s
Fixes one of the three counters declared in BridgedMetrics but never
incremented, so bridged_push_nudges_total{outcome=delivered|exhausted} now
actually appears on /metrics. An absent series reads as 'no push failures ever'
rather than 'not measured', which is the misleading case.
Implemented end to end by an opencode worker on the local-vLLM profile in an
isolated worktree, and this is the first delegation where the worker verified
its own work: it ran mvn, hit a real test failure, iterated, and reported
'Tests run: 326, Failures: 0' — which matches an independent run of its branch
exactly. Every earlier delegation reported results it had no way to check,
because CB-511 had not yet given workers a PATH with a toolchain.
It also committed and pushed its own branch unprompted, and was straight about
the one thing it could not do: opening the PR, since GITEA_HOST/GITEA_TOKEN are
not granted to that profile ('URL rejected: No host part'). That grant is opt-in
per profile by design, so the merge is primary-side as intended.
Diff reviewed and correct on every constraint, including the subtle ones:
delivered counted inside the try after a successful send (not the catch),
exhausted only on the reminder-cap branch and not the other two STOP paths, and
a single Metrics instance moved above pushLoop and shared with MessageService.
|
||
|
|
37a11cd168 | CB-512: wire bridged_push_nudges_total metric increments | ||
|
|
22ad24db6c |
CB-511: give workers a toolchain — propagate the daemon PATH, add profile env:
CI / build (push) Successful in 1m17s
Workers could not run `mvn` or `java`. Every delegated task that asked for a build came back "mvn is not on PATH", and the worker was right. Root cause: HerdrPeerLauncher seeded the worker environment with an EMPTY map, so bridged passed only the vars it explicitly set (OPENCODE_CONFIG, GITEA_TOKEN, ANTHROPIC_*) and never PATH. herdr merges that map into its own process env, so a worker inherited whatever PATH the herdr SERVER was started with. On this host that server (pid 79870, PPID 1) had been up since Jul 4 with a PATH containing neither the JDK nor Maven. Confirmed on a live worker: its PATH was byte-identical to herdr's, and the only var bridged had contributed was OPENCODE_CONFIG. The failure was invisible and non-deterministic: the fleet's capabilities depended on how a long-lived daemon happened to be launched weeks earlier. There are three herdr processes on this box with three different PATHs; the one owning the socket is the one without a toolchain. bridged itself HAD Maven on PATH the whole time — it just never passed it on. It also quietly contradicted the project's own principle that "a worker is a full peer of the primary", and the implementer skill's instruction to build, commit and open a PR. Every delegation so far has depended on the primary running the build gate. Fix: baseEnv(cfg) seeds each worker with the daemon's own PATH, then applies the profile's new optional env: map. Adapter-specific vars are layered on top and therefore win — that ordering is load-bearing, not incidental: it stops an env: entry from overwriting ANTHROPIC_BASE_URL and slipping past SubscriptionGuard, which is checked against the profile's baseUrl alone. Pinned by a test. Because the default is now the daemon's PATH, both supervision units set PATH explicitly — launchd and systemd do not source a login shell, so under CB-504 the daemon (and every worker) would otherwise get a bare /usr/bin:/bin and this bug would silently return in production. 324 tests (was 321): daemon-PATH propagation, profile env: passthrough including an explicit PATH override, and the guard-bypass ordering. Verified live: daemon restarted, worker spawned, and asked to run the tools — "Apache Maven 3.9.16", "java version 25.0.2". Previously both were absent. |
||
|
|
e94c1b8841 |
CB-510: SessionReaper wrapper tests (0% -> 86.7%)
SessionReaper had no tests at all. Its TTL *policy* was already well covered (SessionManager.reapIdle, 6 cases in SessionManagerTest); what was untested was the thread wrapper around it — idempotent start/stop and whether the loop actually runs and actually stops. Observed through an injected clock rather than by sleeping and hoping: reapIdle reads nowNanos exactly once per call, so the tick count IS the iteration count. Waits are bounded polls, not fixed sleeps, and nothing asserts an exact timing-derived number — flaky counts would be worse than no test. 321 tests (was 318); line coverage 66.9% -> 67.9%. Drafted by an opencode worker on the new local-vLLM profile (branch worker/cb-510-session-reaper-test-cd1793-1). Its structure and setup were good and it was honest that it could not run mvn. But its third test asserted NOTHING — it started the reaper, slept, stopped it, and relied on "no throw", with a comment claiming that proved the loop had run. It did not: verified by sabotage, all three of its tests passed against a start() replaced with an immediate return. Rewritten so the assertions can fail for the right reason. Same sabotage now fails 2 of 3 (the third only pins stop()-before-start(), where "does not throw" genuinely is the contract). Uncomfortably on the nose given this task began as a hunt for tests that do not mean anything. |
||
|
|
cc0ec65714 |
CB-509: add JaCoCo coverage reporting
Build-time tooling only — never a compile or runtime dependency, so it adds nothing to the shipped jar and no new transitive surface to the artifact. (Noting per CLAUDE.md that the pom CVE gate could not be run: no JetBrains MCP server is connected this session.) Report at target/site/jacoco/index.html, machine-readable at jacoco.csv. Deliberately NO check rule or threshold. A coverage gate rewards writing tests that merely execute lines, which is the exact failure mode this codebase has already been bitten by — CB-507 shipped a null-argument NPE with 311 green tests because FakeWorktrees.repoRoot records its argument instead of shelling out, so the broken line was covered and still wrong. Coverage is a map of where to look, not a target to hit. Baseline: 66.9% line, 60.6% branch, 76.4% method. |
||
|
|
d67d30c58a |
CB-508: let an opencode profile pin its own OpenAI-compatible endpoint
CI / build (push) Successful in 1m57s
Points an opencode worker at a local vLLM (or llama.cpp / LM Studio / TGI) instead of opencode's own gateway. opencode has no ANTHROPIC_BASE_URL seam, so this could not be a config-only change: setting baseUrl on a kind: opencode profile now makes the launcher emit a custom `provider` block into the generated opencode.json, using @ai-sdk/openai-compatible. The provider id comes from the provider half of the model: selector, so one field drives both the generated declaration and the -m flag and the two cannot drift apart. A bare model name with a baseUrl set is rejected at spawn with a message saying how to fix it — silently falling back to the default gateway would leave a worker talking to the wrong LLM while looking perfectly healthy. A bare host:port gets /v1 appended (where these servers mount the API); a URL that already carries a path is used verbatim. tokenEnv, when set, becomes the provider apiKey; local servers generally ignore it but the AI SDK requires a non-empty value, so a placeholder is used otherwise. Two supporting changes: - writeConfig previously ran only when a bridge MCP url was set. A pinned endpoint needs the config file too, so it now runs when either applies, and the mcp/instructions half is emitted conditionally. - The config is now built with Jackson instead of string concatenation. The provider block is nested and interpolates operator-supplied values (URL, model id, api key), so escaping has to be real rather than a hand-rolled two-character replace. No guard entry is required even with baseUrl set. SubscriptionGuard exists to stop a worker borrowing the primary's Anthropic subscription, and an opencode process has no Anthropic credential path at all — the asymmetry with the Claude adapter reusing the same field is deliberate and documented at the call site. Also fixes a brittle assertion in the existing MCP-mount test, which matched the substring "\"type\": \"remote\"" and broke on Jackson's spacing. It now parses the generated JSON and asserts on structure; whitespace is the formatter's business, not the contract's. 318 tests (was 311): 5 new covering provider generation, /v1 normalisation, path-preserving URLs, the missing-prefix rejection, MCP+provider coexistence, and that no baseUrl still means no provider block. Verified live end to end: daemon restarted on this build, worker spawned on the opencode-local profile, generated config carries baseURL http://127.0.0.1:8000/v1, and a blocking bridge_send returned {"reply":"LOCAL-OK","replySource":"reply"} — a structured reply, not the completion fallback. The worker pane reports "Build · deepseek-v4-flash local-vllm (bridged)", confirming traffic reached the local server rather than silently falling back. |
||
|
|
d75ee1cca5 |
CB-507: regression tests for worktree cwd resolution
Two cases in WorktreeSessionManagerTest, covering the gap that let the NPE ship (313 tests, was 311). 1. worktreeAcquireWithNoRequestedOrCallerCwdStillResolvesANonNullRepoRoot — the null/null case a plain REST spawn produces. 2. worktreeAcquireHonoursTheProfileConfiguredCwd — the quieter second bug on the same line, where a pinned per-profile cwd: was ignored entirely. Both assert on the cwd RECORDED by FakeWorktrees rather than expecting a throw. That is deliberate: FakeWorktrees.repoRoot only records its argument and returns a canned root, so a null passes through the fake harmlessly while the real GitWorktrees runs `git -C null` and NPEs. The fake being more permissive than the real seam is exactly why 311 tests stayed green over a broken feature — asserting "an exception was raised" would be untestable here and would give false confidence. Verified as genuine regressions, not tautologies: with the pre-CB-507 expression restored both fail, with the messages they were written to give (expected: not <null>, and expected </pinned/dir> but was <null>). Restored after. Drafted by an opencode-free worker over the bridge in an isolated worktree (branch worker/cb-507-regression-test-11591f-4). Its test 1 was correct as written. Test 2 was wrong and went red: it passed "/pinned/dir" as the 4th constructor argument, which is configDir, not cwd (the 11th, after mcpUrl), so cwd stayed null and the chain fell through to the daemon cwd. Corrected on integration, along with removing two unused locals and adding the rationale comments. |
||
|
|
6804676a96 |
CB-507: fix NPE on a worktree spawn with no cwd (HTTP 500 over REST)
POST /workers?worktree=true returned HTTP 500 with a NullPointerException out of ProcessBuilder.start(): acquireWithWorktree resolved the repo root from firstNonBlank(requestedCwd, callerCwd), and a plain REST spawn supplies neither (BridgedApp hardcodes callerCwd=null, "no MCP caller over REST"). Both null yielded null, putting `git -C null rev-parse --show-toplevel` on the command line. Now resolved through launcher.effectiveCwd, the CB-112 chain used everywhere else (requested -> profile cwd -> caller -> daemon cwd -> "."), which is documented never to return null. The non-worktree path in this same class already went through it; only the worktree branch was missed. Also fixes a second latent bug in the same line: firstNonBlank never consulted the profile's configured cwd:, so a worktree spawn silently ignored a pinned per-profile working directory. effectiveCwd honours it. Removes firstNonBlank, now dead (this was its only call site) — javac ignores an unused private method but IDE inspections flag it, and CLAUDE.md requires a clean bill. Why 311 tests missed it: the null/null case only arises over REST, and WorktreeSessionManagerTest always passes an explicit cwd. Over MCP callerCwd is populated from the caller PID, so the feature worked there. This is the third REST-vs-MCP divergence found this month, after CB-505's path-trusted session id. The one-line change was implemented by an opencode-free worker over the bridge in an isolated worktree (branch worker/cb-507-worktree-cwd-npe-3e9c3b-3); the dead-helper cleanup and the explanatory comment were added on integration. A regression test is still outstanding and is being delegated separately. |
||
|
|
3e5d742ac7 |
CB-506: keep the test suite out of the production audit log
main/resources/logback.xml routes the `audit` logger to a RollingFileAppender at logs/audit.log — the CB-505 security trail. AuditLogTest and BridgedAppAuthTest exercise that same logger, so every `mvn test` appended fabricated records to the production file. They are byte-identical to genuine ones: runs of denied/forbidden SPAWN/STOP/SEND from worker:term_a, which read exactly like an intrusion attempt. logs/audit.2026-07-29.0.log is 38 fabricated records out of 76 — half that day's security log is test fixtures, and nothing distinguishes them. Fix is one new file, src/test/resources/logback-test.xml: logback prefers it on the test classpath, so tests get a console-only config with no file appender and main/resources/logback.xml is untouched. The `audit` logger stays ENABLED (INFO, additivity=false) because AuditLogTest attaches its own ListAppender and asserts on emitted records — setting it OFF would have silently gutted those assertions. Verified: 311 tests green, and logs/audit.log line count is identical before and after a full `mvn clean install` (zero new records). Implemented by an opencode-free worker over the bridge in an isolated worktree (branch worker/cb-506-audit-test-isolation-e4aa9c-2); it correctly reported it could not run mvn rather than fabricating a result, so the build gate and the before/after audit-count check were run primary-side. Header comment added on integration. |
||
|
|
6da2a71050 |
CI: drop upload-artifact — unsupported on this Gitea instance
CI / build (push) Successful in 1m44s
Run 2 built clean (311 tests, BUILD SUCCESS, Maven 3.6.3 on Java 25.0.4 — JAVA_HOME from setup-java correctly beat the JRE apt pulled in) but the job still went red on the artifact step: GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not currently supported on GHES. Gitea Actions presents as GHES, so v4 artifact upload cannot work here. The artifact was unretrievable regardless, so replace it with a failure-only step that cats the failing surefire .txt reports into the job log, where they are readable. Guarded with 'exit 0' so the dump itself can never mask the real failure. |
||
|
|
e32ac39faf |
CI: install Maven — setup-java provides the JDK only
CI / build (push) Failing after 2m1s
First CI run failed at 'Build and test' with exit code 127 (command not found): actions/setup-java@v4 provisions a JDK but not Maven, and the runner image has no mvn on PATH. The sibling lms/alms workflow apt-installs both; this workflow switched to setup-java for JDK 25 (the image's default-jdk is too old for maven.compiler.release=25) and dropped the maven install with it. Adds an explicit Maven install with Apache Maven 3.9.16 (2bdd9fddda4b155ebf8000e807eb73fd829a51d5) Maven home: /Users/appbuilder/Tool/apache-maven-3.9.16 Java version: 25.0.2, vendor: Oracle Corporation, runtime: /Users/appbuilder/Tool/jdk-25.0.2.jdk/Contents/Home Default locale: en_VN, platform encoding: UTF-8 OS name: "mac os x", version: "26.5.1", arch: "aarch64", family: "mac" so the log proves which JDK it resolved — JAVA_HOME from setup-java must win over the JRE apt drags in. |
||
|
|
daa243d37a |
example config: opencode profile uses the verified zero-credential free tier
CI / build (push) Failing after 2m21s
The commented CB-402 block suggested google/gemini-2.5-pro, which needs credentials. Replaced with the opencode/*-free gateway models proven during the dogfood to work with no auth at all, and noted that the free model names change so `opencode models` is the source of truth. |
||
|
|
2773ab600d |
CB-402: live dogfood complete — Stage B verified against opencode 1.18.5
Closes the one known-unverified item before cross-host. CB-402 merged in
|
||
|
|
19cdf8dc9f |
CB-505 fix: audit lines were not valid JSON
The first cut spliced the timestamp on via a logback pattern:
{"ts":"%d{...}",%replace(%msg){'^\{',''}%n
Logback's variable substitution chokes on the literal braces
("All tokens consumed but was expecting }"), so the encoder failed to
configure. Caught by running the real jar and noticing logback had dumped its
internal status — which it only does when something failed to parse. The build
was green throughout: nothing asserted the audit trail was machine-readable.
AuditLog now emits the complete object including its own ISO-8601 "ts", and the
appender pattern is a bare %msg. Adds AuditLogTest, which parses each emitted
line with Jackson (so a malformed record fails the build) and pins that hostile
ids cannot escape their field to forge a second record.
311 tests green; logback now configures with zero internal errors.
|
||
|
|
9daf1ec5ba |
CB-5xx: Stage 5 hardening — auth, authz+audit, metrics, CI, supervision
Closes out single-host before the cross-host work. Sequenced BEFORE CB-308 deliberately: federation's own gating concern is the trust model, and it inherits whatever identity shape lands here. The finding this stage is built around: bridged had exactly ONE security control, the loopback bind. ConnectionIdentity resolves a worker from its connection (unforgeable), but every caller that was not a recognised worker pane fell through to being treated as the PRIMARY -- the most privileged role on the bus. Latent today; load-bearing the moment a bind widens. CB-501 auth: - Role/Principal/CallerResolver: connection identity first, bearer token second, ANONYMOUS third. Inverts the old default so absence of identity means nothing, not everything. - Worker identity is never token-gated, so enabling auth cannot lock the fleet out of bridge_reply. - Constant-time token compare (MessageDigest.isEqual). - validateAuthExposure(): a non-loopback bind under loopback-trust now REFUSES TO START. Makes the dangerous config unrepresentable rather than merely documented. - TLS terminates at a reverse proxy by design (D3), not in the JVM. CB-505 authz + audit, enforced on BOTH entry paths: - The docs describe MCP as "a thin adapter over the REST core"; at code level it is not. BridgeMcp calls MessageService directly, and /mcp is a raw servlet on Jetty's context handler that never traverses Javalin's before filter. Enforcing only at REST would have left /mcp open. - Load-bearing rule is own-session-only: a worker may reply/ask only as itself. Structurally true over MCP already; over REST the session id in the URL path had simply been trusted. - Audit: JSON lines to a dedicated appender, additivity=false. Never records message content -- this bus carries source and prompts. CB-502 metrics: zero new dependencies. A ~150-line Prometheus text renderer instead of the specced Micrometer, because this pom already hand-pins jackson-annotations to reconcile Jackson 2/3, imports a Jetty BOM against skew, and carries four accepted-CVE advisories -- and CLAUDE.md's mandated dependency CVE gate could not be run (no JetBrains MCP server connected). Instrumented at MessageService, the single funnel both surfaces share. CB-503 CI: .gitea/workflows/ci.yml against the already-running Gitea runner. Needs no contract-exclusion flag -- the pom's default-excludes profile already sets excludedGroups=contract, so plain `mvn clean install` IS the mock-socket surface. Provisions JDK 25 explicitly (runner default-jdk is older). CB-504 supervision: launchd agent (the real target -- this host is macOS, there is no systemd) plus a systemd unit for the Linux gateways CB-308 adds. Ordering directives are advisory, so the actual fix is that startup now waits up to 30s for the herdr socket and then serves degraded, instead of crashing into a restart loop on a boot-order race. Also fixes drift found while surveying: - bridged.example.yaml documented spawn_ready_timeout_ms in snake_case; config binds via plain Jackson with ignoreUnknown, so uncommenting it would have been silently dropped and the default kept. Now camelCase, with a test that loads the shipped example and one that pins every documented knob's spelling -- no test had ever loaded that file. - Added the 6 shipped-but-undocumented knobs (worktreeRoot, parityOverlay, gitTokenEnv, gitHostEnv, configDir, primary:). - README "Next" listed bridge_ask and session lifecycle as upcoming; both shipped long ago. - docs/CB-301-ext and docs/CB-402 status headers said "design"/"pre- implementation" for work already merged. 307 unit/acceptance tests green (was 266), mvn clean install BUILD SUCCESS. Note: CLAUDE.md's per-file ide_diagnostics gate and the pom Mend.io CVE check could not be run -- no JetBrains/intellij-index MCP server is connected this session. mvn clean install is the only gate that ran. |
||
|
|
c9f0ca9359 | wiki: bump submodule pointer to b646be1 (chapter 10 — Cross-Host Messaging & Broker Topology) | ||
|
|
84c8a2d2f0 |
CB-500 §11: resolve distributed-sandbox topology (gateway-per-host × local sandboxes)
Clarifies the open fork from §4/§9: Development A (sandbox launcher) and CB-308 (per-host federation) COMPOSE — each host runs a bridged gateway whose launcher spawns agents into that host's LOCAL sandboxes; the broker moves messages + presence, never keystrokes. The forcing fact: delivery is herdr keystroke-injection into a locally-owned PTY, so "a sandboxed agent on another host" ≡ "a sandbox spawned by that host's gateway" (a remote container with no local herdr can't be injected into). Rules out a central daemon reaching remote PTYs. Adds Figure 11 (composed topology) + Figure 12 (remote-delegation sequence: the local?inject:publish fork with a sandboxed far side — both injection points stay local, only the middle hop crosses the broker), the two forced reachability changes (host-routable mcpUrl; PTY in the local gateway's herdr), and a maps-to-existing-seams table (CB-308 gateway × Dev-A launcher, CB-117 reap, CB-303 container lifecycle, CB-308 #5 trust). No new pillars. Both diagrams mmdc-validated; §4/§9 updated to point at §11. |
||
|
|
ded226abfe |
Merge CB-402: opencode second peer adapter (Stage B of the Peer Launcher SPI)
Proves the CB-401 PeerLauncher SPI is genuinely provider-neutral by landing a second adapter — opencode — that shares NONE of Claude Code's private launch seams (no ANTHROPIC_BASE_URL, no SubscriptionGuard). Merged as one unit: - Incr 2 ( |
||
|
|
9b8d55bc18 |
CB-500: design note — multi-tier coordination (Stage 6)
Scopes the lead's next direction on top of the peer-launcher arc, as a proposal (ticket split deferred): - A · sandboxed, role-specific workers — a sandbox is a *placement*, so it slots into the CB-401 SPI as a new kind: sandbox adapter exactly the way CB-402's opencode slotted in as a new provider (SPI proven placement-neutral, not just provider-neutral). Ownership line held: bridge launches INTO a peer-owned image, never provisions the IDE/dev-tools inside it. - B · main-agent pairs (Opus + cloud) — both mains are MCP clients so neither can be called into; each needs a pull inbox → depends on CB-308's per-agent channels. PrimaryRegistry single-slot → multi-slot. - C · orchestrator tier — SessionManager recursed one tier up (orchestrator:mains :: main:workers) + context scoping; re-roots the human from a live primary to the orchestrator. 10 mermaid diagrams (component + sequence per development, ownership guardrail, staging graph), all mmdc-validated and theme-safe. §7 pins the bus-vs-env-manager boundary as an acceptance criterion on A; §8 stages A → CB-308 substrate → B → C. |
||
|
|
11f8709286 |
CB-402 Increment 4: CompositePeerLauncher — route the fleet by kind
Introduce the router the core holds when more than one adapter is configured: one HerdrPeerLauncher per peer kind, dispatched by profile (spawn/effectiveCwd/parityOverlay), by pane id (stop, via a spawn-time owner map), and fanned out + combined for the fleet-wide queries (list dedup by pane id, reap/caps union, profiles union). The ctor rejects an empty adapter list and a profile two adapters both claim. Wire it in Bridged.main: partition workerProfiles() by kind (claude-code is the always-present default adapter; opencode is added when any profile opts in) and front both with the composite. This lets BridgeMcp and BridgedApp finally take the PeerLauncher SPI instead of a concrete ClaudeCodeLauncher — the two (ClaudeCodeLauncher) casts in Bridged are gone. list() elements are cast to herdr Agent at the point of the herdr-specific roster view, where that assumption actually lives. Add BridgedConfig.Worker.isClaudeCode()/isOpenCode() kind predicates (the wiring uses isOpenCode; both are unit-tested). Drop the long-dead 'rendezvous' constructor param threaded into BridgeMcp and BridgedApp. 10 CompositePeerLauncherTest cases over two real adapters on one FakeHerdr: profile routing (observed via the started agent's claude-/opencode- name prefix), default resolution, unknown-profile and duplicate-profile rejection, caps union, list dedup, reap sum, and stop teardown. 266 tests green. |
||
|
|
b034f105c0 |
CB-402 Increment 3: OpenCodeLauncher — the SPI-proving second adapter
A HerdrPeerLauncher subclass for opencode, a provider-agnostic terminal
coding agent. It reuses every line of shared base transport (tab/pane
placement, CB-306 readiness gate, unique naming + CB-117 reap, teardown,
listing, cwd) and diverges only in buildLaunch:
- No subscription boundary: no ANTHROPIC_BASE_URL, no SubscriptionGuard
(the guard is a Claude-private concern, not part of the SPI).
- File-based MCP mount + instructions: writes an ephemeral opencode.json
declaring the bridge as a remote MCP server + a reply-charter file under
instructions, pointed at via OPENCODE_CONFIG (opencode has no inline
--mcp-config / --append-system-prompt).
- Model selected with -m provider/model, not an env var.
- 'opencode' name prefix so reap matches opencode-* panes only.
configRoot is injectable so tests inspect the generated config/charter under
a @TempDir. 10 tests cover config content, model flag, git-token grant,
capabilities, reap predicate, both production ctors, and the readiness gate
(throws PeerUnreachable on timeout, reaps only the worker pane).
|
||
|
|
6e37722383 |
CB-402 Increment 2: kind: discriminator on Worker profiles
Add a `kind` field to BridgedConfig.Worker — "claude-code" (default) or "opencode" — the discriminator the CompositePeerLauncher will route spawn/reap by so each adapter drives only its own peer kind. Normalised to lower-case; blank/absent ⇒ claude-code, so every existing config and call site is unchanged. argv now defaults to the kind's own binary (claude vs opencode) rather than always `claude`, so an opencode profile never inherits the Claude command. kind is appended at the record tail; a new 14-arg back-compat constructor (git fields, no kind) keeps the CB-302 call sites working, and the existing 12-arg constructor is untouched. Also drop the never-used Primary(String) legacy constructor to keep the file warning-clean. example.yaml documents the key and carries a commented opencode-gemini profile. Tests cover default/normalisation/argv-defaulting. 245 tests green, config files 0 IDE problems. |
||
|
|
ffce30afa2 |
CB-402 Increment 1: extract HerdrPeerLauncher abstract base
Behaviour-preserving refactor ahead of the second-adapter work. All herdr transport shared by any peer kind — tab/pane placement, the CB-306 spawn-readiness gate, unique naming + CB-117 orphan reap, teardown, listing, cwd resolution, and the peer-neutral git-forge grant — moves into a new abstract HerdrPeerLauncher (Template-Method base). ClaudeCodeLauncher becomes a final subclass supplying only the two Claude-specific seams: the `claude` name prefix and buildLaunch(), which encodes the subscription boundary (ANTHROPIC_BASE_URL + SubscriptionGuard assert, inline --mcp-config and --append-system-prompt reply charter). The base owns the injectable clock + sleeper for the readiness gate; the poll interval is baked into the sleeper, so the vestigial spawnReadyPollMs field is dropped from the base and from the full testability constructor (the explicit sleeper already encodes it). The 6-arg and 8-arg production constructors keep their signatures; three full-ctor test sites drop the now-unused poll argument. No behaviour change: 242 tests green, both refactored files 0 IDE problems. |
||
|
|
e724a59f2d |
CB-402: design note — opencode second peer adapter (Stage B)
Design-note-first for gitea #7. Extract HerdrPeerLauncher abstract base (Template Method) + kind: discriminator + OpenCodeLauncher + routing CompositePeerLauncher; finish the Stage-A caster migration off ClaudeCodeLauncher. opencode proves the SPI for a non-Claude peer (no subscription guard, OPENCODE_CONFIG MCP mount, config-based charter). |