Skip to content

fix(qa): persist direct-API QA reviewer verdict and close its async client - #340

Merged
OBenner merged 2 commits into
developfrom
fix/qa-reviewer-runtime-signoff
Jun 14, 2026
Merged

fix(qa): persist direct-API QA reviewer verdict and close its async client#340
OBenner merged 2 commits into
developfrom
fix/qa-reviewer-runtime-signoff

Conversation

@OBenner

@OBenner OBenner commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Problem

After #338 fixed the model-404, the direct-API (generic_edit) QA reviewer path starts on OpenAI, but a full build still ends qa_failed (exit 2): the runtime reviewer never persists its verdict, so get_qa_signoff_status is always empty and the loop reports "QA agent did not update implementation_plan.json (runtime path)", retries 3×, and fails.

Repro (from apps/backend, self-contained spec):

AUTO_CODE_AUTONOMY=safe AGENT_PROVIDER_CODER=openai AGENT_PROVIDER_QA_REVIEWER=openai \
AGENT_PROVIDER_QA_FIXER=openai .venv/bin/python run.py --spec <id> --ci --json \
--max-iterations 15 --force

Root cause

Sign-off never written (the qa_failed cause). The runtime reviewer shares the Claude-oriented prompt, which tells the model to record qa_signoff using the Write/Edit tools at the absolute path {spec_dir}/implementation_plan.json. In generic_edit those tools don't exist (the real tools are write_file/replace_text), and resolve_workspace_path rejects absolute paths outright:

relative .auto-claude/.../qa_signoff.json -> accepted (written)
absolute /…/implementation_plan.json      -> "Patch path must be relative"

The Claude path works because it has a dedicated update_qa_status MCP tool plus real Write/Edit.

Leaked async client (log noise / resource leak). OpenAICompatibleSession.close() only nulled self._client; the AsyncOpenAI/httpx transport was never aclose()d. All QA iterations run under one asyncio.run, and the reviewer session was never closed, so each iteration leaked a transport that fired RuntimeError: ... TCPTransport closed at GC time. (Noise, not the abort — each iteration builds a fresh client in the same loop.)

Fix

  • OpenAICompatibleSession.aclose() — awaits the client's own close() in the same event loop (best-effort, never raises); run_qa_reviewer_runtime_session closes the session in a finally.
  • Deterministic sign-off — a runtime-only prompt addendum (build_qa_reviewer_prompt(..., runtime_signoff_relspec=...)) overrides the Claude guidance: use write_file + workspace-relative paths and drop a small qa_signoff.json. merge_runtime_qa_signoff_artifact() folds that file into implementation_plan.json in Python, so we never depend on the model editing the large plan JSON (dodging both the tool-name and absolute-path traps). A direct plan write is still honored first. The Claude SDK path is unchanged (relspec defaults to None).

Tests (48 passing, ruff clean)

  • test_qa_reviewer_runtime.py: scripted runtime sessions writing the verdict via the plan and via the qa_signoff.json side-file → run_qa_reviewer_via_runtime returns approved/rejected; no-verdict → error; merge-helper, prompt-addendum, and Claude-path-unchanged cases.
  • test_openai_compat_session_lifecycle.py: aclose() awaits the client, clears state, and swallows a teardown error.
  • Adjacent suites green: test_qa_runtime_integration.py, test_qa_fixer_runtime.py, test_provider_factory.py, test_agent_runtime.py.

Follow-up (not in this PR)

run_qa_fixer_via_runtime has the identical latent issue (relies on the model writing fixes_applied into the plan). It doesn't block a self-contained spec reaching approved (the fixer only runs on rejection), so it's left for a focused follow-up.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • QA reviewer now supports file-based sign-off workflow for runtime adapters that cannot directly modify configuration.
  • Bug Fixes

    • Improved session cleanup to ensure proper resource management during QA review operations.
  • Tests

    • Added test coverage for QA reviewer runtime integration and session lifecycle management.

…lient

The direct-API (generic_edit) QA reviewer never recorded a verdict, so a
full build always exited qa_failed even when the implementation was sound.

Two causes:

- Sign-off persistence: the reviewer shares the Claude-oriented prompt,
  which tells the model to write qa_signoff with the Write/Edit tools at
  the absolute path {spec_dir}/implementation_plan.json. In generic_edit
  those tools do not exist (the tools are write_file/replace_text) and
  resolve_workspace_path rejects absolute paths, so the sign-off was never
  written and get_qa_signoff_status stayed empty.
- Client lifecycle: OpenAICompatibleSession.close() only dropped the
  AsyncOpenAI/httpx client reference; it was never aclose()d, leaking the
  TCP transport and flooding the QA loop with "TCPTransport closed" errors
  at GC time.

Fix:

- Add OpenAICompatibleSession.aclose() that awaits the client's own close()
  in the same event loop, and close the reviewer session in a finally in
  run_qa_reviewer_runtime_session.
