CB-619 / fleetd #123: refuse an architect spawn with no matching slot #223

Merged
ltms merged 1 commits from worker/cb-123-role-demotion-c600f7-2 into main 2026-09-01 10:41:47 +02:00
Member

What happened before this fix

A spawn that asked for role=architect on a profile no architect slot carried was silently
held as a plain dev session, but GET /members still reported "role":"architect" while
fleet_whoami (which reads live bindings, not the request) correctly said worker/dev.
Three sources of truth disagreed about one live member, silently.

The root cause: an explicit profile on a spawn bypasses role-pool placement entirely
(CompositePeerLauncher only constrains an unqualified, blank-profile spawn to fleet.<role>
via placement). That is the one path that could ask for a role with no slot to bind it to.

Decision (given by the lead, not reopened here)

Option 1: refuse the spawn. An architect's identity IS the slot it is bound to, so binding
a role with no slot to bind means inventing an identity out of nothing — a silent demotion is
the quiet failure the whole role system exists to prevent.

The refusal is scoped to ARCHITECT only, not a blanket role-pool check for every role. Evidence
for that scoping: CompositePeerLauncher documents fleet_spawn{profile:"opus"} (role defaulting
to dev) as a supported flow, and dev/reviewer pools are placement candidates only, never a live
identity binding — generalizing the refusal to those roles would break that documented flow.

What changed

  • fleetd/src/main/java/dev/ltms/fleet/auth/MemberLifecycle.java — acquired(role, profile, terminal) now returns MemberRole (the role the session actually holds), instead of void.
    New method requireSlotFor(MemberRole role, String profile), throwing IllegalArgumentException
    pre-spawn. NONE singleton updated (no registry configured → nothing to validate/bind against).

  • fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java — the real implementation:

    • acquired(...): on total bind failure for ARCHITECT, now logs at WARN (was INFO),
      naming profile and terminal, and returns MemberRole.DEV instead of silently keeping the
      request's role.
    • requireSlotFor(...): no-op for non-ARCHITECT. For ARCHITECT, checks whether any
      configured architect slot's profile() matches the requested profile; if none does, throws
      with the exact message:
      no architect slot for profile '<profile>' — an architect's identity IS the slot it is bound
      to, so there is nothing to bind this session's identity to. fleet.architects carries
      profiles: <comma-joined list, or "(none configured)">; add profile '<profile>' there, or
      spawn architect on one of those profiles instead
      
      This names (a) the role (architect), (b) the profile asked for, and (c) the pools/profiles
      that do carry that role — exactly the three things the ticket requires.
  • fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java — wired the fix in:

    • Before any spawn happens, calls memberLifecycle.requireSlotFor(memberRole, profile) when an
      explicit (non-blank) profile was given — a blank profile is left to placement, which
      already constrains an unqualified spawn to the role's pool and is not part of this defect.
    • memberLifecycle.acquired(...) is now called before constructing MemberSession (in both
      the no-worktree and worktree-provisioning acquire paths), and its return value (actualRole)
      is what gets recorded on the session — never the originally requested role. Because
      FleetMcp.memberView and SessionManager.rosterView both just read session.role(), this one
      change makes GET /members/fleet_list automatically honest with no changes needed to
      either of those methods or their callers.
  • fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java — new test
    aSecondArchitectOnAnAlreadyBoundProfileIsHeldAsDevNotArchitectAndWarnsLoudly(). Pre-occupies the
    sole opus architect slot with MemberRegistry.bind (the one legitimate use of the registry seam
    here — to set up a pre-existing occupant, not the session under test), then drives a real
    SessionManager.acquire("ltms-local", MemberRole.ARCHITECT, ...) call through the real
    ClaudeCodeLauncher/FakeHerdr. Asserts the returned MemberSession.role() is DEV, that
    SessionManager.rosterView(...) reports "role":"dev", and that a WARN-level log line (captured
    via a logback ListAppender) names both the profile and the terminal.

  • fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java — two new tests:

    • spawnRefusesAnArchitectWithNoMatchingSlotAndNeverTouchesTheLauncher() — drives
      FleetMcp.spawn end to end with role=architect, profile=sonnet against a registry whose only
      architect slot is opus. Asserts the error result names architect, sonnet, and opus;
      asserts the session roster stays empty; and asserts FakeHerdr's recorded calls never
      contain an agent.start call — proving the refusal happens before any process spawns.
    • rosterAndWhoamiAgreeOnceTheArchitectSlotBinds() — positive control. Spawns
      role=architect, profile=opus (a profile that DOES have a slot), pinning the fixture's
      FakeHerdr.WORKER_PID-mapped pane via pinNextStarts. Asserts FleetMcp.spawn's result AND
      FleetMcp.listFleet (GET /members) both say "role":"architect", then builds a real
      CallerResolver against the same MemberRegistry instance, resolves the caller through
      ConnectionIdentity/PaneLocator keyed to that PID, and confirms fleet_whoami (via
      FleetMcp.whoami) also says "role":"architect" — proving GET /members and fleet_whoami
      agree through the same live binding, not by test-double coincidence.

How the tests drive the real path, not the registry seam

Both new spawn-path tests go through FleetMcp.spawn(...) → SessionManager.acquire(...) → the
real ClaudeCodeLauncher (which in turn talks to FakeHerdr, the only stand-in, at the
herdr-protocol boundary) → MemberRegistry.requireSlotFor/acquired (the real production class).
MemberRegistry.bind is called directly exactly once, only to set up the pre-existing occupant
for the race test — never to stand in for the session actually under test. That is the distinction
issue #113 got wrong (it asserted on MemberRegistry.bind alone and shipped dead code behind a
green suite). Both GET /members (FleetMcp.listFleet / SessionManager.rosterView) and
fleet_whoami (FleetMcp.whoami via a real CallerResolver) are read back for the same live
session
in the positive-control test, exactly as the acceptance criteria require.

Red-test evidence (watched fail before being kept)

Verification process: git stash push on just the 3 production files (MemberLifecycle.java,
MemberRegistry.java, SessionManager.java), keeping the 2 test files, then ran
mvn -q -o test -Dtest=SessionManagerTest,FleetMcpTest -pl . against the reverted production code.

Result: Tests run: 103, Failures: 2, Errors: 0, Skipped: 0 — exactly the 2 new tests failed
(the positive-control test in FleetMcpTest correctly still passed, since it exercises the
already-correct opus happy path). Then git stash pop restored the fix, and a re-run of the same
targeted tests went green before the full build below.

mvn clean install — run from inside fleetd/, not repo root, output unpiped

Tests run: 1084, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Full log captured to /tmp/cb619_full_build.log in the worker's own environment (not part of this
repo).

Same-shape sweep (report only, not fixed)

  • In-feature residual TOCTOU race, already handled honestly, not a separate defect: requireSlotFor
    closes the config-gap case (zero slots anywhere carry the profile). A profile that DOES have a
    slot can still lose a race to a concurrent spawn between that pre-check and the actual bind()
    inside acquired(). This is handled by acquired()'s honest fallback (returns DEV, logs WARN)
    rather than left silently wrong — but it is not itself refused pre-spawn, since refusing it would
    require the spawn to have already raced to know the slot's occupancy.
  • SessionManager.resolveProfile(handle, requestedProfile) (line ~561) and the cwd handling
    via launcher.effectiveCwd(new SpawnRequest(...)) were checked for the same shape (a requested
    value echoed back without checking what was actually applied) and found not to have it:
    resolveProfile prefers handle.profile() (what the launcher actually produced) over the
    request, falling back to the request only when the handle reports nothing; cwd is likewise
    resolved through the launcher, not echoed from the request. No fix needed there.
  • No other site exhibiting this same shape (request value trusted and reported back without
    verifying what was actually bound/applied) was found in the reviewed files
    (SessionManager, MemberRegistry, FleetMcp, FleetApp, MemberSession) within the time
    available for this sweep. This is a scoped sweep of the acquire/roster path touched by this
    ticket, not an exhaustive whole-codebase audit.

Ref: fleetd issue #123 / CB-619.

## What happened before this fix A spawn that asked for `role=architect` on a `profile` no architect slot carried was silently held as a plain `dev` session, but `GET /members` still reported `"role":"architect"` while `fleet_whoami` (which reads live bindings, not the request) correctly said `worker`/`dev`. Three sources of truth disagreed about one live member, silently. The root cause: an explicit `profile` on a spawn bypasses role-pool placement entirely (`CompositePeerLauncher` only constrains an *unqualified*, blank-profile spawn to `fleet.<role>` via placement). That is the one path that could ask for a role with no slot to bind it to. ## Decision (given by the lead, not reopened here) Option 1: **refuse the spawn.** An architect's identity IS the slot it is bound to, so binding a role with no slot to bind means inventing an identity out of nothing — a silent demotion is the quiet failure the whole role system exists to prevent. The refusal is scoped to `ARCHITECT` only, not a blanket role-pool check for every role. Evidence for that scoping: `CompositePeerLauncher` documents `fleet_spawn{profile:"opus"}` (role defaulting to `dev`) as a supported flow, and dev/reviewer pools are placement candidates only, never a live identity binding — generalizing the refusal to those roles would break that documented flow. ## What changed - **`fleetd/src/main/java/dev/ltms/fleet/auth/MemberLifecycle.java`** — `acquired(role, profile, terminal)` now *returns* `MemberRole` (the role the session actually holds), instead of `void`. New method `requireSlotFor(MemberRole role, String profile)`, throwing `IllegalArgumentException` pre-spawn. `NONE` singleton updated (no registry configured → nothing to validate/bind against). - **`fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java`** — the real implementation: - `acquired(...)`: on total bind failure for `ARCHITECT`, now logs at **WARN** (was INFO), naming `profile` and `terminal`, and returns `MemberRole.DEV` instead of silently keeping the request's role. - `requireSlotFor(...)`: no-op for non-`ARCHITECT`. For `ARCHITECT`, checks whether any configured architect slot's `profile()` matches the requested profile; if none does, throws with the **exact message**: ``` no architect slot for profile '<profile>' — an architect's identity IS the slot it is bound to, so there is nothing to bind this session's identity to. fleet.architects carries profiles: <comma-joined list, or "(none configured)">; add profile '<profile>' there, or spawn architect on one of those profiles instead ``` This names (a) the role (`architect`), (b) the profile asked for, and (c) the pools/profiles that do carry that role — exactly the three things the ticket requires. - **`fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java`** — wired the fix in: - Before any spawn happens, calls `memberLifecycle.requireSlotFor(memberRole, profile)` when an **explicit** (non-blank) profile was given — a blank profile is left to placement, which already constrains an unqualified spawn to the role's pool and is not part of this defect. - `memberLifecycle.acquired(...)` is now called *before* constructing `MemberSession` (in both the no-worktree and worktree-provisioning acquire paths), and its **return value** (`actualRole`) is what gets recorded on the session — never the originally requested `role`. Because `FleetMcp.memberView` and `SessionManager.rosterView` both just read `session.role()`, this one change makes `GET /members`/`fleet_list` automatically honest with **no changes needed** to either of those methods or their callers. - **`fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java`** — new test `aSecondArchitectOnAnAlreadyBoundProfileIsHeldAsDevNotArchitectAndWarnsLoudly()`. Pre-occupies the sole `opus` architect slot with `MemberRegistry.bind` (the one legitimate use of the registry seam here — to set up a pre-existing occupant, not the session under test), then drives a **real** `SessionManager.acquire("ltms-local", MemberRole.ARCHITECT, ...)` call through the real `ClaudeCodeLauncher`/`FakeHerdr`. Asserts the returned `MemberSession.role()` is `DEV`, that `SessionManager.rosterView(...)` reports `"role":"dev"`, and that a WARN-level log line (captured via a logback `ListAppender`) names both the profile and the terminal. - **`fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java`** — two new tests: - `spawnRefusesAnArchitectWithNoMatchingSlotAndNeverTouchesTheLauncher()` — drives `FleetMcp.spawn` end to end with `role=architect, profile=sonnet` against a registry whose only architect slot is `opus`. Asserts the error result names `architect`, `sonnet`, and `opus`; asserts the session roster stays empty; and asserts `FakeHerdr`'s recorded calls **never** contain an `agent.start` call — proving the refusal happens before any process spawns. - `rosterAndWhoamiAgreeOnceTheArchitectSlotBinds()` — positive control. Spawns `role=architect, profile=opus` (a profile that DOES have a slot), pinning the fixture's `FakeHerdr.WORKER_PID`-mapped pane via `pinNextStarts`. Asserts `FleetMcp.spawn`'s result AND `FleetMcp.listFleet` (`GET /members`) both say `"role":"architect"`, then builds a real `CallerResolver` against the **same** `MemberRegistry` instance, resolves the caller through `ConnectionIdentity`/`PaneLocator` keyed to that PID, and confirms `fleet_whoami` (via `FleetMcp.whoami`) also says `"role":"architect"` — proving `GET /members` and `fleet_whoami` agree through the same live binding, not by test-double coincidence. ## How the tests drive the real path, not the registry seam Both new spawn-path tests go through `FleetMcp.spawn(...)` → `SessionManager.acquire(...)` → the real `ClaudeCodeLauncher` (which in turn talks to `FakeHerdr`, the only stand-in, at the herdr-protocol boundary) → `MemberRegistry.requireSlotFor`/`acquired` (the real production class). `MemberRegistry.bind` is called directly exactly once, only to set up the **pre-existing occupant** for the race test — never to stand in for the session actually under test. That is the distinction issue #113 got wrong (it asserted on `MemberRegistry.bind` alone and shipped dead code behind a green suite). Both `GET /members` (`FleetMcp.listFleet` / `SessionManager.rosterView`) and `fleet_whoami` (`FleetMcp.whoami` via a real `CallerResolver`) are read back for the **same live session** in the positive-control test, exactly as the acceptance criteria require. ## Red-test evidence (watched fail before being kept) Verification process: `git stash push` on just the 3 production files (`MemberLifecycle.java`, `MemberRegistry.java`, `SessionManager.java`), keeping the 2 test files, then ran `mvn -q -o test -Dtest=SessionManagerTest,FleetMcpTest -pl .` against the reverted production code. Result: **`Tests run: 103, Failures: 2, Errors: 0, Skipped: 0`** — exactly the 2 new tests failed (the positive-control test in `FleetMcpTest` correctly still passed, since it exercises the already-correct `opus` happy path). Then `git stash pop` restored the fix, and a re-run of the same targeted tests went green before the full build below. ## `mvn clean install` — run from inside `fleetd/`, not repo root, output unpiped ``` Tests run: 1084, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` Full log captured to `/tmp/cb619_full_build.log` in the worker's own environment (not part of this repo). ## Same-shape sweep (report only, not fixed) - **In-feature residual TOCTOU race**, already handled honestly, not a separate defect: `requireSlotFor` closes the *config-gap* case (zero slots anywhere carry the profile). A profile that DOES have a slot can still lose a race to a concurrent spawn between that pre-check and the actual `bind()` inside `acquired()`. This is handled by `acquired()`'s honest fallback (returns `DEV`, logs WARN) rather than left silently wrong — but it is not itself refused pre-spawn, since refusing it would require the spawn to have already raced to know the slot's occupancy. - **`SessionManager.resolveProfile(handle, requestedProfile)`** (line ~561) and the `cwd` handling via `launcher.effectiveCwd(new SpawnRequest(...))` were checked for the same shape (a requested value echoed back without checking what was actually applied) and found **not** to have it: `resolveProfile` prefers `handle.profile()` (what the launcher actually produced) over the request, falling back to the request only when the handle reports nothing; `cwd` is likewise resolved through the launcher, not echoed from the request. No fix needed there. - No other site exhibiting this same shape (request value trusted and reported back without verifying what was actually bound/applied) was found in the reviewed files (`SessionManager`, `MemberRegistry`, `FleetMcp`, `FleetApp`, `MemberSession`) within the time available for this sweep. This is a scoped sweep of the acquire/roster path touched by this ticket, not an exhaustive whole-codebase audit. Ref: fleetd issue #123 / CB-619.
agent added 1 commit 2026-09-01 10:31:51 +02:00
CB-619 / fleetd #123: refuse an architect spawn with no matching slot
CI / build (pull_request) Successful in 1m16s
CI / contract (pull_request) Successful in 1m18s
866c7f2e9a
An explicit-profile spawn bypasses role-pool placement (CompositePeerLauncher
only constrains an UNQUALIFIED spawn to fleet.<role>), so it was the one path
that could ask for role=architect on a profile no architect slot carries.
MemberRegistry silently held the session as a plain worker while GET /members
still reported the requested "architect" and only fleet_whoami (which reads
live bindings, not the request) told the truth.

