CB-530: multi-leader registry — recognise N leads, not one pinned primary #13

Closed
opened 2026-08-12 22:48:55 +02:00 by ltms · 1 comment
Owner

Problem

primary.terminal is singular by construction. One pane is the lead; every other pane resolving
to a herdr terminal is a worker:

// auth/CallerResolver.java:81-87
if (c.terminal() != null) {
    if (c.terminal().equals(pinnedPrimaryTerminal)) return Principal.primary(c.pid());
    return Principal.worker(c.terminal(), c.pid());
}

That forecloses the case we now actually want: an Opus lead and a gpt-sol/terra lead working as
peers
, neither being the other's worker. Today the second lead is silently demoted and every
orchestration call it makes is refused.

Why this is filed rather than configured

A leaders: block was hand-written into bridged.yaml and appeared to work — the daemon starts,
nothing complains. It is inert. BridgedConfig is annotated
@JsonIgnoreProperties(ignoreUnknown = true), so an unknown top-level key is parsed, dropped, and
never mentioned again: no error, no warning, no log line. Verified by loading the edited file
through BridgedConfig.load — PARSE: ok, PRIMARY_PIN: <<NULL>>, leaders absent from the
object graph.

Worse, adopting the sketch meant deleting primary:, which would have made every pane a worker
on the next restart. The config was strictly worse than before while looking strictly better.

Design

Stage 1 — identity (unblocks everything else)

leaders:
  opus-5.0:
    terminal: term_658aa59414a3a7
    kind: claude
  gpt-sol-5.6:
    terminal: term_xxxxxxxxxxxxxx
    kind: opencode
    model: openai/gpt-5.6-terra
  • CallerResolver resolves any listed terminal to a leader principal carrying its name.
  • A leader holds the primary's rights in Authz — spawn/send/stop, never bridge_reply.
  • primary: stays honoured, mapped to a leader named primary. Precedence must be defined and
    tested when both blocks are present.
  • bridge_whoami gains the leader's name. Keep role reading primary for existing callers
    rather than breaking the fallback ladder documented in CLAUDE.md.

Stage 2 — leader ↔ leader messaging

  • Address by name: bridge_send{leader: "gpt-sol-5.6", content}.

  • The open question is the reply path. bridge_reply is worker-only, one-per-turn, and carries
    a turn obligation. A leader receiving a message is not a worker and owes nothing. Either:

    • (a) reuse the rendezvous — a leader holding an open turnId may bridge_reply against it; or
    • (b) add a distinct bridge_respond for peer exchange, leaving the worker contract untouched.

    (a) is less machinery; (b) keeps the worker turn contract clean. Decide before implementing.

  • Status-gating and one-message-per-turn delivery apply unchanged.

Stage 3 — spawning a leader (out of scope here)

The original sketch carried argv/placement, implying the bridge spawns a lead. A lead normally
pre-exists — that is why the pin exists. Spawning one means a peer with no reply charter and no
worker authz, which is a different animal from both roles we have. File separately if wanted.

Required guardrail (Stage 1)

Warn on unknown top-level config keys at startup. This is the defect that made the whole
problem invisible. Separable in principle, but it is the thing that stops the next person losing an
evening the same way. A WARN naming the key is enough; do not switch to FAIL_ON_UNKNOWN — that
would break forward-compatible configs.

Acceptance criteria

  • Two terminals listed under leaders: both resolve as leaders; bridge_whoami distinguishes
    them by name.
  • A config with primary: only behaves exactly as today — no regression in the pin path.
  • Precedence when both primary: and leaders: are present is defined, documented, tested.
  • An unknown top-level key logs a WARN naming the key; startup still succeeds.
  • CallerResolver unit tests: pin only · leaders only · both · neither.
  • bridged.example.yaml documents the block; the commented sketch in bridged.yaml is replaced
    by the real thing.
  • wiki/11-Features.md entry: what it does · the knob · why · the gotcha.
  • CLAUDE.md — the canonical block's role language assumes one primary; re-read §"Which role am
    I?" and the layering table, and propagate to the wiki template byte-identically.

Out of scope

  • Cross-host federation (CB-308).
  • Spawning leaders (Stage 3 above).
