What happened
On #756, a Copilot review thread was replied to and left unresolved for about seven minutes, across a push and a re-request, and was resolved only on a later pass. The maintainer read the pull request inside that window and saw an answered finding with an open conversation, which is indistinguishable from a finding nobody triaged.
Timeline, all on 2026-08-16 UTC:
| Time |
Event |
| 03:20:42 |
Round 1 review 4945287818 lands on acf70a4, one inline thread on host-setup/bootstrap.sh |
| 03:22:14 |
Reply posted to that thread, fix pushed as 4a9abe4, round 2 requested |
| 03:25:47 |
Round 2 review lands on the new head |
| ~03:29 |
Thread PRRT_kwDOQ5caqM6ZkWcG finally resolved |
The gate noticed even though a human did not have to: the pull request's ruleset requires conversation resolution, so mergeStateStatus read BLOCKED throughout, for a reason no field names.
Root cause
Not a defect in scripts/pr_review.py, because the script was never run. The reply was hand-rolled as a POST to the review comment replies endpoint, and the resolve as a separate resolveReviewThread mutation issued on a later pass. Splitting the two into separate acts is what creates the window.
.github/copilot-instructions.md already says to reach for the helper first, and the helper already closes this by construction:
scripts/pr_review.py reply <N> --repo <owner>/<name> --match "<words from the finding>" --body "<answer>" --resolve queries the thread id itself and passes it straight to the mutation.
The same paragraph also records why another sentence will not fix it:
That rule is known and read by the agents that break it anyway, three times so far, so the shape is what changes rather than the wording.
This is the fourth. The wording is not the problem. The hand-run GraphQL form documented below that paragraph is the path an agent falls into, since it presents reply and resolve as two snippets, which an agent then runs as two decisions.
Suggested fix
Extend host-setup/agent-safety/gh-write-guard.py with a third denial class, alongside the discarded-output and literal-node-id rules it already carries. Deny, from the Bash PreToolUse matcher:
- a
resolveReviewThread mutation issued directly through gh api graphql
- a POST to the pull request review comment replies endpoint
The refusal names scripts/pr_review.py reply --match ... --body ... --resolve as the path to take instead, the way the existing rules name what to do rather than only what is refused.
This mirrors the precedent exactly. The discarded-output rule exists because a mutation whose result is thrown away is a class of mistake no amount of documentation stopped, and it fired correctly during this same session on a requestReviews call carrying a > /dev/null tail. The reply-without-resolve split is the same shape of mistake with the same history.
Two properties worth keeping in the implementation:
- Deny the mechanism, not the intent. Reading threads, requesting reviews, and posting an issue-level comment stay untouched. Only the two calls the helper supersedes are refused.
- Leave an escape. A cross-owner pull request, which
pr_review.py refuses outright, still needs the hand-run form, so the guard permits it on the same GH_WRITE_GUARD_ALLOW grant the cross-owner write rule already reads.
Alternative considered and rejected
Stating "do not defer the resolve to a later pass" in pr-review-conduct and in the runbook. It is the fifth restatement of a rule already stated in three places, and the file itself records that restating it has not worked.
A second, smaller finding in the same file
Filing this issue was itself denied by gh-write-guard.py, as a cross-owner write to an owner named .... The command was an gh issue create --repo ptr727/ProjectTemplate whose --body prose quoted an API path in the abbreviated form a reader expects, and the guard's target extraction read that path out of the body text as the write's target.
The deny was correct in shape and wrong in fact, and the body had to move to --body-file to get the issue filed. Worth handling while the file is open, since a guard that fires on prose describing a path teaches an agent to route around it, and routing around a guard is the behavior least wanted here. Target extraction should read only an argument in target position, never text carried by --body, --title, --field body, or a heredoc feeding one.
What happened
On #756, a Copilot review thread was replied to and left unresolved for about seven minutes, across a push and a re-request, and was resolved only on a later pass. The maintainer read the pull request inside that window and saw an answered finding with an open conversation, which is indistinguishable from a finding nobody triaged.
Timeline, all on 2026-08-16 UTC:
4945287818lands onacf70a4, one inline thread onhost-setup/bootstrap.sh4a9abe4, round 2 requestedPRRT_kwDOQ5caqM6ZkWcGfinally resolvedThe gate noticed even though a human did not have to: the pull request's ruleset requires conversation resolution, so
mergeStateStatusreadBLOCKEDthroughout, for a reason no field names.Root cause
Not a defect in
scripts/pr_review.py, because the script was never run. The reply was hand-rolled as a POST to the review comment replies endpoint, and the resolve as a separateresolveReviewThreadmutation issued on a later pass. Splitting the two into separate acts is what creates the window..github/copilot-instructions.mdalready says to reach for the helper first, and the helper already closes this by construction:The same paragraph also records why another sentence will not fix it:
This is the fourth. The wording is not the problem. The hand-run GraphQL form documented below that paragraph is the path an agent falls into, since it presents reply and resolve as two snippets, which an agent then runs as two decisions.
Suggested fix
Extend
host-setup/agent-safety/gh-write-guard.pywith a third denial class, alongside the discarded-output and literal-node-id rules it already carries. Deny, from theBashPreToolUsematcher:resolveReviewThreadmutation issued directly throughgh api graphqlThe refusal names
scripts/pr_review.py reply --match ... --body ... --resolveas the path to take instead, the way the existing rules name what to do rather than only what is refused.This mirrors the precedent exactly. The discarded-output rule exists because a mutation whose result is thrown away is a class of mistake no amount of documentation stopped, and it fired correctly during this same session on a
requestReviewscall carrying a> /dev/nulltail. The reply-without-resolve split is the same shape of mistake with the same history.Two properties worth keeping in the implementation:
pr_review.pyrefuses outright, still needs the hand-run form, so the guard permits it on the sameGH_WRITE_GUARD_ALLOWgrant the cross-owner write rule already reads.Alternative considered and rejected
Stating "do not defer the resolve to a later pass" in
pr-review-conductand in the runbook. It is the fifth restatement of a rule already stated in three places, and the file itself records that restating it has not worked.A second, smaller finding in the same file
Filing this issue was itself denied by
gh-write-guard.py, as a cross-owner write to an owner named.... The command was angh issue create --repo ptr727/ProjectTemplatewhose--bodyprose quoted an API path in the abbreviated form a reader expects, and the guard's target extraction read that path out of the body text as the write's target.The deny was correct in shape and wrong in fact, and the body had to move to
--body-fileto get the issue filed. Worth handling while the file is open, since a guard that fires on prose describing a path teaches an agent to route around it, and routing around a guard is the behavior least wanted here. Target extraction should read only an argument in target position, never text carried by--body,--title,--field body, or a heredoc feeding one.