fix(edge-worker): let repository.model win over the global default when no explicit model is requested#1360
Open
yscivideo wants to merge 1 commit into
Open
Conversation
5d2c3b3 to
ad77ded
Compare
…en no explicit model is requested determineRunnerSelection() always default-filled modelOverride, so the documented precedence (label override > repository config > global default) never reached the repository tier. The selector now returns undefined when no model label or [model=...] tag is present, letting repository.model / repository.fallbackModel resolve as documented. Fallback inference (inferFallbackModel) is extracted to a public method and applied to the final resolved model, preserving the existing step-down behavior for customized defaults. Also closes a cross-runner compatibility gap: an agent-only selection (a codex/gemini/cursor label or [agent=X] tag with no model tag) resolved modelOverride to undefined, and buildIssueConfig then applied repository.model unconditionally — so a repo with a Claude-shaped model string (e.g. "sonnet") could be handed to a Codex or Gemini runner on a fresh session. inferRunnerFromModel is extracted onto RunnerSelectionService (same mappings as before) and buildIssueConfig now gates both repository.model and repository.fallbackModel: they're only applied when inferRunnerFromModel finds no evidence of a different runner (undefined, e.g. a custom/proxy model name) or explicitly agrees with the resolved runnerType. An incompatible value is skipped (falls through to the runner-appropriate default) and logged via log.debug. Round 2: the compatibility gate above used a raw inferRunnerFromModel comparison, which unconditionally inferred codex for any GPT-shaped model string (e.g. "gpt-5.4") — silently dropping a legitimate Cursor repository.model, since CursorRunner.normalizeCursorModel passes GPT model ids through natively (schema-documented in packages/core/src/config-schemas.ts). A new public isModelCompatibleWithRunner(model, runnerType) on RunnerSelectionService now backs the gate: compatible when inferRunnerFromModel is undefined, or equals runnerType, or infers "codex" while runnerType is "cursor". The four resume-switch branches (input.session.*SessionId forcing a runner switch back onto the session's original runner) are un-reverted: they now discard the selector's cross-runner override (modelOverride/fallbackModelOverride = undefined) instead of default-filling it, since the compatibility gate structurally prevents the cross-runner repository.model leak that motivated the original default-fill — so repository.model can safely reach the finalModel chain on continuation turns too, not just fresh sessions.
ad77ded to
51b6414
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(edge-worker): let
repository.modelwin over the global default when no explicit model is requestedThe bug
The per-repository
modelconfig field is unreachable.RunnerSelectionService.determineRunnerSelection()always resolvesmodelOverrideto a value — when no model label or[model=...]description tag is present, it fills in the global default (claudeDefaultModel || defaultModel || "opus"):Then in
RunnerConfigBuilder.buildIssueConfig():The comment on the config says
// Priority order: label override > repository config > global default, but in practice the repository tier is dead code. The same applies torepository.fallbackModel(shadowed by the always-inferredfallbackModelOverride).Real-world symptom: a self-hosted user pins
"model": "claude-opus-4-8"(or any model) on a repository, configures no global default, and every session still launches with the bare alias"opus"— which the bundled SDK resolves to whatever it considers latest, silently ignoring the pin. The Linear timeline showsUsing model: claude-opus-4-7while the config says otherwise.The fix
determineRunnerSelection()now returnsmodelOverride/fallbackModelOverrideonly when a model was explicitly requested (label or description tag). When nothing explicit is present it returnsundefinedfor both, letting the existing precedence chain inbuildIssueConfig()work as documented:model: explicit label/tag →repository.model→ global default (unchanged expression, now reachable).fallbackModel: explicit override →repository.fallbackModel(newly reachable, mirroring the fix) → inferred from the final model viainferFallbackModel()(extracted to a public method; preserves the existing behavior where a customized default primary steps its fallback down, e.g. defaultsonnet→ fallbackhaiku) → runner-default fallback.The four resume-runner-switch branches in
buildIssueConfig()(labels changed on an existing session) now clear their overrides instead of re-filling with the switched-to runner's default, sorepository.modelalso resolves on continuation turns — safe because the compatibility gate below blocks any foreign-runner repository model from leaking into a resumed session (covered by a dedicated regression test).Cross-runner safety
An agent-only selector (label
codex/gemini/cursoror[agent=...]tag) with no model tag would otherwise fall through torepository.model, handing e.g. a Claude model string to a Codex runner.buildIssueConfigtherefore gatesrepository.model/repository.fallbackModelby runner compatibility (via the extractedinferRunnerFromModel): a value is applied only when its inferred runner is unknown (custom/proxy model names still pass) or matches the resolved runner; incompatible values are skipped with a debug log and the runner default applies. Cursor is special-cased: it legitimately runs GPT model ids, so codex-inferred models stay compatible on a cursor runner. Covered by regression tests for the codex/gemini label,[agent=codex]tag, cursor+GPT, and resume-switch cases.Behavior change
One existing expectation changes: when an explicit model label conflicts with the resolved runner type (e.g. labels
["claude", "gpt-5-codex"]), the conflicting model is reset andmodelOverrideis nowundefined(previously the global default) — the final model then resolves throughrepository.model/ global default downstream, same effective model when no repo pin exists.Tests
undefined; explicit labels still resolve as before.RunnerSelectionService): no label +repository.modelset → repo model wins; explicit label +repository.modelset → label wins; neither → global default.pnpm test:packagesgreen (747/747 in edge-worker),pnpm typecheckclean,pnpm lintclean on touched files.Known follow-up (out of scope here)
inferFallbackModel()matches bare aliases (fable/opus/sonnet/haiku) but not full versioned IDs, so arepository.modellike"claude-fable-5"gets the generic claude fallback (sonnet) rather than the one-tier-downopus. The fallback is always a valid same-provider model, so this is a quality nit, not a safety issue — a natural follow-up is prefix-matching ininferFallbackModel's claude branch, mirroring whatinferRunnerFromModelalready does.🤖 Generated with Claude Code
https://claude.ai/code/session_01AAAaJDxxUWmhtYySCfSQHD