Features: fleet block, role pools, role-aware tab labels, lead auto-launch (CB-557, CB-558)

Dai Ha
2026-08-14 16:49:49 +02:00
parent e5424f4665
commit 2682140d8d
+125 -11
@@ -21,14 +21,18 @@ six weeks, and the table alone will not carry it.
|---|---|---|---|
| [Ask the bridge who you are](#ask-the-bridge-who-you-are) | `bridge_whoami` | CB-517 | `mcp/BridgeMcp` |
| [Primary inside a herdr pane](#primary-inside-a-herdr-pane) | `primary.terminal:` | CB-522 | `auth/CallerResolver` |
| [More than one lead](#more-than-one-lead) | `leaders:` | CB-530 | `auth/CallerResolver` |
| [More than one lead](#more-than-one-lead) | `fleet.leaders:` | CB-530 | `auth/CallerResolver` |
| [Unknown config keys are named](#unknown-config-keys-are-named) | (always on) | CB-530 | `config/BridgedConfig` |
| [Find leads by tab name](#find-leads-by-tab-name) | `leadScan:` | CB-531 | `herdr/LeadTabScanner` |
| [Find leads by tab name](#find-leads-by-tab-name) | `fleet.leaders.<n>.tabPrefix` | CB-531 | `herdr/LeadTabScanner` |
| [One fleet block, role as the key](#one-fleet-block-role-as-the-key) | `fleet:` | CB-557 | `config/BridgedConfig` |
| [Role pools decide the backend](#role-pools-decide-the-backend) | `fleet.developers:` etc. | CB-557 | `member/CompositePeerLauncher` |
| [Tab labels name the role](#tab-labels-name-the-role) | `fleet.tabLabel:` | CB-557 | `member/HerdrPeerLauncher` |
| [Launch a lead when none is live](#launch-a-lead-when-none-is-live) | `fleet.leaders.<n>.profile` + `instances` | CB-558 | `lead/LeadLauncher` |
| [Leads talk to each other](#leads-talk-to-each-other) | (always on, two leads) | CB-532 | `auth/Principal` |
| [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` |
| [Advisory architect slots](#advisory-architect-slots) | `fleet.architects:` | CB-548 | `auth/MemberRegistry` |
| [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` |
@@ -48,18 +52,27 @@ Nearly every knob above lives in one file, on one profile:
```mermaid
flowchart LR
Y["bridged.yaml"] --> G["bind / auth / leaders / architects"]
Y["bridged.yaml"] --> G["bind / auth / guard"]
Y --> B["broker"]
Y --> W["workers:"]
W --> P1["profile: gx10"]
W --> P2["profile: ollama"]
P1 --> K["placement · weight · maxLoad<br/>env · gitTokenEnv · parityOverlay<br/>configDir · cwd · argv"]
P2 --> K
Y --> L["lifecycle"]
Y --> W["profiles:"]
W --> P1["profile: gx10"]
W --> P2["profile: opus"]
P1 --> K["weight · maxLoad · model<br/>env · gitTokenEnv · parityOverlay<br/>configDir · cwd · argv"]
P2 --> K
Y --> F["fleet:"]
F --> FL["leaders"]
F --> FA["architects"]
F --> FD["developers"]
F --> FR["reviewers"]
FA --> K
FD --> K
FR --> K
```
*The configuration surface: daemon-wide settings, then one block per worker profile. A profile is
a backend, not a host.*
*The configuration surface has two halves. `profiles:` answers "which backend" — model, adapter,
cost. `fleet:` answers "who runs, and on which of those backends". A role pool holds profile names,
so the arrows meet: the same profile may serve several roles.*
---
@@ -566,6 +579,107 @@ something this build has not learned yet" into a daemon that will not boot, dest
forward-compatibility the annotation exists for. Nested unknown keys are still silent; only the top
level is checked.
## One fleet block, role as the key
**What.** `fleet:` replaces four top-level keys — `leaders:`, `members:`, `leadScan:` and
`defaultProfile:`. A member's role is now the map key that contains it, not a `role:` field inside
it.
**On.** Write a `fleet:` block. The four old keys are hard errors that name what to use instead, so
an old config does not start silently changed.
```yaml
fleet:
leaders:
opus: {profile: opus, instances: 1, tabPrefix: "lead:"}
architects:
opus: {profile: opus}
developers:
local: {profile: local}
sonnet: {profile: sonnet}
reviewers:
sonnet: {profile: sonnet}
```
**Why.** A misspelled `role: architct` used to parse into a member with a profile, a name, and no
contract at all — nothing rejected it, because `role:` was just a string. A misspelled pool name
declares nothing, which is a shape the loader can see. The four keys also had no relationship to
each other on the page, while all four describe one thing: who is in the fleet.
**Gotcha.** `defaultProfile:` has no single successor key, so its error message explains the new
model rather than pointing at a key that does not exist. Role pools took over its job — see below.
---
## Role pools decide the backend
**What.** `fleet.architects` / `developers` / `reviewers` list the profiles that role **may** run
on. A spawn that names no profile is placed inside the pool of the role it asked for, instead of
across every configured profile.
**On.** List profile names under the role. Order matters under `placement: fixed` — the first entry
wins.
**Why.** Role and profile are separate axes, and collapsing them loses real cases: a reviewer may
run on the very same profile as the dev whose diff it reads. Before this, an unqualified spawn
ranged over all profiles, so a reviewer could land on the architect-only backend and quietly spend
the subscription. Pools also replaced the single global `defaultProfile:`, which could only ever
have one answer for a fleet that has three kinds of member.
**Gotcha.** A role with **no** pool is unconstrained, not blocked — it falls back to every profile,
so a config that pools some roles and not others keeps working. And an **explicit** profile is not
confined to the pool: `bridge_spawn{profile:"opus"}` carries no role, so it defaults to `dev`, and
judging it against the dev pool would refuse a spawn the operator asked for by name. `maxLoad` still
applies to it.
---
## Tab labels name the role
**What.** A member's tab reads `dev: sonnet #4` — role first, then backend, then a counter.
**On.** `fleet.tabLabel:` (default `"{role}: {profile} #{n}"`). `{role}`, `{profile}`, `{model}` and
`{n}` are substituted. A profile may override it with its own `tabLabel:`.
**Why.** The label lives on `fleet:` because a profile cannot know the role of the member launched
on it, and the role is the thing an operator scanning a tab bar actually wants. `{n}` counts per
role **and** profile, so a dev and a reviewer on one profile each start at `#1` — a single fleet-wide
counter would make the number meaningless. Putting `{role}` first also turns the lead/member
namespace check into a structural guarantee: roles are a closed enum, so only a hand-written
template can still collide with a lead's `tabPrefix`.
**Gotcha.** The knob was inert on first release — `HerdrPeerLauncher` accepted the template and
nothing passed it, so the label only looked right because the fallback happened to match the default.
Fixed in CB-557; tests now pin the wiring rather than the coincidence.
---
## Launch a lead when none is live
**What.** The daemon starts a lead declared under `fleet.leaders:` when fewer than `instances` are
running. It labels the tab by the same convention the scanner reads.
**On.** Give the lead a `profile:`. Omit it and the lead stays recognise-only, exactly as before.
`instances: 0` is an off switch. `workspace:` (default `leads`) and `cwd:` control where it lands.
**Why.** A lead was the one pane a human had to open by hand before anything else worked, so a
daemon restart after a reboot left a fleet with no orchestrator and no sign of why.
**Gotcha — three, and they are the whole design.** An auto-launched lead is **not** a member: it
gets no worker reply charter (that text tells its reader it is an off-subscription worker who must
end every turn with `bridge_reply` — the opposite of an orchestrator), it is never registered with
`SessionManager` (the idle reaper would kill it for being idle, which is a lead's normal state), and
`ANTHROPIC_BASE_URL`/`AUTH_TOKEN` are stripped from its env whatever the profile says.
A lead counts as live only when herdr reports a **running agent** — either in a tab labelled
`lead: <name>`, or on a pinned `terminal:`. Both are needed: label-only would relaunch a hand-opened
pinned lead on every boot, and pin-only would miss one the daemon started itself. A labelled tab with
nothing running in it is **not** a lead, so one crash does not disable auto-launch forever. If herdr
cannot be reached the daemon starts **nothing** — a second orchestrator is worse than none.
`workspace:` must not name a member workspace: those are excluded from the lead scan, so a lead
placed in one would never be found again and would be relaunched on every boot.
---
## Backfill status