Files
nisse/nisse-review/tests/test_review_stop.py
T
dongarandClaude Opus 5.5 080205db7e
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 1m15s
CI / e2e (pull_request) Skipped
nisse-review: a stop ends a review in progress; quieter repo failures
SIGTERM used to stop only new rounds: a review in progress ran on until
systemd killed it 90s later. Now the scheduler's stop sets the driver's
cancel flag, so the run ends at its next turn, and a model call in flight
is broken off (Interruptible, raising Stopped from the signal handler, only
ever inside a model call). The item goes back to ready, with nothing
drafted posted and no failure counted; replies already posted carry their
markers and are not repeated.

intake.repo_failed is logged when a repository starts failing or the reason
changes, and intake.repo_ok when it lists again, not every round.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-17 15:41:55 +00:00

171 lines
6.2 KiB
Python

"""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()