Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/zendev-acceptor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ jobs:
id: select
env:
GH_TOKEN: ${{ steps.identity.outputs.token }}
run: python scripts/select_review_target.py --repo "${{ github.repository }}"
run: |
python scripts/select_review_target.py --repo "${{ github.repository }}" --acceptor "${{ steps.identity.outputs.app-slug }}"

# The subscription is metered in rolling windows, not dollars. One reading before
# the model and one after; the recorder writes the difference to the private
Expand Down Expand Up @@ -216,6 +217,13 @@ jobs:
status:needs-decision and stop; a human accepts that class of change.
- If you cannot decide, name the exact gate in a comment and leave the
pull request open. Never merge to clear a queue.
- Standing refusals from accounts other than yours on this pull request:
"${{ steps.select.outputs.blocked_by }}". When that list is not empty,
branch protection will refuse the merge and you do not own that gate.
Do not post ACCEPT you cannot carry out: say in a comment which account
holds the merge and what it asked for, label the pull request
status:needs-decision, and stop. Judging the change is still yours —
if it also needs rework, request changes as usual.

- name: Read the subscription windows after the model ran
if: always() && steps.select.outputs.target != 'none'
Expand Down
23 changes: 21 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ tests prove coverage.

## Roles

Exactly two automated roles run against this repository. They never run in the same
job, and a run performs exactly one of them.
Exactly two automated roles run *in this repository's workflows*. They never run in the
same job, and a run performs exactly one of them.

- **AUTHOR** (`.github/workflows/zendev-author.yml`) — selects one unit of work,
implements it, verifies it, opens a pull request. Runbook:
Expand All @@ -39,6 +39,25 @@ job, and a run performs exactly one of them.

An AUTHOR run must never merge. An ACCEPTOR run must never implement.

### Voices that are not roles

Other accounts post here. None of them is a role, none has authority over the merge,
and a run that mistakes one for a role deadlocks the queue — which is what happened
on #208, #223 and #238.

