Externalise plans to Gitea and accept approvals there (A4, Part H)
CI / checks (push) Failing after 6s
CI / checks (push) Failing after 6s
- approvals.py: PlanBoard opens one issue per project and posts each plan revision as a comment carrying its rev tag. The state store keeps only a pointer, so gate state can be re-derived from Gitea after a state-volume loss — external truth wins - Gitea comments are a second approval source: revision binding, the typed word approve, and first-approval-wins work exactly as over transport, with a per-channel allowlist because a Gitea login is not a Matrix MXID - Outcomes are echoed to the other channel, so the two records cannot silently disagree about what was approved - A Gitea outage degrades durability of the record, not the ability to approve: planning proceeds and Matrix remains a complete channel - Live test now asserts the server refuses the agent's own merge and its push to the protected branch. That property was previously true only because our code did not attempt it; branch protection makes it structural, and this test fails if the protection is ever relaxed - Fixed a race in the process-group test: killing a group is asynchronous and the grandchild is an orphan, so its exit is not synchronised with our return; the test now waits for death Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
affdb9e8aa
commit
67aa82371e
6 files changed
+408
-4
No files matched your search
@@ -0,0 +1,80 @@
|
||||
"""Plan externalisation and Gitea-backed approvals (spec A4, Part H).
|
||||
|
||||
Plans are posted as comments on a per-project Gitea issue. The approval
|
||||
comment — its author, timestamp, and revision tag — is the durable off-box
|
||||
record, authenticated by Gitea's own auth. The state store keeps only a
|
||||
pointer to it, so boot reconciliation can re-derive gate state from Gitea
|
||||
even after the state volume is lost: external truth wins.
|
||||
|
||||
Approvals arriving here bind to a revision exactly as they do over transport
|
||||
(A4). The first valid approval on any channel wins; the orchestrator echoes
|
||||
the outcome to the other channel so the records converge.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nisse_dev.gitea import Comment, GiteaClient, GiteaError
|
||||
|
||||
APPROVAL_HINT = (
|
||||
"Reply `approve {tag}` to approve this revision, or comment with feedback "
|
||||
"to reject it."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanPost:
|
||||
issue: int
|
||||
comment_id: int
|
||||
|
||||
|
||||
class PlanBoard:
|
||||
"""The Gitea side of plan gating: one issue per project, one comment per
|
||||
plan revision."""
|
||||
|
||||
def __init__(self, client: GiteaClient, repo: str) -> None:
|
||||
self._client = client
|
||||
self._repo = repo
|
||||
|
||||
@property
|
||||
def repo(self) -> str:
|
||||
return self._repo
|
||||
|
||||
def open_project_issue(self, project: str, task_id: str) -> int:
|
||||
return self._client.create_issue(
|
||||
self._repo,
|
||||
title=f"nisse-dev: {project}",
|
||||
body=(
|
||||
f"Plan gate for project `{project}` (task `{task_id}`).\n\n"
|
||||
"Each plan revision is posted here as a comment. Approving a "
|
||||
"revision by comment is equivalent to approving it in Matrix; "
|
||||
"the first valid approval on either channel wins."
|
||||
),
|
||||
)
|
||||
|
||||
def post_plan(self, issue: int, gate: str, rev: int, plan: str) -> PlanPost:
|
||||
tag = f"rev-{rev}"
|
||||
body = "\n".join(
|
||||
[
|
||||
f"## Plan for `{gate}` ({tag})",
|
||||
"",
|
||||
plan,
|
||||
"",
|
||||
"---",
|
||||
APPROVAL_HINT.format(tag=tag),
|
||||
]
|
||||
)
|
||||
comment = self._client.post_comment(self._repo, issue, body)
|
||||
return PlanPost(issue=issue, comment_id=comment.id)
|
||||
|
||||
def echo(self, issue: int, text: str) -> None:
|
||||
"""Mirror an outcome decided on another channel, so the Gitea record
|
||||
does not silently disagree with Matrix."""
|
||||
try:
|
||||
self._client.post_comment(self._repo, issue, text)
|
||||
except GiteaError:
|
||||
pass
|
||||
|
||||
def comments_since(self, issue: int, after_id: int) -> list[Comment]:
|
||||
return [
|
||||
c for c in self._client.list_comments(self._repo, issue) if c.id > after_id
|
||||
]
|
||||
@@ -94,6 +94,17 @@ class MilestonePublisher(Protocol):
|
||||
def comment(self, index: int, body: str) -> None: ...
|
||||
|
||||
|
||||
class PlanBoard(Protocol):
|
||||
"""The Gitea side of plan gating; the concrete one is in
|
||||
nisse_dev.approvals."""
|
||||
|
||||
def open_project_issue(self, project: str, task_id: str) -> int: ...
|
||||
|
||||
def post_plan(self, issue: int, gate: str, rev: int, plan: str) -> Any: ...
|
||||
|
||||
def echo(self, issue: int, text: str) -> None: ...
|
||||
|
||||
|
||||
class PlanningAgent(Protocol):
|
||||
def plan(
|
||||
self,
|
||||
@@ -133,6 +144,10 @@ def _fresh_data() -> dict[str, Any]:
|
||||
"task_id": None,
|
||||
"roles": None, # A11 mapping, pinned at task start
|
||||
"pr": None, # the open PR for the current milestone, once published
|
||||
# Pointer to the Gitea plan record (Part H): the state store holds
|
||||
# only this, so gate state can be re-derived from Gitea after a
|
||||
# state-volume loss.
|
||||
"board": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +162,7 @@ class Orchestrator:
|
||||
executor: "MilestoneExecutor | None" = None,
|
||||
publisher: "MilestonePublisher | None" = None,
|
||||
gitea_operators: Iterable[str] = (),
|
||||
board: "PlanBoard | None" = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._transport = transport
|
||||
@@ -159,6 +175,7 @@ class Orchestrator:
|
||||
# are different names for the same person, and neither implies
|
||||
# the other.
|
||||
self._gitea_operators = set(gitea_operators)
|
||||
self._board = board
|
||||
# Transport and the comment poller deliver on separate threads;
|
||||
# state transitions must not interleave.
|
||||
self._lock = threading.RLock()
|
||||
@@ -260,6 +277,19 @@ class Orchestrator:
|
||||
self._data = {**_fresh_data(), "task_id": task_id, "roles": pinned}
|
||||
self._project = project
|
||||
self._store.oplog.task_id = task_id
|
||||
if self._board is not None:
|
||||
try:
|
||||
self._data["board"] = {
|
||||
"issue": self._board.open_project_issue(project, task_id),
|
||||
"last_comment": 0,
|
||||
}
|
||||
except GiteaError as exc:
|
||||
# Gitea being down must not block planning; Matrix remains a
|
||||
# complete approval channel on its own.
|
||||
self._transport.send_message(
|
||||
f"note: could not open the Gitea plan issue ({exc}); "
|
||||
"approvals will be Matrix-only for this task"
|
||||
)
|
||||
self._transition(PLANNING_ARCH, cause=f"operator command: use {project}")
|
||||
self._run_planning_turn("arch")
|
||||
|
||||
@@ -308,6 +338,7 @@ class Orchestrator:
|
||||
f"{action.content}\n\nreply 'approve' to approve rev-{rev}; "
|
||||
"any other reply is rejection feedback"
|
||||
)
|
||||
self._post_plan_to_board(gate_name, rev, action.content)
|
||||
self._store.oplog.emit(
|
||||
"gate.open",
|
||||
cause=f"plan submitted (rev-{rev})",
|
||||
@@ -317,6 +348,12 @@ class Orchestrator:
|
||||
)
|
||||
|
||||
def _handle_gate_reply(self, msg: InboundMessage, text: str) -> None:
|
||||
self._apply_gate_reply(sender=msg.sender, text=text, channel="transport")
|
||||
|
||||
def _apply_gate_reply(self, *, sender: str, text: str, channel: str) -> None:
|
||||
"""Shared by every approval source (A4). The allowlist is per channel:
|
||||
a Matrix MXID and a Gitea login are different names, and holding one
|
||||
says nothing about holding the other."""
|
||||
gate_data = self._data["gate"]
|
||||
name, rev = gate_data["name"], gate_data["rev"]
|
||||
milestone = self._data["milestone"]
|
||||
@@ -332,9 +369,8 @@ class Orchestrator:
|
||||
)
|
||||
return
|
||||
revision = PlanRevision(gate=name, rev=rev, content=gate_data["plan"])
|
||||
result = Gate(revision, self._operators).evaluate(
|
||||
msg.sender, text, channel="transport"
|
||||
)
|
||||
allowlist = self._operators if channel == "transport" else self._gitea_operators
|
||||
result = Gate(revision, allowlist).evaluate(sender, text, channel=channel)
|
||||
if isinstance(result, Ignored):
|
||||
if result.notice:
|
||||
self._transport.send_message(result.notice)
|
||||
@@ -347,12 +383,19 @@ class Orchestrator:
|
||||
rev=f"rev-{rev}",
|
||||
channel=result.channel,
|
||||
)
|
||||
self._echo_outcome(
|
||||
channel,
|
||||
f"{name} rev-{rev} rejected by {result.approver}: {result.feedback}",
|
||||
)
|
||||
self._data["gate"] = None
|
||||
back = PLANNING_ARCH if name == "arch" else MILESTONE_PLANNING
|
||||
self._transition(back, cause=f"plan rejected ({name} rev-{rev})")
|
||||
self._run_planning_turn(name, feedback=result.feedback)
|
||||
return
|
||||
assert isinstance(result, Approved)
|
||||
self._echo_outcome(
|
||||
channel, f"{name} rev-{rev} approved by {result.approver} via {channel}"
|
||||
)
|
||||
self._store.oplog.emit(
|
||||
"gate.approve",
|
||||
cause=f"approved by {result.approver}",
|
||||
@@ -395,6 +438,52 @@ class Orchestrator:
|
||||
pending={"kind": "milestone_stopped", "milestone": milestone},
|
||||
)
|
||||
|
||||
def _echo_outcome(self, deciding_channel: str, text: str) -> None:
|
||||
"""A gate satisfied on one channel is echoed to the other, so neither
|
||||
record silently disagrees about what was approved (A4)."""
|
||||
board_state = self._data.get("board")
|
||||
if deciding_channel != "gitea" and self._board is not None and board_state:
|
||||
self._board.echo(board_state["issue"], text)
|
||||
elif deciding_channel == "gitea":
|
||||
self._transport.send_message(text)
|
||||
|
||||
def _post_plan_to_board(self, gate_name: str, rev: int, plan: str) -> None:
|
||||
board_state = self._data.get("board")
|
||||
if self._board is None or not board_state:
|
||||
return
|
||||
try:
|
||||
post = self._board.post_plan(board_state["issue"], gate_name, rev, plan)
|
||||
except GiteaError:
|
||||
# Matrix already carries the plan; a Gitea outage degrades the
|
||||
# durability of the record, not the ability to approve.
|
||||
return
|
||||
board_state["comment_id"] = post.comment_id
|
||||
|
||||
def handle_plan_comment(self, comment: Any) -> None:
|
||||
"""A comment on the project issue may satisfy an open plan gate (A4).
|
||||
|
||||
Approvals bind to a revision here exactly as over transport, and the
|
||||
first valid approval on either channel wins.
|
||||
"""
|
||||
with self._lock:
|
||||
self._handle_plan_comment_locked(comment)
|
||||
|
||||
def _handle_plan_comment_locked(self, comment: Any) -> None:
|
||||
if comment.author not in self._gitea_operators:
|
||||
return
|
||||
state = self._store.get_state()
|
||||
if state is None or state.state not in (
|
||||
AWAIT_ARCH_SIGNOFF,
|
||||
AWAIT_MILESTONE_SIGNOFF,
|
||||
):
|
||||
return
|
||||
self._store.append_event(
|
||||
UserMessage(text=comment.body, source=f"gitea:comment:{comment.id}")
|
||||
)
|
||||
self._apply_gate_reply(
|
||||
sender=comment.author, text=comment.body.strip(), channel="gitea"
|
||||
)
|
||||
|
||||
def handle_review_comment(self, comment: Any) -> None:
|
||||
"""React to a PR review comment (B2). Called from the poller thread.
|
||||
|
||||
|
||||
@@ -151,6 +151,12 @@ class GiteaClient:
|
||||
created_at=data.get("created_at", ""),
|
||||
)
|
||||
|
||||
def create_issue(self, repo: str, title: str, body: str) -> int:
|
||||
data = self._request(
|
||||
"POST", f"/repos/{repo}/issues", json={"title": title, "body": body}
|
||||
)
|
||||
return int(data["number"])
|
||||
|
||||
def close_pull_request(self, repo: str, index: int) -> None:
|
||||
self._request("PATCH", f"/repos/{repo}/pulls/{index}", json={"state": "closed"})
|
||||
|
||||
|
||||
Reference in new issue
Block a user