fleetd #175: check opencode's actual model against the profile, quarantine on a real mismatch #231
Reference in New Issue
Block a user
Delete Branch "worker/cb175-model-readback-0f085f-1"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
fleetd #175 — opencode model read-back
opencode does not fail on an unknown
-m <model>flag — it silently falls back to a default model. Thexfprofile named a withdrawn model and every spawn actually ran on a paid credential (gpt-5.6-sol) undetected, becauseBackendQuarantinenever saw evidence of what model actually ran. This extends the existing #209 late-resolve path to catch that.Production diff summary
OpenCodeSessionDiscovery.java: newActualModel(provider, id)record andactualModelForDirectory(String directory), reading thesession.modelJSON column ({"id":...,"providerID":...}) via its OWN independent query/connection — deliberately NOT combined withsessionIdForDirectory's query, so a schema that lacks themodelcolumn can never break id resolution (proven by a dedicated test). Absent database, absent row, null/blank column, or unparseable/incomplete JSON all resolve tonull(UNKNOWN), never thrown.OpenCodeLauncher.java:spawn()now re-resolves the profile config (cheap map lookup, same deterministic name already used bybuildLaunch) and wraps the inner handle inSessionAwareHandlewith that config plus a newExhaustionSinkfield.SessionAwareHandle.agentSessionId()calls a newcheckModelMatch()on every invocation (naturally once-only in practice, since the caller stops calling it once the id resolves); it splits the profile's model on the FIRST/into provider+id, compares id always, compares provider only when the profile specified one, and returns silently on UNKNOWN evidence (actualModelForDirectoryreturnednull) or no configured model (claude-code profiles never reach this code at all —SessionAwareHandleonly wrapsOpenCodeLauncherspawns). On a real mismatch it logs one ERROR naming the profile, the requested model, and the actual model, then calls the existingExhaustionSink.onExhausted(...)— no new quarantine mechanism. AnAtomicBooleanguards against a duplicate report on a later call.Fleetd.java: wires a realExhaustionSinkintoOpenCodeLauncher, using the sameAtomicReferenceforwarding pattern already used forliveCountRefin this file — needed because the real sink depends onsessions, which depends onworkers, which depends on the adapter being constructed. A forwarding sink is handed to the constructor now and pointed at the real one once it exists. Also reworded the sink's log line since it now serves two triggers, not just backend exhaustion.Build result (verbatim)
Full unpiped output read start to finish (1827 lines); every module's test class reported
Failures: 0, Errors: 0, includingOpenCodeLauncherTest(43 tests) andOpenCodeSessionDiscoveryTest(16 tests).FleetHealthMonitorTest's visible stack traces are from a test double literally namedAlwaysThrowingFailTarget— intentional retry-logic fixture noise, not a failure.Tests added, and whether each was watched to fail first
All new tests were run against a temporarily neutered version of the fix (three separate red/green cycles) and confirmed RED before the fix was restored and confirmed GREEN:
if (true) return null;at the top ofactualModelForDirectory→OpenCodeSessionDiscoveryTest: 16 run, 1 failure + 1 error (the two tests expecting real model data). Reverted → 16/16 green.if (true) return;at the top ofcheckModelMatch()→OpenCodeLauncherTest: 43 run, 2 failures (the mismatch-detection tests). Reverted → 43/43 green.idMatches = false;forced right before the match check →OpenCodeLauncherTest: 43 run, 3 failures (all three THE TRAP "must be a match" tests correctly caught the false positive). Reverted → 43/43 green.OpenCodeSessionDiscoveryTest(8 new):parsesTheModelJsonIntoProviderAndId,prefersTheModelOfTheMostRecentlyUpdatedRow,aNonMatchingDirectoryYieldsUnknownModelRatherThanAMismatch,aNullModelColumnYieldsUnknownWithoutThrowing,unparseableModelJsonYieldsUnknownWithoutThrowing,modelJsonMissingIdYieldsUnknown,aMissingDatabaseYieldsUnknownModelWithoutThrowing,aMissingModelColumnYieldsUnknownButIdResolutionStillWorks(proves a schema with NOmodelcolumn at all still resolves the session id — this is what forced the two-query design).OpenCodeLauncherTest(8 new):theRealSessionManagerLateResolvePathCatchesAModelMismatch,aProviderPrefixedModelMatchingBothIdAndProviderIsNotAMismatch,aGxProviderPrefixedModelMatchingBothIdAndProviderIsNotAMismatch,aBareModelWithNoProviderPrefixMatchesOnIdAloneAndIsNotAMismatch,aRealIdMismatchLogsAnErrorNamingBothModelsAndQuarantinesThroughTheSink,unknownOrUnparseableModelEvidenceNeverQuarantines,aProfileWithNoConfiguredModelIsNeverCheckedForAMismatch.Acceptance criterion 1 — which test proves the check runs on the REAL late-resolve path
theRealSessionManagerLateResolvePathCatchesAModelMismatchinOpenCodeLauncherTest. It builds a REALSessionManagerwrapping a REALOpenCodeLauncher(not a mock of either), callssessions.acquire(...), thensessions.get(paneId)BEFORE any opencode DB row exists (assertsagentSessionId()is still null and nothing is exhausted yet), then writes the session row with a mismatched model, then callssessions.get(paneId)AGAIN — this second call is what drivesSessionManager's existingresolveAgentSessionIdre-poll (from #209), which is what callshandle.agentSessionId(), which is wherecheckModelMatch()lives. Only after that second, genuine re-poll does the test assert the id resolved AND exactly one exhaustion entry was recorded naming both models. This is the exact timing #203 got wrong (its check ran insidespawn(), before the row existed, so it never fired against real data) — this test forces the check through the real gapSessionManagerfills by re-polling.THE TRAP — how each case was handled
openai/gpt-5.6-terra{"id":"gpt-5.6-terra","providerID":"openai"}aProviderPrefixedModelMatchingBothIdAndProviderIsNotAMismatchgx/deepseek-v4-flash{"id":"deepseek-v4-flash","providerID":"gx"}aGxProviderPrefixedModelMatchingBothIdAndProviderIsNotAMismatchdeepseek-v4-flash(no/){"id":"deepseek-v4-flash","providerID":"gx"}aBareModelWithNoProviderPrefixMatchesOnIdAloneAndIsNotAMismatchSessionAwareHandleis only ever constructed byOpenCodeLauncher.spawn();ClaudeCodeLaunchernever builds one, so this code path cannot run for a claude-code profile at allA bare model name (no
/) is compared onidalone regardless of which provider opencode actually resolved it to — the profile never asked for a specific provider, so that dimension is not checked. Unknown/unparseable evidence (actualModelForDirectoryreturningnull) always short-circuits before any comparison, covered byunknownOrUnparseableModelEvidenceNeverQuarantines.Scope note (not fixed, per instruction — one line)
OpenCodeLauncher.buildLaunch'sautoCompactWindowis set the same fire-and-forget way (no read-back that it took effect) — same shape as this bug, out of scope, not touched.Not touched
SessionManager.javarequired zero changes — the existing unchanged #209resolveAgentSessionId→handle.agentSessionId()re-poll IS the real late-resolve path this hooks into. The periodic tick (comment atSessionManager.java:591) was left untouched, per instruction.Review round 2 (addressed)
Lead found:
parseModelreturns a populatedActualModelwhen the JSON has anidbut noproviderID(a real shape opencode can write), andcheckModelMatch'sproviderMatchescheck treated that missing provider as a mismatch for any provider-prefixed profile whose id genuinely matched. That is incomplete evidence being treated as a mismatch, which acceptance rule 4 forbids.Fix chosen (as recommended): compare the provider only when both sides have one —
requestedProvider == null || actual.provider() == null || requestedProvider.equals(actual.provider()). Comment added at the call site explaining why.parseModelwas NOT changed to reject a missingproviderID— theidis still real evidence and is what actually caught thexfbug.New tests, both watched to fail first:
aMissingProviderIdInTheEvidenceIsUnknownNotAMismatchWhenTheIdMatches— evidence{"id":"gpt-5.6-terra"}(noproviderID) against profileopenai/gpt-5.6-terra— must NOT quarantine. Run against the pre-fixproviderMatcheslogic: FAILED (Tests run: 2, Failures: 1— this exact test), confirming the reported bug reproduces. Then the fix was applied and this test went green.aMissingProviderIdInTheEvidenceStillCatchesARealIdMismatch— evidence{"id":"gpt-5.6-sol"}(noproviderID) against profileopenai/gpt-5.6-terra— must STILL quarantine, since the id genuinely differs. This one already passed before the fix (id comparison was never broken) and still passes after — proves the fix narrows the provider check rather than disabling the whole comparison.After the fix: both tests green,
mvn -o test -Dtest=OpenCodeLauncherTest→Tests run: 45, Failures: 0, Errors: 0(43 existing + 2 new).Full build after the fix (verbatim, unpiped, full output read):