Repository navigation
fix: report incomplete batch gap-fill analysis - #797
yashrajp22 wants to merge 23 commits into
Conversation
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Hi @yashrajp22, thank you for routing the batch scanner's gap-fill pass through the core batch executor! A failed batch is now retried under the core policy, recorded in the inspection ledger and kept out of gap_fill_applied. Completeness is recomputed with finalize_ledger rather than by editing counters by hand, and the new integration test drives the real retry loop, ledger and report formatters.
Value and readiness: The problem is real on main. I ran main's own run_gap_fill and run_one with malformed JSON, {"findings": 1} and a provider RuntimeError. In each case run_gap_fill returned [] after one call per file with no retry, and run_one returned error=None with gap_fill_applied=True, is_complete=True, execution_successful=True and recommendation SAFE. I then transcribed the PR's gap_fill.py:218-306 and runner.py:778-802 (nothing was imported from the PR) and ran them on main's core, with and without deepseek_compat. Every failure inside a batch is now handled as described:
- Malformed, schema-invalid and BOM-prefixed JSON, and plain-text refusals, get 4 attempts and are then recorded as
llm_structured_response_invalid. - Provider errors are recorded as
llm_batch_failed. - In every failure mode,
gap_fill_applied=False,is_complete=False,SAFEbecomesCAUTION, an error is returned and the command exits 2. - Core findings and findings from successful batches are kept.
- Valid empty responses still succeed after one call.
Two gaps remain against the PR's stated outcome ("Stops failed gap-fill analysis from being marked as successfully applied. Preserves existing findings and reports the failure."), and both block merge:
- A gap-fill failure before batching, such as the pool-mode
TypeError, now replaces the entry with an ERROR stub and drops the core findings thatmainkept (finding 1). - A JSON object without a
findingskey, such as{"error": ...}or{}, is still accepted as a successful, complete pass (finding 2).
The other findings are non-blocking. Once findings 1 and 2 are fixed and CI has run, the PR is ready for final maintainer review. The merge also needs sequencing with #796.
Material findings
- [Blocker]
contrib/batch_scan/runner.py:804: a gap-fill failure before batching now replaces the whole entry with an ERROR stub, which drops the core findings.- The broad
except Exception: return []is gone, soGapFillAnalyzer(...)andget_batches(gap_fill.py:301-302) now raise freely.run_onecatches onlyGapFillError(runner.py:782), so any other exception reachesrunner.py:804-805.entry_from_error(runner.py:811-838) then returnsissues=[], score 0 and severity/recommendationERROR. - Concrete trigger, which is pool mode:
create_api_key_pool_from_envreturns a pool whenever at least 2 keys are configured, fromSKILLSPECTOR_API_KEYSor fromOPENAI_API_KEYplusOPENAI_API_KEY_2..9(api_pool.py:603-664).batch_scan.py:195-197then callsset_api_pool, which installs_pooled_get_chat_model(model=None)(runner.py:89-98).LLMAnalyzerBase.__init__always callsget_chat_model(model=model, timeout=...)(llm_analyzer_base.py:938), so constructingGapFillAnalyzerraisesTypeError: ... unexpected keyword argument 'timeout'.- This affects every non-English skill with
use_llmand a non-empty provider-eligible cache.
- I reproduced this with
main's code, a stub pool anddeepseek_compat, on a Chinese skill using the real graph:main'srun_onekept score 68,HIGH,DO NOT INSTALLand issues E1/PE3/SC2/TM2. It also reportedgap_fill_applied=True, which was the old silent bug.- The transcribed PR
run_onereturned the TypeError text with{score 0, severity ERROR, recommendation ERROR}andissues: []. - The graph itself completes in pool mode. Core LLM analyzers already fail on
mainbut are caught, somain's entry was alreadyis_complete=False. The core findings are what this PR loses.
- Scope:
ValueErrorwas already re-raised onmain, so a missing-credential failure was an ERROR stub before this PR as well. The types whose behaviour changed areTypeErrorand other construction or batching errors, andNotImplementedErrorfromrun_batches_detailed. - Consequence: this fails closed (ERROR, exit 2), so it is not a bypass. But it contradicts "Preserves existing findings and reports the failure" for failures outside
run_batches_detailed. The new test mocksget_chat_modelwithlambda **kw, so the test does not exercise this path. - Expected fix:
- Treat gap-fill exceptions separately from graph exceptions. The simplest way is for
run_gap_fillto wrap construction andget_batchesfailures in aGapFillErrorwhose outcome marks every planned file as failed. The existing branch atrunner.py:790-802then keeps the core entry, marks it incomplete and returns the error. - Decide whether
ValueError(configuration) should stay a hard error. - Add a test in which
GapFillAnalyzerconstruction raises (for example, aget_chat_modelthat rejectstimeout), and assert that TM1 survives. - The pool-mode
timeoutmismatch in_pooled_get_chat_modeland compat Patch 1 is a separate bug onmainand belongs in its own change.
- Treat gap-fill exceptions separately from graph exceptions. The simplest way is for
- The broad
- [Blocker]
contrib/batch_scan/gap_fill.py:260: a JSON object without afindingskey is still accepted as a successful, complete pass.GapFillResult.findingsdefaults to an empty list (gap_fill.py:94), soGapFillResult.model_validate(data)accepts any dict that lacks the key.run_gap_fillthen raises nothing, andrunner.py:788setsgap_fill_applied=True. The PR pins this behaviour incontrib/batch_scan/tests/tests-pro/test_gap_fill.py:226-227.- Transcription on
main's core:{"error": "I cannot help with that"},{"refusal": "..."},{}and a fenced{"error": ...}each gave 1 call,error=None,gap_fill_applied=Trueand 0 findings. A plain-text refusal and{"findings": null}correctly gave 4 calls andllm_structured_response_invalid. - This is not a regression.
mainreturned[]for these inputs as well, and core'sLLMAnalysisResultuses the same default (llm_analyzer_base.py:644). The gap is narrow: gap-fill runs in raw chat mode, so a provider refusal usually arrives as prose, and that case is caught. - Consequence: a non-compliant response that happens to be valid JSON is still reported as "applied, no findings, complete". If the core scan said
SAFE, the skill staysSAFE. That is the silent pass this PR sets out to remove. - Expected fix:
- In
_parse_json_response, raise_StructuredResponseValidationErrorwhennot isinstance(data, dict) or "findings" not in data, or validate against a private model in whichfindingsis required. LeaveGapFillResult's default unchanged. - Change
test_missing_findings_key_keeps_schema_defaultto expect the raise, and add{"other": "value"}and{}to the invalid list. - Trade-off: a bare
{}meaning "nothing found" would then retry and end incomplete. That is defensible because the prompt requires the key (gap_fill.py:147-160).
- In
- [Non-blocking]
contrib/batch_scan/gap_fill.py:199: gap-fill has no deadline. The new structured retries add time inside the 90 s worker budget, and theruntime_limitpath cannot occur in production.super().__init__(base_prompt=prompt, model=resolved_model)passes notimeout, and compat Patch 1_patched_base_init(runner.py:128-136) has notimeoutparameter. So_require_time_remaining()returnsNone(llm_analyzer_base.py:986-991),LLMRuntimeLimitErroris never raised, andRUNTIME_LIMITis never recorded for gap-fill.- The test's
timeoutcase raisesLLMRuntimeLimitError("deadline")from the fake model (tests/test_batch_scan_security.py:188) and sets the retry delays to zero (:195). Both are synthetic. - Retry cost:
- Each malformed batch now gets
STRUCTURED_RESPONSE_MAX_ATTEMPTS=4calls with 0.5/1.0/2.0 s backoff (llm_analyzer_base.py:79-87), wheremainmade 1 call. - Batches run one after another, and a large file can span several batches.
- Transcription with the real delays, 2 malformed files: at L=0.2 s,
maintook 0.41 s for 2 calls and the PR 8.68 s for 8 calls (7.08 s of it backoff). At L=1.0 s, it was 2.01 s against 15.07 s. - That matches N·(4L+3.5 s) against N·L. By arithmetic (not run), 2 batches at L=10 s is about 87 s against 20 s, before core-graph time.
- Each malformed batch now gets
- The worker is killed at 90 s (
batch_scan.py:204,:232-234), and the supervisor substitutes an ERROR stub with no issues. - The missing deadline predates this PR, since
mainalready retried provider and rate-limit errors. What the PR adds is the malformed-output retry cost. Per-request HTTP timeouts and provider errors are correctly recorded asllm_batch_failed, with findings kept. - Consequence: a slow provider that keeps returning malformed output can push a skill past the worker kill, where it would otherwise be reported as incomplete with findings kept. Readers may also take the
timeouttest and the PR body's "runtime limit" wording as covering production timeouts, which they do not. - Expected fix:
- Thread a monotonic deadline through
run_one→run_gap_fill→GapFillAnalyzer, for exampletimeout=lambda: deadline - time.monotonic() - margin. A counterfactual transcription with a 3 s deadline at L=0.5 s stopped after 3 calls in 3.03 s and recordedruntime_limitfor both batches. - This needs Patch 1 to forward
timeout, which #796 adds. - If the fix is deferred, drop "runtime limit" from the PR body's list of handled failures, note the per-batch retry cost, and relabel the synthetic
timeouttest case.
- Thread a monotonic deadline through
- [Non-blocking]
contrib/batch_scan/runner.py:802: every gap-fill incompleteness exits 2, but the JSON shows no error for the non-fatal modes.runner.py:802returnsstr(gap_error)for everyGapFillError.batch_scan.py:468-473prints a per-skillERROR, and:534-535exits 2. The entry never gets anerrorkey, andreports.py:118countsErrors:only fromr.get("error").- Transcription of the PR branch on a real
maingraph result, passed tomain'sreports._format_json/_format_terminal:- json and schema:
execution_successful=True, noskills[].error,failed_executions=0,incomplete_skills=1, row(llm_structured_response_invalid, fatal=False), noErrors:line, exit 2. - timeout: the same, with
(runtime_limit, fatal=False), exit 2. - provider:
execution_successful=False,failed_executions=1,(llm_batch_failed, fatal=True), exit 2.
- json and schema:
- Comparison:
- Core
skillspector scanexits 2 on a result only whenexecution_successfulis False (cli.py:858-859). - Inside
batch_scan,main'srun_onereturnserror=Nonefor core LLM failures. I measured a malformed core output and a fatal core provider failure (failed_executions=1), and both exit 0. - #796 does not change
run_one's error return.
- Core
- The JSON is not completely silent:
gap_fill_applied: false,incomplete_skillsandledger_exceptionsare present. Butgap_fill_applied: falseis also set for an empty cache with no error. - Consequence: a CI job that gets exit 2 finds no per-skill
errorandfailed_executions: 0for two of the three modes, and the same reason code exits differently depending on whether gap-fill or a core analyzer produced it. This fails closed but is inconsistent. - Expected fix: pick one rule and document it.
- Option (a): return an error only when
completeness["execution_successful"]is False. To treat core and gap-fill fatal failures the same way,batch_scan.pywould also countentry.get("execution_successful") is False, which is a gap onmainoutside this PR. - Option (b): keep exit 2 and add a machine-readable field such as
enhancements.gap_fill_status/gap_fill_errorwith the reason codes. - Either way, update the exit-code table in
contrib/batch_scan/docs/README.md:310-316and coordinate with #796.
- Option (a): return an error only when
- [Non-blocking]
tests/test_batch_scan_security.py:133: the new test does not assert two of the behaviours the PR claims.- The parametrized test runs in CI and would fail on
mainin every case. With the test's mocks,main'srun_onereturnserror=Noneandgap_fill_applied=True, so the assertion at:201fails. The core claim is covered. - The assertions (
:201-234) never readentry["risk_assessment"]orentry["execution_successful"], andbatch_scan's exit code depends only onerrors. - Mutation check (transcription): deleting
runner.py:799-801, theexecution_successfulcopy and theSAFE→CAUTIONchange, still passes every assertion in all 8 cases. The recommendation starts asSAFEin this test, so lines 800-801 run but their result is never checked. - There is no case where the failure happens before
run_batches_detailed(finding 1), and thetimeoutcase is synthetic (finding 3). - The edits to
contrib/batch_scan/tests/tests-pro/test_gap_fill.pydo not run in CI:pyproject.toml:117setstestpaths = ["tests"]andMakefile:110runspytest ... tests/. That file pins a BOM-prefixed valid response as a failure (:218) and a missingfindingskey as a valid empty result (:226). - Expected fix:
- Add
assert entry["risk_assessment"]["recommendation"] == "CAUTION"andassert entry["execution_successful"] is (failure_mode != "provider"). - Optionally assert
failed_executions == int(failure_mode == "provider"). - Add a construction-failure case.
- Optionally move the parse-failure unit cases under
tests/.
- Add
- The parametrized test runs in CI and would fail on
- [Non-blocking]
contrib/batch_scan/runner.py:759: docstrings and docs do not reflect the changed contracts.runner.py:756-760says that on failure entry is a stub error entry. For a gap-fill failure,runner.py:802returns the full entry, recomputed and marked incomplete, together with an error.run_gap_fill's docstring (gap_fill.py:276-297) still says it returns "A (possibly empty) list" and has no Raises section. It now raisesGapFillError(:305), and construction errors propagate.- The section header at
gap_fill.py:266still says "Backward-compatible entry point". - The
parse_responsedocstring (:219-224) does not mention the new raise. run_gap_fillis public API (contrib/batch_scan/__init__.py:30,:67), butGapFillError, which a caller needs in order to read.outcome.successful, is not exported.contrib/batch_scan/docs/README.md:205saysgap_fill_appliedis true "if LLM gap-fill was used". On partial success it is now false whilegap_fill_findings> 0.contrib/batch_scan/tests/docs/TEST_GUIDE.md:122still lists 9TestParseResponseInvalidInputtests; there are now 2 (the file goes from 35 to 28 tests).- Expected fix:
- Add Raises:
GapFillError(with.outcome) torun_gap_fill, and updaterun_one's Returns section and the stale header. - Optionally export
GapFillError. - Define
gap_fill_appliedin the README as "true only if every gap-fill batch completed". - Update the TEST_GUIDE row.
- Add Raises:
- [Non-blocking]
contrib/batch_scan/runner.py:784: minor duplication, and a branch reachable only from tests.- The comprehension at
runner.py:784repeatscollect_findings(llm_analyzer_base.py:1512-1525). The runner has no analyzer instance to call it on. - The
isinstance(response, GapFillResult)branch (gap_fill.py:225-226) cannot be reached in production.response_schema = None(:192), so_invoke_batchalways passes astr. The branch exists only to keepTestParseResponsePydanticModelpassing; that test'slen >= 0assertion predates this PR. - Expected fix (optional): expose the retained findings on
GapFillError(for example afindingsattribute) and use it atrunner.py:784. Either tighten the Pydantic-path test to assert the returned rule IDs, or drop the branch and expect the raise.
- The comprehension at
PIC tradeoffs:
- Exit semantics. Exit 2 for any gap-fill incompleteness fails closed but is stricter than core, which exits 2 only on fatal execution failure. Within the same batch run, core-analyzer failures exit 0 (finding 4).
- Surfacing misconfiguration. Removing the broad
except Exceptionexposes real misconfiguration thatmainhid, such as the pool-mode TypeError. Today the cost is losing that skill's core findings (finding 1). - Retry cost. Retrying malformed gap-fill output follows core policy and recovers from transient bad output. It also multiplies calls and latency by up to 4 per batch, with no deadline (finding 3).
- BOM-prefixed JSON. A BOM-prefixed but otherwise valid response is now retried and counted as a failed batch, where
maindropped it silently. This fails closed, and providers rarely emit a BOM. Stripping a leading U+FEFF in_parse_json_responsewould accept it instead. - Empty object. Requiring the
findingskey (finding 2) means a bare{}is no longer accepted as "nothing found". - Report shape. Gap-fill ledger rows appear in
analysis_completenessonly on failure. On success the gap-fill pass is not visible there, so the data shape differs between the two paths. - Merge order with #796. #796 conflicts textually in
contrib/batch_scan/runner.py(theskillspector.llm_analyzer_baseimport line), and both PRs edittests/test_batch_scan_security.py. #796 also makes Patch 1 forwardtimeout, which the deadline fix in finding 3 needs.
Verification and gaps:
- Baseline on
main: reproduced withmain's own code, as described above. Each failure mode made one call, returnederror=None,gap_fill_applied=True,is_complete=Trueandexecution_successful=True, and keptSAFE. - PR behaviour: checked through my own transcriptions of
gap_fill.py:218-306andrunner.py:778-802, run onmain's core with and withoutdeepseek_compat.- A 200k-deep JSON nesting (
RecursionError) givesllm_batch_failed, fatal. - Fenced valid JSON succeeds after 1 call.
_StructuredResponseValidationErroris retried, and provider and runtime errors are not.GapFillAnalyzeroverridesparse_response, so compat Patch 2 does not apply to it.
- A 200k-deep JSON nesting (
- Completeness recompute:
finalize_ledgeron real graph results reproduced the graph's ownanalysis_completenessexactly, on 16 fixtures plus oversized, binary-referenced and >1 MB variants. The PR-style merge adds exactly onegap_fillexception row per failed batch, with nofinding_accounting_errororunaccounted_work, including when a successful batch emits a finding.- Finding IDs are random (
models.py:118-129), so gap findings cannot collide with core IDs. - The worker result stays JSON-serializable, because the reason and outcome codes are
StrEnum.
- Finding IDs are random (
- Tests:
test_gap_fill_failure_is_incomplete_and_preserves_findingsfails onmainin every case. So does the new empty-cache assertion attests/test_batch_scan_security.py:258. Both run in CI throughmake test-ci.- The
tests-prochanges do not run in CI. tests/test_batch_scan_security.pypassesruff checkandruff format --check.
- CI: no checks have run. The workflow run on
46af7b5isaction_required(waiting for a maintainer), so the PR description's test counts are unverified, and CI must pass before merge. - Conflicts: mergeable with
main; themaincommits merged since the review (#691, #577, #608) touch none of these files. It conflicts with open PR #796 incontrib/batch_scan/runner.py, and both edittests/test_batch_scan_security.pyin different places. - Head update: I reviewed
46af7b553b1667402d52d8dc2996f806453b6191. The current heada8fb26641167df4b8eadf71213d928e84bc4e23eonly adds automated merges ofmain(#691, #577, #608) fromupdate-pr-branches.yml; those commits touch none of this PR's files (#691 only routes structured-output binding through an overridable method inllm_analyzer_base.py), so line references tollm_analyzer_base.pybelow are to the current head. The PR's own diff is unchanged (same patch-id), so this review applies to the current head. - I did not run the PR's tests or code, per policy.
- Unverified areas:
- No live provider was available. The recompute was checked against static and no-credential graph results, not against real LLM-analyzer and meta-analyzer ledger rows.
- The 90 s worker kill in finding 3 is arithmetic from measured call counts and backoff, not an observed kill.
- Pool mode was reproduced with a stub pool object.
- Chunked files, where only some chunks of one file fail, were not exercised.
- Local Python is 3.13; CI uses 3.12.
Decision: Changes Requested (reviewed head 46af7b553b1667402d52d8dc2996f806453b6191; current head a8fb26641167df4b8eadf71213d928e84bc4e23e only adds merges of main)
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
…res' into yashraj/fix-batch-gap-fill-failures Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
…res' into yashraj/fix-batch-gap-fill-failures Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Malformed responses, setup errors, provider failures, or exhausted runtime in gap-fill analysis could become an empty result marked as applied. Gap-fill now requires an explicit findings field, retries invalid structured responses, records failed work in the inspection ledger, and keeps both core findings and successful gap-fill findings. Constructor and batching errors also record each planned file.
Gap-fill and the provider pool use the remaining per-skill deadline, including retries, with time reserved for reporting. Incomplete results return batch exit 2 and expose
gap_fill_status,gap_fill_error, and reason codes underenhancements; they remain included in report details and incomplete counts.GapFillErrorexposes retained findings directly.Validation: 542 source tests and 571 installed-wheel-core tests passed, plus 29 contributor parser tests with 10 subtests. Four added regressions reproduce failures against the original source and wheel core. Cases include actual deadline expiry, setup failures, malformed and valid empty responses, partial success, preserved findings, and report visibility. Contributor tools are not packaged in the wheel. Tests use deterministic fake providers; these focused tests made no live provider calls.
Combined verification across the updated PRs: 6,243 regression tests passed against source and again against the freshly installed wheel, with seven conditional skips and four expected failures per run. All 19 source/wheel sample pairs matched. The 12-skill corpus retained its findings and risk ratings; four former hangs now finish with explicit partial-analysis results. The 93 extension tests passed. Two synthetic live NVIDIA Build checks passed on the final wheel: benign-note was complete/SAFE, and the exfiltration sample retained SSD-3 with complete semantic and meta analysis and a DO_NOT_INSTALL recommendation. All seven recorded LLM analyses succeeded. Live checks used the configured model/reasoning defaults through a test-only proxy that kept the real credential outside the scanner.