diff --git a/10-Cross-Host-Messaging.md b/10-Cross-Host-Messaging.md index 3c329e7..3118653 100644 --- a/10-Cross-Host-Messaging.md +++ b/10-Cross-Host-Messaging.md @@ -25,13 +25,14 @@ Every cross-host interaction is one of these. Each is a message flow between two | U3 | **Stranded / late reply** — B replies after A's blocking send timed out (or was never open); held durably until A pulls | B → A (held) | `REPLY` (deferred) | | U4 | **Cross-host spawn** — A requests a new agent *on host B*; gateway B launches it locally and announces it | A → gateway B (control) | `SPAWN` | | U5 | **Roster / presence** — every gateway announces its local agents so all build one union "who/where/status" view | each gateway → all | `PRESENCE` | -| U6 | **Main-pair** — the two mains (Opus + cloud, CB-500 B) message each other | A ↔ A' | `SEND`/`REPLY` | -| U7 | **Orchestrator ↔ mains** — the orchestrator tier (CB-500 C) drives/monitors its main sessions | O ↔ main | `SEND`/`REPLY` | -| U8 | **Broadcast / group announce** — a main publishes **once** to all workers (or a named group); the broker copies the message into every bound inbox | A → all | `BROADCAST` | +| U6 | **Lead ↔ architect** — the human-driven lead engages either independent advisory architect | L ↔ A | `SEND`/`REPLY` | +| U7 | **Parallel architect advice** — the lead sends the same brief to Claude Sonnet 5 and GPT-5.6/opencode, then compares independent results | L → A1, A2 | `SEND`/`REPLY` | +| U8 | **Broadcast / group announce** — a lead publishes **once** to all workers (or a named group); the broker copies the message into every bound inbox | L → all | `BROADCAST` | U1–U3 are the [CB-307 rendezvous](9-Implementation) semantics, now spanning hosts. U4–U5 are the -net-new federation control plane. U6–U7 reuse the *same* per-entity inbox as U1 — a main and an -orchestrator are just message-addressable entities with their own inbox. U8 is for **identical +net-new federation control plane. U6–U7 reuse the *same* per-entity inbox as U1 — a lead and an +architect are just message-addressable entities with their own inbox. The two architect model families +are deliberate: independent agreement is evidence rather than correlated echo. U8 is for **identical announcements** ("everyone: stop", "everyone: report status") — tailored task briefs keep the per-worker fan-out pattern ([Team](6-Team#parallel-fan-out-map--reduce)), which works unchanged across hosts because each send routes to its recipient's inbox wherever it lives. @@ -41,12 +42,12 @@ across hosts because each send routes to its recipient's inbox wherever it lives ```mermaid flowchart LR subgraph e["Message-addressable entities (each owns ONE inbox)"] - orch["orchestrator
(MCP client)"] - main["primary / main
(MCP client)"] + arch["architect
(MCP client)"] + main["human-driven lead
(MCP client)"] wrk["worker / sandboxed agent
(herdr pane)"] end gw["gateway = bridged
(one per host)"] - orch -->|"delivered by local MCP pull"| gw + arch -->|"delivered by local MCP pull"| gw main -->|"delivered by local MCP pull"| gw wrk -->|"delivered by local herdr inject"| gw gw -->|"consumes its local entities' inboxes,
publishes to remote inboxes"| broker["BROKER (AMQP)"] diff --git a/11-Features.md b/11-Features.md index 4870c7a..430d07a 100644 --- a/11-Features.md +++ b/11-Features.md @@ -28,14 +28,18 @@ six weeks, and the table alone will not carry it. | [A lead can be delivered to](#a-lead-can-be-delivered-to) | automatic | CB-534 | `Bridged.deliverableTo` | | [Leads are visible in bridge_list](#leads-are-visible-in-bridge_list) | automatic | CB-535 | `mcp/BridgeMcp.listFleet` | | [Reply nudges follow the delegating lead](#reply-nudges-follow-the-delegating-lead) | automatic (retires `primary:`) | CB-532 | `mcp/PrimaryRegistry` | +| [Advisory architect slots](#advisory-architect-slots) | `architects:` | CB-548 | `auth/ArchitectRegistry` | | [Weighted worker placement](#weighted-worker-placement) | `placement: weighted` + `weight` / `maxLoad` | CB-518 | `placement/` | | [Give workers a toolchain](#give-workers-a-toolchain) | per-profile `env:` | CB-511 | `worker/HerdrPeerLauncher` | | [Run a worker on the subscription](#run-a-worker-on-the-subscription) | profile `subscription: true` | CB-539 | `worker/ClaudeCodeLauncher` | +| [Keep a worker conversation](#keep-a-worker-conversation) | session name + resume id on spawn | CB-547 | `peer/SpawnRequest` | | [Isolated worktree per worker](#isolated-worktree-per-worker) | `bridge_spawn{worktree, ticket}` | CB-301-ext | `session/GitWorktrees` | | [Worker tool-surface isolation](#worker-tool-surface-isolation) | automatic | CB-525 | `session/GitWorktrees` | +| [Worktree-hostile config isolation](#worktree-hostile-config-isolation) | automatic | CB-543 | `session/GitWorktrees` | | [Worker opens its own PR](#worker-opens-its-own-pr) | `gitTokenEnv:` / `gitHostEnv:` | CB-302 | `worker/HerdrPeerLauncher` | | [Session lifecycle caps](#session-lifecycle-caps) | `lifecycle:` | CB-303 | `session/SessionManager` | | [Durable reply inbox](#durable-reply-inbox) | `broker:` | CB-307 | `msg/AmqpReplyInbox` | +| [Reject overlapping rendezvous](#reject-overlapping-rendezvous) | automatic | CB-548 | `msg/Rendezvous` | | [Pin an opencode endpoint](#pin-an-opencode-endpoint) | profile `baseUrl:` | CB-508 | `worker/OpenCodeLauncher` | | [Onboard a project with the plugin](#onboard-a-project-with-the-plugin) | `/plugin install claude-bridge` → `/claude-bridge:setup` | CB-527 | `plugin/` | | [Port a workspace to OpenCode](#port-a-workspace-to-opencode) | `port-to-opencode` skill + `opencode.json` | CB-529 | `.claude/skills/port-to-opencode` | @@ -44,7 +48,7 @@ Nearly every knob above lives in one file, on one profile: ```mermaid flowchart LR - Y["bridged.yaml"] --> G["bind / auth / primary.terminal"] + Y["bridged.yaml"] --> G["bind / auth / leaders / architects"] Y --> B["broker"] Y --> W["workers:"] W --> P1["profile: gx10"] @@ -121,6 +125,15 @@ requirement *for that profile only* — every other profile keeps the hard refus boundary: a claude-code profile with no base_url may not spawn, because doing so would bill the subscription. +```yaml +workers: + sonnet: + kind: claude-code + subscription: true + model: claude-sonnet-5 + argv: ["ccs", "sonnet"] +``` + **Why.** Some model families (e.g. `sonnet` on `ccs`) have no off-subscription endpoint to point a worker at. Rather than leave those profiles unspawnable, this is an explicit, visible opt-in — a spawn under it logs a WARN naming the profile, so billing the subscription is never an accident. @@ -130,6 +143,57 @@ refused at spawn if both are set. The same contradiction is refused at config lo map: on the subscription path the guard is skipped and the adapter writes neither Anthropic key, so an `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` in `env:` would reach the worker having passed no guard at all (CB-542). A subscription profile may still carry `env:` — just not those two keys. +`weight: 0` cannot reserve this profile from unqualified weighted placement: worker-config +normalization changes non-positive weights to `1.0`. Use an explicit `profile: sonnet` spawn until +the placement policy gains an exclusion setting. + +## Advisory architect slots + +**What.** Declares named, strong-model advisory slots. A bound architect resolves as `architect`, can +send, reply, ask, and read, but cannot spawn, stop, or drain the fleet. The operating shape is one +human-driven lead, two short-lived architects, and N workers: the lead consults the architects sideways +and discards them, rather than creating a supervisor above the lead. + +**On.** Declare each slot against a configured worker profile: + +```yaml +architects: + sonnet-adviser: + profile: sonnet + gpt-adviser: + profile: gpt +``` + +**Why.** The rejected alternative put an orchestrator above the lead and made the lead a managed, +resumable session. That breaks the actual control boundary: the human drives a lead that pre-exists, +is recognised, and cannot be resumed by bridged. Two advisory model families, Claude Sonnet 5 and +GPT-5.6 through opencode, receive the same brief independently so agreement is evidence rather than +correlated echo. + +**Gotcha.** `architects:` declares slots, not sessions: no terminal is configured and nothing becomes +an architect until the lifecycle binds a live terminal to a slot. The profile name is validated at boot, +as are duplicate slot names. An architect has delegation authority but no lifecycle authority, by +design. + +## Keep a worker conversation + +**What.** Carries a bridge logical session name and a peer-owned resume id through `SpawnRequest`, then +returns them from the peer handle where the adapter supports them. Claude Code mints a UUID for a fresh +named session, passes it as `--session-id`, exposes the logical name with `-n`, and resumes with `-r`. +OpenCode resumes with `-s` and discovers its id after launch from its on-disk session record by matching +the worker's unique worktree cwd. + +**On.** Supply a session name and/or resume id when the spawn lifecycle has one to carry. Adapter +capabilities state the asymmetry: Claude Code offers `SESSION_NAME` and `SESSION_RESUME`; OpenCode +offers `SESSION_RESUME` only. + +**Why.** A bridge session name is an operator-facing roster label, while a provider session id is the +only handle that can resume the actual conversation. Treating them as peer-neutral values prevents the +core from assuming Claude Code's flags are a universal protocol. + +**Gotcha.** OpenCode has no name flag, and it writes its session record only after it persists a +conversation. Its id is therefore discovered lazily and may be absent immediately after spawn; its slot +name remains only in bridged's roster. ## Give workers a toolchain @@ -183,6 +247,22 @@ build passed. is deliberate — the bridge is a message bus, and the primary is the gate. Never accept a worker's claim about a check it had no way to run. +## Worktree-hostile config isolation + +**What.** Every provisioned worktree neutralizes tracked `.mcp.json`, `opencode.json`, and `.autoenv` +with a valid format-specific stub, then marks a tracked replacement `--skip-worktree`. + +**On.** Automatic at worktree provisioning; no configuration. + +**Why.** The tracked `opencode.json` can reference gitignored `.secrets/` files that a worktree never +contains, and OpenCode refuses to start with that dangling reference. The same isolation rule also +prevents a primary-only MCP configuration or an autoenv authorization prompt from entering a worker's +tool surface. + +**Gotcha.** The replacement is deliberately valid, not deleted: `{}` for `opencode.json`, an empty +server map for `.mcp.json`, and an empty `.autoenv`. A deletion could be undone by a later checkout; the +skip-worktree bit keeps the safe local replacement from looking like work for a worker to commit. + ## Worker opens its own PR **What.** Injects a repo-scoped forge token so a worker can commit, push over SSH, and open its own @@ -228,6 +308,20 @@ real task's runtime. Without a durable inbox, a reply arriving after that window writing into someone else's broker. Also see the known hole: an async ticket that times out while the session is still BUSY currently discards the later completion rather than parking it. +## Reject overlapping rendezvous + +**What.** A second attempt to open a reply waiter for the same worker session fails atomically instead +of replacing the first waiter. + +**On.** Always on; no configuration. + +**Why.** One session has one outstanding delegated turn. Replacing its waiter silently would strand the +first caller and let a reply resolve the wrong request. `MessageService` serializes normal sends, but +the atomic rejection is the tripwire that makes a future violation loud rather than corrupt. + +**Gotcha.** This is not concurrent-turn support. A terminal send closes its own waiter before the next +turn can open one; a double-open is an invariant failure that must be investigated. + ## Pin an opencode endpoint **What.** An opencode profile can target its own OpenAI-compatible endpoint.