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
171 lines
6.2 KiB
Python
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()
|