Skip to content

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
cyrusagents:mainfrom
yscivideo:fix/repo-model-precedence
Open

fix(edge-worker): let repository.model win over the global default when no explicit model is requested#1360
yscivideo wants to merge 1 commit into
cyrusagents:mainfrom
yscivideo:fix/repo-model-precedence

Conversation

@yscivideo

@yscivideo yscivideo commented Jul 2, 2026

Copy link
Copy Markdown

fix(edge-worker): let repository.model win over the global default when no explicit model is requested

The bug

The per-repository model config field is unreachable. RunnerSelectionService.determineRunnerSelection() always resolves modelOverride to a value — when no model label or [model=...] description tag is present, it fills in the global default (claudeDefaultModel || defaultModel || "opus"):

const resolvedModelOverride =
    modelOverride ||
    defaultModelByRunner[runnerType] ||
    this.getDefaultModelForRunner(runnerType);

Then in RunnerConfigBuilder.buildIssueConfig():

const finalModel =
    modelOverride ||            // ← always set, so…
    input.repository.model ||   // ← …this can never win
    this.runnerSelector.getDefaultModelForRunner(runnerType);

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 to repository.fallbackModel (shadowed by the always-inferred fallbackModelOverride).

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 shows Using model: claude-opus-4-7 while the config says otherwise.

The fix

determineRunnerSelection() now returns modelOverride / fallbackModelOverride only when a model was explicitly requested (label or description tag). When nothing explicit is present it returns undefined for both, letting the existing precedence chain in buildIssueConfig() 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 via inferFallbackModel() (extracted to a public method; preserves the existing behavior where a customized default primary steps its fallback down, e.g. default sonnet → fallback haiku) → 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, so repository.model also 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/cursor or [agent=...] tag) with no model tag would otherwise fall through to repository.model, handing e.g. a Claude model string to a Codex runner. buildIssueConfig therefore gates repository.model / repository.fallbackModel by runner compatibility (via the extracted inferRunnerFromModel): 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 and modelOverride is now undefined (previously the global default) — the final model then resolves through repository.model / global default downstream, same effective model when no repo pin exists.

Tests

  • Selector: no labels/tags → both overrides undefined; explicit labels still resolve as before.
  • Builder precedence (wired through the real RunnerSelectionService): no label + repository.model set → repo model wins; explicit label + repository.model set → label wins; neither → global default.
  • Fallback: customized default primary with no explicit anything → fallback is inferred from the final model (not the hardcoded runner literal), so the fallback can no longer silently equal the primary.

pnpm test:packages green (747/747 in edge-worker), pnpm typecheck clean, pnpm lint clean on touched files.

Known follow-up (out of scope here)

inferFallbackModel() matches bare aliases (fable/opus/sonnet/haiku) but not full versioned IDs, so a repository.model like "claude-fable-5" gets the generic claude fallback (sonnet) rather than the one-tier-down opus. 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 in inferFallbackModel's claude branch, mirroring what inferRunnerFromModel already does.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AAAaJDxxUWmhtYySCfSQHD

@yscivideo yscivideo force-pushed the fix/repo-model-precedence branch 2 times, most recently from 5d2c3b3 to ad77ded Compare July 2, 2026 14:03
…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.
@yscivideo yscivideo force-pushed the fix/repo-model-precedence branch from ad77ded to 51b6414 Compare July 2, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants