Make a wait report its own failure instead of reading as pending - #526
Conversation
A backgrounded wait emits nothing when its command is broken, which is exactly what a wait whose condition has not happened yet emits, so a watcher that stopped working reads as a watcher still working. One session polled `gh pr checks --json` against an installed gh that has no such flag, took the `|| echo '[]'` fallback on every error, and reported "still waiting" for 25 minutes while CI had been green for most of them. The rule text gains the failure clause it was missing, in AGENTS.md for the wait mechanics and in GOVERNANCE.md for the reporting: a launched process is not a result, and a cause nobody observed is not a diagnosis. Both sections are verbatim fleet content, so TODO.md records the re-vendor they owe. The Copilot runbook gains the terminal case. A response naming a quota or a rate limit ends the wait rather than extending it, since no formal review follows one and re-requesting does not change it, and the newest Copilot activity of any kind is read before another wait opens, because a wait still reporting "pending" against a landed review is a broken wait rather than a slow reviewer. `pr_review.py wait` implements that structurally rather than by matching a phrase: a reviewer comment postdating its newest review is an answer that satisfies no coverage check by design, so it exits 40 and prints the body whole for the maintainer to read. The timeout path prints the full digest, since a bare PENDING line reports a broken poll and a slow reviewer identically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Updates the PR-review wait/runbook tooling and governance docs so a “wait” can clearly distinguish met, pending, and cannot succeed outcomes—preventing broken watchers or terminal Copilot responses (e.g., quota refusal comments) from being misreported as “still pending”.
Changes:
- Extend
scripts/pr_review.py waitto detect “answered outside formal review” and exit40, and to print the full digest on timeout instead of a barePENDING. - Add focused unit tests covering terminal comment detection and the new reporting/exit behavior.
- Update carried governance/runbook text and record the required fleet re-vendor sweep in
TODO.md.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| TODO.md | Records fleet re-vendor follow-ups for carried verbatim sections and a downstream-pointer question. |
| scripts/test_pr_review.py | Adds tests for “answered outside review”, digest printing, timeout reporting, and exit 40. |
| scripts/README.md | Documents the new wait exit code 40 and explains why terminal comments are printed whole. |
| scripts/pr_review.py | Implements terminal-comment detection, includes comment metadata in GraphQL queries, and improves timeout output. |
| GOVERNANCE.md | Adds a verification-discipline rule clarifying that a launched process isn’t a result and unobserved causes aren’t diagnoses. |
| AGENTS.md | Adds the “three outcomes” clause for waits (met/pending/cannot reach) and avoiding fallbacks that suppress failures. |
| .github/copilot-instructions.md | Adds guidance to bound waits and treat quota/rate-limit replies as terminal rather than pending. |
Suppressed comments (1)
scripts/pr_review.py:60
- Same issue as in
Q_LIVE: limiting tocomments(last:5)can omit the reviewer’s newest terminal comment once the PR has moderate discussion, which prevents the digest from printing the body thatwaittells the user to read. Widen the window so the terminal comment is included when present.
comments(last:5){ nodes{ author{login} createdAt body } }
The queries read the newest comments rather than the reviewer's, since GraphQL offers no author filter, so ordinary discussion is what pushes an answer out of reach. At five comments a codecov post and two replies were enough, and the digest then reported no answer on a pull request carrying one, which is the false clean the field exists to prevent. A full window is reported as unknown rather than as no, because that is the one case where finding nothing and having nothing to find are the same reading, and a case holds the guard's number equal to the one the queries carry so a drift between them cannot read clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Suppressed comments (1) from review #526 (review), confirmed and fixed in b404164.
Fixed in b404164. Correct, and it is the same defect the inline thread raised against Measured against a real pull request rather than judged in the abstract: PhotoCleaner#35 carries 17 issue comments, so a codecov post plus two maintainer replies after a refusal would have buried it under the old window. Both queries now read 100. Widening on its own would leave the class open, so the guard lands with it. GraphQL has no author filter on |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (4)
scripts/pr_review.py:163
digest()can raise if a review thread’s first comment hasauthor: null(GitHub GraphQL can return null authors for deleted accounts). Here.get('author', {})returns None when the key exists but is null, and the chained.get('login')will crash the digest/wait.
unresolved = [t for t in threads
if not t['isResolved']
and ((t.get('comments') or {}).get('nodes') or [{}])[0]
.get('author', {}).get('login') == REVIEWER]
scripts/pr_review.py:195
- The
COPILOT COMMENTmarker text states or strongly implies a quota/rate-limit refusal, but the code doesn’t inspect the comment body. Rephrase this as conditional so the digest doesn’t diagnose a cause it hasn’t actually detected.
lines.append(f' COPILOT COMMENT ({answer.get("createdAt")}, newer than any review): '
'read it before waiting again, since a quota or rate-limit refusal is '
'terminal and no review follows it')
scripts/pr_review.py:268
waitprints a status line that frames the outcome as a quota/rate-limit refusal, but exit 40 is triggered by “answered outside review” regardless of the comment’s content. Make the message conditional on the comment actually being a refusal to avoid misdiagnosis.
print('status=ANSWERED_OUTSIDE_REVIEW read the comment above, '
'a quota or rate-limit refusal is terminal and re-requesting does not clear it')
scripts/pr_review.py:91
- The
answered_outside_review()docstring currently states Copilot comments outside a review happen “when it will not review at all”, but this function only compares timestamps and does not validate the reason/content. Consider rewording to avoid asserting a specific cause that isn’t actually checked.
This issue also appears in the following locations of the same file:
- line 193
- line 267
Copilot answers a request with a comment rather than a review when it will not review at all,
a quota refusal among them, and that answer satisfies no coverage check by design.
Reading it as an unmet condition is what turns a refusal into a wait with nothing at its end.
Three of the messages around exit 40 named a quota or rate limit as the
reason the reviewer answered without reviewing, while the code compares
timestamps and reads no word of the body. That is the rule this same
branch adds to GOVERNANCE.md, that a cause nobody observed is not a
diagnosis, written into the tool that reports it.
The marker, the status line, the docstring and the module header now
state what is known: a comment carries no commit, so it covers no head
whatever it says, and the reader decides whether it is a refusal, which
is terminal, or a remark, which is not. The class is swept rather than
the three instances, so `scripts/README.md` gains the same distinction.
A deleted account also crashed the digest. GraphQL sends `author` present
and null, which `.get('author', {})` returns as None rather than as the
default, so the chained lookup raised and took the whole wait with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Suppressed comments (4) from review #526 (review), all four confirmed and fixed in c24c2fc. The one remaining block in the digest is the earlier round's, already answered above. Three of the four are one defect, so they are answered together, and the sweep is why they are worth stating as a class.
Fixed in c24c2fc. Correct on all three, and the finding is sharper than it looks. This branch adds a rule to The messages now state what is actually known: a comment carries no commit, so it covers no head whatever it says, and the reader decides whether it is a refusal, which is terminal, or a remark, which is not. Swept as a class rather than as three instances, so the module header and
Fixed in c24c2fc. A real crash, reproduced before fixing: with a null author on a thread, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/pr_review.py:179
answered_outside_reviewcan only be "unknown" due to a saturated window when no reviewer-authored comments are present in the fetched window. If the reviewer has any comment in the window (even a spent/older-than-latest-review one), then any older reviewer comment must be even earlier and therefore cannot be newer than the latest review; in that case the correct summary isanswered_outside_review=no, notunknown.
answer = answered_outside_review(pr)
answered = 'yes' if answer else ('unknown' if answer_window_saturated(pr) else 'no')
The saturation guard fired on any full window, where only a full window carrying none of the reviewer's own comments is actually unreadable. Comments arrive in creation order, so anything behind the window is older than everything inside it, and one spent reviewer comment in view proves every hidden one is spent too. That case is a provable no. The guard was broader than the line it prints, which already said the newest comments carry none from the reviewer. An unknown a reader can disprove from the digest in front of them is what teaches them to skim the field, so the field says unknown only where the question is open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Suppressed comments (1) from review #526 (review), confirmed and fixed in 4b6bcdb. The two earlier blocks still in the digest are answered above, in the comments closing reviews 4840586518 and 4840604036.
Fixed in 4b6bcdb. The proof holds and I checked it rather than taking it: The guard was also broader than the line it prints, which already read "the newest N comments carry none from the reviewer". The code did not require that, so the message described the narrower rule the code should have had. It does now: Worth naming why this one matters beyond correctness. An A case covers the settled shape, a full window whose single reviewer comment is spent, alongside the existing one for the genuinely open shape. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/pr_review.py:114
answer_window_saturated()treatslen(comments) >= COMMENT_WINDOWas “window is full so older comments may exist”, but withcomments(last:100)you can’t distinguish “exactly 100 total comments” from “more than 100” unless you also fetchpageInfo.hasPreviousPage/totalCount. In the exactly-100 case (no older comments), this will reportanswered_outside_review=unknowneven though there cannot be an older Copilot comment. Consider extending both GraphQL queries to requestcomments(last:...){ nodes{...} pageInfo{hasPreviousPage} }(ortotalCount) and only returningTruewhen the window is full and there are earlier pages.
comments = (pr.get('comments') or {}).get('nodes') or []
return len(comments) >= COMMENT_WINDOW and not reviewer_nodes(pr, 'comments')
A full window and a window holding every comment the pull request has are the same length, so the node count cannot tell a gap from a complete reading, and a pull request with exactly the window's worth of comments reported unknown while nothing was hidden. `pageInfo.hasPreviousPage` answers precisely the question the count was standing in for. A case holds both queries to asking for it, since a query that stops asking reports no rather than unknown, which is the silent narrowing one level up from the one the field exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The session rule ended a session on a third review round, which reads as license to leave a loop open while it is still producing real defects, and it fires on nearly every pull request now that rounds routinely run past ten. A count measures how much was found rather than whether the work is done. The style heuristic counted rounds the same way, licensing a rule change only at three. Both are replaced by the four outcomes a finding can actually end in: fix it, disprove it in the thread with proof the reviewer can read so it stops raising the same thing, get the maintainer's explicit answer where it is real and deliberately unfixed, or fix the class where the code keeps earning the same finding. That last one is why a comment carrying the non-obvious why is a legitimate remedy rather than an appeasement, under the same comment rules as any other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Suppressed comments (1) from review #526 (review), confirmed and fixed in bced438. The three earlier blocks in the digest are answered above, closing reviews 4840586518, 4840604036 and 4840621353.
Fixed in bced438, taking the Worth noting what this means about the guard's own history. It was added last round to close a false clean, was itself too broad in the round after, and was measuring the wrong quantity in this one. Each round narrowed it toward the single unreadable case that remains: comments sit behind the window and none in view are the reviewer's. Everything else is now decidable and says so. A case holds both queries to asking for |
A finding worth doing later had no honest close. The four outcomes forced it into a decline it does not deserve or into silence, and a deferral recorded in a thread is lost the moment the pull request merges. Filing the issue first and replying with its link is what makes the deferral exist rather than be intended. The boundary matters more than the outcome. It covers work the change did not create, an adjacent defect noticed in passing or a fix too large to ride along, and never a defect in the change under review, since filing an issue about a bug about to merge is the deliberate-decline outcome wearing other clothes and that one is the maintainer's to decide. TODO.md queues the size question this loop raised. Five rounds each found something new, which reads as a large change earning a different finding every round where a small one converges, and the review history already carries the data to test it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
scripts/pr_review.py:66
- Same truncation risk as in
Q_LIVE:reviews(last:20)can omit the reviewer’s most recent review when there are 20+ newer reviews, which can makedigest()andanswered_outside_review()reason about the wrong ‘newest review’. Consider widening this window to matchCOMMENT_WINDOW.
headRefOid mergeable mergeStateStatus
reviews(last:20){ nodes{ author{login} state commit{oid} submittedAt body } }
reviewThreads(first:100){ nodes{ id isResolved
comments(first:1){ nodes{ author{login} path line body } } }}
comments(last:100){ nodes{ author{login} createdAt body } pageInfo{ hasPreviousPage } }
}}}
scripts/pr_review.py:54
reviews(last:20)can drop the reviewer’s most recent review if the PR has 20+ newer (human) reviews. That makeslive_state()misreportdone=Falseand can also trigger a falseanswered_outside_review(becauselatest_reviewbecomes empty when no reviewer reviews are in the slice). Widen the review window so the reviewer’s latest review is very unlikely to fall out of view.
This issue also appears on line 61 of the same file.
headRefOid
reviews(last:20){ nodes{ author{login} state commit{oid} submittedAt } }
comments(last:100){ nodes{ author{login} createdAt } pageInfo{ hasPreviousPage } }
}}}
The review window carried the same truncation the comment window had, and consuming it for the answer baseline made it load-bearing. Twenty reviews is inside reach of a long pull request, and once the reviewer's own fall out of view the baseline is empty, which dates every comment as newer so each one reads as an answer. That is a wrong terminal rather than a missed one: it stops a loop on a pull request whose review landed. Both windows widen to a shared WINDOW and both connections carry `hasPreviousPage`, so one guard covers all four. Blind on comments leaves the question open and reports unknown. Blind on reviews leaves it unaskable, so nothing is reported and the wait keeps polling, since a wait that runs on is visible where a wrong terminal is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Suppressed comments (2) from review #526 (review), both confirmed and fixed in 17e65e8. They are one defect on two lines, so they are answered together. The four earlier blocks are answered above.
Fixed in 17e65e8, and the second half of the first finding is the one that matters, so it is worth separating from the widening it is bundled with.
Twenty is also well inside reach: PhotoCleaner#35 carries more than thirty reviews, so this is a live path rather than a theoretical one. Both windows now share one A case covers each side, and the reviews-blind case was watched failing against the unguarded expression, where it returned the comment as an answer exactly as described. A contract case holds all four windows to the same constant and to asking for |
…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)
Closes #524. Closes #525.
Both issues describe the same defect from two sides: a wait has no failure case, so "broken" and "not yet" are the same observation.
What the record shows
#525 reports a Copilot quota refusal that leaves the coverage check waiting forever. The refusal is not in the record. On the pull request that prompted it, the timeline reads
review_requested 00:46:48Zthenreviewed 00:48:54Z, so the review landed on the current head about two minutes after the request, and Copilot posted no comment of any kind. No quota or rate-limit message appears on the other repository's open pull request either. The stall it describes is the broken watcher of #524, with an external cause attached to it afterwards.That shapes the fix. The issue's suggested clause is right and is adopted, because a real quota refusal would behave exactly as it describes. The detection is not built on the wording it reports, since no sample of that wording exists.
The changes
AGENTS.md, the wait mechanics. A wait separates three outcomes and says which one it reached. Run the command once in the foreground and read it before backgrounding it. Never let|| echo '[]',|| true, or2>/dev/nullstand in for a failed command, which is the suppression the write-safety rules already forbid on a mutation. Emit on failure as loudly as on success, and bound the wait.GOVERNANCE.md, the reporting half, next to "a green check is not evidence the work happened". A launched process is not a result, and a cause nobody observed is not a diagnosis. Naming a throttle that appears nowhere in the record turns a local defect into a story about someone else and closes the investigation on the wrong party..github/copilot-instructions.md. A quota or rate-limit answer is terminal rather than pending, because no formal review follows one and re-requesting does not clear it. Before another wait opens, the request is compared against the newest Copilot activity of any kind, since a wait still reporting "pending" against a landed review is a broken wait rather than a slow reviewer.scripts/pr_review.py.waitexits40when the reviewer answers with a comment postdating its newest review, which is the shape a refusal takes and satisfies no coverage check by design. The comment prints whole, because its wording is the only thing separating a refusal from an ordinary remark. The timeout path prints the full digest, since a barePENDINGline reports a broken poll and a slow reviewer identically.Both edited rule sections are carried
verbatim, soTODO.mdrecords the fleet re-vendor they owe, alongside a second entry forGOVERNANCE.mdpointing atscripts/pr_review.py, a path the repos carrying that section do not have.Verification
The new cases were watched failing against a build with the detection removed. One of them hung for the full default wait rather than failing, which is the bug itself and a case that gates nothing, so it now runs against a zero timeout.
scripts/test_pr_review.py37 pass,scripts/test_prose_lint.py153 pass,scripts/test_repo_gate.py23 pass,spec/audit.py --selftestpass,scripts/repo_gate.pyclean, bothprose_lint.pyinvocations clean over the diff and over every touched file,spec/validate.pyclean,editorconfig-checkerclean. Three pre-existingcomment-wrapviolations in the touched script are corrected under the correct-as-next-edited rule.🤖 Generated with Claude Code