From 2682140d8ded571a4a338a51ed559d06fe1fd561 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 14 Aug 2026 16:49:49 +0200 Subject: [PATCH] Features: fleet block, role pools, role-aware tab labels, lead auto-launch (CB-557, CB-558) --- 11-Features.md | 136 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 125 insertions(+), 11 deletions(-) diff --git a/11-Features.md b/11-Features.md index 430d07a..444b190 100644 --- a/11-Features.md +++ b/11-Features.md @@ -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..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..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
env · gitTokenEnv · parityOverlay
configDir · cwd · argv"] - P2 --> K Y --> L["lifecycle"] + Y --> W["profiles:"] + W --> P1["profile: gx10"] + W --> P2["profile: opus"] + P1 --> K["weight · maxLoad · model
env · gitTokenEnv · parityOverlay
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: `, 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