- Add a runtime-only prompt addendum (build_qa_reviewer_prompt's new
  runtime_signoff_relspec) that overrides the Claude guidance: use
  write_file with workspace-relative paths and record the verdict in a
  small qa_signoff.json. merge_runtime_qa_signoff_artifact folds that file
  into implementation_plan.json deterministically, so we never rely on the
  model editing the large plan JSON. The Claude SDK path is unchanged
  (relspec defaults to None).

Tests: scripted runtime sessions that write the verdict via the plan and
via the qa_signoff.json side-file assert run_qa_reviewer_via_runtime
returns approved/rejected; merge-helper, prompt-addendum, and aclose()
lifecycle tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Oleg Miagkov <mrobenner@gmail.com>
@github-actions github-actions Bot added area/backend bug Something isn't working size/L labels Jun 13, 2026
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@OBenner, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 46 minutes and 14 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49411bdd-b678-4512-98e0-de4897cbd060

📥 Commits

Reviewing files that changed from the base of the PR and between 92af800 and 3b72adf.

📒 Files selected for processing (2)
  • apps/backend/qa/reviewer.py
  • tests/test_qa_reviewer_runtime.py
📝 Walkthrough

Walkthrough

This PR adds async cleanup support for OpenAI-compatible sessions and implements a runtime QA sign-off persistence mechanism. Runtime adapters now write standalone qa_signoff.json artifacts which are validated, merged into implementation_plan.json, and re-read. Sessions are explicitly closed in-event-loop to prevent transport leaks.

Changes

Runtime QA Reviewer with Async Session Lifecycle

Layer / File(s) Summary
Session async close implementation and lifecycle tests
apps/backend/core/providers/adapters/openai_compat.py, tests/test_openai_compat_session_lifecycle.py
OpenAICompatibleSession.aclose() detaches the client, awaits its close() when available and awaitable, suppresses exceptions with debug logging, and delegates final bookkeeping to sync close(). Tests inject a fake async client and verify proper state cleanup, safe handling of missing clients, and error suppression.
Runtime sign-off persistence and prompt integration
apps/backend/qa/reviewer.py, tests/test_qa_reviewer_runtime.py
Defines runtime verdict filename, derives workspace-relative spec directory, parses and validates qa_signoff.json artifacts, merges them into implementation_plan.json with metadata backfill. Extends build_qa_reviewer_prompt with optional runtime_signoff_relspec to conditionally append portable-runtime instructions for generic-edit tool usage, workspace-relative paths, and qa_signoff.json/qa_report.md writes.
Runtime reviewer integration with session cleanup
apps/backend/qa/reviewer.py
Passes runtime_signoff_relspec to prompt builder from runtime path. Implements fallback merging: if get_qa_signoff_status returns no status, merges qa_signoff.json and re-reads canonical status. Adds _aclose_agent_session helper preferring async aclose() and falling back to sync close(). Wraps direct-provider runtime QA invocation in try/finally to ensure cleanup in the same event loop, preventing transport leaks.
Runtime sign-off and end-to-end QA tests
tests/test_qa_reviewer_runtime.py
Tests validate runtime addendum presence, write-file guidance, workspace-relative paths, and absence of absolute path leakage. Merge artifact tests cover approved/rejected verdicts, metadata backfill, missing artifacts, and malformed payload rejection. End-to-end tests simulate runtime file writes and assert direct-plan edits, side-file verdict merging, qa_session/issue location inclusion, and error status on missing verdict.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • OBenner/Auto-Coding#276: Introduces the base run_qa_reviewer_via_runtime function in apps/backend/qa/reviewer.py, which this PR extends with sign-off artifact merging and session cleanup.

Suggested labels

area/backend, bug, size/M

Poem

🐰 A session closes with await so neat,
Where artifacts merge and verdicts complete.
Runtime paths portable, no leaks in the flow,
Cleanup in finally—we always let go! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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 accurately and specifically describes the main change: fixing QA reviewer verdict persistence via direct-API and closing its async client. It directly reflects the dual objectives addressed in 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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/qa-reviewer-runtime-signoff

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.

@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: 4

🤖 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/reviewer.py`:
- Around line 332-397: The merged runtime qa_signoff side-file (artifact =
spec_dir / RUNTIME_QA_SIGNOFF_FILENAME) is left in place and can be replayed
later; after a successful merge (i.e., after plan["qa_signoff"] is set and
save_implementation_plan(spec_dir, plan) returns True) delete the artifact to
prevent stale replay, catching and logging any OSError when unlinking but not
treating unlink errors as merge failures; only perform the unlink when
save_implementation_plan succeeds and do not remove the artifact earlier or on
save failure.
- Around line 767-777: The current cleanup returns unconditionally after an
aclose() exception and skips the sync close() fallback; fix by making the early
return occur only on successful await of session.aclose() — move the return (or
set a closed flag and return) inside the try block right after await aclose() so
that if await aclose() raises, execution falls through to the close() branch;
keep the except logging for session.aclose() as-is and preserve the
contextlib.suppress wrapper around session.close().
- Around line 373-381: The code currently mis-coerces coverage_passed and
accepts arbitrary items for issues_found; change the handling so coverage_passed
is only set when the source is a real bool or a canonical string/number that can
be safely parsed (e.g. if raw["coverage_passed"] is bool use it, else if it's a
str map lower() of "true"/"false" (or "1"/"0") to True/False, otherwise
skip/leave unset), and change issues handling so issues =
raw.get("issues_found") is accepted only if it's a list then filtered to keep
only dict items (e.g. issues_filtered = [i for i in issues if isinstance(i,
dict)]) before assigning signoff["issues_found"] = issues_filtered (default to
[] if missing or no valid dicts); keep the existing tests_passed dict handling
as-is.

In `@tests/test_qa_reviewer_runtime.py`:
- Around line 263-274: The test
test_merge_runtime_qa_signoff_artifact_rejects_bad_payloads should be extended
to cover malformed field types and stale-artifact reuse: add parametrized
payloads where "coverage_passed" is a non-boolean (e.g., "true" or 1) and where
"issues_found" is not a list of dicts (e.g., a list of strings or a non-list),
and assert for each that merge_runtime_qa_signoff_artifact(tmp_path,
qa_session=1) returns False and the plan's "qa_signoff" remains None; also add a
case that simulates reusing an old qa_signoff.json across runs (e.g., write a
valid-looking payload but with a timestamp/session marker indicating an earlier
run) and assert the function rejects it (returns False and leaves plan
unchanged). Use the existing helper _write_plan and the qa_signoff.json write
sequence used in the test to place these new cases.
🪄 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: e1af64b4-4b3b-4087-89b2-42650822560b

📥 Commits

Reviewing files that changed from the base of the PR and between dd5673e and 92af800.

📒 Files selected for processing (4)
  • apps/backend/core/providers/adapters/openai_compat.py
  • apps/backend/qa/reviewer.py
  • tests/test_openai_compat_session_lifecycle.py
  • tests/test_qa_reviewer_runtime.py

Comment thread apps/backend/qa/reviewer.py
Comment thread apps/backend/qa/reviewer.py
Comment thread apps/backend/qa/reviewer.py
Comment thread tests/test_qa_reviewer_runtime.py
…input

Address review feedback on the direct-API QA reviewer sign-off:

- Stale replay: clear any leftover qa_signoff.json before the runtime
  reviewer runs (so a merge only ever reflects the current session) and
  remove the file after a successful merge. Prevents a verdict from a prior
  session being replayed when the plan signoff was reset for revalidation.
- Field types: only carry coverage_passed through when it is a real bool
  (bool("false") is True), and filter issues_found to dict items so the
  rejection path and QA report never crash on a stray non-dict entry.
- Cleanup fallback: in _aclose_agent_session, fall through to the sync
  close() when aclose() raises instead of returning early.

Tests: malformed coverage_passed/issues_found sanitation, real-bool
coverage retention, artifact consumption, stale-file clearing before a run,
and the aclose()->close() fallback.

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

OBenner commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the review — addressed all four findings in 3b72adf:

  1. Stale qa_signoff.json replay — the verdict file is now cleared before the runtime reviewer runs (_clear_runtime_qa_signoff_artifact, the primary guard so a merge only ever reflects the current session) and also removed after a successful merge. Covered by test_runtime_reviewer_clears_stale_signoff_before_run and test_merge_runtime_qa_signoff_artifact_consumes_file.
  2. Field-type validationcoverage_passed is only carried through when it's a real bool (so "false" isn't coerced to True; the authoritative coverage_results are added afterward by update_qa_signoff_with_coverage), and issues_found is filtered to dict items so the rejection path / QA report can't crash on issue.get(...). I kept this lenient rather than rejecting the whole verdict, so a valid approved/rejected status is never lost over a malformed optional field. Covered by test_merge_runtime_qa_signoff_artifact_sanitizes_malformed_fields and ..._keeps_real_bool_coverage.
  3. aclose() failure fallthrough — moved the return inside the try so a raising aclose() falls through to the best-effort sync close(). Covered by test_aclose_agent_session_falls_back_to_sync_close_on_error.
  4. Missing regression cases — added the malformed-field and stale-artifact tests listed above.

All 53 tests across test_qa_reviewer_runtime.py, test_openai_compat_session_lifecycle.py, test_qa_runtime_integration.py, test_qa_fixer_runtime.py pass; ruff clean.

@sonarqubecloud

Copy link
Copy Markdown

@OBenner
OBenner merged commit 9652407 into develop Jun 14, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/backend bug Something isn't working size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant