"""A clean stop (L2): SIGTERM ends a review run at its next turn, or breaks off its model call, and the item goes back to ready with no failure counted; a repository Gitea won't list is logged when that changes, not every round.""" import io import json from collections.abc import Callable from pathlib import Path from typing import Any from review_fakes import FakeGitea, Scripted, call, pr from test_review import REPO, REVIEW, item, world from nisse_core.models.interruptible import Interruptible, Stopped from nisse_core.oplog import OpLog from nisse_review.build import build from nisse_review.config import ReviewConfig from nisse_review.review import reply_marker from nisse_review.state import READY, REVIEWED, pr_of class Stopping(Scripted): """Scripted, but at call ``at`` (0-based) it first runs ``then``: a stop arriving while the model works on that turn.""" def __init__(self, *turns: list[Any], at: int) -> None: super().__init__(*turns) self.at = at self.then: Callable[[], None] = lambda: None self.calls = 0 def complete(self, events, tools, model, system=None): # type: ignore[no-untyped-def] now, self.calls = self.calls, self.calls + 1 if now == self.at: self.then() return super().complete(events, tools, model, system) def events(out: io.StringIO, name: str) -> list[dict[str, Any]]: found = (json.loads(line) for line in out.getvalue().splitlines()) return [e for e in found if e["event"] == name] def logged(w: dict[str, Any]) -> io.StringIO: out = io.StringIO() w["scheduler"]._oplog = OpLog("t", out) return out def test_a_stop_ends_the_run_at_its_next_turn(tmp_path: Path) -> None: model = Stopping(*REVIEW, at=1) w = world(tmp_path, model) model.then = w["scheduler"].stop out = logged(w) assert w["scheduler"].round() is True assert model.calls == 2 # the turn in flight finished; no third assert (item(w).position, pr_of(item(w)).attempts) == (READY, 0) assert pr_of(item(w)).run == "" and w["gitea"].posted == [] assert len(events(out, "review.interrupted")) == 1 assert events(out, "review.failed") == [] w["scheduler"].run_forever() # stopped: returns at once def test_a_stop_breaks_off_the_model_call_in_flight(tmp_path: Path) -> None: model = Stopping(*REVIEW, at=1) wrapped = Interruptible(model) w = world(tmp_path, wrapped) # type: ignore[arg-type] def sigterm() -> None: # what Built.stop does, in the signal handler w["scheduler"].stop() wrapped.interrupt() model.then = sigterm out = logged(w) assert w["scheduler"].round() is True assert (item(w).position, pr_of(item(w)).attempts) == (READY, 0) assert w["gitea"].posted == [] [said] = events(out, "review.interrupted") assert "broke off the model call" in said["cause"] def test_outside_a_model_call_interrupt_does_nothing() -> None: wrapped = Interruptible(Scripted([])) wrapped.interrupt() # nothing in flight: no Stopped assert wrapped.complete([], None, "m") == [] wrapped.interrupt() # the call is over: still nothing assert wrapped.offered == [set()] # the rest reaches the client def test_stopped_is_no_failure_handlers_would_take() -> None: assert not issubclass(Stopped, Exception) def test_replies_posted_before_a_stop_are_not_posted_again(tmp_path: Path) -> None: model = Stopping( *REVIEW, [call("reply", point="P1", text="Tabs never occur here.")], [call("reply", point="P2", text="It counts words, yes.")], # stopped here [call("reply", point="P1", text="It counts words, yes.")], [call("done")], at=5, ) gitea = FakeGitea(repos={REPO: [pr()]}) w = world(tmp_path, model, gitea, operators=frozenset({"don"})) model.then = w["scheduler"].stop assert w["scheduler"].round() is True first = w["gitea"].say("nisse-dev", "Why flag split()?") second = w["gitea"].say("nisse-dev", "Does it count words?") assert w["scheduler"].round() is True # replied to P1, then stopped assert item(w).position == READY [(_, _, posted)] = w["gitea"].comments assert reply_marker(f"comment-{first}") in posted w["scheduler"]._stop.clear() w["scheduler"]._cancel.clear() assert w["scheduler"].round() is True # only the second is left bodies = [body for _, _, body in w["gitea"].comments] assert sum(reply_marker(f"comment-{first}") in b for b in bodies) == 1 assert sum(reply_marker(f"comment-{second}") in b for b in bodies) == 1 assert item(w).position == REVIEWED # --- a repository Gitea won't list --------------------------------------------- def test_a_repository_that_keeps_failing_is_logged_once(tmp_path: Path) -> None: gitea = FakeGitea(repos={"a/empty": [], REPO: []}, unlisted={"a/empty"}) w = world(tmp_path, Scripted(), gitea) out = logged(w) for _ in range(3): w["scheduler"].round() assert len(events(out, "intake.repo_failed")) == 1 gitea.unlisted.clear() w["scheduler"].round() w["scheduler"].round() [ok] = events(out, "intake.repo_ok") assert ok["repo"] == "a/empty" gitea.unlisted.add("a/empty") # failing again is news again w["scheduler"].round() assert len(events(out, "intake.repo_failed")) == 2 def test_build_wires_a_stop_to_every_model_call(tmp_path: Path) -> None: config = ReviewConfig.from_tree( { "state_db": str(tmp_path / "state.db"), "llm": {"base_url": "http://llm.test", "token": "t"}, "roles": {"reviewer": "m", "expert": "e"}, "gitea": { "url": "https://gitea.test", "token": "t", "operators": ["don"], }, "repositories": [REPO], }, {}, ) built = build( config, gitea=FakeGitea(repos={REPO: []}), # type: ignore[arg-type] client=Scripted(), oplog=OpLog("t", io.StringIO()), ) try: driver = built.scheduler._reviewer._driver assert driver._client is built.model assert built.scheduler._reviewer._expert._client is built.model built.stop() # no call in flight: nothing raised assert driver._cancel.requested and built.scheduler._stop.is_set() finally: built.close()