Bind approvals and feedback to the revision they were about
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m16s

Fixes counterexamples 1-4 from specs/orchestrator.qnt: a repeated
'approve', 'approve rev-1' meant for the architecture, approving on both
channels, and 'approve' plus an afterthought all landed on the next
plan, one the operator had not seen.

- Revisions are numbered across the work item (A4 already required
  monotonic tags; the code numbered per gate), so rev-N names one plan.
- Each reply is stamped on arrival with the revision that was up: in
  Matrix the one whose approval request had been sent (a plain value
  the transport thread reads, restored on boot), in Gitea the latest
  plan comment older than the reply (board.posts).
- Gate.evaluate applies a reply that names no revision only if its stamp
  is the current revision. Otherwise an approval is not applied and
  feedback rejects nothing (it is still in the transcript), and the
  agent says which revision is up. 'approve rev-N' binds by its number.
- Spec A4 states both rules. The model applies them; scenarios 1-4 now
  expect the properties to hold, and both held over 200,000 random
  traces and Apalache's exhaustive check to depth 8.

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:
dongarandClaude Opus 5.5 committed 2026-09-29 19:13:20 +00:00
1 parent 6c83839f4d
commit 97863c1939
10 files changed
+324 -44

No files matched your search

+57 -10
View File
@@ -172,6 +172,16 @@ class StubAgent:
return SubmitPlan(f"[stub] {gate} plan for {project}{notes}")
def _rev_before(board_state: dict[str, Any] | None, comment_id: int) -> int | None:
"""The revision a Gitea comment was about: the latest plan posted before
it. None for a record kept before plan posts were."""
posts = (board_state or {}).get("posts")
if posts is None:
return None
earlier = [int(rev) for cid, rev in posts if int(cid) < comment_id]
return max(earlier) if earlier else 0
def _project_name(text: str) -> str:
"""The name in 'use <name>', as written: only the command word is
case-insensitive."""
@@ -180,7 +190,13 @@ def _project_name(text: str) -> str:
def _fresh_data() -> dict[str, Any]:
return {
# Latest revision per gate; revision numbers themselves run across
# the whole work item (last_rev), so rev-N names one plan (A4).
"revs": {},
"last_rev": 0,
# The revision last put up for sign-off: a reply that names none
# binds to the one up when it arrived.
"shown_rev": 0,
"gate": None,
# What an operator reply in AWAIT_INPUT should resume. Every path into
# AWAIT_INPUT must set this, or the reply has nowhere to go.
@@ -248,6 +264,8 @@ class Orchestrator:
# long work is running, and the state name for a "busy" reply.
self._working = False
self._state_name = IDLE
# The revision up for sign-off, stamped on each message as it arrives.
self._shown_rev = 0
# Set by whichever path halted because of a stop, so the stop's own
# job knows whether it stopped anything.
self._halted_by_stop = False
@@ -279,6 +297,13 @@ class Orchestrator:
self._project = state.project
self._store.oplog.task_id = self._data.get("task_id")
gate = self._data.get("gate")
# An open gate is announced again below, so its plan counts as up.
self._shown_rev = max(
int(self._data.get("shown_rev") or 0),
int(gate["rev"])
if gate and state.state in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF)
else 0,
)
if report.action == "await_input":
self._await_input(
cause="boot: unresolved intents",
@@ -359,8 +384,11 @@ class Orchestrator:
)
self._worker.submit(lambda: self._record(msg))
return
# Stamped here, on arrival: by the time the worker reaches it, the
# plan it was about may have been replaced.
about = self._shown_rev
self._worker.submit(
lambda: self._settled("your message", lambda: self._handle_now(msg))
lambda: self._settled("your message", lambda: self._handle_now(msg, about))
)
def _settled(self, what: str, job: Callable[[], None]) -> None:
@@ -434,7 +462,7 @@ class Orchestrator:
pending=pending,
)
def _handle_now(self, msg: InboundMessage) -> None:
def _handle_now(self, msg: InboundMessage, about: int | None = None) -> None:
# Every operator message enters the canonical transcript, so planning
# turns see feedback and answers as conversation rather than as
# out-of-band parameters (A10).
@@ -479,7 +507,7 @@ class Orchestrator:
},
)
return
self._handle_gate_reply(msg, text)
self._handle_gate_reply(msg, text, about)
elif current == PR_OPEN:
self._handle_pr_open(text)
elif current == AWAIT_INPUT:
@@ -563,7 +591,8 @@ class Orchestrator:
pending={"kind": "question", "gate": gate_name},
)
return
rev = self._data["revs"].get(gate_name, 0) + 1
rev = int(self._data.get("last_rev") or 0) + 1
self._data["last_rev"] = rev
self._data["revs"][gate_name] = rev
self._data["gate"] = {
"name": gate_name,
@@ -583,9 +612,12 @@ class Orchestrator:
)
self._transition(await_state, cause=f"plan submitted ({gate_name} rev-{rev})")
self._transport.send_approval_request(
f"{action.content}\n\nreply 'approve' to approve rev-{rev}; "
f"{action.content}\n\nreply 'approve' to approve {gate_name} rev-{rev}; "
"any other reply is rejection feedback"
)
# Up only once sent: a reply that arrived before was about another.
self._data["shown_rev"] = rev
self._shown_rev = rev
self._post_plan_to_board(gate_name, rev, action.content)
self._store.oplog.emit(
"gate.open",
@@ -595,10 +627,16 @@ class Orchestrator:
channel="transport",
)
def _handle_gate_reply(self, msg: InboundMessage, text: str) -> None:
self._apply_gate_reply(sender=msg.sender, text=text, channel="transport")
def _handle_gate_reply(
self, msg: InboundMessage, text: str, about: int | None
) -> None:
self._apply_gate_reply(
sender=msg.sender, text=text, channel="transport", about=about
)
def _apply_gate_reply(self, *, sender: str, text: str, channel: str) -> None:
def _apply_gate_reply(
self, *, sender: str, text: str, channel: str, about: int | None = None
) -> 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."""
@@ -618,7 +656,9 @@ class Orchestrator:
return
revision = PlanRevision(gate=name, rev=rev, content=gate_data["plan"])
allowlist = self._operators if channel == "transport" else self._gitea_operators
result = Gate(revision, allowlist).evaluate(sender, text, channel=channel)
result = Gate(revision, allowlist).evaluate(
sender, text, channel=channel, about=about
)
if isinstance(result, Ignored):
if result.notice:
self._transport.send_message(result.notice)
@@ -720,6 +760,9 @@ class Orchestrator:
# durability of the record, not the ability to approve.
return
board_state["comment_id"] = post.comment_id
# Which comment put which revision up: a Gitea reply binds to the
# latest plan posted before it, since comment IDs only grow.
board_state.setdefault("posts", []).append([post.comment_id, rev])
# Approvals can arrive on the issue as well as over transport (A4),
# so watch it for as long as the gate stands. A new revision skips
# what came before it: an early "approve" must not approve it.
@@ -758,7 +801,10 @@ class Orchestrator:
UserMessage(text=comment.body, source=f"gitea:comment:{comment.id}")
)
self._apply_gate_reply(
sender=comment.author, text=comment.body.strip(), channel="gitea"
sender=comment.author,
text=comment.body.strip(),
channel="gitea",
about=_rev_before(board_state, comment.id),
)
def handle_review_comment(self, comment: Any) -> None:
@@ -1131,3 +1177,4 @@ class Orchestrator:
new_state, cause=cause, project=self._project, data=self._data
)
self._state_name = new_state
self._shown_rev = int(self._data.get("shown_rev") or 0)
+28 -2
View File
@@ -7,6 +7,13 @@ approval wins. Approval is always the typed word ``approve`` (optionally
``approve rev-N``); any other reply is a rejection with feedback. The approver
is authenticated against a per-channel operator allowlist.
Revisions are numbered across a whole work item, not per gate, so ``rev-N``
names one plan. A reply that does not name a revision binds to the one that
was up when it arrived (its ``about``): if that is not the current revision,
it is ambiguous and not applied, neither as an approval nor as feedback. That
is how a second "approve", or an afterthought, sent while one plan was up can
never land on the next.
The channel seam: any approval source (Matrix transport, Gitea comment poller,
Vikunja comments) feeds operator replies to ``Gate.evaluate`` with its channel
name.
@@ -64,15 +71,34 @@ class Gate:
self._operators = operators
def evaluate(
self, sender: str, text: str, channel: str
self, sender: str, text: str, channel: str, about: int | None = None
) -> Approved | Rejected | Ignored:
"""`about` is the revision that was up when the reply arrived; None
only for records kept before replies were stamped."""
if sender not in self._operators:
return Ignored(notice=None)
text = text.strip()
match = _APPROVE_RE.match(command_form(text))
named = match.group(1) if match else None
current = self._revision
if named is None and about is not None and about != current.rev:
was = f"rev-{about}" if about else "no plan"
if match is None:
return Ignored(
notice=(
f"noted, but it arrived while {was} was up, so it is not "
f"taken as feedback on {current.gate} {current.tag}"
)
)
return Ignored(
notice=(
f"that approval arrived while {was} was up; the current plan is "
f"{current.gate} {current.tag}, not applied. Reply again to "
"approve it"
)
)
if match is None:
return Rejected(feedback=text, approver=sender, channel=channel)
named = match.group(1)
if named is not None and named != self._revision.tag:
return Ignored(
notice=(