diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/MemberLifecycle.java b/fleetd/src/main/java/dev/ltms/fleet/auth/MemberLifecycle.java index cdce524..a7f1186 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/MemberLifecycle.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/MemberLifecycle.java @@ -7,15 +7,43 @@ public interface MemberLifecycle { MemberLifecycle NONE = new MemberLifecycle() { @Override - public void acquired(MemberRole role, String profile, String terminal) { + public MemberRole acquired(MemberRole role, String profile, String terminal) { + return role; // no registry configured — nothing to bind against, so the request stands } @Override public void released(String terminal) { } + + @Override + public void requireSlotFor(MemberRole role, String profile) { + // no registry configured — nothing to validate against, so nothing is refused + } }; - void acquired(MemberRole role, String profile, String terminal); + /** + * Try to bind a newly spawned {@code terminal} into the role it was granted. + * + * @return the role this session actually holds: {@code role} unchanged for a role with no + * live slot-binding semantics (dev, reviewer), or when the bind succeeded; a fallback + * role — never {@code role} — when a slot-bound role (architect) could not be bound. + * Callers must record THIS value on the session, never the requested {@code role}, so + * a later roster read never reports a role the session does not hold (CB-619). In + * normal operation this fallback should not happen once {@link #requireSlotFor} has + * refused every unbindable spawn upfront — but a slot can still be lost between that + * check and this call to a concurrent spawn racing for the same slot, so the honest + * answer is still needed here too. + */ + MemberRole acquired(MemberRole role, String profile, String terminal); void released(String terminal); + + /** + * Refuse an acquire before anything spawns when {@code role} requires a live slot binding and + * no configured slot carries {@code profile} (CB-619 / fleetd #123). A no-op for a role with + * no slot-binding semantics. + * + * @throws IllegalArgumentException naming the role, the profile, and the pools that do carry it + */ + void requireSlotFor(MemberRole role, String profile); } diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java b/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java index 7bfd463..3a97dd4 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java @@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -206,19 +207,72 @@ public final class MemberRegistry implements MemberLifecycle { * *

The role check is lifecycle policy. {@link CallerResolver} repeats it when resolving a * binding, so a later lifecycle regression cannot turn a worker into an architect. + * + *

CB-619 / fleetd #123: the return value is the role this session actually holds, and the + * caller is required to record THAT — never the requested {@code role} — on the session. Before + * this fix the caller kept the requested role regardless of whether the bind below succeeded, so + * a demoted session's {@code GET /members} row still said {@code "architect"} while + * {@code fleet_whoami} (which reads the live binding, not the request) correctly said + * {@code "worker"} — three sources of truth that disagreed about one live member, silently. */ @Override - public void acquired(MemberRole role, String profile, String terminal) { + public MemberRole acquired(MemberRole role, String profile, String terminal) { if (role != MemberRole.ARCHITECT || terminal == null || terminal.isBlank()) { - return; + return role; } // slotsFor preserves definition order, so duplicate-profile slots use the first free one. for (Entry entry : slotsFor(MemberRole.ARCHITECT).values()) { if (Objects.equals(profile, entry.profile()) && bind(entry.key(), terminal)) { - return; + return MemberRole.ARCHITECT; } } - log.info("member slot: no free architect slot for profile={}; session remains a worker", profile); + // fleetd #123: at least WARN — a role downgrade that the roster must now also reflect is + // not routine bookkeeping. requireSlotFor already refuses the config-gap case (no slot at + // all carries this profile) before a process ever spawns; reaching here means the config DID + // carry a matching slot but every one of them was already bound to a different terminal — a + // race this pre-spawn check cannot close on its own (see requireSlotFor's javadoc). + log.warn("member slot: no free architect slot for profile={} terminal={}; holding the session " + + "as {} instead of the architect it asked for — every configured slot for this " + + "profile is already bound to a different terminal", profile, terminal, + MemberRole.DEV.wireName()); + return MemberRole.DEV; + } + + /** + * CB-619 / fleetd #123: refuse an architect acquire before anything spawns when no configured + * slot carries {@code profile} — the config-gap case from the original defect report (a spawn + * asked for {@code role=architect, profile=sonnet}, and {@code fleet.architects} carried only + * {@code opus} and {@code sol}). A dev/reviewer acquire is always a no-op: those pools are + * placement candidates only (see {@code CompositePeerLauncher}), never a live identity binding, + * so there is nothing here to refuse — an explicit profile outside the pool for those roles is a + * documented operator override, not a defect. + * + *

This closes the config-gap case, not the live-capacity case: a profile that DOES carry a + * slot can still lose the race to a concurrent spawn between this check and the actual + * {@link #bind}, which is why {@link #acquired} must still answer honestly even after this + * check has passed. + */ + @Override + public void requireSlotFor(MemberRole role, String profile) { + if (role != MemberRole.ARCHITECT) { + return; + } + boolean hasSlot = slotsFor(MemberRole.ARCHITECT).values().stream() + .anyMatch(e -> Objects.equals(profile, e.profile())); + if (hasSlot) { + return; + } + List pools = slotsFor(MemberRole.ARCHITECT).values().stream() + .map(Entry::profile) + .distinct() + .toList(); + throw new IllegalArgumentException( + "no " + role.wireName() + " 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." + role.configKey() + " carries profiles: " + + (pools.isEmpty() ? "(none configured)" : String.join(", ", pools)) + + "; add profile '" + profile + "' there, or spawn " + role.wireName() + + " on one of those profiles instead"); } /** Unbind a released terminal using the compare-safe registry operation. */ diff --git a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java index eb2f938..9480ba0 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java +++ b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java @@ -183,6 +183,13 @@ public final class SessionManager implements TurnListener { String sessionName, String resumeSessionId) { MemberRole memberRole = (role == null) ? MemberRole.DEV : role; requireResumeCapability(profile, resumeSessionId); + // CB-619 / fleetd #123: an explicit profile bypasses placement (CompositePeerLauncher only + // constrains an UNQUALIFIED spawn to the role's pool), so it is the one path that can ask + // for a role with no slot to bind it to. Refuse before anything spawns. A blank profile is + // left to placement, which already restricts an unqualified spawn to the role's pool. + if (profile != null && !profile.isBlank()) { + memberLifecycle.requireSlotFor(memberRole, profile); + } if (wt == null) { // CB-557: the role must ride on the SpawnRequest, not stay a local. The launcher needs it // to pick the profile out of that role's pool and to label the tab; a role kept only on @@ -198,11 +205,15 @@ public final class SessionManager implements TurnListener { String resolvedProfile = resolveProfile(handle, profile); String cwd = launcher.effectiveCwd(new SpawnRequest(resolvedProfile, requestedCwd, callerCwd)); long now = nowNanos.getAsLong(); + // CB-619: bind (or fail to bind) BEFORE the session is recorded, and store whatever role + // this call actually returns — never the requested memberRole — so the session's role, + // what GET /members and fleet_list report, is never a lie about what this terminal holds. + MemberRole actualRole = memberLifecycle.acquired(memberRole, resolvedProfile, handle.terminalId()); MemberSession session = new MemberSession( handle.id(), handle.terminalId(), resolvedProfile, - memberRole, + actualRole, cwd, ownerTerminal, now, @@ -215,7 +226,6 @@ public final class SessionManager implements TurnListener { handle.agentSessionId()); registry.put(handle.id(), session); handles.put(handle.id(), handle); - memberLifecycle.acquired(session.role(), session.profile(), session.terminalId()); log.debug("acquired session id={} terminal={} profile={} owner={}", handle.id(), handle.terminalId(), session.profile(), session.ownerTerminal()); notifyAcquired(session.terminalId()); @@ -509,11 +519,14 @@ public final class SessionManager implements TurnListener { String resolvedProfile = resolveProfile(handle, profile); String cwd = launcher.effectiveCwd(new SpawnRequest(resolvedProfile, path, callerCwd)); long now = nowNanos.getAsLong(); + // CB-619: see the no-worktree path above — bind before recording, and store the returned + // actual role, so this session's role is never a lie about what it actually holds. + MemberRole actualRole = memberLifecycle.acquired(memberRole, resolvedProfile, handle.terminalId()); MemberSession session = new MemberSession( handle.id(), handle.terminalId(), resolvedProfile, - memberRole, + actualRole, cwd, ownerTerminal, now, @@ -526,7 +539,6 @@ public final class SessionManager implements TurnListener { handle.agentSessionId()); registry.put(handle.id(), session); handles.put(handle.id(), handle); - memberLifecycle.acquired(session.role(), session.profile(), session.terminalId()); log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}", handle.id(), handle.terminalId(), session.profile(), session.branch(), session.worktree()); notifyAcquired(session.terminalId()); diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java index e53cbfb..5f97c77 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java @@ -1,10 +1,13 @@ package dev.ltms.fleet.mcp; +import dev.ltms.fleet.auth.CallerResolver; +import dev.ltms.fleet.auth.MemberRegistry; import dev.ltms.fleet.auth.Principal; import dev.ltms.fleet.config.FleetConfig; import dev.ltms.fleet.guard.SubscriptionGuard; import dev.ltms.fleet.herdr.AgentControl; import dev.ltms.fleet.herdr.FakeHerdr; +import dev.ltms.fleet.herdr.PaneLocator; import dev.ltms.fleet.herdr.WorkspaceControl; import dev.ltms.fleet.inject.Injector; import dev.ltms.fleet.msg.MessageService; @@ -1024,6 +1027,87 @@ class FleetMcpTest { assertTrue(textOf(res).contains("architect, dev, reviewer"), textOf(res)); } + // ── CB-619 / fleetd #123: a spawn asking for a role its profile has no slot for must be + // refused, never silently demoted with the roster still lying about it ─────────────────── + + /** Two profiles on one launcher, so a role's pool can name one and exclude the other. */ + private static ClaudeCodeLauncher architectCapableLauncher(FakeHerdr h) { + FleetConfig.Profile opus = new FleetConfig.Profile( + "opus", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null, + "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null); + FleetConfig.Profile sonnet = new FleetConfig.Profile( + "sonnet", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null, + "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null); + return new ClaudeCodeLauncher(new AgentControl(h), new WorkspaceControl(h), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of("opus", opus, "sonnet", sonnet), + "sonnet", _ -> "tok"); + } + + /** {@code fleet.architects} carries only {@code opus} — {@code sonnet} has no matching slot. */ + private static MemberRegistry architectRegistry() { + return new MemberRegistry(new FleetConfig.Fleet(Map.of(), + Map.of("opus", new FleetConfig.Slot("opus")), Map.of(), Map.of(), null)); + } + + /** + * The literal defect (fleetd #123): {@code role=architect, profile=sonnet}, where + * {@code fleet.architects} carries only {@code opus}. Drives the real path — + * {@link FleetMcp#spawn} calls {@link SessionManager#acquire}, which must refuse before ever + * reaching the real {@link ClaudeCodeLauncher} — never {@link MemberRegistry#bind} called + * directly, which would walk around the gate under test. + */ + @Test + void spawnRefusesAnArchitectWithNoMatchingSlotAndNeverTouchesTheLauncher() { + FakeHerdr h = new FakeHerdr(); + SessionManager sessions = new SessionManager(architectCapableLauncher(h)); + sessions.setMemberLifecycle(architectRegistry()); + + McpSchema.CallToolResult res = FleetMcp.spawn(sessions, "sonnet", "architect", + null, null, null, null, null, null); + + assertEquals(Boolean.TRUE, res.isError(), textOf(res)); + String msg = textOf(res); + assertTrue(msg.contains("architect"), "names the role asked for: " + msg); + assertTrue(msg.contains("sonnet"), "names the profile: " + msg); + assertTrue(msg.contains("opus"), "names the pool that does carry the role: " + msg); + assertTrue(sessions.roster().isEmpty(), "a refused spawn must register no session"); + assertTrue(h.calls.stream().noneMatch(c -> c.method().equals("agent.start")), + "a refused spawn must never reach the launcher — no process should ever start"); + } + + /** + * Positive control / parity check: when the profile DOES carry a slot, the spawn succeeds, and + * {@code GET /members} ({@link FleetMcp#listFleet}) and {@code fleet_whoami} + * ({@link FleetMcp#whoami}) — resolved through the SAME live {@link MemberRegistry} binding via + * a real {@link CallerResolver}, exactly as the daemon resolves a real MCP caller — must never + * disagree about this one live member's role. + */ + @Test + void rosterAndWhoamiAgreeOnceTheArchitectSlotBinds() { + FakeHerdr h = new FakeHerdr(); + // Pin the spawn onto the one pane FakeHerdr's canned pane.process_info maps to WORKER_PID, + // so a CallerResolver can resolve THIS session's own terminal, not a fixture double. + h.pinNextStarts(1, "term_a", "w2:p7"); + SessionManager sessions = new SessionManager(architectCapableLauncher(h)); + MemberRegistry members = architectRegistry(); + sessions.setMemberLifecycle(members); + + McpSchema.CallToolResult spawnRes = FleetMcp.spawn(sessions, "opus", "architect", + null, null, null, null, null, null); + assertNotEquals(Boolean.TRUE, spawnRes.isError(), textOf(spawnRes)); + assertTrue(textOf(spawnRes).contains("\"role\":\"architect\""), textOf(spawnRes)); + + String roster = textOf(FleetMcp.listFleet(architectCapableLauncher(h), sessions, Map.of(), "")); + assertTrue(roster.contains("\"role\":\"architect\""), "GET /members: " + roster); + + ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(h), _ -> FakeHerdr.WORKER_PID); + CallerResolver resolver = CallerResolver.withLeadsAndMembers(identity, false, null, Map::of, members); + Principal caller = resolver.resolve("127.0.0.1", 42, null); + assertTrue(caller.isArchitect(), "fleet_whoami's own resolver must agree the terminal is bound"); + String whoami = textOf(FleetMcp.whoami(caller, sessions)); + assertTrue(whoami.contains("\"role\":\"architect\""), "fleet_whoami: " + whoami); + } + // ── CB-584: fleet_spawn accepts sessionName/resumeSessionId; roster shows agentSessionId ── @Test diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java index 9c3a56c..2d9dd20 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java @@ -4,6 +4,7 @@ import ch.qos.logback.classic.Level; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; +import dev.ltms.fleet.auth.MemberRegistry; import dev.ltms.fleet.config.FleetConfig; import dev.ltms.fleet.guard.SubscriptionGuard; import dev.ltms.fleet.herdr.AgentControl; @@ -333,6 +334,57 @@ class SessionManagerTest { } } + /** + * CB-619 / fleetd #123: {@code requireSlotFor} closes the config-gap case (no slot at all + * carries the profile) before anything spawns, but a profile that DOES carry a slot can still + * lose the bind to a concurrent spawn racing for the same slot. This drives that residual case + * through the REAL path — {@link SessionManager#acquire} against the real {@link + * dev.ltms.fleet.member.ClaudeCodeLauncher} and {@link FakeHerdr} — never {@link + * dev.ltms.fleet.auth.MemberRegistry#bind} directly for the session under test (only the + * precondition uses it, to occupy the slot before the real spawn happens). The session that + * loses the race must be held as a plain {@code dev}, never left claiming {@code architect} in + * the roster, and the daemon log must say so at WARN. + */ + @Test + void aSecondArchitectOnAnAlreadyBoundProfileIsHeldAsDevNotArchitectAndWarnsLoudly() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + ch.qos.logback.classic.Logger registryLog = (ch.qos.logback.classic.Logger) + LoggerFactory.getLogger(MemberRegistry.class); + ListAppender appender = new ListAppender<>(); + appender.setContext(ctx); + appender.start(); + registryLog.addAppender(appender); + registryLog.setLevel(Level.WARN); + try { + FakeHerdr herdr = new FakeHerdr(); + SessionManager sessions = sessionManager(herdr); + MemberRegistry members = new MemberRegistry( + new FleetConfig.Fleet(Map.of(), Map.of("opus", new FleetConfig.Slot("ltms-local")), + Map.of(), Map.of(), null)); + assertTrue(members.bind("architect:opus", "term_already_bound"), + "precondition: occupy the sole architect slot before the real spawn under test"); + sessions.setMemberLifecycle(members); + + MemberSession session = sessions.acquire("ltms-local", MemberRole.ARCHITECT, null, + "/caller", "term_primary", null); + + assertEquals(MemberRole.DEV, session.role(), + "the slot is taken, so this session must be held as a plain member, never a lie"); + assertEquals("dev", SessionManager.rosterView(session, null).get("role"), + "the roster must report what this session actually holds, not what it asked for"); + + String warn = appender.list.stream() + .filter(e -> e.getLevel().equals(Level.WARN)) + .map(ILoggingEvent::getFormattedMessage) + .findFirst() + .orElse("no slot-exhaustion WARN logged"); + assertTrue(warn.contains("ltms-local"), "the log names the profile: " + warn); + assertTrue(warn.contains(session.terminalId()), "the log names the terminal: " + warn); + } finally { + registryLog.detachAppender(appender); + } + } + @Test void rosterReflectsAcquiredMinusReleased() { FakeHerdr herdr = new FakeHerdr();