Read the pickup event, so a dead request stops looking like patience - #527
Conversation
A request the reviewer never picks up produces exactly what a slow review produces, which is nothing, and one sat for thirteen and a half hours while the pull request read as waiting on the reviewer. Elapsed time cannot separate them, since a genuinely slow round shows no review either, so the wait reads `copilot_work_started` instead of the clock. A request with no pickup after it ends the wait with exit 50 and the timestamp, because waiting on does not start what nothing is acting on. Two readings this depended on were wrong. `gh pr view --json reviewRequests` omits a Bot reviewer outright, reporting an empty set while the reviewer sits in it, so the pending set is read through GraphQL. And the runbook called removal impossible for want of a named mutation, where `requestReviews` replaces the set when `union` is false, which is the clear half of the recovery it now documents. Recovery stays out of the script, which keeps its no-mutation contract. The digest names the state and the runbook carries the two mutations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR enhances scripts/pr_review.py to detect and report a third “wait” state where a Copilot review request is pending but never gets picked up (no copilot_work_started), adding a new exit code (50) and updating the runbook and tests accordingly.
Changes:
- Add GraphQL visibility into pending review requests (
reviewRequests) and incorporate it into the digest/wait output. - Add a REST timeline reader to detect a “requested but never picked up” state and surface it as
status=REQUEST_NOT_PICKED_UPwith exit code50. - Expand test coverage and update documentation/runbook to describe the new state and recovery steps.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| scripts/pr_review.py | Adds pending-request detection via GraphQL, pickup detection via REST timeline, and new wait/digest signaling with exit code 50. |
| scripts/test_pr_review.py | Extends fixtures and adds tests for pickup/stalled-request detection and pending-set reading. |
| scripts/README.md | Documents the new wait state, exit code 50, and the rationale for checking pickup events. |
| .github/copilot-instructions.md | Updates the runbook to include the “stuck pending request” state and the clear/re-request recovery flow. |
The comment and the README said the read avoids running per poll, and past the grace it ran on every one. A forty-minute wait against a picked up request turned one REST reader into thirty-two, which is the rate limit exposure the design was written to avoid, and the documentation described the design rather than the code. The read now waits an interval between readings, the grace serving as both the first delay and the spacing. One reading settles the request in front of it, and the next covers a request a push raises mid-wait, so the later readings are not redundant. A case counts the readings against a stepped clock and was watched failing at thirty-two against the bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/pr_review.py:96
timeline()usesgh api --paginatewithout increasingper_page, so on PRs/issues with long timelines it may require many REST requests per pickup check (default page size is 30). Since this runs repeatedly duringwait, it increases rate-limit exposure. Consider requesting the maximum page size to reduce the number of API calls.
r = subprocess.run(
['gh', 'api', '--paginate', f'repos/{owner}/{repo}/issues/{num}/timeline',
'--jq', '.[] | select(.event == "copilot_work_started" or .event == "review_requested")'
' | "\\(.event) \\(.created_at)"'],
capture_output=True, text=True)
`--paginate` walks the default page size of 30, so the reader's cost scales with how long a pull request has run rather than with what it reads. The 168-event timeline this state was found on takes six requests a reading at 30 and two at 100, against a reader that repeats for the life of a wait. Two cases cover it, one holding the request to the largest page and to being a read, the other holding a failed read to raising rather than returning no events, since an empty list reads as no request pending, which is the false clean this reader exists to close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Suppressed comments (1) from review #527 (review), confirmed and fixed in 93b3701.
Fixed in 93b3701. Correct, and it lands on the same nerve as the threaded finding from the previous round, which is what makes it worth more than a one-line change. Both are the reader costing more requests than its design admits: that one ran per poll instead of per interval, this one walks the pull request's whole history 30 events at a time. The interval fix bounded how often the reader runs and left how much it costs each time untouched. Measured on the pull request this state was actually found on rather than in the abstract. Two cases went in with it. One holds the request to the largest page and to being a read, asserting no Also verified live: the reader now returns 69 request and pickup events from that timeline and correctly reports the current request as picked up rather than stalled. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
scripts/pr_review.py:97
timeline()treats everyreview_requestedevent as a Copilot request. The REST timeline event payload includesrequested_reviewer, and other review requests (e.g., a human reviewer requested after Copilot) can occur later; using the newest request timestamp regardless of who was requested can incorrectly flagREQUEST_NOT_PICKED_UPeven if Copilot was picked up normally.
Filter review_requested events to only those where requested_reviewer.login is Copilot (or at least starts with copilot-pull-request-reviewer) before feeding them to never_picked_up().
['gh', 'api', '--paginate', f'repos/{owner}/{repo}/issues/{num}/timeline?per_page=100',
'--jq', '.[] | select(.event == "copilot_work_started" or .event == "review_requested")'
' | "\\(.event) \\(.created_at)"'],
scripts/pr_review.py:314
--pickup-graceaccepts negative values, which makesnext_pickupstay behindelapsedand effectively forces a timeline read on every poll iteration (reintroducing the per-poll REST-call pattern) due toelapsed > next_pickupalways being true. Reject negative values at argument-parse time.
ap.add_argument('--pickup-grace', type=int, default=300,
help='seconds before the first pickup read, and between reads (default 5m)')
a = ap.parse_args(argv)
owner, repo = a.repo.split('/', 1)
Every `review_requested` event counted as the reviewer's, so a human requested after a review landed became the newest request with no pickup after it, and a picked-up review reported as never picked up. That is a false stop rather than a missed one, on a pull request whose review is sitting there. The timeline spells the reviewer a third way, login `Copilot` with type `Bot`, against GraphQL's `copilot-pull-request-reviewer` and the `[bot]` suffix REST user objects carry. Filtering on either of those two selects nothing, so the predicate is the type plus a loose login match, and the runbook records the third form beside the other two. A case drives the real jq over a crafted timeline rather than asserting on the filter text, which would pass on a filter matching nothing. A negative `--pickup-grace` is also rejected, since it leaves the next reading behind the clock and reinstates the per-poll reads by argument. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Suppressed comments (2) from review #527 (review), both confirmed and fixed in 3766943. The earlier block still in the digest is answered above.
Fixed in 3766943, though not with the filter as suggested, and the difference is worth stating because the suggested one fails silently. The diagnosis is exactly right and the failure is the bad kind: requesting a human reviewer after a review lands makes that the newest request, nothing picks it up because nothing is meant to, and a picked-up review reports as The suggested predicate does not work here. The timeline spells the reviewer a third way: A filter keyed to either of the documented spellings selects nothing, so every request disappears, The case for it drives the real
Fixed in 3766943. Correct, and it is the previous round's finding walking back in through the argument parser: the interval bounds the reads only while the interval is positive. Rejected at parse time with a case covering it. |
…ckup-event tooling to main (#528) Promotes four commits to `main`. Two of them change **carried** files, so downstream repos read the stale text until this lands, which is what makes the promotion the delivery step rather than bookkeeping. ## What this delivers to the fleet **Carried rule text**, picked up by every repo on its next re-vendor: | PR | File and section | Change | | --- | --- | --- | | #526 | `AGENTS.md`, Context and Delegation Discipline (**verbatim**) | A wait separates three outcomes and says which one it reached: run the command in the foreground before backgrounding it, never let `\|\| echo '[]'`, `\|\| true` or `2>/dev/null` stand in for a failure, emit on failure, and bound the wait. The session rule also stops ending a session on a third review round, which read as license to leave a loop open while it was still producing defects | | #526 | `GOVERNANCE.md`, Verification Discipline (**verbatim**) | A launched process is not a result, and a cause nobody observed is not a diagnosis | | #526 | `GOVERNANCE.md`, PR Review Etiquette (**verbatim**) | Every finding ends in one of five actions rather than at a round count: fixed, disproven with proof the reviewer can read, deferred against a filed issue, declined with the maintainer's explicit answer, or fixed as a class where the code keeps earning it | | #520 | `CODESTYLE.md` and the prose gate | The comment rules the gate now reaches, carried to the fleet through a public composite action | | #526, #527 | `.github/copilot-instructions.md` | A quota or rate-limit answer is terminal rather than pending; a request pending with no pickup is a third state with a recovery recipe; three corrections below | **Runbook corrections**, each one a path an agent followed to a wrong answer this week: - `gh pr view --json reviewRequests` **omits a Bot reviewer entirely**, reporting an empty set while Copilot sits in it. This is how a live stall was misdiagnosed to the maintainer as no request having been made. - Removal was called impossible for want of a named mutation. `requestReviews` **replaces** the reviewer set when `union` is false, which is the clear half of the recovery, and it resolved a real thirteen-and-a-half-hour stall in 35 seconds. - The reviewer login has a **third** spelling. A timeline `review_requested` carries login `Copilot` with type `Bot`, against GraphQL's `copilot-pull-request-reviewer` and the `[bot]` suffix REST user objects add. A filter keyed to either documented form selects nothing there. **Hub-only tooling** (`scripts/`, not carried): `pr_review.py` gains exit `40` for a reviewer answer that carries no commit and exit `50` for a request nothing picked up, with the window and interval guards those needed. `prose_lint.py` and the gate queue changes from #520 and #522 ride along. ## Why the rules moved Three stalls in one day, each reported as waiting on the reviewer, none of them that. A CI watcher whose command did not exist on the installed `gh` and whose fallback turned every error into "nothing yet". A stall explained afterwards with a throttle that appears nowhere in the record. And a review request that was pending while nothing acted on it. The common shape is a wait that cannot tell "not yet" from "never", and a report of patience standing in for a reading nobody took. ## Verification `scripts/test_pr_review.py` 59 pass, `scripts/test_prose_lint.py` 153 pass, `scripts/test_repo_gate.py` 23 pass, `spec/audit.py --selftest` pass, `spec/validate.py` clean, `repo_gate.py` clean, both `prose_lint.py` invocations clean, `editorconfig-checker` clean. Both source pull requests were driven to a clean Copilot round: #526 over seven rounds and ten findings, #527 over four rounds and five findings, every one of the fifteen real and answered. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A live stall on another repository's pull request exposed a third wait state and two wrong readings behind it. The pull request had been sitting for thirteen and a half hours reading as waiting on the reviewer. The request was pending, and nothing was acting on it.
The state
Copilot raises
copilot_work_startedwithin about half a minute of accepting a request, and reviews a few minutes later. A request that never draws one stays that way indefinitely. From the reviews alone it is identical to a slow round, and elapsed time cannot separate them either, since a slow round also shows no review. Only the pickup event says whether anything is working.waitnow exits50on a pending request with no pickup after it, naming the request's timestamp. The pickup is read before the timeout, so the stall reports as itself rather than asPENDINGonce the clock runs out, and only after--pickup-grace(five minutes) rather than per poll, since inside that grace a pending request is simply a review in progress. It is the one reading here taken over REST, because no GraphQL timeline item carries the event.The two wrong readings
gh pr view --json reviewRequestsomits a Bot reviewer entirely. It returned an empty set while GraphQL returnedtotalCount: 1with the reviewer in it. This is what makes the state so easy to misdiagnose, and it did: on the strength of that projection the stall was reported to the maintainer as "no request was ever made", which was wrong. The pending set is now read through GraphQL.The runbook called removal impossible, on the grounds that no
removePullRequestFromReviewRequestmutation exists. That much is true and the conclusion drawn from it was not:requestReviewsreplaces the reviewer set whenunionis false, which the schema states outright, so an emptybotIdsclears the request. That is the clear half of the recovery, and the runbook had ruled it out.Recovery
Clear the request, then request again. The pull request UI offers no re-request control while a request is pending, and
union: trueadds a reviewer already in the set, which changes nothing. Both mutations are in the runbook with the caveat thatunion: falsereplaces the whole set, so the pending set is read first lest a human reviewer be dropped alongside the bot. Run against the stalled pull request, this drewcopilot_work_started35 seconds later, on a request that had produced nothing for thirteen and a half hours, and the review that followed raised a real finding.Recovery stays out of
pr_review.py, which holds its no-mutation contract: the digest names the state, and the mutations stay visible to the write guard and to review.Verification
The stuck-request case was watched failing against a build with the pickup read removed, where it runs the wait out and reports the stall as
PENDING. It fails rather than hangs, which took a second pass: the first version of the case spun for the whole timeout instead of failing, and a case that hangs gates nothing.scripts/test_pr_review.py54 pass,scripts/test_prose_lint.py153 pass,spec/audit.py --selftestpass,spec/validate.pyclean,repo_gate.pyclean, bothprose_lint.pyinvocations clean over the diff and over every touched file. The new digest was also run against two live pull requests, one with a pending request and one without.🤖 Generated with Claude Code