Add PR comment intake and the review loop (B2) — step 6b
- poller.py: CommentPoller polls PR comments rather than taking webhooks, keeping the VM outbound-only (B3). On an outage it backs off exponentially, caps, and retries indefinitely while logging: polling is pure intake, so an outage delays a reaction but never fails a task or forces a transition - prime() marks the existing thread as seen without delivering it, so starting a watch does not re-address feedback already handled - Review loop: a comment in PR_OPEN enters the canonical transcript, the executor responds, and the fix is pushed to the same branch with a reply on the PR. No intent is recorded for a follow-up push — the PR already exists, so a repeated push is harmless, unlike opening a second PR - Comment authors are checked against a separate Gitea operator allowlist: a PR comment is an instruction channel into an agent that writes code, so it is operator-scoped, and a Gitea login is not a Matrix MXID (A4 per-channel allowlists) - Orchestrator now holds a lock: transport and the poller deliver on separate threads, and state transitions must not interleave - Backoff timings are injectable, so the outage path is tested in milliseconds instead of being untestable Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3117565928
commit
affdb9e8aa
6 files changed
+540
-1
No files matched your search
@@ -0,0 +1,104 @@
|
||||
"""Gitea comment intake (spec B2).
|
||||
|
||||
Polling, not webhooks: the agent VM stays outbound-only (B3), so nothing
|
||||
needs to reach in. Intake is structured JSON straight from the API and never
|
||||
routed through a chat bridge.
|
||||
|
||||
Outage posture, per B2: back off exponentially, cap at a few minutes, retry
|
||||
indefinitely, and log. Polling is pure intake — an outage delays a reaction
|
||||
but must never fail a task or force a state transition.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from nisse_dev.gitea import Comment, GiteaClient, GiteaError
|
||||
from nisse_dev.oplog import OpLog
|
||||
|
||||
DEFAULT_INTERVAL_S = 20.0
|
||||
INITIAL_BACKOFF_S = 5.0
|
||||
MAX_BACKOFF_S = 300.0
|
||||
|
||||
|
||||
class CommentPoller:
|
||||
def __init__(
|
||||
self,
|
||||
client: GiteaClient,
|
||||
repo: str,
|
||||
on_comment: Callable[[Comment], None],
|
||||
*,
|
||||
oplog: OpLog | None = None,
|
||||
ignore_authors: Iterable[str] = (),
|
||||
interval_s: float = DEFAULT_INTERVAL_S,
|
||||
initial_backoff_s: float = INITIAL_BACKOFF_S,
|
||||
max_backoff_s: float = MAX_BACKOFF_S,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._repo = repo
|
||||
self._on_comment = on_comment
|
||||
self._oplog = oplog
|
||||
self._ignore = set(ignore_authors)
|
||||
self._interval_s = interval_s
|
||||
self._initial_backoff_s = initial_backoff_s
|
||||
self._max_backoff_s = max_backoff_s
|
||||
self._seen: set[int] = set()
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def prime(self, index: int) -> None:
|
||||
"""Mark existing comments as seen without delivering them.
|
||||
|
||||
A poller that starts by replaying the whole thread would re-act to
|
||||
feedback already addressed — including its own earlier replies.
|
||||
"""
|
||||
try:
|
||||
for comment in self._client.list_comments(self._repo, index):
|
||||
self._seen.add(comment.id)
|
||||
except GiteaError:
|
||||
# Nothing seen means the first successful poll delivers the
|
||||
# backlog once. Better than refusing to start.
|
||||
pass
|
||||
|
||||
def poll_once(self, index: int) -> list[Comment]:
|
||||
"""One pass; returns what was newly delivered. Separated from the
|
||||
loop so behaviour is testable without threads or sleeps."""
|
||||
delivered: list[Comment] = []
|
||||
for comment in self._client.list_comments(self._repo, index):
|
||||
if comment.id in self._seen:
|
||||
continue
|
||||
self._seen.add(comment.id)
|
||||
if comment.author in self._ignore:
|
||||
continue # the agent's own replies are not new input
|
||||
delivered.append(comment)
|
||||
self._on_comment(comment)
|
||||
return delivered
|
||||
|
||||
def start(self, index: int) -> None:
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._loop, args=(index,), daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=self._interval_s + 5)
|
||||
self._thread = None
|
||||
|
||||
def _loop(self, index: int) -> None:
|
||||
backoff = self._initial_backoff_s
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.poll_once(index)
|
||||
except GiteaError as exc:
|
||||
if self._oplog is not None:
|
||||
self._oplog.emit(
|
||||
"gitea.poll_failed",
|
||||
cause="gitea unreachable",
|
||||
error=str(exc)[:300],
|
||||
retry_in_s=backoff,
|
||||
)
|
||||
self._stop.wait(backoff)
|
||||
backoff = min(backoff * 2, self._max_backoff_s)
|
||||
continue
|
||||
backoff = self._initial_backoff_s
|
||||
self._stop.wait(self._interval_s)
|
||||
Reference in new issue
Block a user