Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
42760c1
fix(batch): report incomplete gap-fill passes and retain their evidence
yashrajp22 Oct 7, 2026
839cb7f
test(batch): enforce failure exit and empty gap-fill accounting
yashrajp22 Oct 7, 2026
b4cca09
Check preserved findings through public issue IDs
yashrajp22 Oct 7, 2026
cc31b94
Exercise batch report formatting helpers directly
yashrajp22 Oct 7, 2026
ab7c850
Format gap-fill failure regressions
yashrajp22 Oct 7, 2026
46af7b5
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 7, 2026
8c9867b
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 8, 2026
f3745bc
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 8, 2026
a8fb266
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 8, 2026
952c043
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 8, 2026
c8d909f
fix: retain gap-fill evidence through setup and deadline failures
yashrajp22 Oct 9, 2026
b758edd
fix: bound pooled request waiting and retries by one deadline
yashrajp22 Oct 9, 2026
3d8ff22
fix: keep incomplete gap-fill reports in inspection summaries
yashrajp22 Oct 9, 2026
fe4dc6d
style: format gap-fill regression checks
yashrajp22 Oct 9, 2026
cfe7795
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 9, 2026
d04115b
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 9, 2026
7b947fd
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 9, 2026
319cc6c
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 9, 2026
7f31cec
Merge remote-tracking branch 'origin/yashraj/fix-batch-gap-fill-failu…
yashrajp22 Oct 9, 2026
53ca344
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 9, 2026
45f3024
Merge remote-tracking branch 'origin/yashraj/fix-batch-gap-fill-failu…
yashrajp22 Oct 9, 2026
0bc8976
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 9, 2026
6fdb573
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 9, 2026
0eb6dcb
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 12, 2026
0452124
Merge branch 'main' into yashraj/fix-batch-gap-fill-failures
github-actions[bot] Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion contrib/batch_scan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
from .api_pool import ApiKey, ApiKeyPool, PooledChatModel, create_api_key_pool_from_env
from .detection import detect_language, detect_skill_language
from .discovery import discover_skills
from .gap_fill import GapFillAnalyzer, GapFillFinding, GapFillResult, run_gap_fill
from .gap_fill import GapFillAnalyzer, GapFillError, GapFillFinding, GapFillResult, run_gap_fill
from .runner import run_one

