Skip to content

fix(jiradozer): model-aware plan-limit pre-flight + drop incompatible fallback effort#281

Merged
mzhaom merged 4 commits into
mainfrom
fix/fallback1
Jul 8, 2026
Merged

fix(jiradozer): model-aware plan-limit pre-flight + drop incompatible fallback effort#281
mzhaom merged 4 commits into
mainfrom
fix/fallback1

Conversation

@mzhaom

@mzhaom mzhaom commented Jul 8, 2026

Copy link
Copy Markdown
Member

Problem

Four jiradozer runs on 2026-07-08 (INF-1906/1907/1908/1912) died terminally with:

agent.go:521] claude plan pre-flight step=plan model=opus utilization=100 threshold=98
agent.go:451] claude plan near limit; skipping to fallback from=opus to=composer-2.5 threshold=98
workflow finished final_step=failed error="plan step: agent execution: provider does not support reasoning effort: provider=cursor level=\"high\""

The proactive plan-limit pre-flight (#277) fired, skipped opus → composer-2.5, and the fallback immediately hard-failed. Two independent root causes, both confirmed against the live /api/oauth/usage payload.

Root cause 1 — false limit detection (model-blind)

The live payload had the opus session window at only 20%, but a Fable-scoped weekly cap at 100% (is_active: true, scope.model.display_name: "Fable"). PlanUsage.MaxActiveUtilization() collapsed every active bucket into one scalar and ignored scope.model, so a Fable-only cap tripped the pre-flight skip for Opus, which still had plenty of headroom.

Fix: parse scope in PlanLimit and add MaxActiveUtilizationForModel(modelID, modelLabel) — it counts unscoped buckets plus scoped buckets that match the target model (case-insensitive on id/display-name), and excludes scoped buckets belonging to a different model. ClaudeSessionUtilization now uses the model-aware variant. The model-blind MaxActiveUtilization() is retained for account-wide reporting.

Root cause 2 — incompatible effort carried to fallback

On fallback, runAgent swapped only the model (activeCfg.Model = modelID) and kept the inherited effort: high. Cursor/Gemini/Agy have no effort knob and hard-fail on any non-auto level, so the rescue fallback was structurally guaranteed to die.

Fix: added agent.ProviderSupportsEffort(provider) (Claude/Codex → yes; Cursor/Gemini/Agy → no) and, in the fallback loop, blank activeCfg.Effort for any model whose provider can't honor it. Codex fallbacks still keep the configured effort (negative-control test).

Extra — opt-out switch

Added agent.disable_limit_preflight (default false) to turn off the proactive pre-flight entirely and rely only on the reactive out-of-credits fallback. Threaded through ResolveStep/ResolveRound, documented in the bootstrap agent block, and jiradozer.example.yaml regenerated.

Tests

  • MaxActiveUtilizationForModel — table-driven, including the verbatim incident payload (session 20% inactive + Fable weekly cap 100% active): Opus not gated, Fable gated at 100%, model-blind view unchanged.
  • ProviderSupportsEffort — all five providers + unknown.
  • Fallback effort dropped for cursor, kept for codex.
  • disable_limit_preflight skips the pre-flight even at 100% and never queries utilization.

Verification

  • bazel test //... → 92/92 pass
  • scripts/lint.sh → all checks pass
  • Replayed the verbatim live /api/oauth/usage payload end-to-end through the real PlanUsage JSON decoding.

mzhaom added 4 commits July 8, 2026 16:31
… fallback effort

Four runs (INF-1906/1907/1908/1912) died terminally on
"provider does not support reasoning effort: provider=cursor level=high"
after the plan-limit pre-flight (#277) skipped opus -> composer-2.5. Two
independent bugs, both confirmed against the live /api/oauth/usage payload.

Bug 1 — false limit detection (model-blind). The payload had opus's session
window at 20% but a Fable-scoped weekly cap at 100% (is_active, scope.model
= "Fable"). MaxActiveUtilization() collapsed all buckets into one scalar and
ignored scope.model, so the Fable cap tripped the skip for opus.
- Parse `scope` in PlanLimit; add MaxActiveUtilizationForModel(id,label) that
  counts unscoped buckets plus scoped buckets matching the target model
  (case-insensitive on id/display_name). ClaudeSessionUtilization now calls it.
- Keep model-blind MaxActiveUtilization() for account-wide reporting.

Bug 2 — incompatible effort carried to fallback. runAgent swapped only the
model on fallback and kept the inherited effort=high; cursor/gemini/agy have no
effort knob and hard-fail, so the rescue fallback was guaranteed to die.
- Add agent.ProviderSupportsEffort (claude/codex yes; cursor/gemini/agy no).
- Blank activeCfg.Effort for any fallback model whose provider can't honor it;
  codex fallbacks keep the configured effort.

Also add agent.disable_limit_preflight (default false) to turn off the
proactive pre-flight entirely and rely only on reactive out-of-credits
fallback. Threaded through ResolveStep/ResolveRound; documented in the
bootstrap agent block and regenerated jiradozer.example.yaml.

Tests: model-aware utilization (incl. verbatim incident payload),
ProviderSupportsEffort, fallback effort drop/keep, and pre-flight disable.
Cleanup pass over the model-aware pre-flight change: extract a named
ScopedModel type (was an anonymous struct repeated verbatim in a test),
trim doc/test comments that narrated the task/incident down to load-bearing
WHY, de-duplicate the DisableLimitPreflight doc to one canonical spot, and
share a claudeAtLimit test helper across the fallback tests.
…drop

Address two codex review findings:
- MaxActiveUtilizationForModel was model-aware only for the limits[] schema;
  the legacy named-window fallback still lumped SevenDayOpus/SevenDaySonnet
  (model-scoped caps) into every model's max, so Opus could be skipped because
  Sonnet was capped. Gate the named model-scoped windows by their own model.
- The fallback effort-drop also fired for a directly-configured primary,
  silently downgrading an explicit effort that a no-knob provider should reject
  (per the --thinking-level contract). Restrict the drop to failover (mi > 0).
Codex flagged that the model-aware usage matcher compared plan scopes against
exact resolved model strings, so a versioned config (claude-opus-4-8) would
bypass a scoped cap the API reports under the bare family ("Opus") — in both
the limits[] path and the legacy SevenDayOpus/SevenDaySonnet fallback.

scopeIDMatches now also compares by Claude family (opus/sonnet/haiku/fable) via
a new claudeModelFamily helper, so versioned ids, bare aliases, and API display
names all resolve to the same family. Single-point fix at the matcher covers
every cited call site; added versioned-id regression cases.
@mzhaom
mzhaom merged commit 975162b into main Jul 8, 2026
4 checks passed
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.

1 participant