From e1d7ddeabbe1aa404167e06daf1dae7a15c269f1 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 10 Sep 2026 12:23:42 +0700 Subject: [PATCH] fleetd #425: fleet_profiles' default and worktree provisioning must read live placement fleet_profiles' "default" was CompositePeerLauncher.defaultProfile, a value frozen at construction from cfg.effectiveDefaultProfile(). An unqualified fleet_spawn instead resolves the dev pool live via defaultProfileFor(DEV) on every call, so reordering fleet.developers and reloading changed where a spawn landed without ever changing what fleet_profiles reported. - CompositePeerLauncher.defaultProfile() now delegates to defaultProfileFor(MemberRole.DEV) -- the same live, reload-aware pool read placement already uses -- falling back to the frozen field only when no profiles are configured at all. - PeerLauncher gains a default defaultProfileFor(MemberRole) method so a generic PeerLauncher reference can ask for a role's live default; the default implementation delegates to defaultProfile() for launchers with no pool concept of their own. - SessionManager.acquireWithWorktree resolved a profile via launcher.defaultProfile() (DEV-only) to provision repoRoot/parityOverlay, then spawned with the original (possibly blank) profile, which re-resolves independently through placement -- for any non-DEV role, or across a config reload between the two reads, the two resolutions could disagree and provision a worktree for a profile the member never runs on. Fixed by resolving once, through defaultProfileFor(the caller's actual role), and reusing that same resolved name for repoRoot, parityOverlay, and the spawn itself. Trade-off: this path now spawns with an explicit profile rather than a blank one, so it loses CompositePeerLauncher's cross-candidate retry on PeerUnreachableException -- accepted because a worktree provisioned for the wrong backend is worse than a spawn that fails cleanly and can be retried. Tests: CompositePeerLauncherTest (live dev-pool reorder + empty-pool fallback), FleetProfilesLiveDefaultTest (drives FleetMcp.profilesView directly), SessionManagerTest (worktree overlay follows a reorder, and a non-DEV role's worktree spawn uses that role's pool, not DEV's). --- .../fleet/member/CompositePeerLauncher.java | 33 ++++- .../dev/ltms/fleet/peer/PeerLauncher.java | 25 ++++ .../ltms/fleet/session/SessionManager.java | 22 +++- .../mcp/FleetProfilesLiveDefaultTest.java | 114 ++++++++++++++++ .../member/CompositePeerLauncherTest.java | 87 ++++++++++++ .../fleet/session/SessionManagerTest.java | 124 ++++++++++++++++++ 6 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 fleetd/src/test/java/dev/ltms/fleet/mcp/FleetProfilesLiveDefaultTest.java diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java index f4eced5..597c577 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java @@ -508,8 +508,23 @@ public final class CompositePeerLauncher implements PeerLauncher { return known.isEmpty() ? List.copyOf(configured.keySet()) : known; } - /** The profile an unqualified spawn for {@code role} falls back to under {@code fixed} placement. */ - private String defaultProfileFor(MemberRole role) { + /** + * {@inheritDoc} + * + *

Live: reads {@link #poolFor}, which reads {@link #profileConfigs} and {@link #fleet} fresh + * on every call, so a config reload is visible without a restart (fleetd #425) — unlike {@link + * #defaultProfile}, the field captured once at construction, which this falls back to only when + * {@link #poolFor} has nothing to offer at all (no profiles configured for this composite). + * + *

Exact only under the {@code fixed} placement policy — the one that reads this value + * ({@code FixedPlacementPolicy}, package-private, hence not linked) as its first, preferred + * candidate. {@code weighted}/{@code round-robin} placement can choose a different candidate + * from {@code role}'s pool even on the very first spawn; this method does not simulate that + * choice, matching what the {@code defaultProfile:}-derived reporting this replaces has always + * done. + */ + @Override + public String defaultProfileFor(MemberRole role) { List pool = poolFor(role); return pool.isEmpty() ? defaultProfile : pool.getFirst(); } @@ -647,9 +662,21 @@ public final class CompositePeerLauncher implements PeerLauncher { return byProfile.keySet(); } + /** + * {@inheritDoc} + * + *

fleetd #425: reports the live {@code dev} pool's first entry — the same value + * {@link #defaultProfileFor} computes for {@link MemberRole#DEV} — not the {@link + * #defaultProfile} field captured at construction. An unqualified {@code fleet_spawn} defaults + * to {@code MemberRole#DEV} (see {@link dev.ltms.fleet.peer.SpawnRequest}), so "the dev pool's + * live first entry" is exactly the profile such a spawn actually lands on right now — the + * question {@code fleet_profiles}' {@code "default"} field exists to answer. The frozen field is + * a role-agnostic fallback used only when {@link #poolFor} has nothing to report at all (no + * profiles configured), which {@link #defaultProfileFor} already handles. + */ @Override public String defaultProfile() { - return defaultProfile; + return defaultProfileFor(MemberRole.DEV); } /** diff --git a/fleetd/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java index a80184c..7b4941c 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/peer/PeerLauncher.java @@ -143,9 +143,34 @@ public interface PeerLauncher { /** * The profile a no-argument {@link #spawn(SpawnRequest)} uses, or {@code null} if none is configured. + * + *

fleetd #425: for an implementation with role pools (a no-argument spawn is read as {@link + * MemberRole#DEV}, see {@link SpawnRequest}), this must be the profile a live spawn of that role + * would actually be placed on right now, not a value captured once at startup — a caller such as + * {@code fleet_profiles} relies on this to report a live, not frozen, fact. */ String defaultProfile(); + /** + * The profile an unqualified spawn of {@code role} would resolve to right now — the role-aware, + * live counterpart of {@link #defaultProfile()} (fleetd #425). + * + *

A caller that must provision something profile-specific (working directory, parity overlay + * files) before the actual spawn — {@code SessionManager.acquireWithWorktree} is the one + * that exists today — needs the exact profile that spawn will use, for the caller's real role, + * not a role-agnostic guess. Calling {@link #defaultProfile()} for that purpose reads {@code + * MemberRole#DEV}'s answer regardless of the caller's actual role, which is wrong for any other + * role and can provision for a profile the spawn never lands on. + * + *

Default implementation returns {@link #defaultProfile()}, ignoring {@code role} — the right + * answer for a launcher with no role-pool concept of its own (e.g. a single {@code + * HerdrPeerLauncher} adapter, which is never reached this way in production: {@code + * CompositePeerLauncher} always fronts it and resolves roles itself). + */ + default String defaultProfileFor(MemberRole role) { + return defaultProfile(); + } + /** * Resolve the effective working directory for a spawn {@code req} without actually spawning. * Resolution order: requestedCwd → profile cwd → callerCwd → daemon cwd. 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 c1f3977..eb955a5 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java +++ b/fleetd/src/main/java/dev/ltms/fleet/session/SessionManager.java @@ -584,8 +584,21 @@ public final class SessionManager implements TurnListener { String ownerTerminal, WorktreeRequest wt, String sessionName, String resumeSessionId, MemberLifecycle.SlotReservation reservation) { + // fleetd #425: resolved through the role's live pool (launcher.defaultProfileFor(memberRole)), + // never launcher.defaultProfile() — that answers for MemberRole.DEV only, and a worktree spawn + // can be for any role. This same resolved name is reused below for repoRoot, parityOverlay, + // AND the spawn itself (an explicit profile, not a blank one) so the worktree is always + // provisioned for the profile the member actually runs on. Before this fix the two could + // disagree: this name picked repoRoot/overlay, but the spawn below passed the ORIGINAL + // (blank) profile through to placement, which re-resolves live and can pick a different + // profile if the pool changed between the two reads, or a genuinely different one under + // weighted/round-robin placement. The cost is that an unqualified worktree-provisioned spawn + // no longer gets CompositePeerLauncher's cross-candidate retry on PeerUnreachableException — + // it is now a single explicit-profile spawn, same as one where the caller names a profile. + // That trade is deliberate: a worktree provisioned for the wrong backend (the #425 hazard) is + // worse than a spawn that fails cleanly and can be retried by the caller. String preResolvedProfile = (profile == null || profile.isBlank()) - ? launcher.defaultProfile() : profile; + ? launcher.defaultProfileFor(memberRole) : profile; // CB-507: resolve through the launcher's CB-112 chain (requested → profile cwd → caller → // daemon cwd → "."), never the raw args. A plain REST spawn supplies neither a requested // nor a caller cwd, so taking the first non-blank of those two yielded null and put @@ -608,7 +621,10 @@ public final class SessionManager implements TurnListener { // copies more files into the worktree after add() returns, so sharing the group any earlier // leaves those overlay files operator-owned and read-only for a different-uid member. worktrees.shareWithGroup(repoRoot, path); - handle = launcher.spawn(new SpawnRequest(profile, path, callerCwd, sessionName, resumeSessionId, memberRole)); + // fleetd #425: preResolvedProfile, not the original (possibly blank) profile — see the + // comment above where it is resolved. The overlay/repoRoot above and the spawn here must + // name the same profile. + handle = launcher.spawn(new SpawnRequest(preResolvedProfile, path, callerCwd, sessionName, resumeSessionId, memberRole)); } catch (RuntimeException e) { log.warn("spawn failed for profile={} role={} branch={} path={}: {}", preResolvedProfile, memberRole, branch, path, e.getMessage()); @@ -636,7 +652,7 @@ public final class SessionManager implements TurnListener { } throw e; } - String resolvedProfile = resolveProfile(handle, profile); + String resolvedProfile = resolveProfile(handle, preResolvedProfile); 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 diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetProfilesLiveDefaultTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetProfilesLiveDefaultTest.java new file mode 100644 index 0000000..08e571e --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetProfilesLiveDefaultTest.java @@ -0,0 +1,114 @@ +package dev.ltms.fleet.mcp; + +import dev.ltms.fleet.config.ConfigRef; +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.WorkspaceControl; +import dev.ltms.fleet.member.ClaudeCodeLauncher; +import dev.ltms.fleet.member.CompositePeerLauncher; +import dev.ltms.fleet.peer.MemberRole; +import dev.ltms.fleet.peer.PeerLauncher; +import dev.ltms.fleet.peer.SpawnRequest; +import dev.ltms.fleet.placement.BackendQuarantine; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #425: {@code fleet_profiles}' {@code "default"} field was captured once at boot + * ({@code cfg.effectiveDefaultProfile()}, frozen into {@code CompositePeerLauncher.defaultProfile} + * at construction) while an unqualified spawn resolves the same underlying key + * ({@code fleet.developers}' first entry) live, on every call. Reordering {@code fleet.developers} + * and reloading changed where a spawn landed without ever changing what {@code fleet_profiles} + * reported — a lead following {@code CLAUDE.md}'s "check {@code fleet_profiles} once per session" + * instruction was told a stale answer. + * + *

This test drives the exact caller {@code fleet_profiles} uses — + * {@link FleetMcp#profilesView(PeerLauncher, FleetMcp.QuarantineSource, FleetMcp.OutageSource)} — + * against a real, reloadable {@link ConfigRef}, so it fails if the reporting path is ever recoupled + * to a frozen value instead of {@link CompositePeerLauncher#defaultProfile()}'s live answer. + */ +class FleetProfilesLiveDefaultTest { + + /** A minimal fleetd.yaml whose dev pool is {@code profilesInOrder}, in that definition order. */ + private static String yamlWithDevPool(String... profilesInOrder) { + StringBuilder devPool = new StringBuilder(); + for (int i = 0; i < profilesInOrder.length; i++) { + devPool.append(" slot").append(i).append(":\n profile: ") + .append(profilesInOrder[i]).append('\n'); + } + return """ + bind: + host: 127.0.0.1 + port: 8765 + herdrSocket: ~/.config/herdr/herdr.sock + profiles: + opus: + baseUrl: http://gx00.gw:8000 + model: opus-coder + sonnet: + baseUrl: http://gx00.gw:8000 + model: sonnet-coder + guard: + offSubscriptionHosts: + - gx00.gw + fleet: + developers: + """ + devPool; + } + + @Test + void fleetProfilesDefaultTracksALiveDevPoolReorderAfterReload(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, yamlWithDevPool("opus", "sonnet")); + ConfigRef ref = new ConfigRef(f, FleetConfig.load(f)); + + Map profiles = Map.of( + "opus", new FleetConfig.Profile("opus", "http://gx00.gw:8000", "opus-coder", null, + "FLEETD_WORKER_TOKEN", List.of("claude"), "tab", "fleetd-workers", + "worker: {profile} #{n}", null, null, null), + "sonnet", new FleetConfig.Profile("sonnet", "http://gx00.gw:8000", "sonnet-coder", null, + "FLEETD_WORKER_TOKEN", List.of("claude"), "tab", "fleetd-workers", + "worker: {profile} #{n}", null, null, null)); + FakeHerdr herdr = new FakeHerdr(); + ClaudeCodeLauncher adapter = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), profiles, "opus", _ -> null); + PeerLauncher workers = new CompositePeerLauncher( + List.of(adapter), "opus", ref, _ -> 0, BackendQuarantine.none()); + + assertReportedDefaultMatchesAnUnqualifiedSpawn(workers, "opus"); + + Files.writeString(f, yamlWithDevPool("sonnet", "opus")); + ConfigRef.Outcome out = ref.reload(); + assertTrue(out.applied(), () -> "reload should apply cleanly: " + out.error()); + + assertReportedDefaultMatchesAnUnqualifiedSpawn(workers, "sonnet"); + } + + /** + * Asserts BOTH that {@code fleet_profiles}' {@code "default"} equals {@code expected}, AND that + * it equals what a real unqualified {@code MemberRole#DEV} spawn actually gets placed on right + * now — the two facts fleetd #425 found disagreeing. + */ + private static void assertReportedDefaultMatchesAnUnqualifiedSpawn(PeerLauncher workers, String expected) { + Map view = FleetMcp.profilesView( + workers, FleetMcp.QuarantineSource.none(), FleetMcp.OutageSource.none()); + assertEquals(expected, view.get("default"), + "fleet_profiles' \"default\" must be the live dev-pool answer, not a boot-time snapshot"); + + String placed = workers.spawn( + new SpawnRequest(null, null, null, null, null, MemberRole.DEV)).profile(); + assertEquals(expected, placed, + "sanity: the profile an unqualified dev spawn actually lands on"); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java index 3ef032c..b0c8791 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java @@ -3,6 +3,7 @@ package dev.ltms.fleet.member; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; +import dev.ltms.fleet.config.ConfigRef; import dev.ltms.fleet.config.FleetConfig; import dev.ltms.fleet.guard.SubscriptionGuard; import dev.ltms.fleet.herdr.Agent; @@ -22,8 +23,11 @@ import dev.ltms.fleet.placement.BackendQuarantine; import dev.ltms.fleet.placement.PlacementException; import dev.ltms.fleet.placement.PlacementPolicies; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.slf4j.LoggerFactory; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.EnumSet; import java.util.HashMap; import java.util.LinkedHashMap; @@ -908,6 +912,89 @@ class CompositePeerLauncherTest { assertTrue(e.getMessage().contains("maxLoad"), e.getMessage()); } + // ── fleetd #425: defaultProfile()/defaultProfileFor() must track a live reload ───────────── + + /** A minimal fleetd.yaml whose dev pool is {@code profilesInOrder}, in that definition order. */ + private static String yamlWithDevPool(String... profilesInOrder) { + StringBuilder devPool = new StringBuilder(); + for (int i = 0; i < profilesInOrder.length; i++) { + devPool.append(" slot").append(i).append(":\n profile: ") + .append(profilesInOrder[i]).append('\n'); + } + return """ + bind: + host: 127.0.0.1 + port: 8765 + herdrSocket: ~/.config/herdr/herdr.sock + profiles: + opus: + baseUrl: http://gx00.gw:8000 + model: opus-coder + sonnet: + baseUrl: http://gx00.gw:8000 + model: sonnet-coder + guard: + offSubscriptionHosts: + - gx00.gw + fleet: + developers: + """ + devPool; + } + + /** + * Criterion 1 (fleetd #425): reorder {@code fleet.developers}, reload, and assert the reported + * default ({@link CompositePeerLauncher#defaultProfile()} — what {@code fleet_profiles}' {@code + * "default"} is built from, see {@code FleetMcp.profilesView}) matches what an unqualified + * {@code MemberRole#DEV} spawn is actually placed on, both before and after the reorder. Asserts + * {@code applied()} so the test proves the reload actually took, not that nothing changed. + */ + @Test + void defaultProfileTracksALiveDevPoolReorderAfterReload(@TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, yamlWithDevPool("opus", "sonnet")); + ConfigRef ref = new ConfigRef(f, FleetConfig.load(f)); + + FakeHerdr herdr = new FakeHerdr(); + StubLauncher adapter = new StubLauncher("claude", herdr, threeProfiles(), "opus", Set.of()); + CompositePeerLauncher composite = new CompositePeerLauncher( + List.of(adapter), "opus", ref, _ -> 0, BackendQuarantine.none()); + + assertEquals("opus", composite.defaultProfile(), + "reported default starts at the dev pool's first entry"); + assertEquals("opus", composite.spawn( + new SpawnRequest(null, null, null, null, null, MemberRole.DEV)).profile(), + "an unqualified dev spawn must land on the same profile that was just reported"); + + Files.writeString(f, yamlWithDevPool("sonnet", "opus")); + ConfigRef.Outcome out = ref.reload(); + assertTrue(out.applied(), () -> "reload should apply cleanly: " + out.error()); + + assertEquals("sonnet", composite.defaultProfile(), + "the reported default must follow the reorder with no daemon restart"); + assertEquals("sonnet", composite.spawn( + new SpawnRequest(null, null, null, null, null, MemberRole.DEV)).profile(), + "and it must still be exactly what an unqualified spawn actually gets"); + } + + /** + * Criterion 2 — the mirror, and the load-bearing half (fleetd #425): with NOTHING configured (no + * profiles at all, hence an empty pool for every role), the frozen {@code defaultProfile} field + * is still what gets reported. A fix that always returns {@code poolFor(role).getFirst()} with no + * empty-pool fallback throws or returns the wrong thing here even though criterion 1 above still + * passes — this is the test that catches it. + */ + @Test + void defaultProfileFallsBackToTheFrozenFieldWhenNothingIsConfiguredAtAll() { + FakeHerdr herdr = new FakeHerdr(); + StubLauncher adapter = new StubLauncher("claude", herdr, Map.of(), "opus", Set.of()); + CompositePeerLauncher composite = new CompositePeerLauncher( + List.of(adapter), "opus", Map.of(), PlacementPolicies.fixed(), _ -> 0); + + assertEquals("opus", composite.defaultProfile(), + "with no profiles configured at all, the frozen field is the only answer available"); + assertEquals("opus", composite.defaultProfileFor(MemberRole.DEV)); + } + // ── CB-578 stage B: a BACKEND_EXHAUSTED classification quarantines the credential ────────── @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 77e1ff2..b09ee1b 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/SessionManagerTest.java @@ -6,12 +6,14 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import dev.ltms.fleet.auth.MemberRegistry; import dev.ltms.fleet.auth.MemberLifecycle; +import dev.ltms.fleet.config.ConfigRef; 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.WorkspaceControl; import dev.ltms.fleet.member.ClaudeCodeLauncher; +import dev.ltms.fleet.member.CompositePeerLauncher; import dev.ltms.fleet.msg.TestTurnTokens; import dev.ltms.fleet.peer.Capability; import dev.ltms.fleet.peer.CharterReceipt; @@ -20,9 +22,14 @@ import dev.ltms.fleet.peer.PeerHandle; import dev.ltms.fleet.peer.PeerLauncher; import dev.ltms.fleet.peer.PeerUnreachableException; import dev.ltms.fleet.peer.SpawnRequest; +import dev.ltms.fleet.placement.BackendQuarantine; +import dev.ltms.fleet.placement.PlacementPolicies; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.slf4j.LoggerFactory; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Set; @@ -1991,4 +1998,121 @@ class SessionManagerTest { sessions.rosterResolved(); assertEquals(2, handle.callCount(), "once resolved, the id must not be looked up again"); } + + // ── fleetd #425 criterion 3: acquireWithWorktree must provision for the profile it actually + // spawns, never a name resolved before a live pool change is accounted for ──────────────────── + + /** Two profiles with distinct {@code cwd}/{@code parityOverlay}, and a dev pool of {@code first,second}. */ + private static String worktreeReorderYaml(String first, String second) { + return """ + bind: + host: 127.0.0.1 + port: 8765 + herdrSocket: ~/.config/herdr/herdr.sock + profiles: + a: + baseUrl: http://gx00.gw:8000 + model: coder-a + b: + baseUrl: http://gx00.gw:8000 + model: coder-b + guard: + offSubscriptionHosts: + - gx00.gw + fleet: + developers: + slot0: + profile: %s + slot1: + profile: %s + """.formatted(first, second); + } + + @Test + void acquireWithWorktreeProvisionsTheOverlayForTheProfileActuallySpawned( + @TempDir Path dir) throws Exception { + Path f = dir.resolve("fleetd.yaml"); + Files.writeString(f, worktreeReorderYaml("a", "b")); + ConfigRef ref = new ConfigRef(f, FleetConfig.load(f)); + + Map profiles = Map.of( + "a", new FleetConfig.Profile("a", "http://gx00.gw:8000", "coder-a", null, + "FLEETD_WORKER_TOKEN", List.of("claude"), "tab", "fleetd-workers", + "worker: {profile} #{n}", null, "/repo/a", List.of("a.mcp.json")), + "b", new FleetConfig.Profile("b", "http://gx00.gw:8000", "coder-b", null, + "FLEETD_WORKER_TOKEN", List.of("claude"), "tab", "fleetd-workers", + "worker: {profile} #{n}", null, "/repo/b", List.of("b.mcp.json"))); + FakeHerdr herdr = new FakeHerdr(); + ClaudeCodeLauncher adapter = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), profiles, "a", _ -> null); + PeerLauncher launcher = new CompositePeerLauncher( + List.of(adapter), "a", ref, _ -> 0, BackendQuarantine.none()); + + // The pool changes AFTER the composite/launcher is built, and BEFORE the unqualified + // worktree spawn — exactly the fleetd #425 scenario: the live pool's first entry is "b" by + // the time acquireWithWorktree runs, even though nothing here was rebuilt. + Files.writeString(f, worktreeReorderYaml("b", "a")); + ConfigRef.Outcome out = ref.reload(); + assertTrue(out.applied(), () -> "reload should apply cleanly: " + out.error()); + + FakeWorktrees worktrees = new FakeWorktrees(); + SessionManager sessions = new SessionManager(launcher, worktrees, () -> 0L); + + MemberSession s = sessions.acquire(null, null, "/caller", + null, new WorktreeRequest("fleetd-425", null)); + + assertEquals("b", s.profile(), + "the live dev pool now starts at b, so the unqualified spawn must land there"); + FakeWorktrees.OverlayCall overlay = worktrees.lastOverlay(); + assertNotNull(overlay, "overlayParity must have been called"); + assertEquals(List.of("b.mcp.json"), overlay.requested(), + "the worktree must be provisioned with profile b's overlay — the one actually " + + "spawned — never a's, the pool's stale first entry"); + } + + /** + * The deterministic, mutation-pinning half of criterion 3: {@code launcher.defaultProfile()} + * only ever answers for {@link MemberRole#DEV} (see {@link CompositePeerLauncher#defaultProfile()}), + * so resolving a worktree spawn's profile through it — instead of through {@link + * PeerLauncher#defaultProfileFor(MemberRole)}, resolved against the CALLER's actual role — picks + * the wrong pool's answer for any role other than DEV. No reload or race is needed to see it: an + * ARCHITECT pool and a DEV pool that simply disagree, held constant, are enough. + */ + @Test + void acquireWithWorktreeForANonDevRoleUsesThatRolesPoolNotTheDevPool() { + Map profiles = Map.of( + "a", new FleetConfig.Profile("a", "http://gx00.gw:8000", "coder-a", null, + "FLEETD_WORKER_TOKEN", List.of("claude"), "tab", "fleetd-workers", + "worker: {profile} #{n}", null, "/repo/a", List.of("a.mcp.json")), + "b", new FleetConfig.Profile("b", "http://gx00.gw:8000", "coder-b", null, + "FLEETD_WORKER_TOKEN", List.of("claude"), "tab", "fleetd-workers", + "worker: {profile} #{n}", null, "/repo/b", List.of("b.mcp.json"))); + FakeHerdr herdr = new FakeHerdr(); + ClaudeCodeLauncher adapter = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), profiles, "a", _ -> null); + // developers -> a (first/only entry); architects -> b (first/only entry). The two pools + // disagree on purpose, so a role-blind resolution (DEV's answer, "a") is visibly wrong for + // an ARCHITECT spawn, which must land on "b". + FleetConfig.Fleet fleet = new FleetConfig.Fleet(Map.of(), + Map.of("s0", new FleetConfig.Slot("b")), + Map.of("s0", new FleetConfig.Slot("a")), + Map.of(), null); + PeerLauncher launcher = new CompositePeerLauncher(List.of(adapter), "a", profiles, + PlacementPolicies.fixed(), _ -> 0, fleet); + + FakeWorktrees worktrees = new FakeWorktrees(); + SessionManager sessions = new SessionManager(launcher, worktrees, () -> 0L); + + MemberSession s = sessions.acquire(null, MemberRole.ARCHITECT, null, "/caller", + null, new WorktreeRequest("fleetd-425b", null)); + + assertEquals("b", s.profile(), + "an unqualified ARCHITECT worktree spawn must land on the architect pool's profile"); + FakeWorktrees.OverlayCall overlay = worktrees.lastOverlay(); + assertNotNull(overlay, "overlayParity must have been called"); + assertEquals(List.of("b.mcp.json"), overlay.requested(), + "the worktree must be provisioned with profile b's overlay — the ARCHITECT pool's " + + "answer, the one actually spawned — never a's, the DEV pool's answer that " + + "launcher.defaultProfile() alone would have given"); + } }