__all__ = [
Expand All @@ -60,6 +60,7 @@
"detect_skill_language",
"discover_skills",
"GapFillAnalyzer",
"GapFillError",
"GapFillFinding",
"GapFillResult",
"is_language_compatible",
Expand Down
125 changes: 57 additions & 68 deletions contrib/batch_scan/api_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,106 +475,95 @@ async def ainvoke_with_usage(self, prompt: str, collector: object) -> object:

# -- Internal -------------------------------------------------------------

@staticmethod
def _remaining(deadline: float) -> float:
from skillspector.llm_analyzer_base import LLMRuntimeLimitError

remaining = deadline - time.monotonic()
if remaining <= 0:
raise LLMRuntimeLimitError("pooled request runtime limit reached")
return remaining

def _invoke_with_retry(
self,
prompt: str,
*,
callbacks: list[object] | None = None,
self, prompt: str, *, callbacks: list[object] | None = None,
) -> object:
"""Sync retry loop — acquire slot, call LLM, release, retry on 429."""
last_exception: Exception | None = None

"""Share one deadline across slot waiting, requests, and key retries."""
deadline = time.monotonic() + self._timeout
for attempt in range(self._max_retries + 1):
key = self._pool.acquire()
llm = self._build_llm(key)
try:
if callbacks is None:
result = llm.invoke(prompt)
else:
result = llm.invoke(prompt, config={"callbacks": callbacks})
self._pool.release(key, success=True)
if attempt > 0:
self._pool.record_retry_success()
return result
key = self._pool.acquire(timeout=self._remaining(deadline))
except Exception:
self._remaining(deadline)
raise
rate_limited = False
try:
llm = self._build_llm(key, timeout=self._remaining(deadline))
result = llm.invoke(prompt) if callbacks is None else llm.invoke(
prompt, config={"callbacks": callbacks}
)
except Exception as exc:
if self._is_rate_limit(exc) and attempt < self._max_retries:
self._pool.release(key, success=False)
logger.debug(
"PooledChatModel: rate-limited, retrying "
"(attempt %d/%d)",
attempt + 1,
self._max_retries,
)
rate_limited = self._is_rate_limit(exc)
if rate_limited and attempt < self._max_retries:
continue
self._pool.release(key, success=True)
last_exception = exc
raise

raise RuntimeError(
f"PooledChatModel: exhausted {self._max_retries} retries "
"due to rate-limit errors"
) from last_exception
finally:
self._pool.release(key, success=not rate_limited)
if attempt > 0:
self._pool.record_retry_success()
return result
raise RuntimeError("PooledChatModel exhausted key retries")

async def _ainvoke_with_retry(
self,
prompt: str,
*,
callbacks: list[object] | None = None,
self, prompt: str, *, callbacks: list[object] | None = None,
) -> object:
"""Async retry loop — non-blocking acquire first, block only if full."""
"""Bound asynchronous slot waiting without leaving an acquiring thread."""
import asyncio
last_exception: Exception | None = None

deadline = time.monotonic() + self._timeout
for attempt in range(self._max_retries + 1):
self._remaining(deadline)
key = self._pool.try_acquire()
if key is None:
key = await asyncio.to_thread(self._pool.acquire)
llm = self._build_llm(key)
while key is None:
await asyncio.sleep(min(0.05, self._remaining(deadline)))
self._remaining(deadline)
key = self._pool.try_acquire()
rate_limited = False
try:
if callbacks is None:
result = await llm.ainvoke(prompt)
else:
result = await llm.ainvoke(prompt, config={"callbacks": callbacks})
self._pool.release(key, success=True)
if attempt > 0:
self._pool.record_retry_success()
return result
llm = self._build_llm(key, timeout=self._remaining(deadline))
result = await llm.ainvoke(prompt) if callbacks is None else await llm.ainvoke(
prompt, config={"callbacks": callbacks}
)
except Exception as exc:
if self._is_rate_limit(exc) and attempt < self._max_retries:
self._pool.release(key, success=False)
logger.debug(
"PooledChatModel: rate-limited, retrying "
"(attempt %d/%d)",
attempt + 1,
self._max_retries,
)
rate_limited = self._is_rate_limit(exc)
if rate_limited and attempt < self._max_retries:
continue
self._pool.release(key, success=True)
last_exception = exc
raise

raise RuntimeError(
f"PooledChatModel: exhausted {self._max_retries} retries "
"due to rate-limit errors"
) from last_exception

def _build_llm(self, key: ApiKey):
finally:
self._pool.release(key, success=not rate_limited)
if attempt > 0:
self._pool.record_retry_success()
return result
raise RuntimeError("PooledChatModel exhausted key retries")

def _build_llm(self, key: ApiKey, *, timeout: float | None = None):
"""Build a fresh :class:`~langchain_openai.ChatOpenAI` for *key*."""
from langchain_openai import ChatOpenAI
from pydantic import SecretStr

timeout = self._timeout if timeout is None else timeout
try:
import httpx
_timeout = httpx.Timeout(self._timeout, connect=8.0)
_timeout = httpx.Timeout(timeout, connect=min(8.0, timeout))
except ImportError:
_timeout = self._timeout
_timeout = timeout

return ChatOpenAI(
model=key.model,
base_url=key.base_url,
api_key=SecretStr(key.key),
max_completion_tokens=self._max_tokens,
timeout=_timeout,
max_retries=0,
)

@staticmethod
Expand Down
3 changes: 3 additions & 0 deletions contrib/batch_scan/batch_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ def _scan_skill(
require_llm: bool,
api_pool=None,
verbose: bool = False,
timeout: float = 90,
) -> tuple[dict[str, object], str | None, str]:
"""Scan a single skill through the full pipeline.

Expand All @@ -139,6 +140,7 @@ def _scan_skill(
detected_language=lang,
apply_gap_fill=True,
api_pool=api_pool,
timeout=timeout,
)

return entry, error_msg, rel_name
Expand Down Expand Up @@ -205,6 +207,7 @@ def _scan_skill_bounded(
startup_timeout: float = 90, **options
) -> tuple[dict[str, object], str | None, str]:
"""Enforce the wall-clock limit on actual work, including local analysis."""
options["timeout"] = timeout
owner = uuid4().hex
if api_pool is not None:
options["api_pool"] = SimpleNamespace(
Expand Down
14 changes: 12 additions & 2 deletions contrib/batch_scan/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f json -o batch.json

Key differences in batch output:
- `scan_mode: "multilingual-enhanced"` — provenance marker
- `enhancements.gap_fill_applied` — true if LLM gap-fill was used
- `enhancements.gap_fill_applied` — true only if every gap-fill batch completed;
partial passes can still have `gap_fill_findings > 0`
- `enhancements.english_keyword_rules_skipped` — count of static rules bypassed
- `skill.language` — detected language tag

Expand Down Expand Up @@ -313,7 +314,7 @@ python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --no-require-llm --no-
|------|---------|
| 0 | All safe (no HIGH/CRITICAL) |
| 1 | ≥1 skill has HIGH or CRITICAL risk |
| 2 | Scan errors occurred |
| 2 | Scan errors or incomplete gap-fill occurred; `skills[].error` or `enhancements.gap_fill_error` explains the failure |

CI usage:

Expand Down Expand Up @@ -431,3 +432,12 @@ tests/
---

**Next:** [DESIGN.md](DESIGN.md) — architecture & concurrency model · [REVIEW_RESPONSE.md](REVIEW_RESPONSE.md) — PR #100 review response · [CONTRIBUTING.md](../CONTRIBUTING.md) — dev setup & code conventions


Gap-fill uses the remaining per-skill budget, leaving up to five seconds for

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.

[Non-blocking] Four wording issues in this paragraph and the related text:

  1. Placement: this paragraph sits after the "Next:" navigation footer at line 434. It belongs in the exit-code section (lines 311-317).
  2. Line 443 is incomplete for the worker as a whole. The deadline from runner.py:773 reaches only run_gap_fill (:795), and graph.invoke (:777) is not bounded by it. In a measurement on main's code, the core analyzers received about 600 s (the shared scan budget). So core analysis can plausibly reach the 90 s kill even when providers behave (not observed).
  3. PR body: "Gap-fill and the provider pool use the remaining per-skill deadline" holds only for gap-fill's pooled calls. PooledChatModel takes the caller's timeout.
  4. Other docs:
    • The run_one and run_gap_fill docstrings do not document the new timeout parameter.
    • TEST_GUIDE.md:42 and :117 still say "41 tests". The file has 29.

Expected fix:

  • Move this paragraph into the exit-code section.
  • Say that the deadline covers gap-fill only.
  • Correct the PR-body sentence.
  • Document timeout.
  • Fix the TEST_GUIDE totals.

See finding 3.

reporting before the worker's 90-second hard limit. Invalid responses can take
up to four attempts while that budget remains. Failed setup and failed batches
retain core findings and completed gap-fill findings. An incomplete pass records
`enhancements.gap_fill_status: "incomplete"`, `gap_fill_error_reasons`, and
`enhancements.gap_fill_error`, and exits 2 even when its ledger outcome is nonfatal.
The worker hard limit still applies if a provider ignores its request timeout.
Loading