nisse-review: a stop ends a review in progress; quieter repo failures
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m22s
CI / e2e (pull_request) Successful in 4m20s

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
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-17 20:37:12 +00:00
1 parent 52d30ccab9
commit a85e855fc2
8 files changed
+287 -10

No files matched your search

+15 -4
View File
@@ -12,6 +12,7 @@ from nisse_core.loop.driver import Driver
from nisse_core.loop.expert import ExpertDesk
from nisse_core.models.catalog import ModelCatalog
from nisse_core.models.context import BudgetedClient
from nisse_core.models.interruptible import Interruptible
from nisse_core.models.llm import LiteLLMClient
from nisse_core.oplog import OpLog
from nisse_core.state.cancel import Cancel
@@ -36,6 +37,13 @@ class Built:
repositories: list[str]
store: StateStore
gitea: GiteaClient
model: Interruptible
def stop(self) -> None:
"""From the signal handler: no more rounds, the run in progress
ends at its next turn, and a model call in flight is broken off."""
self.scheduler.stop()
self.model.interrupt()
def close(self) -> None:
self.gitea.close()
@@ -97,7 +105,8 @@ def build(
oplog = oplog or OpLog(AGENT)
gitea = gitea or GiteaClient(config.gitea_url, config.gitea_token)
account, repos = check_access(gitea, config.repositories, oplog)
client = client if client is not None else llm_client(config)
# Every model call, the reviewer's and ask_expert's, can be broken off.
model = Interruptible(client if client is not None else llm_client(config)) # type: ignore[arg-type]
config.state_db.parent.mkdir(parents=True, exist_ok=True)
store = StateStore(config.state_db, oplog=oplog)
vault = (
@@ -116,16 +125,17 @@ def build(
else None
)
tuning = config.tuning
cancel = Cancel()
reviewer = Reviewer(
store,
gitea,
Driver(client, config.roles.resolve("driver"), cancel=Cancel()), # type: ignore[arg-type]
Driver(model, config.roles.resolve("driver"), cancel=cancel), # type: ignore[arg-type]
account=account,
gitea_url=config.gitea_url,
oplog=oplog,
knowledge=Knowledge(vault, memory, oplog),
expert=ExpertDesk(
client, # type: ignore[arg-type]
model, # type: ignore[arg-type]
config.roles,
budget=tuning.expert.consult_budget,
),
@@ -147,5 +157,6 @@ def build(
operators=config.gitea_operators,
max_rounds=tuning.review.max_rounds,
patterns=config.repositories,
cancel=cancel,
)
return Built(scheduler, account, repos, store, gitea)
return Built(scheduler, account, repos, store, gitea, model)
+1 -1
View File
@@ -26,7 +26,7 @@ def main() -> int:
def shutdown(_signum: int, _frame: FrameType | None) -> None:
print("nisse-review: stopping", file=sys.stderr)
built.scheduler.stop()
built.stop()
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
+38 -3
View File
@@ -18,7 +18,9 @@ comment quiets it until their `review` (R5), which asks for a fresh review.
Crash safety (L8): an item found ``running`` was interrupted. Whether its
review got posted is asked of Gitea, never assumed; until Gitea can say, the
item waits rather than risk a second review. A reply carries the key of the
point it answers, so a re-run never answers it twice.
point it answers, so a re-run never answers it twice. A stop (SIGTERM) ends
a run at its next turn, or breaks off its model call, and the item goes back
to ready, with nothing drafted posted and no failure counted.
"""
import re
@@ -30,7 +32,9 @@ from dataclasses import replace
from pathlib import Path
from nisse_core.gitea import GiteaClient, GiteaError, PullRequest
from nisse_core.models.interruptible import Stopped
from nisse_core.oplog import OpLog
from nisse_core.state.cancel import Cancel
from nisse_core.state.store import WorkItem
from nisse_review.access import check, reviewed
from nisse_review.reader import Repository
@@ -96,6 +100,7 @@ class Scheduler:
ci_grace_s: float = 120.0,
operators: frozenset[str] = frozenset(),
max_rounds: int = 4,
cancel: Cancel | None = None,
clock: Callable[[], float] = time.time,
) -> None:
self._store = store
@@ -114,6 +119,9 @@ class Scheduler:
self._clock = clock
self._said_waiting: set[str] = set() # "item@head", logged once
self._stop = threading.Event()
# The reviewer's driver's flag: a stop ends a run at its next turn.
self._cancel = cancel or Cancel()
self._repo_failing: dict[str, str] = {} # repo: why, logged on change
# -- the loop ---------------------------------------------------------------
@@ -123,7 +131,10 @@ class Scheduler:
self._stop.wait(self._poll_s)
def stop(self) -> None:
"""No more rounds, and a run in progress ends at its next turn,
back to ready: a stop is no failure of the review."""
self._stop.set()
self._cancel.set()
def round(self) -> bool:
"""The wall, intake, then one run if one is due; True if one ran.
@@ -158,9 +169,14 @@ class Scheduler:
except GiteaError as exc:
# One repository Gitea won't list (an empty one, one without
# pull requests) holds up none of the others; its items stay
# as they are.
self._oplog.emit("intake.repo_failed", cause=str(exc)[:300], repo=repo)
# as they are. Said when it starts, or the reason changes.
why = str(exc)[:300]
if self._repo_failing.get(repo) != why:
self._repo_failing[repo] = why
self._oplog.emit("intake.repo_failed", cause=why, repo=repo)
continue
if self._repo_failing.pop(repo, None) is not None:
self._oplog.emit("intake.repo_ok", cause="listed again", repo=repo)
seen: set[str] = set()
for pr in prs:
key = item_id(repo, pr.number)
@@ -392,6 +408,9 @@ class Scheduler:
"review.partial", cause=outcome.note, work_item=item.id
)
tool, summary = "post_review", PARTIAL.format(n=len(draft.comments))
elif outcome.kind == "stopped":
self._interrupted(item, known, outcome.note)
return
elif outcome.kind != "wait":
self._failed(item, known, pr, outcome.note or outcome.kind)
return
@@ -404,6 +423,10 @@ class Scheduler:
posted = self._reviewer.post(
item.id, known.repo, pr, summary, kept, n=known.posts + 1
)
except Stopped as exc:
# A stop broke off a model call: a stop all the same.
self._interrupted(item, known, str(exc))
return
except GiteaError as exc:
self._failed(item, known, None, f"Gitea: {exc}")
return
@@ -462,6 +485,18 @@ class Scheduler:
cause=f"{tool}",
)
def _interrupted(self, item: WorkItem, known: PR, why: str) -> None:
"""A stop ended the run: nothing drafted is posted, no attempt is
counted, and it runs again as it was. Replies it posted carry their
markers, so the next run doesn't repeat them."""
self._oplog.emit("review.interrupted", cause=why[:300], work_item=item.id)
self._store.update_work_item(
item.id,
position=READY,
data=data(replace(known, run="")),
cause="stopped mid-run",
)
def _failed(
self, item: WorkItem, known: PR, pr: PullRequest | None, why: str
) -> None: