diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index a3fbe44..2b578fb 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -174,11 +174,12 @@ public final class Fleetd { // genuine cycle. Break it exactly like liveCountRef below: a forwarding sink built now, // pointed at the real one once it exists. AtomicReference exhaustionSinkRef = new AtomicReference<>(ExhaustionSink.none()); - // fleetd #234, round 3: this MUST go through ExhaustionSink.forwardingTo, never a lambda or - // a hand-written anonymous class here — see that factory's javadoc for why a lambda at this - // call site silently drops OpenCodeLauncher's profile hint. Routing through the shared - // factory also lets a test call the exact same object this line builds, instead of - // asserting a copy of its shape. + // fleetd #234, round 4: routed through the shared ExhaustionSink.forwardingTo factory + // rather than written inline here — not because a lambda at this call site is unsafe + // anymore (it is not: the 3-arg overload is now the interface's single abstract method, so + // there is no 2-arg overload left for any lambda to silently bind to instead), but so a + // test can call the exact same object this line builds, instead of asserting a copy of its + // shape (round 3's lesson). ExhaustionSink forwardingExhaustionSink = ExhaustionSink.forwardingTo(exhaustionSinkRef::get); // The claude-code adapter is the always-present default; keep it even with no profiles (so a // bridge configured with no workers, or opencode-only, still has a well-defined base adapter) @@ -361,34 +362,29 @@ public final class Fleetd { // fallback whenever the roster lookup misses; (2) if a profile still cannot be resolved // (neither the roster nor the hint names a configured one), this logs loudly at ERROR // instead of silently doing nothing — a control that cannot act must say so. - ExhaustionSink exhaustionSink = new ExhaustionSink() { - @Override - public void onExhausted(String target, String reason) { - onExhausted(target, reason, null); - } - - @Override - public void onExhausted(String target, String reason, String profileHint) { - String profileName = sessions.roster().stream() - .filter(session -> target.equals(session.terminalId())) - .findFirst() - .map(MemberSession::profile) - .orElse(profileHint); - FleetConfig.Profile profile = profileName == null ? null : config.get().profiles().get(profileName); - if (profile == null) { - log.error("quarantine requested for target '{}' ({}) but no profile could be " - + "resolved — the target is not (yet) in the roster, and {} — " - + "credential NOT quarantined (fleetd #234)", - target, reason, - profileHint == null ? "no profile hint was given" - : "the hinted profile '" + profileHint + "' is not configured"); - return; - } - String credentialId = profile.effectiveCredentialId(); - quarantine.quarantine(credentialId); - log.warn("credential '{}' quarantined for {}s (profile '{}'): {}", credentialId, - cfg.quarantineCooldownSeconds(), profile.profile(), reason); + // fleetd #234, round 4: the 3-arg overload is now ExhaustionSink's single abstract method, + // so this is safely a lambda — there is no separate 2-arg overload left for it to bind to + // instead and silently drop profileHint (that was rounds 1-3's whole hazard). + ExhaustionSink exhaustionSink = (target, reason, profileHint) -> { + String profileName = sessions.roster().stream() + .filter(session -> target.equals(session.terminalId())) + .findFirst() + .map(MemberSession::profile) + .orElse(profileHint); + FleetConfig.Profile profile = profileName == null ? null : config.get().profiles().get(profileName); + if (profile == null) { + log.error("quarantine requested for target '{}' ({}) but no profile could be " + + "resolved — the target is not (yet) in the roster, and {} — " + + "credential NOT quarantined (fleetd #234)", + target, reason, + profileHint == null ? "no profile hint was given" + : "the hinted profile '" + profileHint + "' is not configured"); + return; } + String credentialId = profile.effectiveCredentialId(); + quarantine.quarantine(credentialId); + log.warn("credential '{}' quarantined for {}s (profile '{}'): {}", credentialId, + cfg.quarantineCooldownSeconds(), profile.profile(), reason); }; // fleetd #175: point the forwarding sink handed to OpenCodeLauncher above at the real one, // now that `sessions` exists to resolve target -> session -> profile. diff --git a/fleetd/src/main/java/dev/ltms/fleet/inject/ExhaustionSink.java b/fleetd/src/main/java/dev/ltms/fleet/inject/ExhaustionSink.java index 5a61d11..dc39cc6 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/inject/ExhaustionSink.java +++ b/fleetd/src/main/java/dev/ltms/fleet/inject/ExhaustionSink.java @@ -12,91 +12,81 @@ import java.util.function.Supplier; * profiles or credentials, so mapping {@code target} to whatever should be quarantined is entirely * the sink's job — see {@code Fleetd.main}'s wiring, which resolves target → session → profile → * {@code effectiveCredentialId()} and calls {@code BackendQuarantine.quarantine} on it. + * + *

The 3-arg overload is the single abstract method — fleetd #234, round 4. Two + * earlier rounds each shipped a caller that silently dropped the profile hint (see {@link + * #onExhausted(String, String, String)}): a lambda written against this interface can only ever + * implement whichever overload is abstract, and while the 2-arg form held that position, EVERY + * lambda site — a call-site forwarder in {@code Fleetd.java}, a hand-built test double — bound to + * it and silently inherited the profile-dropping default, whether or not its author remembered the + * hazard. Making the 3-arg form abstract instead removes the shape entirely: a lambda declared + * against this interface today is forced by the compiler to take {@code (target, reason, + * profile)}, so there is no overload left for it to bind to that can drop the hint. This is a type + * change, not a test — it holds even for a caller that has never heard of fleetd #234. */ @FunctionalInterface public interface ExhaustionSink { /** - * @param target the herdr terminal id whose turn was classified {@code BACKEND_EXHAUSTED} - * @param reason the matched-line reason carried by the classification + * @param target the herdr terminal id whose turn was classified {@code BACKEND_EXHAUSTED} + * @param reason the matched-line reason carried by the classification + * @param profile the profile the caller already knows should be quarantined, or {@code null} + * when the caller has no better answer than {@code target} alone (fleetd #234): + * a caller whose {@code target} is not yet resolvable through whatever roster + * the sink's implementation consults — {@link + * dev.ltms.fleet.member.OpenCodeLauncher}'s model-mismatch check fires from + * {@code SessionAwareHandle.agentSessionId()}, which runs during {@code + * SessionManager.acquire()} before that session is registered, so a + * target -> session -> profile lookup finds nothing at that point. That launcher + * already has its own {@code FleetConfig.Profile} in hand and does not need the + * roster to know which profile to quarantine, so it supplies this directly + * instead of leaving the sink to guess. */ - void onExhausted(String target, String reason); + void onExhausted(String target, String reason, String profile); /** - * Same notification, plus a profile name the CALLER already knows — for a caller whose - * {@code target} is not yet resolvable through whatever roster the sink's implementation - * consults (fleetd #234). {@link dev.ltms.fleet.member.OpenCodeLauncher}'s model-mismatch check - * fires from {@code SessionAwareHandle.agentSessionId()}, which runs during {@code - * SessionManager.acquire()} before that session is registered — a target -> session -> - * profile lookup finds nothing at that point. That launcher already has its own {@code - * FleetConfig.Profile} in hand and does not need the roster to know which profile to - * quarantine, so it calls this overload instead of leaving the sink to guess. + * Convenience for a caller with no profile to offer — every existing call site that predates + * the hint (fleetd #234): {@link dev.ltms.fleet.inject.CompletionResolver}'s two call sites + * always call with a {@code target} that IS live in the roster at the time of the call, so they + * need no hint and keep working exactly as before, unchanged by this default. * - *

Defaults to the two-arg overload, discarding {@code profile} — the correct behaviour for - * every caller that has not been updated to supply one: {@link - * dev.ltms.fleet.inject.CompletionResolver}'s two call sites always call {@code target} that - * IS live in the roster at the time of the call, so they need no hint and keep working exactly - * as before. A sink that wants to use the hint (see {@code Fleetd.main}'s wiring) overrides this - * method directly rather than relying on the default. - * - * @param target as {@link #onExhausted(String, String)} - * @param reason as {@link #onExhausted(String, String)} - * @param profile the profile the caller already knows should be quarantined, or {@code null} - * when the caller has no better answer than {@code target} alone + * @param target as {@link #onExhausted(String, String, String)} + * @param reason as {@link #onExhausted(String, String, String)} */ - default void onExhausted(String target, String reason, String profile) { - onExhausted(target, reason); + default void onExhausted(String target, String reason) { + onExhausted(target, reason, null); } /** * Inert sink — nothing happens on exhaustion. The explicit stand-in a caller (or a test not * exercising this feature) passes instead of a defaulting overload, exactly like - * {@link ExhaustedPatternLookup#none()}. Safe as a lambda regardless of arity: its 2-arg body - * is empty, and the inherited 3-arg {@code default} just calls that same empty body — there is - * no hint to drop because this sink does nothing either way. + * {@link ExhaustedPatternLookup#none()}. Safe as a lambda: the 3-arg form is now the interface's + * single abstract method, so a lambda here has no other overload to silently bind to instead — + * it simply does nothing with all three arguments. */ static ExhaustionSink none() { - return (target, reason) -> { }; + return (target, reason, profile) -> { }; } /** - * A sink that forwards BOTH overloads to whatever {@code target} currently supplies (fleetd - * #234, round 3). Exists to break a genuine construction-order cycle: {@code Fleetd.main} - * builds its adapters (including {@link dev.ltms.fleet.member.OpenCodeLauncher}) before - * {@code sessions} exists, so it cannot hand them the real sink yet — it hands them a - * forwarder pointed at an {@code AtomicReference} that starts at {@link #none()} - * and gets {@code .set()} to the real sink once {@code sessions} is built. {@code target} is - * evaluated on every call, never cached, so the forwarder keeps working after the reference is - * repointed. + * A sink that forwards to whatever {@code target} currently supplies (fleetd #234). Exists to + * break a genuine construction-order cycle: {@code Fleetd.main} builds its adapters (including + * {@link dev.ltms.fleet.member.OpenCodeLauncher}) before {@code sessions} exists, so it cannot + * hand them the real sink yet — it hands them a forwarder pointed at an {@code + * AtomicReference} that starts at {@link #none()} and gets {@code .set()} to the + * real sink once {@code sessions} is built. {@code target} is evaluated on every call, never + * cached, so the forwarder keeps working after the reference is repointed. * - *

This is the one place that forwarding shape is written, on purpose. A - * forwarder written directly at a call site as a lambda — - * {@code (t, r) -> target.get().onExhausted(t, r)} — implements only the interface's single - * abstract method (the 2-arg overload) and silently inherits the 3-arg overload's {@code - * default} body, which discards whatever profile hint the real caller supplied and calls back - * into the 2-arg method instead. {@link dev.ltms.fleet.member.OpenCodeLauncher}'s model-mismatch - * check relies on that hint reaching the real sink (it fires before its session is registered - * in the roster, so the roster alone cannot resolve which profile to quarantine) — a lambda - * forwarder here silently reproduces that exact bug. Routing every forwarder through this one - * factory, instead of re-writing the shape at each call site, means a test can pin the shape - * once and have both {@code Fleetd.java} and the test call the identical object — see {@code - * ExhaustionSinkForwardingHazardTest} and {@code OpenCodeLauncherTest} for the coverage this - * makes possible. + *

Now safe as a one-line lambda (round 4): forwarding the single 3-arg abstract method + * forwards everything a caller can supply — there is no separate 2-arg overload left for a + * forwarder to bind to instead and silently lose the hint. Kept as a named factory rather than + * written inline at each call site anyway, so a test can call the exact object {@code + * Fleetd.java} builds instead of asserting a rebuilt copy of its shape (round 3's lesson). * * @param target supplies the sink to forward to, evaluated fresh on every call - * @return a sink whose every overload delegates to {@code target.get()}'s matching overload + * @return a sink whose call delegates to {@code target.get()} */ static ExhaustionSink forwardingTo(Supplier target) { - return new ExhaustionSink() { - @Override - public void onExhausted(String t, String r) { - target.get().onExhausted(t, r); - } - - @Override - public void onExhausted(String t, String r, String p) { - target.get().onExhausted(t, r, p); - } - }; + return (t, r, p) -> target.get().onExhausted(t, r, p); } } diff --git a/fleetd/src/test/java/dev/ltms/fleet/inject/CompletionResolverTest.java b/fleetd/src/test/java/dev/ltms/fleet/inject/CompletionResolverTest.java index d837f1f..01e8939 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/inject/CompletionResolverTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/inject/CompletionResolverTest.java @@ -499,7 +499,7 @@ class CompletionResolverTest { Rendezvous rendezvous = new Rendezvous(); ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached"); java.util.List notified = new java.util.ArrayList<>(); - ExhaustionSink sink = (target, reason) -> notified.add(target + ": " + reason); + ExhaustionSink sink = (target, reason, profile) -> notified.add(target + ": " + reason); CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, sink); var waiter = rendezvous.open("term_a"); @@ -520,7 +520,7 @@ class CompletionResolverTest { Rendezvous rendezvous = new Rendezvous(); ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached"); java.util.List notified = new java.util.ArrayList<>(); - ExhaustionSink sink = (target, reason) -> notified.add(target + ": " + reason); + ExhaustionSink sink = (target, reason, profile) -> notified.add(target + ": " + reason); CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, sink); var waiter = rendezvous.open("term_a"); @@ -648,7 +648,7 @@ class CompletionResolverTest { Rendezvous rendezvous = new Rendezvous(); ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached"); java.util.List notified = new java.util.ArrayList<>(); - ExhaustionSink sink = (target, reason) -> notified.add(target + ": " + reason); + ExhaustionSink sink = (target, reason, profile) -> notified.add(target + ": " + reason); CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, sink); var waiter = rendezvous.open("term_a"); @@ -701,7 +701,7 @@ class CompletionResolverTest { Rendezvous rendezvous = new Rendezvous(); ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached"); java.util.List notified = new java.util.ArrayList<>(); - ExhaustionSink sink = (target, reason) -> notified.add(target + ": " + reason); + ExhaustionSink sink = (target, reason, profile) -> notified.add(target + ": " + reason); CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, sink); var waiter = rendezvous.open("term_a"); @@ -728,7 +728,7 @@ class CompletionResolverTest { Rendezvous rendezvous = new Rendezvous(); ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached"); java.util.List notified = new java.util.ArrayList<>(); - ExhaustionSink sink = (target, reason) -> notified.add(target + ": " + reason); + ExhaustionSink sink = (target, reason, profile) -> notified.add(target + ": " + reason); CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, sink); var waiter = rendezvous.open("term_a"); diff --git a/fleetd/src/test/java/dev/ltms/fleet/inject/ExhaustionSinkForwardingHazardTest.java b/fleetd/src/test/java/dev/ltms/fleet/inject/ExhaustionSinkForwardingHazardTest.java index 6c42381..2a2e538 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/inject/ExhaustionSinkForwardingHazardTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/inject/ExhaustionSinkForwardingHazardTest.java @@ -7,31 +7,25 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; /** - * fleetd #234, round 3: calls the REAL production factory, {@link ExhaustionSink#forwardingTo}, - * rather than rebuilding its shape locally. A round-2 version of this test built its own copy of - * the forwarder inline — mutating {@code Fleetd.java}'s actual forwarder back into a lambda left - * that copy untouched, so the test kept passing while production had regressed to exactly the bug - * it was meant to catch. Calling the shared factory here means the same object under test IS the - * object {@code Fleetd.java} builds via {@code ExhaustionSink.forwardingTo(exhaustionSinkRef::get)} - * — a defect in the factory body, or a call site reverting to a hand-written lambda instead of the - * factory, has exactly one place left to hide, and this test reaches into it. + * fleetd #234. Round 3: calls the REAL production factory, {@link ExhaustionSink#forwardingTo}, + * rather than rebuilding its shape locally — a round-2 version of this test built its own copy of + * the forwarder inline, so mutating {@code Fleetd.java}'s actual forwarder back into a lambda left + * that copy untouched and the test kept passing while production had regressed. Calling the shared + * factory here means the same object under test IS the object {@code Fleetd.java} builds via + * {@code ExhaustionSink.forwardingTo(exhaustionSinkRef::get)}. + * + *

Round 4: the 3-arg overload is now {@link ExhaustionSink}'s single abstract method, so {@code + * forwardingTo} itself is a one-line lambda and every sink below can safely be one too — there is + * no 2-arg overload left for any of them to silently bind to instead. This test still catches a + * regression inside {@code forwardingTo}'s body (e.g. one that calls the 2-arg default and drops + * the hint that way) because it still goes through the shared factory rather than a rebuilt copy. */ class ExhaustionSinkForwardingHazardTest { @Test void forwardingToDeliversTheProfileHintToWhateverSinkTheSupplierCurrentlyReturns() { AtomicReference hintSeenByRealSink = new AtomicReference<>("NEVER CALLED"); - ExhaustionSink real = new ExhaustionSink() { - @Override - public void onExhausted(String target, String reason) { - onExhausted(target, reason, null); - } - - @Override - public void onExhausted(String target, String reason, String profileHint) { - hintSeenByRealSink.set(profileHint); - } - }; + ExhaustionSink real = (target, reason, profileHint) -> hintSeenByRealSink.set(profileHint); // Same shape Fleetd.java uses: a reference that starts at none() and is repointed later. AtomicReference ref = new AtomicReference<>(ExhaustionSink.none()); diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java index ee67781..6fd791b 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java @@ -925,7 +925,7 @@ class OpenCodeLauncherTest { // credentialId — the profile that actually escaped the fleet's accounting. FleetConfig.Profile cfg = opencodeCfg("opencode/nemotron-3-ultra-free", null, null); List exhausted = new ArrayList<>(); - ExhaustionSink sink = (target, reason) -> exhausted.add(target + "|" + reason); + ExhaustionSink sink = (target, reason, profile) -> exhausted.add(target + "|" + reason); OpenCodeLauncher launcher = serviceWithSink(herdr, configRoot, discRoot, cfg, sink); SessionManager sessions = new SessionManager(launcher); @@ -960,7 +960,7 @@ class OpenCodeLauncherTest { void aProviderPrefixedModelMatchingBothIdAndProviderIsNotAMismatch(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { List exhausted = new ArrayList<>(); - ExhaustionSink sink = (target, reason) -> exhausted.add(reason); + ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("openai/gpt-5.6-terra", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) .spawn(new SpawnRequest(null, "/work/dir", null)); @@ -976,7 +976,7 @@ class OpenCodeLauncherTest { void aGxProviderPrefixedModelMatchingBothIdAndProviderIsNotAMismatch(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { List exhausted = new ArrayList<>(); - ExhaustionSink sink = (target, reason) -> exhausted.add(reason); + ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("gx/deepseek-v4-flash", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) .spawn(new SpawnRequest(null, "/work/dir", null)); @@ -998,7 +998,7 @@ class OpenCodeLauncherTest { void aMissingProviderIdInTheEvidenceIsUnknownNotAMismatchWhenTheIdMatches( @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { List exhausted = new ArrayList<>(); - ExhaustionSink sink = (target, reason) -> exhausted.add(reason); + ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("openai/gpt-5.6-terra", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) .spawn(new SpawnRequest(null, "/work/dir", null)); @@ -1019,7 +1019,7 @@ class OpenCodeLauncherTest { void aMissingProviderIdInTheEvidenceStillCatchesARealIdMismatch( @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { List exhausted = new ArrayList<>(); - ExhaustionSink sink = (target, reason) -> exhausted.add(reason); + ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("openai/gpt-5.6-terra", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) .spawn(new SpawnRequest(null, "/work/dir", null)); @@ -1043,7 +1043,7 @@ class OpenCodeLauncherTest { void aBareModelWithNoProviderPrefixMatchesOnIdAloneAndIsNotAMismatch(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { List exhausted = new ArrayList<>(); - ExhaustionSink sink = (target, reason) -> exhausted.add(reason); + ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("deepseek-v4-flash", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) .spawn(new SpawnRequest(null, "/work/dir", null)); @@ -1065,7 +1065,7 @@ class OpenCodeLauncherTest { void aRealIdMismatchLogsAnErrorNamingBothModelsAndQuarantinesThroughTheSink( @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { List exhausted = new ArrayList<>(); - ExhaustionSink sink = (target, reason) -> exhausted.add(target + "|" + reason); + ExhaustionSink sink = (target, reason, profile) -> exhausted.add(target + "|" + reason); FleetConfig.Profile cfg = opencodeCfg("opencode/nemotron-3-ultra-free", null, null); Logger logger = (Logger) LoggerFactory.getLogger(OpenCodeLauncher.class); @@ -1112,7 +1112,7 @@ class OpenCodeLauncherTest { void unknownOrUnparseableModelEvidenceNeverQuarantines(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { List exhausted = new ArrayList<>(); - ExhaustionSink sink = (target, reason) -> exhausted.add(reason); + ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("openai/gpt-5.6-terra", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) .spawn(new SpawnRequest(null, "/work/dir", null)); @@ -1138,7 +1138,7 @@ class OpenCodeLauncherTest { void aProfileWithNoConfiguredModelIsNeverCheckedForAMismatch(@TempDir Path configRoot, @TempDir Path discRoot) throws Exception { List exhausted = new ArrayList<>(); - ExhaustionSink sink = (target, reason) -> exhausted.add(reason); + ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg(null, null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) .spawn(new SpawnRequest(null, "/work/dir", null)); @@ -1167,7 +1167,7 @@ class OpenCodeLauncherTest { void modelCheckReadsTheResolvedSessionsOwnRowNotWhateverIsNewestInTheSharedDirectory( @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { List exhausted = new ArrayList<>(); - ExhaustionSink sink = (target, reason) -> exhausted.add(reason); + ExhaustionSink sink = (target, reason, profile) -> exhausted.add(reason); FleetConfig.Profile cfg = opencodeCfg("openai/gpt-5.6-terra", null, null); PeerHandle handle = serviceWithSink(new FakeHerdr(), configRoot, discRoot, cfg, sink) .spawn(new SpawnRequest(null, "/work/dir", null)); @@ -1217,18 +1217,12 @@ class OpenCodeLauncherTest { Map profiles = Map.of(cfg.profile(), cfg); BackendQuarantine quarantine = new BackendQuarantine(() -> 0L, TimeUnit.SECONDS.toNanos(1800)); - ExhaustionSink sink = new ExhaustionSink() { - @Override - public void onExhausted(String target, String reason) { - onExhausted(target, reason, null); // no roster resolution modelled here — see below - } - - @Override - public void onExhausted(String target, String reason, String profileHint) { - FleetConfig.Profile profile = profileHint == null ? null : profiles.get(profileHint); - if (profile != null) { - quarantine.quarantine(profile.effectiveCredentialId()); - } + // fleetd #234, round 4: safely a lambda now — the 3-arg overload is the interface's single + // abstract method, so there is no 2-arg overload left to bind to instead. + ExhaustionSink sink = (target, reason, profileHint) -> { + FleetConfig.Profile profile = profileHint == null ? null : profiles.get(profileHint); + if (profile != null) { + quarantine.quarantine(profile.effectiveCredentialId()); } }; @@ -1270,7 +1264,7 @@ class OpenCodeLauncherTest { // Deliberately ignores the profile hint — the pre-fix shape: only a roster lookup (modelled // here as always empty, since acquire() has not registered the session yet either way). - ExhaustionSink rosterOnlySink = (target, reason) -> { }; + ExhaustionSink rosterOnlySink = (target, reason, profile) -> { }; FakeHerdr herdr = new FakeHerdr(); OpenCodeLauncher launcher = serviceWithSink(herdr, configRoot, discRoot, cfg, rosterOnlySink); @@ -1329,19 +1323,11 @@ class OpenCodeLauncherTest { SessionManager sessions = new SessionManager(launcher); // Fleetd.java:359-390 — the real sink is only built and pointed to AFTER `sessions` exists, - // same order as production. - ExhaustionSink realSink = new ExhaustionSink() { - @Override - public void onExhausted(String target, String reason) { - onExhausted(target, reason, null); // no roster resolution modelled — mirrors a miss - } - - @Override - public void onExhausted(String target, String reason, String profileHint) { - FleetConfig.Profile profile = profileHint == null ? null : profiles.get(profileHint); - if (profile != null) { - quarantine.quarantine(profile.effectiveCredentialId()); - } + // same order as production. Safely a lambda (round 4): see the note on the forwarder above. + ExhaustionSink realSink = (target, reason, profileHint) -> { + FleetConfig.Profile profile = profileHint == null ? null : profiles.get(profileHint); + if (profile != null) { + quarantine.quarantine(profile.effectiveCredentialId()); } }; exhaustionSinkRef.set(realSink);