## Problem `primary.terminal` is singular by construction. One pane is the lead; every other pane resolving to a herdr terminal is a worker: ```java // auth/CallerResolver.java:81-87 if (c.terminal() != null) { if (c.terminal().equals(pinnedPrimaryTerminal)) return Principal.primary(c.pid()); return Principal.worker(c.terminal(), c.pid()); } ``` That forecloses the case we now actually want: **an Opus lead and a gpt-sol/terra lead working as peers**, neither being the other's worker. Today the second lead is silently demoted and every orchestration call it makes is refused. ## Why this is filed rather than configured A `leaders:` block was hand-written into `bridged.yaml` and *appeared* to work — the daemon starts, nothing complains. It is inert. `BridgedConfig` is annotated `@JsonIgnoreProperties(ignoreUnknown = true)`, so an unknown top-level key is parsed, dropped, and never mentioned again: no error, no warning, no log line. Verified by loading the edited file through `BridgedConfig.load` — `PARSE: ok`, `PRIMARY_PIN: <<NULL>>`, `leaders` absent from the object graph. Worse, adopting the sketch meant deleting `primary:`, which would have made **every** pane a worker on the next restart. The config was strictly worse than before while looking strictly better. ## Design ### Stage 1 — identity (unblocks everything else) ```yaml leaders: opus-5.0: terminal: term_658aa59414a3a7 kind: claude gpt-sol-5.6: terminal: term_xxxxxxxxxxxxxx kind: opencode model: openai/gpt-5.6-terra ``` - `CallerResolver` resolves *any* listed terminal to a leader principal carrying its **name**. - A leader holds the primary's rights in `Authz` — spawn/send/stop, never `bridge_reply`. - `primary:` stays honoured, mapped to a leader named `primary`. Precedence must be defined and tested when both blocks are present. - `bridge_whoami` gains the leader's name. Keep `role` reading `primary` for existing callers rather than breaking the fallback ladder documented in `CLAUDE.md`. ### Stage 2 — leader ↔ leader messaging - Address by name: `bridge_send{leader: "gpt-sol-5.6", content}`. - **The open question is the reply path.** `bridge_reply` is worker-only, one-per-turn, and carries a turn obligation. A leader receiving a message is not a worker and owes nothing. Either: - (a) reuse the rendezvous — a leader holding an open `turnId` may `bridge_reply` against it; or - (b) add a distinct `bridge_respond` for peer exchange, leaving the worker contract untouched. (a) is less machinery; (b) keeps the worker turn contract clean. **Decide before implementing.** - Status-gating and one-message-per-turn delivery apply unchanged. ### Stage 3 — spawning a leader (out of scope here) The original sketch carried `argv`/`placement`, implying the bridge spawns a lead. A lead normally pre-exists — that is *why* the pin exists. Spawning one means a peer with no reply charter and no worker authz, which is a different animal from both roles we have. File separately if wanted. ## Required guardrail (Stage 1) **Warn on unknown top-level config keys at startup.** This is the defect that made the whole problem invisible. Separable in principle, but it is the thing that stops the next person losing an evening the same way. A WARN naming the key is enough; do not switch to `FAIL_ON_UNKNOWN` — that would break forward-compatible configs. ## Acceptance criteria - [ ] Two terminals listed under `leaders:` both resolve as leaders; `bridge_whoami` distinguishes them by name. - [ ] A config with `primary:` only behaves **exactly** as today — no regression in the pin path. - [ ] Precedence when both `primary:` and `leaders:` are present is defined, documented, tested. - [ ] An unknown top-level key logs a WARN naming the key; startup still succeeds. - [ ] `CallerResolver` unit tests: pin only · leaders only · both · neither. - [ ] `bridged.example.yaml` documents the block; the commented sketch in `bridged.yaml` is replaced by the real thing. - [ ] `wiki/11-Features.md` entry: what it does · the knob · **why** · the gotcha. - [ ] `CLAUDE.md` — the canonical block's role language assumes one primary; re-read §"Which role am I?" and the layering table, and propagate to the wiki template byte-identically. ## Out of scope - Cross-host federation (CB-308). - Spawning leaders (Stage 3 above).
Author
Owner

Closing — this shipped and the issue went stale. Verified on main @ 2124e04:

  • The registry exists, and it moved further than this ticket asked. fleet.leaders replaced four top-level keys (leaders:, members:, leadScan:, defaultProfile:) — BridgedConfig.Fleet, bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java:699.
  • CallerResolver resolves a listed pane to a leader, not a worker — its own javadoc names leaders: as a resolution source (auth/CallerResolver.java:22).
  • The guardrail landed. An unknown top-level key now logs a WARN naming the key instead of being silently dropped, which is the defect that made this whole problem invisible (BridgedConfig.java:1123).
  • bridge_whoami carries the name (mcp/BridgeMcp.java:882).
  • CB-579 went past the design here and removed the per-entry terminal: pin entirely; a config still carrying it is now rejected at load (BridgedConfig.java:1199-1222). So the shape in this issue's Stage 1 sketch is not the shape that shipped — the shipped one is better, and the sketch would now fail to load.

Stage 2 (leader ↔ leader messaging) also works in practice — CLAUDE.md documents the peer protocol and bridge_list reports leads with self: true.

Stage 3 (spawning a leader) was out of scope here and stays unfiled.

Not moved to the 1.1 milestone: there is nothing left to do.

Closing — this shipped and the issue went stale. Verified on `main` @ `2124e04`: - **The registry exists**, and it moved further than this ticket asked. `fleet.leaders` replaced four top-level keys (`leaders:`, `members:`, `leadScan:`, `defaultProfile:`) — `BridgedConfig.Fleet`, `bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java:699`. - **`CallerResolver` resolves a listed pane to a leader**, not a worker — its own javadoc names `leaders:` as a resolution source (`auth/CallerResolver.java:22`). - **The guardrail landed.** An unknown top-level key now logs a WARN naming the key instead of being silently dropped, which is the defect that made this whole problem invisible (`BridgedConfig.java:1123`). - **`bridge_whoami` carries the name** (`mcp/BridgeMcp.java:882`). - **CB-579 went past the design here** and removed the per-entry `terminal:` pin entirely; a config still carrying it is now rejected at load (`BridgedConfig.java:1199-1222`). So the shape in this issue's Stage 1 sketch is not the shape that shipped — the shipped one is better, and the sketch would now fail to load. Stage 2 (leader ↔ leader messaging) also works in practice — `CLAUDE.md` documents the peer protocol and `bridge_list` reports `leads` with `self: true`. Stage 3 (spawning a leader) was out of scope here and stays unfiled. Not moved to the 1.1 milestone: there is nothing left to do.
ltms closed this issue 2026-08-16 16:49:18 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#13