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 f28f23f..f4eced5 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/CompositePeerLauncher.java @@ -369,8 +369,7 @@ public final class CompositePeerLauncher implements PeerLauncher { // CB-547a: route the chosen profile but keep the caller's session identity — dropping it // here would silently sever the resume handle on every policy-routed spawn. CB-557: the // role rides along for the same reason, or a routed spawn would be labelled as a dev. - SpawnRequest routedReq = new SpawnRequest(chosen.profile(), req.requestedCwd(), req.callerCwd(), - req.sessionName(), req.resumeSessionId(), req.role()); + SpawnRequest routedReq = req.withProfile(chosen.profile()); try { PeerHandle handle = d.spawn(routedReq); spawnedBy.put(handle.id(), d); diff --git a/fleetd/src/main/java/dev/ltms/fleet/peer/SpawnRequest.java b/fleetd/src/main/java/dev/ltms/fleet/peer/SpawnRequest.java index 00f0ffd..943dd37 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/peer/SpawnRequest.java +++ b/fleetd/src/main/java/dev/ltms/fleet/peer/SpawnRequest.java @@ -29,4 +29,9 @@ public record SpawnRequest(String profileName, String requestedCwd, String calle String sessionName, String resumeSessionId) { this(profileName, requestedCwd, callerCwd, sessionName, resumeSessionId, null); } + + /** Return a copy of this request with {@code profileName} replaced by {@code profile}. */ + public SpawnRequest withProfile(String profile) { + return new SpawnRequest(profile, requestedCwd, callerCwd, sessionName, resumeSessionId, role); + } } diff --git a/fleetd/src/test/java/dev/ltms/fleet/peer/SpawnRequestWithProfilePreservesEveryComponentTest.java b/fleetd/src/test/java/dev/ltms/fleet/peer/SpawnRequestWithProfilePreservesEveryComponentTest.java new file mode 100644 index 0000000..a75abe9 --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/peer/SpawnRequestWithProfilePreservesEveryComponentTest.java @@ -0,0 +1,144 @@ +package dev.ltms.fleet.peer; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Fleetd #382, the same "defect factory" #357 and #358 guarded on {@code FleetConfig.withDefaults()} + * and {@link dev.ltms.fleet.session.MemberSession}'s rebuild sites — reproduced here on + * {@link SpawnRequest#withProfile(String)}. + * + *
{@code SpawnRequest} has back-compat constructors at arity 3 and 5 alongside its canonical + * arity-6 constructor. Before this ticket, {@code CompositePeerLauncher} routed a profile by + * building a fresh {@code SpawnRequest} from a literal {@code new SpawnRequest(...)} call listing + * six of the original request's own accessors. That call is only correct because it happens to + * name exactly six arguments today — add a 7th component and the established back-compat pattern + * (a new constructor at the old, now-shorter arity) and a call one argument short of the new + * canonical arity would silently rebind to that back-compat constructor, dropping the new + * component on every profile-routed spawn without any compile error. {@link #withProfile} replaces + * that literal call, so this test guards the ONE rebuild site instead of a call site scattered + * through a launcher. + * + *
The check below builds a {@link SpawnRequest} through the TRUE canonical constructor — + * resolved by the record's own component types via {@code getDeclaredConstructor}, never by + * argument count, so it can never itself land on a back-compat overload — with a real, distinctive, + * non-null value in every component, calls {@link SpawnRequest#withProfile(String)}, and asserts + * every component the method is not documented to change survives unchanged, while {@code + * profileName} comes back as the new value it was given. A component that comes back anything else + * was silently dropped — the shape of the defect this test exists to catch. + * + *
{@link #EXCLUDED_FROM_SURVIVAL_CHECK} is kept deliberately empty and size-pinned by
+ * {@link #exclusionListSizeIsPinned()}, for the same reason the other two guards pin theirs at
+ * zero: a checker whose escape hatch can grow to silence a failure is not a checker. Every one of
+ * {@link SpawnRequest}'s 6 current components has a real, non-null value here and none is excluded.
+ */
+class SpawnRequestWithProfilePreservesEveryComponentTest {
+
+ private static final RecordComponent[] COMPONENTS = SpawnRequest.class.getRecordComponents();
+
+ /** Deliberately empty today; grow it only with a matching justification, and re-pin the size. */
+ private static final Set