Skip to content

Commit b85410a

Browse files
tarekziadeclaude
andauthored
review_runner: surface real crash cause instead of "(see pod log)" (#55)
## Problem A UI review of `huggingface/peft#3395` on `zai-org/GLM-5.2` failed twice with only: ``` LLMResponseError: review crashed (see pod log) ``` …and the pod log was **unrecoverable**. Two compounding issues: 1. **`review_runner.py` discarded the detail.** Its catch-all reported the generic `"{type}: review crashed (see pod log)"`, throwing away `str(exc)` — which for `LLMResponseError` already carries the LLM endpoint's status + body excerpt. (`task_runner.py`, the write-capable path, already handled this correctly; the read-only review path had regressed.) 2. **`(see pod log)` is a dead reference.** On the self-reported-error path the Job watcher sees `status=error` from the callback and reaps the runner pod immediately — it never waits for `ttlSecondsAfterFinished`, and skips the `collect_task_result` log-tail capture the *crash* path uses. The pod (and its traceback) is gone within seconds. Net effect: an upstream HF-router failure for GLM-5.2 left the operator with no legible cause. ## Fix Bring `review_runner.run()` to parity with `task_runner`: - `except LLMResponseError` → `format_llm_error(exc)` (status + body excerpt: 429/400/auth become legible). - `except requests.HTTPError` → `format_github_http_error(exc)`. - catch-all now embeds `crash_detail(exc)` (cause + crashing frame) instead of `(see pod log)`. - `crash_detail` moves to `errors.py` (its shared home); `task_runner` imports it — one copy for both entrypoints. ## Also `make format`/`test` built a **Python 3.9** venv because bare `python3` is 3.9 on stock macOS, violating `requires-python >=3.10` (and stalling dependency resolution). The Makefile now selects the newest available ≥3.10 interpreter and hard-fails with a clear message otherwise. CI (3.12) unaffected. ## Tests `tests/test_review_runner.py`: strengthened the crash test (asserts the cause travels, never `"see pod log"`) and added one proving a 429's status + body land on the job. Full suite: **398 passed**, ruff clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8813ab8 commit b85410a

5 files changed

Lines changed: 64 additions & 20 deletions

File tree

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
.PHONY: format style test
22

3-
PYTHON ?= python3
3+
# reviewbot needs Python >= 3.10 (pyproject requires-python). Bare `python3` is
4+
# 3.9 on stock macOS, which silently builds a broken venv — so prefer an
5+
# explicit 3.1x if present, and hard-fail below if the resolved one is too old.
6+
PYTHON ?= $(shell command -v python3.12 || command -v python3.11 || command -v python3.10 || command -v python3)
47
VENV ?= .venv
58
VENV_PYTHON := $(VENV)/bin/python
69
RUFF := $(VENV)/bin/ruff
710

811
$(VENV)/.installed: pyproject.toml
12+
@$(PYTHON) -c 'import sys; sys.exit(sys.version_info[:2] < (3, 10))' || { \
13+
echo "Error: $(PYTHON) is $$($(PYTHON) -V 2>&1); reviewbot needs Python >= 3.10."; \
14+
echo "Install it (e.g. 'brew install python@3.10') or run 'make PYTHON=python3.10 ...'."; \
15+
exit 1; }
916
$(PYTHON) -m venv $(VENV)
1017
$(VENV_PYTHON) -m pip install --upgrade pip
1118
$(VENV_PYTHON) -m pip install -e '.[web]' pytest ruff

reviewbot/errors.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,27 @@
1111

1212
from __future__ import annotations
1313

14+
import os
15+
1416
import requests
1517

1618
from .llm_client import LLMResponseError
1719

1820

21+
def crash_detail(exc: BaseException) -> str:
22+
"""A one-line cause plus the crashing frame — enough to see *why* an
23+
exception fired in the job's ``error`` without needing the (already-reaped)
24+
pod log. Used by both runner entrypoints for their catch-all handlers."""
25+
frame = ""
26+
tb = exc.__traceback__
27+
while tb is not None: # walk to the innermost frame
28+
code = tb.tb_frame.f_code
29+
frame = f"{os.path.basename(code.co_filename)}:{tb.tb_lineno} in {code.co_name}"
30+
tb = tb.tb_next
31+
detail = f"{type(exc).__name__}: {exc}".strip()
32+
return f"{detail} (at {frame})" if frame else detail
33+
34+
1935
def format_llm_error(exc: LLMResponseError) -> str:
2036
"""Render an LLMResponseError for the SSE client. Surfaces the status
2137
code + a body excerpt so the UI shows whether it was a 429 (rate

reviewbot/review_runner.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@
2121
import time
2222
from typing import Optional
2323

24+
import requests
25+
2426
from .clone_cache import Checkout, CloneCache
27+
from .errors import crash_detail, format_github_http_error, format_llm_error
2528
from .github_client import GitHubClient
29+
from .llm_client import LLMResponseError
2630
from .reviewer import ReviewRequest, _UnparseableLLMOutput, prepare_review
2731
from .store import encode_draft
2832
from .task_runner import CallbackEmitter, RunnerSpec, build_runner_config
@@ -106,11 +110,22 @@ def emit(kind: str, text: str) -> None:
106110
raw_llm_output=getattr(exc, "content", None),
107111
)
108112
return 1
113+
except LLMResponseError as exc:
114+
# The runner pod is reaped right after it reports, so "(see pod log)"
115+
# is a dead reference. Surface the LLM endpoint's own status + body
116+
# excerpt (429 rate-limit, 400 bad schema, auth, …) on the job itself.
117+
log.warning(
118+
"LLM endpoint returned %d for review %s", exc.status_code, spec.job_id
119+
)
120+
emitter.finish("error", error=format_llm_error(exc))
121+
return 1
122+
except requests.HTTPError as exc:
123+
log.warning("review %s GitHub API error: %s", spec.job_id, exc)
124+
emitter.finish("error", error=format_github_http_error(exc))
125+
return 1
109126
except Exception as exc: # noqa: BLE001 — always report a terminal outcome
110127
log.exception("review runner crashed for job %s", spec.job_id)
111-
emitter.finish(
112-
"error", error=f"{type(exc).__name__}: review crashed (see pod log)"
113-
)
128+
emitter.finish("error", error=f"review crashed: {crash_detail(exc)}")
114129
return 1
115130
finally:
116131
if checkout is not None:

reviewbot/task_runner.py

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from . import sandbox
4141
from .clone_cache import Checkout, CloneCache
4242
from .config import Config
43-
from .errors import format_github_http_error, format_llm_error
43+
from .errors import crash_detail, format_github_http_error, format_llm_error
4444
from .github_client import GitHubClient
4545
from .llm_client import LLMResponseError
4646
from .reviewer import _UnparseableLLMOutput
@@ -375,7 +375,7 @@ def emit(kind: str, text: str) -> None:
375375
return _fail(emitter, format_github_http_error(exc))
376376
except Exception as exc: # noqa: BLE001
377377
log.exception("task runner crashed for job %s", spec.job_id)
378-
return _fail(emitter, f"task runner crashed: {_crash_detail(exc)}")
378+
return _fail(emitter, f"task runner crashed: {crash_detail(exc)}")
379379
finally:
380380
if clone_cache is not None:
381381
clone_cache.release(checkout)
@@ -391,19 +391,6 @@ def _fail(
391391
return 1
392392

393393

394-
def _crash_detail(exc: BaseException) -> str:
395-
"""A one-line cause plus the crashing frame — enough to see *why* a startup
396-
exception fired in the job's ``error`` without needing the pod log."""
397-
frame = ""
398-
tb = exc.__traceback__
399-
while tb is not None: # walk to the innermost frame
400-
code = tb.tb_frame.f_code
401-
frame = f"{os.path.basename(code.co_filename)}:{tb.tb_lineno} in {code.co_name}"
402-
tb = tb.tb_next
403-
detail = f"{type(exc).__name__}: {exc}".strip()
404-
return f"{detail} (at {frame})" if frame else detail
405-
406-
407394
# ---------------------------------------------------------------------------
408395
# Entrypoint
409396
# ---------------------------------------------------------------------------
@@ -446,7 +433,7 @@ def main(argv: Optional[list[str]] = None) -> int:
446433
try:
447434
CallbackEmitter(
448435
spec.callback.get("url"), spec.callback.get("token"), spec.job_id
449-
).finish("error", error=f"task runner crashed: {_crash_detail(exc)}")
436+
).finish("error", error=f"task runner crashed: {crash_detail(exc)}")
450437
except Exception: # noqa: BLE001
451438
log.exception("could not report crash for job %s", spec.job_id)
452439
return 1

