Skip to content

feat(qa): bring direct-API qa_fixer to Claude recovery parity - #316

Merged
OBenner merged 1 commit into
developfrom
feat/qa-fixer-runtime-recovery-parity
Jun 13, 2026
Merged

feat(qa): bring direct-API qa_fixer to Claude recovery parity#316
OBenner merged 1 commit into
developfrom
feat/qa-fixer-runtime-recovery-parity

Conversation

@OBenner

@OBenner OBenner commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Context

Half of the autonomy goal is non-coder agent promotion. qa_reviewer/qa_fixer already run through the runtime layer, but behind a double gate: AUTO_CODE_QA_DIRECT_RUNTIME=true opt-in and the direct-API promotion gate. A key reason the manual opt-in still exists: the runtime qa_fixer was a single generic_edit pass with no recovery, unlike the Claude qa_fixer which wraps each attempt in a RecoveryManager loop.

This PR closes that parity gap (the prerequisite for dropping the opt-in in a follow-up).

What changes (apps/backend/qa/fixer.py)

  • run_qa_fixer_runtime_session now drives the same RecoveryManager loop the Claude path uses:
    • up to MAX_FIXER_ITERATIONS attempts,
    • classify_failuredetermine_recovery_action → dispatch: retry (exponential backoff), model fallback (get_fallback_model + MODEL_FALLBACK_CHAIN, e.g. gpt-5.2gpt-5gpt-4o), rollback to a commit, continue, skipstuck, escalate→dead-letter queue.
    • Model fallback rebuilds the runtime session with the next provider model between attempts.
  • _build_qa_fixer_runtime_session extracted so each attempt can swap the model.
  • run_qa_fixer_via_runtime stays the per-attempt primitive; gained an optional recovery_guidance arg threaded into the next prompt (mirrors the Claude path's ## RECOVERY STRATEGY injection).
  • The Claude path (run_qa_fixer_session) is untouched — zero risk to the existing flow.

Tests

4 new recovery-loop cases + the existing 3 → 10/10 in tests/test_qa_fixer_runtime.py:

  • model fallback → success (asserts the second attempt rebuilds with the next chain model),
  • strategy guidance threaded into the next attempt's prompt,
  • escalate("escalate", reason),
  • skip("stuck", …).

tests/test_qa_runtime_integration.py (15) + tests/test_qa_reviewer_runtime.py also green.

Note: tests/test_qa_fixer.py / test_qa_loop.py fail at collection with a pre-existing AttributeError: __spec__ (a leaked agents.runtime mock) — reproduced on a clean checkout without this change, so unrelated.

Next

Follow-up PR: drop the AUTO_CODE_QA_DIRECT_RUNTIME opt-in now that qa_fixer is at parity, so QA roles promote on the evidence gate alone (driven by AUTO_CODE_AUTONOMY=safe).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • QA fixer now supports automatic model fallback, allowing recovery strategies to be injected into prompts for improved subsequent fix attempts.
    • Enhanced recovery mechanisms including exponential backoff, rollback handling, and intelligent escalation when recovery is exhausted.
  • Tests

    • Added comprehensive coverage for the recovery loop across multiple fallback scenarios and recovery outcomes.

The runtime qa_fixer path (run_qa_fixer_via_runtime) did a single
generic_edit pass with no recovery — unlike the Claude qa_fixer, which
wraps each attempt in a RecoveryManager loop (exponential backoff, model
fallback, commit rollback, strategy guidance, skip/escalate to the
dead-letter queue). That parity gap is one reason QA roles stayed behind
the AUTO_CODE_QA_DIRECT_RUNTIME opt-in.

Close the gap:

- run_qa_fixer_runtime_session now drives the same RecoveryManager loop
  the Claude path uses: up to MAX_FIXER_ITERATIONS attempts, classify ->
  determine_recovery_action -> dispatch (retry/backoff, model fallback via
  get_fallback_model + MODEL_FALLBACK_CHAIN, rollback, continue,
  skip->stuck, escalate->dead-letter). Model fallback rebuilds the runtime
  session with the next provider model between attempts.
- Extracted _build_qa_fixer_runtime_session so each attempt can swap the
  model; the single-pass run_qa_fixer_via_runtime stays the per-attempt
  primitive and gained an optional recovery_guidance arg threaded into the
  next prompt.
- The Claude path (run_qa_fixer_session) is untouched.

Tests: 4 new recovery-loop cases (model fallback -> success, guidance
threading, escalate, skip->stuck) plus the existing 3 pass — 10/10 in
tests/test_qa_fixer_runtime.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Oleg Miagkov <mrobenner@gmail.com>
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The QA fixer runtime gains a recovery loop powered by RecoveryManager. The single-pass fixer now accepts optional recovery guidance that is injected into prompts; a new session factory rebuilds runtime environments for model fallback attempts; and an expanded recovery loop handles transient failures with exponential backoff, guidance threading, and terminal outcomes (fixed/stuck/escalate). Tests cover all recovery paths.

Changes

Recovery Loop and Fallback

Layer / File(s) Summary
Recovery guidance parameter and prompt injection
apps/backend/qa/fixer.py
run_qa_fixer_via_runtime accepts optional recovery_guidance parameter; when present, guidance is appended to the generated prompt under a "RECOVERY STRATEGY" section.
Session factory and model fallback imports
apps/backend/qa/fixer.py
get_fallback_model imported from core.model_fallback; new _build_qa_fixer_runtime_session helper constructs direct-provider runtime sessions that can be rebuilt between recovery attempts when the model changes.
Recovery loop implementation with backoff and fallback
apps/backend/qa/fixer.py
run_qa_fixer_runtime_session is reimplemented as a full recovery loop that records attempts/outcomes via RecoveryManager, applies backoff delays and rollbacks, rebuilds sessions when model changes, runs the single-pass fixer with threaded recovery guidance, handles retry/rollback/skip/escalate actions, and returns terminal statuses.
Test coverage for recovery loop scenarios
tests/test_qa_fixer_runtime.py
Async pytest tests with fake RecoveryManager validate: retry with model fallback and session rebuild, strategy guidance threading into next prompt, escalate terminal outcome with reason, and skip/stuck terminal outcome with failed outcome recording.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant RecoveryLoop as run_qa_fixer_runtime_session
  participant RecoveryMgr as RecoveryManager
  participant SessionBuilder as _build_qa_fixer_runtime_session
  participant SinglePass as run_qa_fixer_via_runtime
  
  Caller->>RecoveryLoop: call with config
  RecoveryLoop->>RecoveryMgr: init with escalation/backoff strategy
  loop attempt until fixed/stuck/escalate
    RecoveryLoop->>SessionBuilder: build runtime session
    SessionBuilder->>SinglePass: return configured session
    RecoveryLoop->>SinglePass: run with recovery_guidance from prior action
    SinglePass-->>RecoveryLoop: return fixed/error result
    alt transient error
      RecoveryLoop->>RecoveryMgr: classify and request recovery
      RecoveryMgr-->>RecoveryLoop: retry action with next fallback model/strategy
      RecoveryLoop->>RecoveryLoop: update current_model, queue backoff
    else fatal error or escalate
      RecoveryLoop->>RecoveryMgr: request escalate action
      RecoveryMgr-->>RecoveryLoop: escalate action with reason
      RecoveryLoop-->>Caller: return (escalate, reason)
    else skip/stuck
      RecoveryLoop->>RecoveryMgr: record failed outcome
      RecoveryLoop-->>Caller: return (stuck, reason)
    else fixed
      RecoveryLoop-->>Caller: return (fixed, result)
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • OBenner/Auto-Coding#54: Introduces RecoveryManager with exponential backoff, model fallback, and recovery actions that are directly integrated into this PR's enhanced run_qa_fixer_runtime_session loop.

Suggested labels

area/backend, size/L, feature

Poem

🐰 A fixer that falters now bounces right back,
With guidance and fallback to get back on track,
Recovery strategies thread through the night,
Attempt after attempt until problems are right,
The loop keeps on looping till victory's found! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: implementing recovery parity for the direct-API qa_fixer to match Claude's behavior, which is the primary objective of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/qa-fixer-runtime-recovery-parity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud

sonarqubecloud Bot commented Jun 8, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/backend/qa/fixer.py`:
- Around line 290-298: The recovery_guidance value must be consumed (cleared)
before awaiting run_qa_fixer_via_runtime to avoid leaking the same guidance into
later retries; change the call site so you capture the current guidance into a
local (e.g., rg = recovery_guidance), immediately set recovery_guidance = None,
and then pass that local (rg) into run_qa_fixer_via_runtime; ensure you
reference the existing symbols run_qa_fixer_via_runtime, recovery_guidance, and
the surrounding fix_session/runtime_session context so the one-shot semantics
match the approach used in agents.coder.

In `@tests/test_qa_fixer_runtime.py`:
- Around line 185-309: Add tests that exercise the queued recovery branch and
the exception-path retry behavior: create a _FakeRecoveryManager script that
returns a pending recovery action with wait_seconds and a flag to trigger
rollback, then assert that run_qa_fixer_via_runtime is called again after the
pending action and that the session's rollback_to_commit (or equivalent) was
invoked on the next attempt; also add a test where run_qa_fixer_via_runtime
raises an exception on the first call and returns ("fixed", ...) on retry, and
assert the RecoveryManager's strategy guidance is consumed exactly once (first
retry gets the guidance, subsequent calls do not), referencing
run_qa_fixer_runtime_session, run_qa_fixer_via_runtime, _FakeRecoveryManager,
and _recovery_action to locate the code under test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6a274974-4375-4497-a5ad-dc32f3203188

📥 Commits

Reviewing files that changed from the base of the PR and between 7e5cd91 and b9fb9db.

📒 Files selected for processing (2)
  • apps/backend/qa/fixer.py
  • tests/test_qa_fixer_runtime.py

Comment thread apps/backend/qa/fixer.py
Comment on lines +290 to +298
status, response_text = await run_qa_fixer_via_runtime(
runtime_session,
project_dir,
spec_dir,
fix_session,
verbose=verbose,
recovery_guidance=recovery_guidance,
)
recovery_guidance = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Consume recovery_guidance before awaiting the attempt.

run_qa_fixer_via_runtime() uses the guidance while building the prompt. If that await raises, Line 298 never executes, so the same guidance leaks into the next recovery attempt and can stack stale instructions on later retries.

🐛 Proposed fix
-            status, response_text = await run_qa_fixer_via_runtime(
+            attempt_guidance = recovery_guidance
+            recovery_guidance = None
+            status, response_text = await run_qa_fixer_via_runtime(
                 runtime_session,
                 project_dir,
                 spec_dir,
                 fix_session,
                 verbose=verbose,
-                recovery_guidance=recovery_guidance,
+                recovery_guidance=attempt_guidance,
             )
-            recovery_guidance = None

Based on learnings, apps/backend/agents/coder.py:1195-1200 clears recovery guidance immediately after adding the ## RECOVERY STRATEGY section, so this runtime path should preserve the same one-shot contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/qa/fixer.py` around lines 290 - 298, The recovery_guidance value
must be consumed (cleared) before awaiting run_qa_fixer_via_runtime to avoid
leaking the same guidance into later retries; change the call site so you
capture the current guidance into a local (e.g., rg = recovery_guidance),
immediately set recovery_guidance = None, and then pass that local (rg) into
run_qa_fixer_via_runtime; ensure you reference the existing symbols
run_qa_fixer_via_runtime, recovery_guidance, and the surrounding
fix_session/runtime_session context so the one-shot semantics match the approach
used in agents.coder.

Comment on lines +185 to +309
@pytest.mark.asyncio
async def test_runtime_fixer_recovers_via_model_fallback(tmp_path):
"""A failed attempt retries on the next model in the fallback chain."""
from qa import fixer

built_models: list[str] = []

def fake_build(*, provider_name, runtime_mode, model, project_dir, fix_session):
built_models.append(model)
return MagicMock()

via = AsyncMock(side_effect=[("error", "boom"), ("fixed", "done")])
fake_rm = _FakeRecoveryManager(
action_script=[_recovery_action("retry", use_model_fallback=True)]
)

with (
patch("qa.fixer._build_qa_fixer_runtime_session", side_effect=fake_build),
patch("qa.fixer.run_qa_fixer_via_runtime", new=via),
patch("qa.fixer.RecoveryManager", return_value=fake_rm),
):
status, response = await fixer.run_qa_fixer_runtime_session(
provider_name="openai",
runtime_mode="generic_edit",
model="gpt-5.2",
project_dir=tmp_path,
spec_dir=tmp_path,
fix_session=1,
)

assert status == "fixed"
assert response == "done"
# Second attempt rebuilt the session with the next model in the chain.
assert built_models == ["gpt-5.2", "gpt-5"]
assert via.await_count == 2
assert fake_rm.outcomes == [True]


@pytest.mark.asyncio
async def test_runtime_fixer_threads_recovery_guidance_into_next_attempt(tmp_path):
"""Strategy guidance from a retry action is passed into the next prompt."""
from qa import fixer

via = AsyncMock(side_effect=[("error", "boom"), ("fixed", "done")])
strategy = types.SimpleNamespace(guidance="TRY HARDER", description="d")
fake_rm = _FakeRecoveryManager(
action_script=[_recovery_action("retry", strategy=strategy)]
)

with (
patch("qa.fixer._build_qa_fixer_runtime_session", return_value=MagicMock()),
patch("qa.fixer.run_qa_fixer_via_runtime", new=via),
patch("qa.fixer.RecoveryManager", return_value=fake_rm),
):
await fixer.run_qa_fixer_runtime_session(
provider_name="openai",
runtime_mode="generic_edit",
model="gpt-5.2",
project_dir=tmp_path,
spec_dir=tmp_path,
fix_session=1,
)

# First attempt: no guidance; second attempt: guidance threaded in.
assert via.await_args_list[0].kwargs["recovery_guidance"] is None
assert via.await_args_list[1].kwargs["recovery_guidance"] == "TRY HARDER"


@pytest.mark.asyncio
async def test_runtime_fixer_escalates_when_recovery_exhausted(tmp_path):
"""An escalate action returns ('escalate', reason) for the dead-letter queue."""
from qa import fixer

via = AsyncMock(return_value=("error", "boom"))
fake_rm = _FakeRecoveryManager(
action_script=[_recovery_action("escalate", reason="dead-letter")]
)

with (
patch("qa.fixer._build_qa_fixer_runtime_session", return_value=MagicMock()),
patch("qa.fixer.run_qa_fixer_via_runtime", new=via),
patch("qa.fixer.RecoveryManager", return_value=fake_rm),
):
status, response = await fixer.run_qa_fixer_runtime_session(
provider_name="openai",
runtime_mode="generic_edit",
model="gpt-5.2",
project_dir=tmp_path,
spec_dir=tmp_path,
fix_session=1,
)

assert status == "escalate"
assert response == "dead-letter"
assert via.await_count == 1
assert fake_rm.outcomes == [False]


@pytest.mark.asyncio
async def test_runtime_fixer_marks_stuck_on_skip(tmp_path):
"""A skip action returns ('stuck', ...) and records a failed outcome."""
from qa import fixer

via = AsyncMock(return_value=("error", "boom"))
fake_rm = _FakeRecoveryManager(
action_script=[_recovery_action("skip", reason="circular_fix")]
)

with (
patch("qa.fixer._build_qa_fixer_runtime_session", return_value=MagicMock()),
patch("qa.fixer.run_qa_fixer_via_runtime", new=via),
patch("qa.fixer.RecoveryManager", return_value=fake_rm),
):
status, response = await fixer.run_qa_fixer_runtime_session(
provider_name="openai",
runtime_mode="generic_edit",
model="gpt-5.2",
project_dir=tmp_path,
spec_dir=tmp_path,
fix_session=1,
)

assert status == "stuck"
assert "circular_fix" in response
assert fake_rm.outcomes == [False]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add coverage for the queued recovery branch and exception-path retries.

These cases only exercise retry/skip/escalate after run_qa_fixer_via_runtime() returns ("error", ...). They never assert the pending_recovery_action branch that applies wait_seconds and rollback_to_commit() on the next iteration, and they do not cover the exception path where guidance must be consumed exactly once. A regression in either path would currently pass this file.

As per coding guidelines, tests/**: Ensure tests are comprehensive and follow pytest conventions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_qa_fixer_runtime.py` around lines 185 - 309, Add tests that
exercise the queued recovery branch and the exception-path retry behavior:
create a _FakeRecoveryManager script that returns a pending recovery action with
wait_seconds and a flag to trigger rollback, then assert that
run_qa_fixer_via_runtime is called again after the pending action and that the
session's rollback_to_commit (or equivalent) was invoked on the next attempt;
also add a test where run_qa_fixer_via_runtime raises an exception on the first
call and returns ("fixed", ...) on retry, and assert the RecoveryManager's
strategy guidance is consumed exactly once (first retry gets the guidance,
subsequent calls do not), referencing run_qa_fixer_runtime_session,
run_qa_fixer_via_runtime, _FakeRecoveryManager, and _recovery_action to locate
the code under test.

Source: Coding guidelines

OBenner added a commit that referenced this pull request Jun 12, 2026
Live-build finding (2026-06-12): when a gpt-5.2 coder session failed and
the recovery manager recommended model fallback, the inline fallback
block guessed a Claude shorthand from the model name and defaulted
ANY non-Claude model to "sonnet". The chain then yielded "haiku",
resolve_model_id() expanded it to claude-haiku-4-5-20251001, and the
retry sent a Claude model id to the OpenAI provider — 404
model_not_found, fatal for the whole build.

Use core.model_fallback.get_fallback_model() instead: it looks up the
ACTUAL current model and returns its same-provider fallback
(gpt-5.2 -> gpt-5, claude-sonnet-4-5 -> claude-haiku-4-5,
gemini-2.5-flash -> gemini-2.0-flash). This is the same helper the QA
fixer already uses for its retry parity (#316), so coder recovery now
matches that behavior.

Signed-off-by: Oleg Miagkov <mrobenner@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@OBenner
OBenner merged commit 9c5b1c7 into develop Jun 13, 2026
20 checks passed
OBenner added a commit that referenced this pull request Jun 13, 2026
Live-build finding (2026-06-13, spec 901): with QA roles routed to a
direct provider (qa_reviewer/qa_fixer on openai via the promotion gate,
now that #317 dropped the opt-in), the QA runtime session was built with
the QA phase-model DEFAULT — which phase_config resolves to a claude-*
id — and OpenAI 404'd on claude-sonnet-4-5-20250929. This is the QA-path
analog of the coder fix in #337: once routing correctly sends QA to the
direct provider, the Claude-centric phase default leaks straight into
the session.

New ProviderConfig.coherent_session_model() returns the provider's own
configured model when the requested model is a Claude-family id and the
provider is non-Claude; otherwise it passes the request through
unchanged. Both QA runtime shims (run_qa_reviewer_runtime_session,
_build_qa_fixer_runtime_session) now route their model through it before
create_session. The qa_fixer recovery loop already uses
get_fallback_model() (#316 parity), so once the initial model is the
provider's, the fallback chain (gpt-5.2 -> gpt-5 -> ...) stays coherent.

Tests: 5 helper cases (claude->provider, explicit preserved, None,
claude-provider unchanged, no-configured-model keeps request) + existing
QA runtime / factory suites. 85 passed.

Signed-off-by: Oleg Miagkov <mrobenner@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant