fleetd #425: fleet_profiles' default and worktree provisioning must read live placement
CI / build (pull_request) Successful in 1m29s
CI / contract (pull_request) Successful in 1m32s

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).
This commit is contained in:
Dai Ha
2026-09-10 12:23:42 +07:00
parent e60f892efd
commit e1d7ddeabb
6 changed files with 399 additions and 6 deletions
@@ -508,8 +508,23 @@ public final class CompositePeerLauncher implements PeerLauncher {
return known.isEmpty() ? List.copyOf(configured.keySet()) : known; 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}
*
* <p>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).
*
* <p>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<String> pool = poolFor(role); List<String> pool = poolFor(role);
return pool.isEmpty() ? defaultProfile : pool.getFirst(); return pool.isEmpty() ? defaultProfile : pool.getFirst();
} }
@@ -647,9 +662,21 @@ public final class CompositePeerLauncher implements PeerLauncher {
return byProfile.keySet(); return byProfile.keySet();
} }
/**
* {@inheritDoc}
*
* <p>fleetd #425: reports the <em>live</em> {@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 @Override
public String defaultProfile() { public String defaultProfile() {
return defaultProfile; return defaultProfileFor(MemberRole.DEV);
} }
/** /**
@@ -143,9 +143,34 @@ public interface PeerLauncher {
/** /**
* The profile a no-argument {@link #spawn(SpawnRequest)} uses, or {@code null} if none is configured. * The profile a no-argument {@link #spawn(SpawnRequest)} uses, or {@code null} if none is configured.
*
* <p>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(); 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).
*
* <p>A caller that must provision something profile-specific (working directory, parity overlay
* files) <em>before</em> 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.
*
* <p>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. * Resolve the effective working directory for a spawn {@code req} without actually spawning.
* Resolution order: requestedCwd → profile cwd → callerCwd → daemon cwd. * Resolution order: requestedCwd → profile cwd → callerCwd → daemon cwd.
@@ -584,8 +584,21 @@ public final class SessionManager implements TurnListener {
String ownerTerminal, WorktreeRequest wt, String ownerTerminal, WorktreeRequest wt,
String sessionName, String resumeSessionId, String sessionName, String resumeSessionId,
MemberLifecycle.SlotReservation reservation) { 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()) 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 → // 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 // 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 // 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 // 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. // leaves those overlay files operator-owned and read-only for a different-uid member.
worktrees.shareWithGroup(repoRoot, path); 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) { } catch (RuntimeException e) {
log.warn("spawn failed for profile={} role={} branch={} path={}: {}", log.warn("spawn failed for profile={} role={} branch={} path={}: {}",
preResolvedProfile, memberRole, branch, path, e.getMessage()); preResolvedProfile, memberRole, branch, path, e.getMessage());
@@ -636,7 +652,7 @@ public final class SessionManager implements TurnListener {
} }
throw e; throw e;
} }
String resolvedProfile = resolveProfile(handle, profile); String resolvedProfile = resolveProfile(handle, preResolvedProfile);
String cwd = launcher.effectiveCwd(new SpawnRequest(resolvedProfile, path, callerCwd)); String cwd = launcher.effectiveCwd(new SpawnRequest(resolvedProfile, path, callerCwd));
long now = nowNanos.getAsLong(); long now = nowNanos.getAsLong();
// CB-619: see the no-worktree path above — bind before recording, and store the returned // CB-619: see the no-worktree path above — bind before recording, and store the returned
@@ -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.
*
* <p>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<String, FleetConfig.Profile> 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<String, Object> 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");
}
}
@@ -3,6 +3,7 @@ package dev.ltms.fleet.member;
import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender; import ch.qos.logback.core.read.ListAppender;
import dev.ltms.fleet.config.ConfigRef;
import dev.ltms.fleet.config.FleetConfig; import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.guard.SubscriptionGuard; import dev.ltms.fleet.guard.SubscriptionGuard;
import dev.ltms.fleet.herdr.Agent; 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.PlacementException;
import dev.ltms.fleet.placement.PlacementPolicies; import dev.ltms.fleet.placement.PlacementPolicies;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -908,6 +912,89 @@ class CompositePeerLauncherTest {
assertTrue(e.getMessage().contains("maxLoad"), e.getMessage()); 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 ────────── // ── CB-578 stage B: a BACKEND_EXHAUSTED classification quarantines the credential ──────────
@Test @Test
@@ -6,12 +6,14 @@ import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender; import ch.qos.logback.core.read.ListAppender;
import dev.ltms.fleet.auth.MemberRegistry; import dev.ltms.fleet.auth.MemberRegistry;
import dev.ltms.fleet.auth.MemberLifecycle; import dev.ltms.fleet.auth.MemberLifecycle;
import dev.ltms.fleet.config.ConfigRef;
import dev.ltms.fleet.config.FleetConfig; import dev.ltms.fleet.config.FleetConfig;
import dev.ltms.fleet.guard.SubscriptionGuard; import dev.ltms.fleet.guard.SubscriptionGuard;
import dev.ltms.fleet.herdr.AgentControl; import dev.ltms.fleet.herdr.AgentControl;
import dev.ltms.fleet.herdr.FakeHerdr; import dev.ltms.fleet.herdr.FakeHerdr;
import dev.ltms.fleet.herdr.WorkspaceControl; import dev.ltms.fleet.herdr.WorkspaceControl;
import dev.ltms.fleet.member.ClaudeCodeLauncher; import dev.ltms.fleet.member.ClaudeCodeLauncher;
import dev.ltms.fleet.member.CompositePeerLauncher;
import dev.ltms.fleet.msg.TestTurnTokens; import dev.ltms.fleet.msg.TestTurnTokens;
import dev.ltms.fleet.peer.Capability; import dev.ltms.fleet.peer.Capability;
import dev.ltms.fleet.peer.CharterReceipt; 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.PeerLauncher;
import dev.ltms.fleet.peer.PeerUnreachableException; import dev.ltms.fleet.peer.PeerUnreachableException;
import dev.ltms.fleet.peer.SpawnRequest; 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.Test;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -1991,4 +1998,121 @@ class SessionManagerTest {
sessions.rosterResolved(); sessions.rosterResolved();
assertEquals(2, handle.callCount(), "once resolved, the id must not be looked up again"); 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<String, FleetConfig.Profile> 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<String, FleetConfig.Profile> 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");
}
} }