#382: give SpawnRequest a withProfile wither, guard it against the arity trap #387

Closed
agent wants to merge 0 commits from worker/t381-cc-748314-2 into main
Member

#382: give SpawnRequest a withProfile wither, guard it against the arity trap

What changed

SpawnRequest has a canonical arity-6 constructor and back-compat constructors at 3 and 5. CompositePeerLauncher.java:372 rebuilt a routed request from a literal new SpawnRequest(...) call listing six of the original request's own accessors. That call was only correct because it happened to match the canonical arity today — add a 7th component plus the established back-compat pattern (a new constructor at the old, now-shorter arity) and this call would silently rebind to it, dropping the new field on every profile-routed spawn with no compile error. Same defect shape already guarded on FleetConfig.withDefaults() (#357) and MemberSession (#358).

Per the ticket's second option (taken, not the CompositePeerLauncher-routing-scaffolding option):

  1. Added SpawnRequest.withProfile(String profile), modeled on MemberSession.withState/withActivity — returns a copy with only profileName replaced.
  2. Changed CompositePeerLauncher.java:372 to call req.withProfile(chosen.profile()) instead of building a new SpawnRequest(...) from six accessors.
  3. Added SpawnRequestWithProfilePreservesEveryComponentTest, in the pattern of MemberSessionRebuildPreservesEveryComponentTest: resolves the true canonical constructor by exact component types via getDeclaredConstructor (never by argument count), gives every one of the 6 components a distinctive non-null value, calls withProfile, and asserts every component but profileName survives unchanged. The exclusion list is pinned at size 0 by its own test.

Not in scope (per the ticket): deleting the arity-3/5 back-compat constructors — left alone.

Proof of the mechanism (not simulated with null)

Temporarily dropped the last (role) argument from the 6-arg new SpawnRequest(...) call inside withProfile(), so it read new SpawnRequest(profile, requestedCwd, callerCwd, sessionName, resumeSessionId):

  • It still compiled, silently binding to the 5-arg back-compat constructor (which defaults role to MemberRole.DEV).
  • The new guard test failed against that mutant: withProfile() silently dropped these components: [role: ... expected (REVIEWER) ... returned DEV].
  • Restored the real 6-arg call and reconfirmed the full build green.

Build

Ran mvn clean install in fleetd/, full unpiped output read.

Tests run: 1463, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

New test alone: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in dev.ltms.fleet.peer.SpawnRequestWithProfilePreservesEveryComponentTest, printing:

SpawnRequest.withProfile() component-survival coverage — 6 components, 6 checked, 0 excluded, 6 survived

Related: #357, #358.

#382: give SpawnRequest a withProfile wither, guard it against the arity trap ## What changed `SpawnRequest` has a canonical arity-6 constructor and back-compat constructors at 3 and 5. `CompositePeerLauncher.java:372` rebuilt a routed request from a literal `new SpawnRequest(...)` call listing six of the original request's own accessors. That call was only correct because it happened to match the canonical arity today — add a 7th component plus the established back-compat pattern (a new constructor at the old, now-shorter arity) and this call would silently rebind to it, dropping the new field on every profile-routed spawn with **no compile error**. Same defect shape already guarded on `FleetConfig.withDefaults()` (#357) and `MemberSession` (#358). Per the ticket's second option (taken, not the `CompositePeerLauncher`-routing-scaffolding option): 1. Added `SpawnRequest.withProfile(String profile)`, modeled on `MemberSession.withState`/`withActivity` — returns a copy with only `profileName` replaced. 2. Changed `CompositePeerLauncher.java:372` to call `req.withProfile(chosen.profile())` instead of building a `new SpawnRequest(...)` from six accessors. 3. Added `SpawnRequestWithProfilePreservesEveryComponentTest`, in the pattern of `MemberSessionRebuildPreservesEveryComponentTest`: resolves the true canonical constructor by exact component types via `getDeclaredConstructor` (never by argument count), gives every one of the 6 components a distinctive non-null value, calls `withProfile`, and asserts every component but `profileName` survives unchanged. The exclusion list is pinned at size 0 by its own test. Not in scope (per the ticket): deleting the arity-3/5 back-compat constructors — left alone. ## Proof of the mechanism (not simulated with null) Temporarily dropped the last (`role`) argument from the 6-arg `new SpawnRequest(...)` call inside `withProfile()`, so it read `new SpawnRequest(profile, requestedCwd, callerCwd, sessionName, resumeSessionId)`: - It **still compiled**, silently binding to the 5-arg back-compat constructor (which defaults `role` to `MemberRole.DEV`). - The new guard test **failed** against that mutant: `withProfile() silently dropped these components: [role: ... expected (REVIEWER) ... returned DEV]`. - Restored the real 6-arg call and reconfirmed the full build green. ## Build Ran `mvn clean install` in `fleetd/`, full unpiped output read. ``` Tests run: 1463, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` New test alone: `Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in dev.ltms.fleet.peer.SpawnRequestWithProfilePreservesEveryComponentTest`, printing: ``` SpawnRequest.withProfile() component-survival coverage — 6 components, 6 checked, 0 excluded, 6 survived ``` Related: #357, #358.
agent added 1 commit 2026-09-10 01:44:54 +02:00
#382: give SpawnRequest a withProfile wither, guard it against the arity trap
CI / contract (pull_request) Successful in 44s
CI / build (pull_request) Successful in 1m49s
5fede82468
CompositePeerLauncher:372 rebuilt a routed SpawnRequest from a literal
new SpawnRequest(...) call listing six of the original request's own
accessors. That call is only correct because it happens to match the
canonical 6-arg constructor today; add a 7th component plus the
established back-compat constructor at the old (now-shorter) arity and
this call would silently rebind to it, dropping the new field on every
profile-routed spawn with no compile error — the same defect shape
already guarded on FleetConfig.withDefaults() (#357) and MemberSession
(#358).

Add SpawnRequest.withProfile(String), modeled on
MemberSession.withState/withActivity, and use it at the call site
instead. Add a guard test that resolves the true canonical constructor
by exact component types (never by argument count), gives every
component a distinctive value, and asserts every component but
profileName survives withProfile() unchanged.

Proved the guard against the real mechanism: temporarily dropped the
last (role) argument from withProfile()'s constructor call so it bound
to the 5-arg back-compat constructor — it still compiled, and the new
test failed, catching the silently-defaulted role. Restored the fix
and reconfirmed green.
ltms closed this pull request 2026-09-10 02:04:18 +02:00
Some checks are pending
CI / contract (pull_request) Successful in 44s
CI / build (pull_request) Successful in 1m49s

Pull request closed

Sign in to join this conversation.