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
@@ -8,6 +8,7 @@ arrives over transport. Every position change is checkpointed through the state
|
||||
store before the loop continues, so a restart lands mid-gate, not mid-air.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
@@ -86,6 +87,12 @@ class MilestonePublisher(Protocol):
|
||||
self, *, task_id: str, milestone: int, project: str, plan: str
|
||||
) -> Any: ...
|
||||
|
||||
def push_followup(
|
||||
self, *, task_id: str, milestone: int, message: str
|
||||
) -> str | None: ...
|
||||
|
||||
def comment(self, index: int, body: str) -> None: ...
|
||||
|
||||
|
||||
class PlanningAgent(Protocol):
|
||||
def plan(
|
||||
@@ -139,6 +146,7 @@ class Orchestrator:
|
||||
roles: RoleMap | None = None,
|
||||
executor: "MilestoneExecutor | None" = None,
|
||||
publisher: "MilestonePublisher | None" = None,
|
||||
gitea_operators: Iterable[str] = (),
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._transport = transport
|
||||
@@ -147,6 +155,13 @@ class Orchestrator:
|
||||
self._executor = executor
|
||||
self._publisher = publisher
|
||||
self._operators = set(operators)
|
||||
# Per-channel allowlists (A4): a Matrix MXID and a Gitea login
|
||||
# are different names for the same person, and neither implies
|
||||
# the other.
|
||||
self._gitea_operators = set(gitea_operators)
|
||||
# Transport and the comment poller deliver on separate threads;
|
||||
# state transitions must not interleave.
|
||||
self._lock = threading.RLock()
|
||||
self._data = _fresh_data()
|
||||
self._project: str | None = None
|
||||
transport.on_message(self._handle)
|
||||
@@ -197,6 +212,10 @@ class Orchestrator:
|
||||
)
|
||||
|
||||
def _handle(self, msg: InboundMessage) -> None:
|
||||
with self._lock:
|
||||
self._handle_locked(msg)
|
||||
|
||||
def _handle_locked(self, msg: InboundMessage) -> None:
|
||||
if msg.sender not in self._operators:
|
||||
return
|
||||
# Every operator message enters the canonical transcript, so planning
|
||||
@@ -376,6 +395,72 @@ class Orchestrator:
|
||||
pending={"kind": "milestone_stopped", "milestone": milestone},
|
||||
)
|
||||
|
||||
def handle_review_comment(self, comment: Any) -> None:
|
||||
"""React to a PR review comment (B2). Called from the poller thread.
|
||||
|
||||
Comment authors are checked against the Gitea operator allowlist:
|
||||
a PR comment is an instruction channel into an agent that writes
|
||||
code, so it is operator-scoped like any other (A4, and the same
|
||||
reasoning C4 applies to tickets).
|
||||
"""
|
||||
with self._lock:
|
||||
self._handle_review_comment_locked(comment)
|
||||
|
||||
def _handle_review_comment_locked(self, comment: Any) -> None:
|
||||
if comment.author not in self._gitea_operators:
|
||||
self._store.oplog.emit(
|
||||
"gitea.comment_ignored",
|
||||
cause=f"author {comment.author!r} not an operator",
|
||||
comment_id=comment.id,
|
||||
)
|
||||
return
|
||||
state = self._store.get_state()
|
||||
if state is None or state.state != PR_OPEN:
|
||||
self._store.oplog.emit(
|
||||
"gitea.comment_ignored",
|
||||
cause=f"not in PR_OPEN (state={state.state if state else None})",
|
||||
comment_id=comment.id,
|
||||
)
|
||||
return
|
||||
if self._executor is None or self._publisher is None:
|
||||
return
|
||||
|
||||
pr = self._data.get("pr") or {}
|
||||
milestone = int(pr.get("milestone", self._data.get("milestone", 1)))
|
||||
# The comment enters the transcript, so the executor sees review
|
||||
# feedback as conversation rather than as an out-of-band instruction.
|
||||
self._store.append_event(
|
||||
UserMessage(text=comment.body, source=f"gitea:comment:{comment.id}")
|
||||
)
|
||||
outcome = self._executor.run(milestone)
|
||||
if outcome.status != "completed":
|
||||
self._await_input(
|
||||
cause=f"review response {outcome.status}",
|
||||
prompt=(
|
||||
f"could not address the review comment ({outcome.status}): "
|
||||
f"{outcome.note}"
|
||||
),
|
||||
pending={"kind": "milestone_stopped", "milestone": milestone},
|
||||
)
|
||||
return
|
||||
try:
|
||||
sha = self._publisher.push_followup(
|
||||
task_id=str(self._data.get("task_id")),
|
||||
milestone=milestone,
|
||||
message=f"Address review comment {comment.id}",
|
||||
)
|
||||
except (GiteaError, GitError) as exc:
|
||||
self._transport.send_message(f"could not push the fix: {exc}")
|
||||
return
|
||||
note = (
|
||||
f"pushed {sha[:8]} addressing the review comment"
|
||||
if sha
|
||||
else "no code change was needed for that comment"
|
||||
)
|
||||
self._transport.send_message(note)
|
||||
if pr.get("number"):
|
||||
self._publisher.comment(int(pr["number"]), note)
|
||||
|
||||
def _publish_milestone(self, milestone: int) -> None:
|
||||
if self._publisher is None:
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user