diff --git a/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java b/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java index 28dd5b2..006da0d 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java +++ b/fleetd/src/main/java/dev/ltms/fleet/member/OpenCodeLauncher.java @@ -794,9 +794,16 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { String requestedProvider = requestedParts == null ? null : requestedParts[0]; String requestedId = requestedParts == null ? cfg.model() : requestedParts[1]; boolean idMatches = requestedId.equals(actual.id()); - // Compare the provider ONLY when the profile actually asked for one — a bare model name - // (no "/") is a match on id alone, regardless of which provider opencode resolved it to. - boolean providerMatches = requestedProvider == null || requestedProvider.equals(actual.provider()); + // Compare the provider ONLY when BOTH sides have one. requestedProvider == null covers + // a bare profile model with no "/" — the profile never asked for a specific provider. + // actual.provider() == null covers opencode's model JSON having an id but no providerID + // (a real shape parseModel accepts) — that is missing evidence, not a contradiction, and + // acceptance rule 4 says missing evidence is UNKNOWN, never a mismatch. Narrowing the + // provider comparison this way keeps the id comparison (the part that actually caught the + // xf bug) fully intact — a genuine id mismatch is still caught either way (fleetd #175 + // review round 2). + boolean providerMatches = requestedProvider == null || actual.provider() == null + || requestedProvider.equals(actual.provider()); if (idMatches && providerMatches) { return; } 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 f74abf0..df70228 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/member/OpenCodeLauncherTest.java @@ -932,6 +932,53 @@ class OpenCodeLauncherTest { assertTrue(exhausted.isEmpty(), "id and provider both match → never a mismatch: " + exhausted); } + /** + * Incomplete evidence, not THE TRAP's provider mismatch: opencode's {@code model} JSON had an + * {@code id} but no {@code providerID} at all (a real shape {@code parseModel} accepts — see + * {@code OpenCodeSessionDiscoveryTest}). The id matches; the provider dimension is simply + * unknown, not contradicted. A provider-prefixed profile must NOT be quarantined on this — + * that would quarantine on incomplete evidence, which acceptance rule 4 forbids. + */ + @Test + void aMissingProviderIdInTheEvidenceIsUnknownNotAMismatchWhenTheIdMatches( + @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + List exhausted = new ArrayList<>(); + ExhaustionSink sink = (target, reason) -> 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)); + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + "{\"id\":\"gpt-5.6-terra\"}"); + + assertEquals("ses_x", handle.agentSessionId()); + assertTrue(exhausted.isEmpty(), + "id matches and provider is simply unknown (absent), never a mismatch: " + exhausted); + } + + /** + * The other half of the same fix: a missing {@code providerID} must NOT blind the check to a + * genuine id mismatch. This is what proves the fix narrows the comparison rather than switching + * the whole check off whenever {@code providerID} happens to be absent. + */ + @Test + void aMissingProviderIdInTheEvidenceStillCatchesARealIdMismatch( + @TempDir Path configRoot, @TempDir Path discRoot) throws Exception { + List exhausted = new ArrayList<>(); + ExhaustionSink sink = (target, reason) -> 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)); + OpenCodeSessionDiscoveryTest.writeRecord(discRoot, "ses_x", "/work/dir", 1000L, + "{\"id\":\"gpt-5.6-sol\"}"); + + assertEquals("ses_x", handle.agentSessionId()); + assertEquals(1, exhausted.size(), + "the id genuinely differs, so this must still quarantine even with providerID absent: " + + exhausted); + assertTrue(exhausted.get(0).contains("gpt-5.6-sol") && exhausted.get(0).contains(cfg.model()), + "reports both the requested and actual model: " + exhausted.get(0)); + } + /** * THE TRAP, row 3: a profile that names no provider prefix (bare {@code "deepseek-v4-flash"}) * must match on id alone — the profile never asked for a specific provider, so opencode