Skip to content

Commit 2660430

Browse files
tarekziadeclaude
andauthored
Normalize (or refuse) in publish_task's raw-apply fallback (#58)
## Problem Observed on transformers PR [#47281](huggingface/transformers#47281): a serge task fixed a `glm46v` integration failure by editing `modular_glm4v.py` (the modular *source*), but the opened PR contained **only** that one file — the regenerated `modeling_glm4v.py` / `modeling_glm46v.py` were missing. So the fix never reached the generated file that actually runs, and the PR trips transformers' own `repo-consistency` / `modular_conversion` CI. ## Root cause The task ran the normalizer (`… utils/checkers.py …,modular_conversion,… --fix`) inside the in-loop validation gate (`_validate_patch`), which regenerates the `modeling_*.py` files into the worktree. But that task exhausted its correction budget (`TASK_NORMALIZE_MAX_RETRIES`) without a passing validation, so: 1. `prepare_task` saw `outcome["prepared"] == False` and **reset the worktree** (discarding the regenerated files), and 2. `publish_task` took its **raw-apply fallback** — re-applying `plan.patch` to a pristine checkout **without running the normalizer** — so `collect_changes` saw only the one hand-edited file (`Change touches 1 file(s)`). The fallback existed for "no normalizer configured / validation abandoned", but for a repo with a modular/generated-file invariant it silently shipped a repo-inconsistent patch. ## Fix In `publish_task`'s fallback path, when a normalizer **is** configured: - run it after applying the patch so any regenerated files are staged into the commit, and - **refuse** (return `no_change`) rather than open a PR the normalizer rejects — never commit a raw, repo-inconsistent patch. The shared `run_normalize` invocation is extracted into `_run_repo_normalizer` (returncode `None` = infrastructure failure → best-effort accept, preserving prior behavior) and reused from both `_validate_patch` and `publish_task`. ## Tests `PublishFallbackNormalizeTests` covers: - normalizer regenerates a file → **both** patched + generated files ride along in the commit; - normalizer exits non-zero → **no PR**, worktree reset; - no normalizer configured → raw patch committed unchanged (no regression). `pytest tests/test_tasks.py` → 34 passed. `ruff format` + `ruff check` clean. ## Follow-ups (not in this PR) - The task also burned its entire input-token budget *exploring* before writing a patch, leaving no room to iterate — worth guarding. - Consider bumping `TASK_NORMALIZE_MAX_RETRIES` so genuinely-fixable patches converge before the budget runs out. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f936ee1 commit 2660430

2 files changed

Lines changed: 194 additions & 22 deletions

File tree

