Skip to content

fix(llm): isolate Stage 2 batch failures and keep unanalysed findings#32

Merged
rng1995 merged 2 commits into
NVIDIA:mainfrom
nyxst4ck:fix/stage2-batch-isolation
Jun 24, 2026
Merged

fix(llm): isolate Stage 2 batch failures and keep unanalysed findings#32
rng1995 merged 2 commits into
NVIDIA:mainfrom
nyxst4ck:fix/stage2-batch-isolation

Conversation

@nyxst4ck

Copy link
Copy Markdown
Contributor

Summary

Fixes the two coupled Stage 2 resilience bugs, which the reporter noted should be considered together because fixing the abort without fixing the drop would make the scanner less safe:

Changes

llm_analyzer_base.py — per-batch failure isolation. arun_batches now gathers with return_exceptions=True: a transient error (timeout, 429, oversized-chunk 400) is logged with the batch label and costs only its own batch instead of cancelling the whole fan-out. ValueError and NotImplementedError still propagate — they signal misconfiguration (missing API key, wrong response_schema), which skipping a batch cannot fix. This also benefits the two discovery analyzers (semantic_developer_intent, semantic_quality_policy), which previously lost all discovered findings when any single batch failed.

meta_analyzer.py — no verdict means conservative keep, not delete. With partial results possible, the keep/drop loop must distinguish "the LLM rejected this" from "the LLM never saw this". The node now partitions findings by whether a returned batch actually carried them (each Batch already tracks its findings): analysed findings go through the normal confirm-or-drop filter; unanalysed ones are kept via the existing _fallback_filtered path; a WARNING logs how many findings were kept unfiltered so the gap is visible rather than silent. When all batches fail, behaviour matches today's net result (everything kept via fallback).

Net effect, as suggested in #11: an infra failure can only ever cost enrichment on a file, never the finding itself.

Tests

  • arun_batches: a failing batch doesn't abort the others; all-failed returns empty; ValueError still propagates (5 new tests in test_llm_analyzer_base.py).
  • New tests/nodes/test_meta_analyzer.py: confirmed finding kept enriched + rejected finding dropped + unseen finding kept when one batch fails; all-batches-failed keeps everything; strict confirm-or-drop preserved when nothing fails.
  • The two node-level tests are red on main and green with this fix.
pytest tests/ --ignore=tests/integration   # 598 passed; failures identical to main (pre-existing, unrelated)
ruff check / ruff format                   # clean on touched files

All commits are signed off per the DCO.

One exception anywhere in the arun_batches fan-out aborted the whole
Stage 2 pass: asyncio.gather without return_exceptions cancelled the
remaining batches, the meta-analyzer's blanket except caught the
propagated error, and every file silently fell back to static-only
results while the CLI still exited 0 (NVIDIA#9).

arun_batches now isolates failures per batch: a transient error
(timeout, 429, oversized-chunk 400) is logged and costs only its own
batch. ValueError and NotImplementedError still propagate, since they
signal misconfiguration rather than infra trouble.

With partial results possible, apply_filter could no longer treat a
missing confirmation as a rejection: a finding whose batch never
returned would be silently dropped — a false negative manufactured by
an infrastructure error (NVIDIA#11). The meta-analyzer now partitions
findings by whether a returned batch actually carried them: analysed
findings go through the normal confirm-or-drop filter, unanalysed ones
are kept via the existing fallback path, and a WARNING logs how many
findings were kept unfiltered so the gap is visible.

Net effect: an infra failure can only ever cost enrichment on a file,
never the finding itself, and one bad call no longer turns off the
semantic filter for the whole scan.

Fixes NVIDIA#9, fixes NVIDIA#11

Signed-off-by: nyxst4ck <289980115+nyxst4ck@users.noreply.github.com>
@nyxst4ck
nyxst4ck force-pushed the fix/stage2-batch-isolation branch from d09497d to 0eeae6f Compare June 14, 2026 18:42

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Solid, well-reasoned fix. I traced both halves against the current code and the keep/drop semantics hold up, including the conservative (fail-closed) direction that matters for a security scanner.

Correctness — verified

  • get_batches(files_with_findings, file_cache, findings) builds batches from the same Finding instances (it appends f directly / passes them through findings_in_range), and arun_batches returns those same Batch objects. So the id(f) partition in meta_analyzer is sound: a finding is "analysed" only if a batch that actually returned carried it.
  • When no batch fails (len(batch_results) == len(batches)), the code reduces to apply_filter(findings, batch_results) — byte-for-byte the previous behavior, so there's no regression to the strict confirm-or-drop path. The added node test (test_no_failures_keeps_strict_confirm_or_drop) pins this.
  • analysed and unanalysed are a disjoint partition of findings, so combining apply_filter(analysed, …) with _fallback_filtered(unanalysed) can't double-count or drop.
  • apply_filter is pure confirm-or-drop keyed by (file, rule_id, start/end_line) and _fallback_filtered is keep-all, so routing un-analysed findings through the fallback genuinely prevents an infra failure from turning into a false negative. The all-batches-failed case collapses to today's "keep everything via fallback", as claimed.
  • Re-raising ValueError from arun_batches is consistent with the caller's existing except ValueError: raise, so misconfiguration still surfaces instead of masquerading as a clean scan.

Tests
Good coverage of exactly the risky logic: per-batch isolation, all-failed → empty, ValueError propagation, and the three node-level keep/drop scenarios (confirmed kept + enriched, rejected dropped, unseen kept, all-failed keep-all, strict path preserved). Appreciate that the node tests are red on main / green with the fix.

Minor / optional (non-blocking)

  • In arun_batches, isinstance(result, BaseException) + continue will also swallow asyncio.CancelledError (it derives from BaseException, and return_exceptions=True surfaces child cancellations as results). Consider narrowing the swallow to Exception, or explicitly re-raising asyncio.CancelledError, so cooperative cancellation isn't masked. Low severity since outer-task cancellation still propagates from the gather await itself.
  • The id()-based partition is correct today but silently depends on get_batches not copying findings. A one-line comment noting that invariant (or matching on a stable finding key) would harden it against a future refactor. Worth noting the failure mode is itself fail-safe — if instances ever diverged, findings would be treated as unanalysed and kept, never dropped.

These are optional; the change is correct, conservative in the right direction, and well-tested. Approving.

@rng1995

rng1995 commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

@nyxst4ck - Please resolve the conflicts, minor issues and merge the PR.

Resolve the meta_analyzer test conflict, keep the partial-batch coverage alongside upstream apply_filter tests, and propagate asyncio cancellation instead of treating it as a transient LLM batch failure.
@nyxst4ck

Copy link
Copy Markdown
Contributor Author

Resolved the merge conflict by merging current upstream/main into this branch, and addressed the two minor review notes while I was there:

  • �syncio.CancelledError now propagates instead of being treated as a transient batch failure.
  • Added a short comment documenting the Finding object identity invariant used for partial-batch partitioning.
  • Kept both the upstream �pply_filter regression tests and this PR's partial-batch fallback tests in ests/nodes/test_meta_analyzer.py.

Validation:

  • uv run --extra dev pytest tests/nodes/test_llm_analyzer_base.py tests/nodes/test_meta_analyzer.py (108 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.

2 participants