- MemberLifecycle.requireSlotFor(role, profile): refuses the acquire before
  anything spawns when no configured architect slot carries the profile,
  naming the role, the profile, and the pools that do carry it. No-op for
  dev/reviewer, which are placement candidates only, never a live identity
  binding — refusing a profile mismatch there would break the documented
  fleet_spawn{profile:"opus"} (role defaults to dev) flow.
- MemberLifecycle.acquired(...) now returns the role the session actually
  holds, so a residual race (a slot exists but every instance is already
  bound to a different terminal) still falls back to dev honestly instead of
  lying — this case logs at WARN (was INFO), naming profile and terminal.
- SessionManager now records the role acquired() returns on MemberSession,
  never the requested role, so GET /members and fleet_list can no longer
  report a role the member does not hold; no changes needed to memberView/
  rosterView, which just read session.role().

An architect's identity IS the slot it is bound to — binding a role with no
slot to bind means inventing an identity out of nothing, which is the quiet
failure the whole role system exists to prevent.

Tests: SessionManagerTest and FleetMcpTest each drive a real spawn through
FleetMcp.spawn -> SessionManager.acquire -> the real ClaudeCodeLauncher (via
FakeHerdr), then assert on GET /members and fleet_whoami for that same
session — not on MemberRegistry.bind directly (fleetd issue #113's mistake).
ltms merged commit dcf5fb3be3 into main 2026-09-01 10:41:47 +02:00
Sign in to join this conversation.