reviewbot/tasks.py

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,45 @@ def format_pr_files_diff(files: list[dict[str, Any]], *, limit: int = 30000) ->
316316
# ---------------------------------------------------------------------------
317317
# Agentic loop → patch (with in-loop normalize validation)
318318
# ---------------------------------------------------------------------------
319+
def _run_repo_normalizer(
320+
cfg: Config,
321+
checkout: Checkout,
322+
emit: Callable[[str, str], None],
323+
) -> tuple[Optional[int], str]:
324+
"""Run the configured repo normalizer (``cfg.task_normalize_command``) over
325+
the current worktree with its fixers enabled, writing any regenerated files
326+
(e.g. transformers' ``modeling_*.py`` from ``modular_*.py``) back in place.
327+
328+
Returns ``(returncode, tail)``. ``returncode`` is ``None`` when the
329+
normalizer could not run at all (sandbox unavailable / timeout / launch
330+
failure) — infrastructure, not the patch's fault, which callers treat as a
331+
best-effort pass. Assumes ``cfg.task_normalize_command`` is set."""
332+
command = cfg.task_normalize_command
333+
assert command is not None
334+
emit("step", "normalize")
335+
emit("log", f"Running the repo normalizer: `{' '.join(command)}`…")
336+
try:
337+
return run_normalize(
338+
command,
339+
workdir=checkout.path,
340+
write_root=checkout.path,
341+
backend=cfg.task_sandbox_backend,
342+
image=cfg.task_normalize_image,
343+
mode=cfg.helper_sandbox,
344+
timeout=cfg.task_normalize_timeout,
345+
memory=cfg.task_normalize_memory,
346+
)
347+
except NormalizeError as exc:
348+
# Infrastructure problem (sandbox unavailable, timeout) — not the
349+
# model's fault. Signal best-effort with a None returncode; CI still
350+
# catches anything the normalizer would have.
351+
log.warning("normalizer unavailable: %s", exc)
352+
emit(
353+
"log", f"Normalizer unavailable ({exc}); accepting the patch un-normalized."
354+
)
355+
return None, ""
356+
357+
319358
def _validate_patch(
320359
cfg: Config,
321360
*,
@@ -366,27 +405,10 @@ def _validate_patch(
366405
False,
367406
)
368407

369-
emit("step", "normalize")
370-
emit("log", f"Validating the patch with `{' '.join(command)}`…")
371-
try:
372-
returncode, tail = run_normalize(
373-
command,
374-
workdir=checkout.path,
375-
write_root=checkout.path,
376-
backend=cfg.task_sandbox_backend,
377-
image=cfg.task_normalize_image,
378-
mode=cfg.helper_sandbox,
379-
timeout=cfg.task_normalize_timeout,
380-
memory=cfg.task_normalize_memory,
381-
)
382-
except NormalizeError as exc:
383-
# Infrastructure problem (sandbox unavailable, timeout) — not the
384-
# model's fault. Accept the applied patch best-effort rather than
385-
# blaming the LLM; CI still catches anything the normalizer would have.
386-
log.warning("normalizer unavailable during validation: %s", exc)
387-
emit(
388-
"log", f"Normalizer unavailable ({exc}); accepting the patch un-normalized."
389-
)
408+
returncode, tail = _run_repo_normalizer(cfg, checkout, emit)
409+
if returncode is None:
410+
# Normalizer could not run (infra) — accept the applied patch
411+
# best-effort rather than blaming the LLM.
390412
return None, True
391413

392414
if returncode != 0:
@@ -756,7 +778,9 @@ def publish_task(
756778
(:func:`_validate_patch`) already applied + normalized the worktree, so we
757779
just stage and commit it. Otherwise we apply ``plan.patch`` here (the path
758780
taken when no normalizer is configured, or when validation was abandoned
759-
and left a clean checkout)."""
781+
and left a clean checkout) and, if a normalizer *is* configured, re-run it
782+
so regenerated files ride along — refusing to open a PR it rejects rather
783+
than committing a raw, repo-inconsistent patch."""
760784

761785
def _emit(kind: str, text: str) -> None:
762786
if emit is not None:
@@ -780,6 +804,30 @@ def _emit(kind: str, text: str) -> None:
780804
stderr = (exc.stderr or b"").decode("utf-8", errors="replace")[:800]
781805
raise TaskError(f"patch did not apply cleanly: {stderr}", status_code=422)
782806

807+
# When a normalizer is configured, the committed tree must satisfy it —
808+
# most consequentially transformers' modular/generated-file coupling,
809+
# where editing a ``modular_*.py`` must regenerate its ``modeling_*.py``.
810+
# The in-loop gate (:func:`_validate_patch`) enforces that, but when its
811+
# correction budget is exhausted prepare_task resets the worktree and we
812+
# land here with only the raw LLM patch. Re-run the normalizer so any
813+
# regenerated files ride along in the commit, and refuse rather than open
814+
# a PR the repo's own consistency CI would immediately reject.
815+
if cfg.task_normalize_command:
816+
returncode, tail = _run_repo_normalizer(cfg, checkout, _emit)
817+
if returncode not in (None, 0):
818+
clone_cache.reset_worktree(checkout)
819+
_emit("log", "Normalizer rejected the patch; not opening a PR.")
820+
return TaskResult(
821+
mode=req.mode,
822+
no_change=True,
823+
message=(
824+
"The proposed patch does not pass the repository's "
825+
f"normalizer (exit {returncode}), so no PR was opened — "
826+
"the in-loop correction budget was exhausted before a "
827+
f"clean patch was found:\n\n{tail}"
828+
),
829+
)
830+
783831
clone_cache.stage_all(checkout)
784832
changes = clone_cache.collect_changes(checkout)
785833
if not changes:

tests/test_tasks.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,130 @@ def test_serge_identity_used_for_loop_cap_consistency(self):
521521
self.assertTrue(SERGE_GIT_EMAIL)
522522

523523

524+
class PublishFallbackNormalizeTests(unittest.TestCase):
525+
"""When validation's correction budget is exhausted, publish_task lands on
526+
the raw-apply fallback. With a normalizer configured it must re-run it so
527+
regenerated files (e.g. transformers' modeling_*.py) ride along, and refuse
528+
to open a PR the normalizer rejects — never commit a raw, un-normalized
529+
patch. Uses the bwrap backend + sandbox off so the command runs directly."""
530+
531+
_PATCH = (
532+
"diff --git a/hello.txt b/hello.txt\n"
533+
"--- a/hello.txt\n"
534+
"+++ b/hello.txt\n"
535+
"@@ -1 +1 @@\n"
536+
"-hi from main\n"
537+
"+hi patched\n"
538+
)
539+
540+
def setUp(self) -> None:
541+
self._tmp = tempfile.TemporaryDirectory()
542+
self.addCleanup(self._tmp.cleanup)
543+
root = self._tmp.name
544+
self.src = os.path.join(root, "src")
545+
os.makedirs(self.src)
546+
_git(self.src, "init", "--quiet", "-b", "main")
547+
with open(os.path.join(self.src, "hello.txt"), "w") as f:
548+
f.write("hi from main\n")
549+
_git(self.src, "add", "-A")
550+
_git(self.src, "commit", "--quiet", "-m", "main commit")
551+
self.cache = CloneCache(os.path.join(root, "cache"))
552+
553+
def _checkout(self):
554+
return self.cache.acquire_ref(
555+
token="",
556+
owner="acme",
557+
repo="widget",
558+
ref="main",
559+
job_id="abcd1234",
560+
remote_url=self.src,
561+
)
562+
563+
def _req(self):
564+
return TaskRequest(
565+
owner="acme",
566+
repo="widget",
567+
base_ref="main",
568+
instruction="fix",
569+
context="",
570+
mode="new_pr",
571+
)
572+
573+
def _cfg(self, **overrides):
574+
base = dict(helper_sandbox="off", task_sandbox_backend="bwrap")
575+
base.update(overrides)
576+
return _make_cfg(**base)
577+
578+
def test_fallback_reruns_normalizer_and_ships_regenerated_files(self):
579+
# A raw patch (worktree_prepared=False) whose normalizer regenerates an
580+
# extra file: the commit must include BOTH the patched and generated
581+
# files, mirroring transformers' modular_*.py -> modeling_*.py flow.
582+
co = self._checkout()
583+
cfg = self._cfg(
584+
task_normalize_command=["sh", "-c", "echo generated > extra.txt"],
585+
task_normalize_timeout=30,
586+
)
587+
plan = TaskPlan(title="Fix hello", body="desc", patch=self._PATCH)
588+
gh = _FakeGH()
589+
with patch("reviewbot.tasks.post_task_pr_created_notification"):
590+
result = publish_task(
591+
cfg,
592+
gh,
593+
self._req(),
594+
plan,
595+
checkout=co,
596+
clone_cache=self.cache,
597+
job_id="abcd1234",
598+
)
599+
self.assertFalse(result.no_change)
600+
self.assertEqual(sorted(result.changed_files), ["extra.txt", "hello.txt"])
601+
self.assertIsNotNone(gh.created_pr)
602+
603+
def test_fallback_refuses_when_normalizer_rejects(self):
604+
# Normalizer exits non-zero on the raw patch -> no PR, worktree reset.
605+
co = self._checkout()
606+
cfg = self._cfg(
607+
task_normalize_command=["sh", "-c", "echo boom >&2; exit 3"],
608+
task_normalize_timeout=30,
609+
)
610+
plan = TaskPlan(title="Fix hello", body="desc", patch=self._PATCH)
611+
gh = _FakeGH()
612+
result = publish_task(
613+
cfg,
614+
gh,
615+
self._req(),
616+
plan,
617+
checkout=co,
618+
clone_cache=self.cache,
619+
job_id="abcd1234",
620+
)
621+
self.assertTrue(result.no_change)
622+
self.assertIsNone(gh.created_pr)
623+
self.assertIn("normalizer", result.message.lower())
624+
self.assertIn("exit 3", result.message)
625+
# Worktree restored to the pristine checkout.
626+
self.assertEqual(self.cache.collect_changes(co), [])
627+
628+
def test_no_normalizer_configured_commits_raw_patch(self):
629+
# Without a normalizer the fallback still commits the raw patch as-is.
630+
co = self._checkout()
631+
cfg = self._cfg() # no task_normalize_command
632+
plan = TaskPlan(title="Fix hello", body="desc", patch=self._PATCH)
633+
gh = _FakeGH()
634+
with patch("reviewbot.tasks.post_task_pr_created_notification"):
635+
result = publish_task(
636+
cfg,
637+
gh,
638+
self._req(),
639+
plan,
640+
checkout=co,
641+
clone_cache=self.cache,
642+
job_id="abcd1234",
643+
)
644+
self.assertFalse(result.no_change)
645+
self.assertEqual(result.changed_files, ["hello.txt"])
646+
647+
524648
class ValidatePatchTests(unittest.TestCase):
525649
"""The in-loop verification gate (_validate_patch), against a real
526650
worktree. Runs with the bwrap backend + sandbox off so the normalize

0 commit comments

Comments
 (0)