tests/test_review_runner.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,26 @@ def test_run_reports_error_on_crash(self):
143143
code, emitter = self._run(prepare_side_effect=RuntimeError("boom"))
144144
self.assertEqual(code, 1)
145145
self.assertEqual(emitter.terminal["status"], "error")
146+
# The runner pod is reaped as soon as it self-reports, so the crash
147+
# cause must travel in the reported error itself — not "(see pod log)".
146148
self.assertIn("review crashed", emitter.terminal["error"])
149+
self.assertIn("boom", emitter.terminal["error"])
150+
self.assertNotIn("see pod log", emitter.terminal["error"])
151+
152+
def test_run_surfaces_llm_error(self):
153+
from reviewbot.llm_client import LLMResponseError
154+
155+
exc = LLMResponseError(
156+
429, "Too Many Requests", "https://router/v1/chat", "rate limit exceeded"
157+
)
158+
code, emitter = self._run(prepare_side_effect=exc)
159+
self.assertEqual(code, 1)
160+
self.assertEqual(emitter.terminal["status"], "error")
161+
# The LLM endpoint's own status + body excerpt lands on the job, so a
162+
# 429/400/auth failure is legible without the (reaped) pod log.
163+
self.assertIn("429", emitter.terminal["error"])
164+
self.assertIn("rate limit exceeded", emitter.terminal["error"])
165+
self.assertNotIn("see pod log", emitter.terminal["error"])
147166

148167

149168
if __name__ == "__main__":

0 commit comments

Comments
 (0)