nisse-review: a new head's review waits for its CI
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m59s
CI / e2e (pull_request) Failing after 49m46s

A review judges the CI result (R1), so a new head's review now waits while
its checks are pending, and starts once they finish, passed or failed. It
also keeps reviews off a GPU that CI is using: #154's first review ran
beside CI's end-to-end job and took about 30 minutes.

- Pending: waits up to tuning.review.ci_wait_s (1 h), then reviews anyway.
- No checks: waits tuning.review.ci_grace_s (2 min) for them to appear,
  then reviews.
- A PR waiting on CI holds up no other: the oldest settled one goes first.
- The head's first sighting is kept on the item (seen_at).

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-16 23:08:46 +00:00
1 parent 3283f9540d
commit be5ea5f170
8 files changed
+134 -6

No files matched your search

+3 -1
View File
@@ -888,7 +888,9 @@ else) towards better changes.
- **A work item per PR,** scoped to its repository (L1). It closes when the PR - **A work item per PR,** scoped to its repository (L1). It closes when the PR
merges or closes. merges or closes.
- **What starts a run:** the PR becoming ready, a new push, or a new comment or - **What starts a run:** the PR becoming ready, a new push, or a new comment or
review on it. review on it. A new head's review waits for its checks to finish (passed or
failed), so it is judged with the CI result and stays off a GPU that CI is
using; up to a tuning limit, and not at all where there is no CI.
- **The first review** is given the PR (title, description, its commits and diff, - **The first review** is given the PR (title, description, its commits and diff,
its comments, and the issue it closes); the reviewer reads the rest as it needs its comments, and the issue it closes); the reviewer reads the rest as it needs
it (R3). it (R3).
+22
View File
@@ -638,3 +638,25 @@ reads file by file.
Failed review runs on one PR, at one head, before the reviewer says on the PR Failed review runs on one PR, at one head, before the reviewer says on the PR
that it couldn't review it, and stops trying until the next push. that it couldn't review it, and stops trying until the next push.
### `tuning.review.ci_wait_s` — default 3600
How long a new head's review waits while its checks are pending. A review
judges the CI result (R1), and waiting keeps it off a GPU that CI is using
(the `nisse` project's end-to-end tests). Finished checks, passed or failed,
end the wait; after this long it reviews anyway and the CI result reads as
pending.
- **Too low:** reviews run beside CI, slower on a shared GPU, without its result.
- **Too high:** a stuck runner delays reviews that long.
- **Deciding:** a little longer than the repository's slowest CI run (the full
end-to-end suite takes 25 minutes or more).
### `tuning.review.ci_grace_s` — default 120
How long a new head with no checks at all waits for some to appear: Gitea
Actions reports a push's checks a moment after it. Past this, a repository
without CI is reviewed at once.
- **Too low:** a review starts before CI has reported, and runs beside it.
- **Too high:** repositories without CI wait that long for every review.
+2
View File
@@ -142,6 +142,8 @@ def build(
scratch=Path(scratch), scratch=Path(scratch),
poll_s=tuning.review.poll_s, poll_s=tuning.review.poll_s,
attempts=tuning.review.attempts, attempts=tuning.review.attempts,
ci_wait_s=tuning.review.ci_wait_s,
ci_grace_s=tuning.review.ci_grace_s,
patterns=config.repositories, patterns=config.repositories,
) )
return Built(scheduler, account, repos, store, gitea) return Built(scheduler, account, repos, store, gitea)
+4
View File
@@ -55,6 +55,10 @@ class ReviewTuning:
max_turns: int = 40 # a review run's turn cap max_turns: int = 40 # a review run's turn cap
diff_chars: int = 60000 # the diff in the review's context; files on demand diff_chars: int = 60000 # the diff in the review's context; files on demand
attempts: int = 2 # failed runs on one PR before saying so on it attempts: int = 2 # failed runs on one PR before saying so on it
# A new head waits for its CI to finish: at most ci_wait_s while checks
# are pending, and ci_grace_s for checks to appear at all.
ci_wait_s: float = 3600.0
ci_grace_s: float = 120.0
@dataclass(frozen=True) @dataclass(frozen=True)
+48 -4
View File
@@ -14,6 +14,8 @@ item waits rather than risk a second review.
import re import re
import tempfile import tempfile
import threading import threading
import time
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from nisse_core.gitea import GiteaClient, GiteaError, PullRequest from nisse_core.gitea import GiteaClient, GiteaError, PullRequest
@@ -56,6 +58,9 @@ class Scheduler:
poll_s: float = 60.0, poll_s: float = 60.0,
attempts: int = 2, attempts: int = 2,
patterns: frozenset[str] | None = None, patterns: frozenset[str] | None = None,
ci_wait_s: float = 3600.0,
ci_grace_s: float = 120.0,
clock: Callable[[], float] = time.time,
) -> None: ) -> None:
self._store = store self._store = store
self._gitea = gitea self._gitea = gitea
@@ -66,6 +71,10 @@ class Scheduler:
self._attempts = attempts self._attempts = attempts
# The repositories reviewed, as patterns; None: every one it was given. # The repositories reviewed, as patterns; None: every one it was given.
self._patterns = patterns self._patterns = patterns
self._ci_wait_s = ci_wait_s
self._ci_grace_s = ci_grace_s
self._clock = clock
self._said_waiting: set[str] = set() # "item@head", logged once
self._stop = threading.Event() self._stop = threading.Event()
# -- the loop --------------------------------------------------------------- # -- the loop ---------------------------------------------------------------
@@ -131,13 +140,21 @@ class Scheduler:
title=pr.title, title=pr.title,
scope=repo, scope=repo,
position=position, position=position,
data=data(PR(repo, pr.number, head=pr.head_sha)), data=data(PR(repo, pr.number, head=pr.head_sha, seen_at=self._clock())),
cause="ready for review" if position == READY else "not ready", cause="ready for review" if position == READY else "not ready",
) )
return return
known = pr_of(item) known = pr_of(item)
moved = known.head != pr.head_sha moved = known.head != pr.head_sha
new = PR(known.repo, known.number, pr.head_sha, known.reviewed, known.attempts) seen_at = self._clock() if moved else known.seen_at
new = PR(
known.repo,
known.number,
pr.head_sha,
known.reviewed,
known.attempts,
seen_at,
)
if item.position in (READY, WAITING): if item.position in (READY, WAITING):
if position != item.position or moved: if position != item.position or moved:
self._store.update_work_item( self._store.update_work_item(
@@ -145,7 +162,7 @@ class Scheduler:
) )
elif item.position == FAILED and moved: elif item.position == FAILED and moved:
# It never got a review: new code is worth another try. # It never got a review: new code is worth another try.
new = PR(known.repo, known.number, pr.head_sha, known.reviewed, 0) new = PR(known.repo, known.number, pr.head_sha, known.reviewed, 0, seen_at)
self._store.update_work_item( self._store.update_work_item(
item.id, position=position, data=data(new), cause="new push" item.id, position=position, data=data(new), cause="new push"
) )
@@ -165,8 +182,35 @@ class Scheduler:
) )
def _next(self) -> WorkItem | None: def _next(self) -> WorkItem | None:
"""The oldest ready item whose CI has settled."""
ready_items = [i for i in self._store.work_items("open") if i.position == READY] ready_items = [i for i in self._store.work_items("open") if i.position == READY]
return min(ready_items, key=lambda i: i.opened_at, default=None) for item in sorted(ready_items, key=lambda i: i.opened_at):
if self._ci_settled(item):
return item
return None
def _ci_settled(self, item: WorkItem) -> bool:
"""R1: a review judges the CI result, so a new head waits for its
checks: reviewed once they've finished (passed or not), at once with
no CI at all after a short grace for checks to appear, and anyway
once they've been pending ``ci_wait_s``. Waiting also keeps reviews
off a GPU that CI is using."""
known = pr_of(item)
waited = self._clock() - known.seen_at
try:
state = self._gitea.checks(known.repo, known.head)
except GiteaError as exc:
self._oplog.emit("review.checks_unknown", cause=str(exc)[:300])
return False # asked again next round
if state == "pending" and waited < self._ci_wait_s:
key = f"{item.id}@{known.head}"
if key not in self._said_waiting:
self._said_waiting.add(key)
self._oplog.emit(
"review.waiting_for_ci", cause="checks pending", work_item=item.id
)
return False
return not (state == "none" and waited < self._ci_grace_s)
# -- a review --------------------------------------------------------------- # -- a review ---------------------------------------------------------------
+1
View File
@@ -33,6 +33,7 @@ class PR:
head: str = "" # the commit last seen at the PR's head head: str = "" # the commit last seen at the PR's head
reviewed: str = "" # the commit its review was posted on reviewed: str = "" # the commit its review was posted on
attempts: int = 0 # failed runs at this head attempts: int = 0 # failed runs at this head
seen_at: float = 0.0 # when this head was first seen (the CI wait)
def item_id(repo: str, number: int) -> str: def item_id(repo: str, number: int) -> str:
+2 -1
View File
@@ -73,6 +73,7 @@ class FakeGitea:
# Readable but not given (public, no grant): missing from /user/repos. # Readable but not given (public, no grant): missing from /user/repos.
public: set[str] = field(default_factory=set) public: set[str] = field(default_factory=set)
unlisted: set[str] = field(default_factory=set) # its PRs can't be listed unlisted: set[str] = field(default_factory=set) # its PRs can't be listed
ci: dict[str, str] = field(default_factory=dict) # head -> checks; success
def _up(self) -> None: def _up(self) -> None:
if self.down: if self.down:
@@ -124,7 +125,7 @@ class FakeGitea:
return [] return []
def checks(self, repo: str, sha: str) -> str: def checks(self, repo: str, sha: str) -> str:
return "success" return self.ci.get(sha, "success")
def archive(self, repo: str, ref: str) -> bytes: def archive(self, repo: str, ref: str) -> bytes:
return archive(self.files) return archive(self.files)
+52
View File
@@ -342,6 +342,58 @@ def test_a_repository_whose_prs_cannot_be_listed_holds_up_no_other(
assert '"intake.repo_failed"' in out.getvalue() and "a/empty" in out.getvalue() assert '"intake.repo_failed"' in out.getvalue() and "a/empty" in out.getvalue()
class Clock:
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def test_a_new_head_waits_for_its_ci_to_finish(tmp_path: Path) -> None:
# Follow-up to #154: judged with CI's result, and off CI's GPU.
clock = Clock()
gitea = FakeGitea(repos={REPO: [pr()]})
head = pr().head_sha
gitea.ci[head] = "pending"
w = world(tmp_path, Scripted(*REVIEW), gitea, clock=clock)
assert w["scheduler"].round() is False # pending: not yet
clock.now += 600
assert w["scheduler"].round() is False
gitea.ci[head] = "failure" # finished: failing CI is worth a review too
assert w["scheduler"].round() is True
assert item(w).position == REVIEWED
def test_ci_pending_too_long_is_reviewed_anyway(tmp_path: Path) -> None:
clock = Clock()
gitea = FakeGitea(repos={REPO: [pr()]})
gitea.ci[pr().head_sha] = "pending"
w = world(tmp_path, Scripted(*REVIEW), gitea, clock=clock, ci_wait_s=3600)
assert w["scheduler"].round() is False
clock.now += 3600
assert w["scheduler"].round() is True # a stuck runner blocks nothing
def test_no_ci_waits_only_a_grace_for_checks_to_appear(tmp_path: Path) -> None:
clock = Clock()
gitea = FakeGitea(repos={REPO: [pr()]})
gitea.ci[pr().head_sha] = "none"
w = world(tmp_path, Scripted(*REVIEW), gitea, clock=clock, ci_grace_s=120)
assert w["scheduler"].round() is False
clock.now += 120
assert w["scheduler"].round() is True
def test_a_pr_waiting_on_ci_holds_up_no_other(tmp_path: Path) -> None:
clock = Clock()
gitea = FakeGitea(repos={REPO: [pr(7), pr(8, head_sha="c8")]})
gitea.ci[pr(7).head_sha] = "pending"
w = world(tmp_path, Scripted(*REVIEW), gitea, clock=clock)
assert w["scheduler"].round() is True # 8, though 7 is older
assert (item(w, 7).position, item(w, 8).position) == (READY, REVIEWED)
def test_a_configured_repository_it_cannot_read_is_left_out() -> None: def test_a_configured_repository_it_cannot_read_is_left_out() -> None:
gitea = FakeGitea(repos={REPO: []}) gitea = FakeGitea(repos={REPO: []})
out = io.StringIO() out = io.StringIO()