Keep Gitea threads watched across restarts and interruptions
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m20s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m20s
- Record a comment cursor per thread (board.last_comment, the field that already existed unused; pr.last_comment), advanced durably when a comment is taken up. CommentPoller.resume_after() skips comments at or below it, so re-attaching needs no request and cannot replay. - Boot re-attaches the plan watch at an open gate and the review watch in PR_OPEN from their cursors, catching up on comments posted while the agent was down (#59). Data saved without a cursor skips what is there, so an old approval cannot approve the current revision. - A restart in PR_OPEN resumes with a notice instead of the restart question (#58). A stored restart question for PR_OPEN returns to it on either reply instead of re-planning the milestone. - A stopped or failed review response gets its own pending kind; the reply returns to PR_OPEN and resumes the watch from the cursor, so comments that arrived meanwhile are delivered. It used to return to milestone sign-off, where starting the milestone would open a second PR. - Comments ignored because of the state leave the cursor alone, so they are delivered once the agent is back where they apply. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
ab932f2ef4
commit
3b6aae92c5
3 files changed
+370
-21
No files matched your search
@@ -42,29 +42,43 @@ class CommentPoller:
|
||||
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) -> None:
|
||||
"""Mark existing comments as seen without delivering them.
|
||||
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:
|
||||
if comment.id in self._seen or comment.id <= self._after:
|
||||
continue
|
||||
self._seen.add(comment.id)
|
||||
if comment.author in self._ignore:
|
||||
|
||||
Reference in new issue
Block a user