Repository navigation
Run TP4 batches concurrently - #731
Conversation
_check_tp4 executed its LLM batches through the serial run_batches_detailed loop, while the semantic analyzers and the meta-review fan out through arun_batches. On skills with many code chunks TP4 became the long tail of the scan and could exhaust the workflow deadline on its own. Use run_async(analyzer.arun_batches_detailed(batches)). The shared limiter still honours SKILLSPECTOR_MAX_LLM_CONCURRENCY (1 keeps the old serial behaviour), failures are recorded per batch as before, and successes keep the batch order, so the TP4_MAX_FINDINGS cap and the ledger projection are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: elliottwaves-20 <pail1217@web.de>
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Hi @elliottwaves-20, thank you for measuring the TP4 long tail and moving it onto the shared async path with a focused concurrency test!
Value and readiness: _check_tp4 now runs its batches through run_async(analyzer.arun_batches_detailed(batches)), the same path the semantic analyzers and the meta-review already use. Per-batch failure classification, retries, deadline handling and result order are unchanged. It also closes a quiet gap. The serial loop never took the process-wide limiter, so TP4 could add one request beyond SKILLSPECTOR_MAX_LLM_CONCURRENCY, even with =1. It now shares the single process-wide budget described in the 2.11.1 release notes. CI is green and it merges cleanly with main. Ready for final maintainer review; see the merge-order note for #691 below.
Material findings
- [Non-blocking]
src/skillspector/llm_analyzer_base.py:1425-1455: a batch can end inValueErrororNotImplementedError, for example a refusal that returnsNone, which_TP4Analyzer.parse_responserejects. The async path then raises only after every batch has finished, while the serial loop stopped at the first one. The final TP4 ledger is the same (every batch FAILED), but a model that refuses every batch now spends up toTP4_MAX_BATCHES(64) calls instead of one. The semantic analyzers already behave this way, so no change is needed here. It is worth keeping in mind for #691's Opus 5 refusal handling. - [Non-blocking]
tests/test_mcp_tool_poisoning.py:1715: the new test proves fan-out under the shared limit (peak == 2is deterministic) and fails onmain. One TP4-level case with mixed outcomes would pin the rest of the contract. Make batch 0 finish last and batch 1 raise, then assert that ledger rows and findings follow batch order, batch 1 gets a FAILED row, the others are COMPLETED, andllm_call_logisok: False.gather()already guarantees the ordering, and the shared layer tests failure isolation (test_detailed_outcome_preserves_failed_batch), so this is hardening, not a gap.
PIC tradeoffs: TP4 now draws on the same SKILLSPECTOR_MAX_LLM_CONCURRENCY pool (default 10) as the semantic analyzers. Scans finish sooner, but TP4 sends more requests per minute. Low-RPM tiers and local servers such as Ollama may see more 429s or queued timeouts unless users lower the limit, and those show up as partial TP4 coverage. A release note would help. The limiter is FIFO across analyzers, so under a tight workflow deadline, which analyzer's batches get cut now depends on arrival order. Before, TP4 effectively had its own lane.
Verification and gaps:
- Compared
run_batches_detailed()(llm_analyzer_base.py:1300) witharun_batches_detailed()(:1397). Both use the same structured-response retries, provider retry and backoff (includingRetry-After),RUNTIME_LIMITand provider-failure classification, andValueError/NotImplementedErrorpropagation.gather(..., return_exceptions=True)isolates failures per batch, and results are walked in batch order. Successes, failures, theTP4_MAX_FINDINGScap, dedup and thellm_call_logerror class therefore stay deterministic. Per-callinference_usagerecords now follow completion order, as they already do for the semantic analyzers. - Deadlines: each batch re-checks the shared deadline in
_model_for_call()after it gets a permit, and retry sleeps are capped at the remaining time. With a workflow deadline, provider timeouts are retargeted to the remaining time on each call. Queued batches fail fast withRUNTIME_LIMIT(PARTIAL) once time runs out, and an in-flight call overshoots by at most its capped timeout. - Concurrency safety:
_GlobalLLMLimiteris shared across event loops (covered bytest_three_nodes_on_three_loops_share_one_budget), andInferenceUsageCollectoris lock-protected. CLI providers' async calls areasyncio.to_thread(self.invoke...), so no provider-side serialization is bypassed.run_async()handles callers that already have a running loop. TP4's whole-runexcept Exceptionstill marks unfinished batches FAILED, which the updatedtest_graph.pydouble exercises. semantic_security_discovery.py:218is now the only analyzer still on the serial path outside the limiter (out of scope here).- Overlaps: #693 merges cleanly with this PR. Its tests use the shared
_FakeStructuredLLM, which gainsainvoke_with_usagehere. #691 conflicts on the adjacentskillspector.llm_utilsimport line, which is trivial to resolve. Suggested order: land this PR first, then update #691. - #691 will also need test changes once this lands, because two of its new TP4 tests stub sync-only hooks.
test_wrapped_json_schema_response_stays_incompletepatchesllm_analyzer_base.time.sleep, so its retries would really sleep (about 3.5 s).test_opus5_function_calling_refusal_stays_incompletepatchesChatOpenAI._generate, but the async path calls_agenerate. It would attempt a real request toexample.invalidinstead of exercising the refusal path. Patching_agenerateandLLMAnalyzerBase._asleep_before_retryfixes both. - CI: all 6 checks passed on this head, and
mainhas not touched the changed files since. Tests and the PR's timing figures were not reproduced locally, per review policy.
Decision: Approved (reviewed head 9919c159597e62016214a1d97ae1eb559e547b11)
|
Thank you for the review and the merge. I can follow up with the TP4-level mixed-outcome test you described: batch 0 finishes last, batch 1 raises, ledger rows and findings stay in batch order, batch 1 is FAILED and |
Closes #730
What
_check_tp4now runs its batches throughrun_async(analyzer.arun_batches_detailed(batches))instead of the serialrun_batches_detailedloop, the same way the semantic analyzers and the meta-review already fan out.SKILLSPECTOR_MAX_LLM_CONCURRENCY;1still serializes.arun_batches_detailedrecords failures per batch exactly like the sync path (structured-response retries, runtime limit, provider failures) and returns successes in batch order, so theTP4_MAX_FINDINGScap and the ledger projection are unchanged.Tests
TestTP4Concurrency: four TP4 batches withSKILLSPECTOR_MAX_LLM_CONCURRENCY=2reach two requests in flight and all four complete. It fails on the previous code, which never reaches the async path._asleep_before_retry, since retries back off through the async path; the structured-model double gainedainvoke_with_usage.test_graph.py: the failing TP4 analyzer double implementsarun_batches_detailed.pytest tests/test_mcp_tool_poisoning.py tests/integration/test_graph.py: 79 passed. Unit suite (default-m "not integration and not provider", Windows 11, Python 3.13): 8410 passed. The remaining 18 failures and 2 errors fail identically onupstream/mainin the same environment (release script, CLI rendering, timing and path tests); four tests that differed between the two parallel runs pass on both trees when run serially.ruff checkandruff format --checkclean.Note: on Windows,
tests/nodes/test_security_end_to_end.pydoes not finish within five minutes, with and without this change, so I ran the unit suite without it.Measurement
Provider
codex_cli, one skill with 46 LLM calls (26 of them TP4): TP4 span ~270 s → 75 s withSKILLSPECTOR_MAX_LLM_CONCURRENCY=4, whole scan 320 s → 206 s. Details in #730.🤖 Generated with Claude Code