CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
Development Plan M0.1. Every section reference in code, tests, specs/, the example config, the tuning guide, and the CLAUDE.md and README files now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps it). The plan's old-to-new table goes with it. Mostly mechanical, with a per-site choice where one old section split: A4 became L2 where it meant stop/abandon/the loop and L3 where it meant approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2 became D4 for outbound Gitea and publishing and D5 for review intake; 'A3 step N' became the matching step of L8's boot sequence. Comments describing behaviour the architecture drops (planning always escalating, thresholds tuned at an old build step) now describe the current code and point at the Development Plan. docs/Architecture.md gains one line the rewrite missed: intake outages delay but never fail work (old B2), now in L5, where the poller and Matrix code cite it. Comments and docs only; no behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
119 lines
4.4 KiB
Python
119 lines
4.4 KiB
Python
"""Gitea comment intake (architecture D5).
|
|
|
|
Polling, not webhooks: the agent VM stays outbound-only (D7), so nothing
|
|
needs to reach in. Intake is structured JSON straight from the API and never
|
|
routed through a chat bridge.
|
|
|
|
Outage posture, per L5: 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()
|
|
# Comments at or below this ID were handled before this poller
|
|
# existed (a restart, or a watch attached again); Gitea's comment IDs
|
|
# only grow, so one number says it all.
|
|
self._after = 0
|
|
self._stop = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
|
|
def prime(self, index: int) -> int:
|
|
"""Mark existing comments as seen without delivering them, and
|
|
return the highest ID marked (0 if none).
|
|
|
|
A poller that starts by replaying the whole thread would re-act to
|
|
feedback already addressed — including its own earlier replies.
|
|
"""
|
|
highest = 0
|
|
try:
|
|
for comment in self._client.list_comments(self._repo, index):
|
|
self._seen.add(comment.id)
|
|
highest = max(highest, comment.id)
|
|
except GiteaError:
|
|
# Nothing seen means the first successful poll delivers the
|
|
# backlog once. Better than refusing to start.
|
|
pass
|
|
return max(highest, self._after)
|
|
|
|
def resume_after(self, comment_id: int) -> None:
|
|
"""Deliver only comments newer than comment_id: the ones posted
|
|
while nothing was watching are caught up, the handled ones are not
|
|
replayed. Needs no request, so an outage cannot cause a replay."""
|
|
self._after = comment_id
|
|
|
|
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 or comment.id <= self._after:
|
|
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)
|