CB-619 / fleetd #123: refuse an architect spawn with no matching slot #223
Reference in New Issue
Block a user
Delete Branch "worker/cb-123-role-demotion-c600f7-2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What happened before this fix
A spawn that asked for
role=architecton aprofileno architect slot carried was silentlyheld as a plain
devsession, butGET /membersstill reported"role":"architect"whilefleet_whoami(which reads live bindings, not the request) correctly saidworker/dev.Three sources of truth disagreed about one live member, silently.
The root cause: an explicit
profileon a spawn bypasses role-pool placement entirely(
CompositePeerLauncheronly constrains an unqualified, blank-profile spawn tofleet.<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
ARCHITECTonly, not a blanket role-pool check for every role. Evidencefor that scoping:
CompositePeerLauncherdocumentsfleet_spawn{profile:"opus"}(role defaultingto
dev) as a supported flow, and dev/reviewer pools are placement candidates only, never a liveidentity 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 returnsMemberRole(the role the session actually holds), instead ofvoid.New method
requireSlotFor(MemberRole role, String profile), throwingIllegalArgumentExceptionpre-spawn.
NONEsingleton 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 forARCHITECT, now logs at WARN (was INFO),naming
profileandterminal, and returnsMemberRole.DEVinstead of silently keeping therequest's role.
requireSlotFor(...): no-op for non-ARCHITECT. ForARCHITECT, checks whether anyconfigured architect slot's
profile()matches the requested profile; if none does, throwswith the exact message:
architect), (b) the profile asked for, and (c) the pools/profilesthat 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:memberLifecycle.requireSlotFor(memberRole, profile)when anexplicit (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 constructingMemberSession(in boththe no-worktree and worktree-provisioning acquire paths), and its return value (
actualRole)is what gets recorded on the session — never the originally requested
role. BecauseFleetMcp.memberViewandSessionManager.rosterViewboth just readsession.role(), this onechange makes
GET /members/fleet_listautomatically honest with no changes needed toeither of those methods or their callers.
fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java— new testaSecondArchitectOnAnAlreadyBoundProfileIsHeldAsDevNotArchitectAndWarnsLoudly(). Pre-occupies thesole
opusarchitect slot withMemberRegistry.bind(the one legitimate use of the registry seamhere — 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 realClaudeCodeLauncher/FakeHerdr. Asserts the returnedMemberSession.role()isDEV, thatSessionManager.rosterView(...)reports"role":"dev", and that a WARN-level log line (capturedvia a logback
ListAppender) names both the profile and the terminal.fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java— two new tests:spawnRefusesAnArchitectWithNoMatchingSlotAndNeverTouchesTheLauncher()— drivesFleetMcp.spawnend to end withrole=architect, profile=sonnetagainst a registry whose onlyarchitect slot is
opus. Asserts the error result namesarchitect,sonnet, andopus;asserts the session roster stays empty; and asserts
FakeHerdr's recorded calls nevercontain an
agent.startcall — proving the refusal happens before any process spawns.rosterAndWhoamiAgreeOnceTheArchitectSlotBinds()— positive control. Spawnsrole=architect, profile=opus(a profile that DOES have a slot), pinning the fixture'sFakeHerdr.WORKER_PID-mapped pane viapinNextStarts. AssertsFleetMcp.spawn's result ANDFleetMcp.listFleet(GET /members) both say"role":"architect", then builds a realCallerResolveragainst the sameMemberRegistryinstance, resolves the caller throughConnectionIdentity/PaneLocatorkeyed to that PID, and confirmsfleet_whoami(viaFleetMcp.whoami) also says"role":"architect"— provingGET /membersandfleet_whoamiagree 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(...)→ thereal
ClaudeCodeLauncher(which in turn talks toFakeHerdr, the only stand-in, at theherdr-protocol boundary) →
MemberRegistry.requireSlotFor/acquired(the real production class).MemberRegistry.bindis called directly exactly once, only to set up the pre-existing occupantfor 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.bindalone and shipped dead code behind agreen suite). Both
GET /members(FleetMcp.listFleet/SessionManager.rosterView) andfleet_whoami(FleetMcp.whoamivia a realCallerResolver) are read back for the same livesession in the positive-control test, exactly as the acceptance criteria require.
Red-test evidence (watched fail before being kept)
Verification process:
git stash pushon just the 3 production files (MemberLifecycle.java,MemberRegistry.java,SessionManager.java), keeping the 2 test files, then ranmvn -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
FleetMcpTestcorrectly still passed, since it exercises thealready-correct
opushappy path). Thengit stash poprestored the fix, and a re-run of the sametargeted tests went green before the full build below.
mvn clean install— run from insidefleetd/, not repo root, output unpipedFull log captured to
/tmp/cb619_full_build.login the worker's own environment (not part of thisrepo).
Same-shape sweep (report only, not fixed)
requireSlotForcloses 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 byacquired()'s honest fallback (returnsDEV, 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 thecwdhandlingvia
launcher.effectiveCwd(new SpawnRequest(...))were checked for the same shape (a requestedvalue echoed back without checking what was actually applied) and found not to have it:
resolveProfileprefershandle.profile()(what the launcher actually produced) over therequest, falling back to the request only when the handle reports nothing;
cwdis likewiseresolved through the launcher, not echoed from the request. No fix needed there.
verifying what was actually bound/applied) was found in the reviewed files
(
SessionManager,MemberRegistry,FleetMcp,FleetApp,MemberSession) within the timeavailable 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.
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).