- **SLOPSTER** (`AndyDev`) — an external QA voice with read access only. It comments
on open pull requests under the heading `## SLOPSTER QA: FINDING` and opens Issues
for post-merge findings. It never posts a formal review, never labels, never merges.
- **The researcher** (`drevendev`, also the operator's account) — owns the
specification. Its notes about the spec are authoritative; its opinions about code
are evidence.

**Only the ACCEPTOR identity's verdict is a verdict.** Every other account's review or
verdict-shaped comment is evidence: read it, weigh it, never treat it as the decision.
A standing `CHANGES_REQUESTED` from another account can still hold the merge — that
gate belongs to branch protection. Name it and stop; do not post a verdict that cannot
be carried out.

## The loop

```text
Expand Down
24 changes: 23 additions & 1 deletion docs/spec/FEEDBACK_TO_RESEARCHER.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,29 @@ rather than minutes.

### How your reviews count

Since 2026-09-06 (Issue #152):
**Changed 2026-09-07. Read this even if you read the section before.**

A formal review from your account is no longer a verdict to the loop. Only the ACCEPTOR
identity's verdict is one. Yours is evidence — read, weighed, often right, and never the
decision. Three pull requests died of the older rule in a single day:

- **#208** — the ACCEPTOR accepted it twice; your standing `CHANGES_REQUESTED` held the
merge at `BLOCKED`, and because the head counted as judged it was never re-selected.
- **#223** — your `APPROVED`, newer than the ACCEPTOR's refusal, told the AUTHOR the
pull request was settled while GitHub kept it blocked. Seven hours, nobody working.
- **#238** — your `APPROVED` was the only verdict on a clean head. Eleven hours idle.

**What still has teeth.** A `CHANGES_REQUESTED` from your account still blocks the merge
at branch protection, and the loop cannot dismiss it. The ACCEPTOR now detects that,
says which account holds the merge, labels the pull request `status:needs-decision` and
stops rather than accepting something it cannot execute. So a refusal you leave and
forget is a pull request nobody can land — it needs you, or the operator, to clear it.

**What to use instead.** Post findings as ordinary comments, or as Issues for anything
found after a merge. Both reach the loop; neither can strand a pull request.

The rules below are what the older section said, and they still describe how a verdict
from *the ACCEPTOR* is read:

- A formal review in the `CHANGES_REQUESTED` state is a verdict on that head whatever its
words. The AUTHOR must answer it; the ACCEPTOR sees it as standing; the head is not
Expand Down
21 changes: 21 additions & 0 deletions docs/zendev/ACCEPTOR_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@ first verdict is already standing and the AUTHOR — which has no memory and rea
verdicts as its input — sees both. Name the head revision in the verdict so a reader
can tell which revision it judged.

**A merge gate you do not own.** Before ACCEPT, check whether any account other than
yours has a standing `CHANGES_REQUESTED` on this pull request. The run tells you: the
selection step prints `#<n> merge held by <account>` and passes the list into your
prompt. Branch protection will refuse the merge while such a review stands, and you
cannot dismiss it. Do not post an ACCEPT you cannot carry out — on #208 that ACCEPT
was posted twice, executed neither time, and took the pull request out of the review
queue for good. Instead: comment naming the account that holds the merge and what it
asked for, label the pull request `status:needs-decision`, and stop. Judging the
change is still yours; if it also needs rework, refuse it as usual.

**ACCEPT** — allowed only when all of the following hold, and you state each one in
the merge comment:

Expand All @@ -263,6 +273,14 @@ Post the accepted revision and the evidence on the Issue, and confirm the Issue
If merged code satisfies the Issue only partially, keep the Issue open and narrow it
with a recorded correction rather than closing it optimistically.

**Never close an Issue whose pull request is not merged.** Before closing, read the
pull request's state and confirm it is `MERGED`. On #240 this role closed the Issue as
completed while its pull request #242 was still open and unmerged: the requirement was
recorded as delivered with no code behind it, and the work was found missing and done
again from scratch six hours later. A closed Issue is the loop's record of truth about
what exists — an Issue closed ahead of its merge is worse than one left open, because
nothing downstream will look at it again.

Once the Issue is confirmed closed, remove every `status:*` label it still carries —
a closed Issue carries no active `status:*` label, since the forge closed state and
its reason are the durable resolution. If the merge did not close the Issue
Expand All @@ -287,3 +305,6 @@ clear a queue.
stop. Policy that expands what agents may do is accepted by a human, not by this role.
- Never push commits to a pull request branch.
- Never close an Issue you did not verify.
- Never close an Issue whose pull request is not in state `MERGED`.
- Never post an ACCEPT while another account's `CHANGES_REQUESTED` stands: the merge
cannot execute, and the verdict takes the pull request out of the queue anyway.
35 changes: 27 additions & 8 deletions docs/zendev/AUTHOR_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,26 +84,45 @@ ACCEPTOR's verdict on a pull request you authored is sometimes a formal review
headed exactly `## ACCEPTOR verdict: REQUEST_CHANGES` (or `## ACCEPTOR verdict:
ACCEPT`) and naming the reviewed revision as `` Head `<sha>` `` in its first
paragraph — see `ACCEPTOR_RUNBOOK.md` section 1. Both forms count equally as a
verdict. A human operator QA comment carrying an equivalent explicit marker (for
example `## Operator QA: REQUEST_CHANGES`) counts the same way as supporting
evidence, but only an ACCEPTOR or formal-review verdict decides whether item 1
applies.
verdict.

**Only the ACCEPTOR's verdict is a verdict.** Other accounts post on your pull
requests too: an operator QA comment, a researcher's formal review, the external QA
voice. All of them are evidence — read them, they are often right — and none of them
decides whether item 1 applies. This is not a matter of courtesy: on #223 an
`APPROVED` from another account, newer than the ACCEPTOR's refusal, told a run the
pull request was settled while GitHub kept it `BLOCKED`, and the pull request stood
for seven hours with nobody working on it.

To decide whether item 1 applies to one of your open pull requests:

1. Collect every formal review on the pull request, and every comment on the pull
request and its linked Issue.
2. Keep only the entries that carry an explicit verdict: a formal review's `state`,
or a comment beginning with an `ACCEPTOR verdict:` marker.
2. Keep only the entries **posted by the ACCEPTOR identity** that carry an explicit
verdict: a formal review's `state`, or a comment beginning with an
`ACCEPTOR verdict:` marker. Discard verdict-shaped entries from every other
account; they are evidence, and step 6 says what to do with them.
3. Order the kept entries by timestamp and take the latest one.
4. Item 1 applies only when that latest verdict is a change request
(`CHANGES_REQUESTED` / `REQUEST_CHANGES`) **and** the revision it names — the
review's own commit, or the `` Head `<sha>` `` the comment states — is exactly the
pull request's current `headRefOid`. A verdict naming an older head was already
superseded by whatever was pushed since; do not re-address it, and do not let it
block or repeat against the new head.
5. A later `ACCEPT`/`APPROVED` verdict at the current head means item 1 does not
apply; move on to item 2.
5. A later `ACCEPT`/`APPROVED` verdict **from the ACCEPTOR** at the current head
means item 1 does not apply; move on to item 2. Another account's approval never
has this effect.
6. A standing `CHANGES_REQUESTED` from any account holds the merge, whoever posted
it — that gate belongs to branch protection, not to the loop. It does not change
whether item 1 applies, and it is not yours to dismiss. When item 1 applies,
address the ACCEPTOR's refusal; when the pull request is otherwise finished and
only a foreign refusal holds it, say so in a comment naming the account and stop.
A pull request nobody can merge is a defect to surface, not a reason to open
another one on the same requirement.
7. When item 1 applies to more than one of your pull requests, take **the oldest**.
Fresh rework otherwise overtakes old rework indefinitely, and the oldest pull
request — the one carrying the most work already paid for — is the one that
never lands.

This is the same-account fallback `ACCEPTOR_RUNBOOK.md` section 1 already
requires the ACCEPTOR to honor when posting a verdict. AUTHOR must recognize the
Expand Down
105 changes: 97 additions & 8 deletions scripts/select_review_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@
the reviews worth having. A red test suite still selects, because the reviewer can
judge the criteria too and return one complete list instead of two partial ones.

## Whose verdict counts

The ACCEPTOR's, and only the ACCEPTOR's. A verdict is not a description of a pull
request, it is an instruction to merge or to rework, and only one identity can carry
it out — so reading every account's review as one cost a full day of throughput:

* on #208 the ACCEPTOR posted ACCEPT twice, while a `CHANGES_REQUESTED` left by the
researcher's account held `mergeStateStatus` at `BLOCKED`. The ACCEPT could not
execute, the head counted as judged, and the pull request left the queue for good.
* on #223 an `APPROVED` from that same account, newer than the ACCEPTOR's refusal,
told the AUTHOR the pull request was settled while GitHub kept it blocked.
* on #238 an `APPROVED` from that account was the *only* verdict on a clean head:
never re-selected, never merged, eleven hours idle.

So another account's review is evidence, exactly like the prose comments below it.
It may still block the merge — that gate belongs to branch protection, not here —
and `standing_blockers` names whoever holds it so the run can say so instead of
issuing an ACCEPT that cannot be carried out.

Without `--acceptor` every account's verdict counts, as before. That is the wider
reading, and it is the safe direction to fail in: a missed verdict re-reviews a head
that was already judged, which is the defect this module exists to prevent (#152).

## What is a verdict

A comment in one of the shapes the runbooks prescribe — a heading or bold marker,
Expand Down Expand Up @@ -121,8 +144,15 @@ def is_verdict(body: str) -> bool:
return bool(MARKED_VERDICT.search(body) or BARE_VERDICT.search(body))


def is_verdict_entry(entry) -> bool:
"""A comment in a verdict shape, or a formal review in a verdict state."""
def is_verdict_entry(entry, acceptor: str = "") -> bool:
"""A comment in a verdict shape, or a formal review in a verdict state.

With `acceptor` named, only that identity's entries can be verdicts. An entry that
records no author keeps the wider reading and stays a verdict: unknown identity is
not evidence of a foreign one, and dropping a verdict costs a duplicate review.
"""
if acceptor and not is_from(entry, acceptor):
return False
if (entry.get("state") or "").upper() in REVIEW_VERDICT_STATES:
return True
return is_verdict(entry.get("body") or "")
Expand All @@ -143,6 +173,17 @@ def normalize_login(login) -> str:
return login.lower()


def is_from(entry, login: str) -> bool:
"""Whether this entry was posted by `login`.

An entry with no author recorded reads as yes, for the reason given in
`is_verdict_entry`: silence about identity must not delete a verdict.
"""
if not login or "author" not in entry:
return True
return normalize_login(entry.get("author")) == normalize_login(login)


def is_the_authors(entry, author: str) -> bool:
"""Whether an entry was posted by the pull request's author.

Expand All @@ -154,15 +195,38 @@ def is_the_authors(entry, author: str) -> bool:
return normalize_login(entry.get("author")) == author


def judges_head(comment, head_sha: str, head_committed_at: str) -> bool:
def judges_head(comment, head_sha: str, head_committed_at: str, acceptor: str = "") -> bool:
"""Whether this entry is a verdict on the current head revision."""
if not is_verdict_entry(comment):
if not is_verdict_entry(comment, acceptor):
return False
if head_sha[:7] and head_sha[:7] in (comment["body"] or ""):
return True
return comment["createdAt"] >= head_committed_at


def standing_blockers(comments, acceptor: str = ""):
"""Accounts other than the ACCEPTOR whose latest formal review still refuses.

GitHub blocks the merge on the *latest* review per reviewer, so an older refusal
followed by that same account's approval is not standing. Only formal reviews are
read: a comment, whatever it says, has never blocked a merge.

This decides nothing about eligibility. It exists so a run can name the gate it
does not own rather than issue a verdict that cannot execute (#211).
"""
latest = {}
for entry in comments:
if entry.get("kind") != "review":
continue
login = normalize_login(entry.get("author"))
if not login or (acceptor and login == normalize_login(acceptor)):
continue
seen = latest.get(login)
if seen is None or entry.get("createdAt", "") >= seen[0]:
latest[login] = (entry.get("createdAt", ""), (entry.get("state") or "").upper())
return sorted(login for login, (_, state) in latest.items() if state == "CHANGES_REQUESTED")


def conflicts_with_base(pull) -> bool:
"""Whether the mergeability check has already ruled this branch out.

Expand Down Expand Up @@ -192,7 +256,7 @@ def is_unmeasured(pull) -> bool:
return not (pull.get("statusCheckRollup") or [])


def eligible(pull, head_committed_at: str, comments):
def eligible(pull, head_committed_at: str, comments, acceptor: str = ""):
"""Return (bool, reason). Pure: no network, no clock."""
if pull.get("isDraft"):
return False, "draft"
Expand All @@ -218,7 +282,9 @@ def eligible(pull, head_committed_at: str, comments):
return False, "nothing has reported on this head yet: unmeasured is not clean"

head_sha = pull["headRefOid"]
verdicts = [c for c in comments if judges_head(c, head_sha, head_committed_at)]
verdicts = [
c for c in comments if judges_head(c, head_sha, head_committed_at, acceptor)
]
if not verdicts:
return True, "no verdict on the current head"

Expand All @@ -227,7 +293,7 @@ def eligible(pull, head_committed_at: str, comments):
corrections = [
c
for c in comments
if not is_verdict_entry(c)
if not is_verdict_entry(c, acceptor)
and c["createdAt"] > newest_verdict
and is_the_authors(c, author)
]
Expand Down Expand Up @@ -284,6 +350,7 @@ def load_comments(repo: str, number: int):
"body": c.get("body", ""),
"createdAt": c.get("createdAt", ""),
"state": None,
"kind": "comment",
"author": (c.get("author") or {}).get("login", ""),
}
for c in data.get("comments", [])
Expand All @@ -297,6 +364,7 @@ def load_comments(repo: str, number: int):
"body": r.get("body", ""),
"createdAt": r.get("submittedAt", ""),
"state": r.get("state"),
"kind": "review",
"author": (r.get("author") or {}).get("login", ""),
}
for r in data.get("reviews", [])
Expand All @@ -313,29 +381,50 @@ def head_commit_date(repo: str, sha: str) -> str:
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", required=True, help="owner/name")
parser.add_argument(
"--acceptor",
default="",
help="login of the ACCEPTOR identity; only its verdicts are verdicts",
)
args = parser.parse_args()

candidates = []
blockers = {}
for pull in load_pulls(args.repo):
number = pull["number"]
committed_at = head_commit_date(args.repo, pull["headRefOid"])
ok, reason = eligible(pull, committed_at, load_comments(args.repo, number))
comments = load_comments(args.repo, number)
ok, reason = eligible(pull, committed_at, comments, args.acceptor)
candidates.append((number, pull["createdAt"], ok, reason))
held_by = standing_blockers(comments, args.acceptor)
if held_by:
blockers[number] = held_by

# Every pull request and why, always. When this picks nothing, the reason it
# picked nothing is the only evidence that the loop is idle by decision rather
# than broken — and a silent selector is indistinguishable from a stalled one.
for number, created_at, ok, reason in sorted(candidates, key=lambda c: c[1]):
print(f" #{number} ({created_at}) {'eligible' if ok else 'skipped'}: {reason}")

# A merge this role cannot perform is worth saying out loud even on pull requests
# this run will not touch: it is the only place the deadlock of #211 is visible
# before someone goes looking for it.
for number, held_by in sorted(blockers.items()):
print(f" #{number} merge held by {', '.join(held_by)} (not this role)")

target = choose(candidates)
value = str(target) if target else "none"
print(f"target={value}")

held = ",".join(blockers.get(target, [])) if target else ""
if held:
print(f"blocked_by={held}")

output = os.environ.get("GITHUB_OUTPUT")
if output:
with open(output, "a", encoding="utf-8") as handle:
handle.write(f"target={value}\n")
handle.write(f"blocked_by={held}\n")
return 0


Expand Down
Loading
Loading