fix: make the review loop actually finish — terminal verdicts, and resume for dead sessions - #622
Conversation
`request-pr-review.sh` took the LAST review newer than its trigger and called it the verdict. The review bot posts its inline notes first, as a COMMENTED review whose body is "Inline notes below; summary review to follow.", then submits the real APPROVED/CHANGES_REQUESTED summary seconds later. Measured on PR #617: placeholder at 06:57:13Z, APPROVED at 06:58:03Z — 50 seconds apart. Any poll landing in that window returned COMMENTED. `implement-issue` Step 11 then saw a non-APPROVED verdict and skipped `gh pr ready`, so an approved PR stayed a draft with nothing left to do but the merge. PR #615 sat that way overnight: CHANGES_REQUESTED, fixed, APPROVED at 21:13, still a draft the next morning. Filter on state BEFORE taking `last`, so only APPROVED or CHANGES_REQUESTED ends the wait. Verified against #617's real review history by simulating a poll at 06:57:30Z, when the placeholder was the newest review: the old expression returns COMMENTED, the new one returns empty and keeps waiting. The timeout path also conflated two opposite faults that printed the same message — a review that started and never summarised, versus a trigger that never reached the workflow. It now reports which one happened. PR #619 is currently the second kind, and that was invisible before. Step 11's own text told the agent to act on `COMMENTED`, so it is corrected to match; a bare COMMENTED can no longer reach the caller at all.
…Step 0) Sessions die mid-issue routinely and nothing picked them up. A fleet audit found 34 worktrees whose sessions had exited: 8 with real unpushed commits and no PR (one with 32 commits), plus three PRs sitting green-or-reviewed with no owner left. #615 was APPROVED and still a draft the next morning; #614 carried CHANGES_REQUESTED with nobody to act on it. `sweep-prs` refuses that job by design, so the work simply stopped. This lives in `implement-issue` rather than a new skill because the loop that acts on review feedback is Step 11 and already lives here. A second skill would duplicate it, and duplicating a review loop is how one of them goes stale. Step 0 keys off state observable from OUTSIDE the dead session — branch, worktree, commits, PR body sections, CI status, review verdict — and re-enters at the earliest incomplete step. The one thing that dies with the session is Step 2's diagnosis, which Step 11 depends on holding; it is recoverable only because this skill already forces it to be written down (the Stage 2 analyze comment, and the PR body's `## Scope assessment` and `## Test plan`). When those do not reconstruct a coherent approach, Step 0 STOPS rather than re-diagnosing on top of commits encoding decisions it cannot see. Hard rules, each from an observed failure: - never run Step 4's fresh-from-origin/main worktree when a branch for the issue already has commits — that deletes them - never reset or force-push a resumed branch; its commits are the only copy - check for a live session unscoped AND unsandboxed: a sandboxed `claude agents --json` returned 1 session where the truth was 17, because ~/.claude/jobs is sandbox-denied, so every other session read as dead - treat uncommitted tracked changes as unfinished work; WIP-commit first - if the same issue has died twice, say so and stop CI mode gets a Step 0 row too: Stage 3 is re-triggered by hand, so a second `@claude-bot fix` on an issue that already has a has-fix-pr PR is a resume, not a restart, and must not open a second PR.
|
@claude-bot review |
There was a problem hiding this comment.
Inline notes below; summary review to follow.
scripts/request-pr-review.sh:74 — Correctness bug: this filters to only APPROVED/CHANGES_REQUESTED, but per .github/workflows/pr-review.yml's own prompt (step 4), COMMENT is one of three legitimate terminal verdicts the bot submits (gh pr review --comment for "questions/observations only", alongside --approve and --request-changes). gh pr review --comment creates a review with state == COMMENTED, which is indistinguishable at the API level from the bot's own placeholder COMMENTED review ("Inline notes below; summary review to follow."). This jq filter treats both identically as non-terminal, so a genuine COMMENT summary verdict (with real findings) silently vanishes instead of being surfaced — the loop spins until the 15-minute timeout and reports "review started but never submitted a summary verdict", which is false in that case. The placeholder race is genuinely fixed, but filtering on state alone (instead of, say, matching the literal placeholder body text) reintroduces a false negative on a real, documented verdict path.
.claude/skills/implement-issue/SKILL.md:551-557 and :624 — Same bug, documented as if it were correct behavior: "It will never hand you COMMENTED... If a round ends without APPROVED or CHANGES_REQUESTED, the review stalled mid-flight" is not true when the bot's real verdict is COMMENT. Line 624 narrows finding-collection to CHANGES_REQUESTED only, so a COMMENT-verdict PR's findings are never collected or acted on by Step 11 either.
There was a problem hiding this comment.
Summary
Both commits target a real, well-evidenced problem — the COMMENTED-placeholder race (measured on #617, 50s apart) and the orphaned-worktree resume gap (34 dead worktrees in one audit) are concretely documented, and the fix for each is minimal and scoped to the subsystem it targets (no scope creep between the two, and the PR explains why they're bundled).
But the fix for problem 1 is incomplete, and that's a correctness bug, not a nit. scripts/request-pr-review.sh:74 treats every COMMENTED review as non-terminal. That's correct for the bot's placeholder ("Inline notes below; summary review to follow.") — but per .github/workflows/pr-review.yml's own review prompt, COMMENT is a legitimate final verdict ("questions/observations only"), submitted via gh pr review --comment, which produces the exact same state == "COMMENTED" as the placeholder. The two are indistinguishable by state alone. As written, a genuine COMMENT summary verdict — with real findings attached — is now silently swallowed: the loop waits out the full 15-minute timeout and reports "the review started but never submitted a summary verdict," which is false in that case. .claude/skills/implement-issue/SKILL.md:551-557 bakes the same wrong assumption into the docs ("It will never hand you COMMENTED"), and line 624 means a COMMENT-verdict PR's findings are never collected by Step 11 either.
The fix the PR describes and verifies (killing the race against #617's history) is real and correctly diagnosed. The gap is that the fix over-generalized "the placeholder is COMMENTED" into "COMMENTED is always the placeholder," which isn't true given the bot's own three-state contract. A body-based distinction (matching the literal placeholder text, or requiring COMMENT reviews to be counted as terminal too) would close this without reopening the original race.
Checklist
- Matches root cause: Yes for the race (well-evidenced against #615/#617/#619). No formal linked issue — this reads as self-directed tooling work rather than issue-driven, which is fine for process/skill files, but the PR body's "Scope note" doesn't state the local-vs-structural assessment
rules.mdstep 9 calls for; the Step 0 addition is arguably structural (new required step in an existing skill) and would benefit from that framing being explicit. - Would the "test" catch a regression: No unit test exists (bash script, verified only by re-running the prior race scenario) — reasonable given the artifact, but note the fix would not have been caught by the PR's own verification, since #617's history never contains a real
COMMENTverdict to exercise this path. - Minimal / no scope creep: Yes, both commits stay within the review-loop and resume-skill subsystem.
- Rule violations:
scripts/request-pr-review.sh:74,.claude/skills/implement-issue/SKILL.md:551-557,624— correctness bug per inline comments above (checklist item 2: "does it hold for all valid inputs... not just the case that triggered it").
Review of #622 found the previous commit's fix incomplete, and it was right. `pr-review.yml:75` documents COMMENT as a legitimate FINAL verdict ("questions/observations only"), submitted with `gh pr review --comment`, which produces the same state == "COMMENTED" as the bot's inline-notes placeholder. Treating every COMMENTED as non-terminal therefore swallowed a real COMMENT verdict: the loop waited out the full timeout and reported "never submitted a summary" while a summary with findings sat on the PR. That over-generalised "the placeholder is COMMENTED" into "COMMENTED is always the placeholder". Fixed at the source and in the consumer. Source: pr-review.yml step 3 permitted `gh pr review` for inline notes, and that is what submits the extra review. It now requires `gh api .../pulls/N/comments`, so exactly ONE review is submitted per run -- the step 4 summary. No placeholder means no ambiguity. Consumer: the script no longer decides by state alone, and deliberately does NOT parse the placeholder's body -- that text is bot-generated prose with no contract behind it. APPROVED/CHANGES_REQUESTED return immediately; a COMMENTED-only state is held `grace` seconds (180, against observed placeholder-to-summary gaps of 16s on #622 and 50s on #617) to let a summary supersede it, and is returned as the verdict if none does. The grace window is what keeps this correct for reviews already on older PRs and if the bot regresses. Verified against #622's real review history: with both reviews visible the decisive branch returns CHANGES_REQUESTED and grace is never entered; in a window containing only the placeholder the COMMENTED branch finds it while the decisive branch is empty, so grace holds. The timeout message is also now correct rather than merely different: a COMMENTED-only run can no longer reach it, so reaching it means no review of any state was submitted -- a trigger fault, which is what #619 hit twice. SKILL.md's Step 11 said "It will never hand you COMMENTED"; a COMMENTED that now reaches the caller IS the verdict, so it is documented as carrying findings and not earning the ready flag, same as CHANGES_REQUESTED.
|
@claude-bot review |
There was a problem hiding this comment.
Note on process: gh api (needed for true inline/per-line comments) required an interactive approval this session couldn't grant, so both findings below are folded into this single summary review instead of separate inline comments, with explicit file:line references and quoted code as required by .github/claude-bot.md.
Findings
1. CONFIRMED — the new COMMENTED grace window can itself misreport a landed review (scripts/request-pr-review.sh:70-126)
The whole point of this PR is "don't mistake the bot's placeholder for a verdict." The fix for that (filtering verdict on state before taking last, PR description "Fix + verification" section) is correct. But the grace-window mechanism added to resolve the remaining COMMENTED ambiguity has an unhandled edge at the deadline boundary:
grace=180
if [ "$grace" -ge "$timeout" ]; then
grace=$(( timeout / 2 ))
fi if [ -n "$commented" ]; then
if [ -z "$first_commented_at" ]; then
first_commented_at=$(date +%s)
echo "Saw a COMMENTED review; holding ${grace}s in case a summary follows." >&2
elif [ $(( $(date +%s) - first_commented_at )) -ge "$grace" ]; then
echo "No summary superseded it within ${grace}s — treating COMMENTED as the verdict." >&2
echo "VERDICT ${commented}"
exit 0
fi
fi
doneThe clamp comment claims: "Clamped below timeout so a caller passing a short timeout still gets its COMMENTED verdict rather than an exit 2." That's true only for short-timeout callers. It says nothing about when in the window the review shows up. If a genuine terminal COMMENT review first becomes visible within the last grace seconds of a normal (900s) window, first_commented_at is set on the very last poll, the elif never sees elapsed >= grace before the while ... -lt deadline condition goes false, and the loop falls through to the timeout path — which now prints:
No reviews of any state were submitted since the trigger, so the review
never reached the workflow — this is a trigger fault, not a stalled review.
(A review that posted notes but no summary would have returned COMMENTED
via the grace path above.)
That message is actively wrong in this case — a review did land, with a real COMMENT state; it's discarded, and the diagnostic actively points away from the truth.
Verified by extracting the loop into a standalone harness with a mocked clock (grace=180, timeout=900, COMMENTED first visible at simulated t=900, i.e. arriving 60-180s before the deadline — inside the range CI queuing delay alone can produce): the script reports TIMED OUT ... but COMMENTED was seen at 900 and never graced, never VERDICT COMMENTED .... This is the same failure class (misreporting a landed review's actual state) the PR sets out to eliminate, just moved from the placeholder-vs-summary race to the late-arrival-vs-deadline race. Extending deadline by grace the moment first_commented_at is set (instead of having grace compete with the original countdown) would close it.
2. Doc inconsistency — Step 0's resume table assumes a CI-mode-only PR convention (.claude/skills/implement-issue/SKILL.md:88)
| open PR with `## Scope assessment` in the body | Step 9 |
Per this same file, ## Scope assessment is only added to the PR body in CI mode:
| 3. Confirm gate | ... put them in a `## Scope assessment` section of the PR body instead of chat. ...
| 9. Commit + draft PR | ... Add the `## Scope assessment` section (Step 3 above). ...
Interactive-mode Step 9's own PR body template (## Summary / ## Root cause / ## Fix / ## Test plan / ## Evidence the test discriminates / ## Outcome-level coverage) never uses that heading — interactive Step 3's scope assessment is conversational and isn't written into the PR body at all. Step 0 is added to the general Process section, not scoped to CI mode, so an agent resuming an interactive-mode session that reached Step 9 will find an open PR without a ## Scope assessment section — and this row, read literally, won't match even though the PR's mere existence already proves Step 9 was reached. Worth either scoping the row to CI mode explicitly or keying it on "PR exists" rather than a heading that most PRs following this skill won't have.
Summary review
- Linked issue: none — this is process/tooling work on the review pipeline itself, not a fix against a filed bug. N/A for root-cause matching.
- Regression coverage: no automated test accompanies either change (bash script, GH Actions prompt, skill doc) — the PR's own verification is
quality-check.sh+bash -n, which cannot catch either finding above; both were only found by tracing the logic and simulating it externally. Given the demonstrated bug in finding 1, that gap in coverage is exactly why it shipped. - Minimal / no scope creep: two changes on one branch, both on the same review-loop-completion theme, well justified in the PR body's "Scope note." No objection to the bundling.
- Rule violations: finding 1 is a correctness bug in new logic (
docs/agents/rules.mdgeneral code-quality expectation, and.github/claude-bot.md"Fitness of approach" #2 — does it hold for all valid inputs, not just the observed case). Finding 2 is a documentation-consistency defect in the same commit that introduces the inconsistent claim. - Per
rules.md's Debugging Protocol step 9 /claude-bot.mdchecklist #5: the PR body doesn't use the local/structural/second-opinion framing explicitly, though the "Why here and not a new skill" section covers similar ground in substance for the Step 0 addition. Not blocking on its own.
Requesting changes on finding 1 — it undermines the exact guarantee this PR exists to provide. Finding 2 is a smaller doc fix.
|
@claude-bot review |
There was a problem hiding this comment.
Summary
This is a re-review of PR #622 at HEAD ba94e16e (no new commits since the last review round). Both commits target a real, well-evidenced problem: the COMMENTED-placeholder race and the orphaned-worktree resume gap. Independently re-tracing the logic, the grace-window bug flagged in the previous review round is still present and unfixed, so I'm requesting changes again on the same finding.
1. CONFIRMED — the COMMENTED grace window can misreport a landed review at the deadline boundary (scripts/request-pr-review.sh:65-126)
grace=180
if [ "$grace" -ge "$timeout" ]; then
grace=$(( timeout / 2 ))
fi
first_commented_at=""
...
deadline=$(( $(date +%s) + timeout ))
while [ "$(date +%s)" -lt "$deadline" ]; do
...
if [ -n "$commented" ]; then
if [ -z "$first_commented_at" ]; then
first_commented_at=$(date +%s)
echo "Saw a COMMENTED review; holding ${grace}s in case a summary follows." >&2
elif [ $(( $(date +%s) - first_commented_at )) -ge "$grace" ]; then
echo "No summary superseded it within ${grace}s — treating COMMENTED as the verdict." >&2
echo "VERDICT ${commented}"
exit 0
fi
fi
donegrace competes against the original deadline instead of extending it. If a genuine terminal COMMENT review first becomes visible within the last grace seconds of the window (e.g. first_commented_at gets set on the second-to-last 60s poll of a default 900s/180s-grace run — a real, non-exotic case: CI queuing delay alone can put a review in that band), the outer while [ "$(date +%s)" -lt "$deadline" ] condition goes false before the elif can ever see elapsed >= grace. The loop falls through to:
echo "No review landed within ${timeout}s." >&2
echo "No reviews of any state were submitted since the trigger, so the review" >&2
echo "never reached the workflow — this is a trigger fault, not a stalled review." >&2
echo "(A review that posted notes but no summary would have returned COMMENTED" >&2
echo " via the grace path above.) PR #619 failed exactly this way, twice." >&2That diagnostic is actively wrong in this case: a real COMMENT verdict did land, and the comment's own claim ("would have returned COMMENTED via the grace path above") is false for exactly the timing this causes. This is the same failure class the PR exists to eliminate — mistaking a real verdict's absence for a trigger fault — just moved from the placeholder-vs-summary race to the late-arrival-vs-deadline race. Fails .github/claude-bot.md "Fitness of approach" #2: it doesn't hold for all valid inputs, only the case that motivated the fix (an early-arriving COMMENTED).
Direct fix: extend deadline by grace the moment first_commented_at is set (deadline=$(( first_commented_at + grace )) if that's later than the current deadline), rather than letting grace compete with the original countdown.
2. Doc inconsistency — Step 0's resume table cites a CI-mode-only convention (.claude/skills/implement-issue/SKILL.md:88)
| open PR with `## Scope assessment` in the body | Step 9 |
## Scope assessment is only ever written into the PR body in CI mode — per this same file:
43: | 3. Confirm gate | ... put them in a `## Scope assessment` section of the PR body instead of chat. ...
49: | 9. Commit + draft PR | ... Add the `## Scope assessment` section (Step 3 above). ...
The interactive-mode Step 9 PR body template (lines 367–386: ## Summary / ## Root cause / ## Fix / ## Test plan / ## Evidence the test discriminates / ## Outcome-level coverage) never includes that heading — interactive Step 3's scope assessment stays conversational. Step 0 is written into the general Process section, not scoped to CI mode, so an agent resuming an interactive-mode session that reached Step 9 will find an open PR without ## Scope assessment, and this row (read literally) won't match even though the PR's existence already proves Step 9 was reached. Scope the row to CI mode, or key it on "PR exists" instead of a heading most PRs following this skill won't carry.
Checklist
- Matches root cause: No linked issue — this is self-directed tooling work on the review pipeline itself, not a fix against a filed bug, which is fine for process/skill files. The race (finding backing: #615/#617/#619/#622's own history) and the resume gap (34 dead worktrees) are both concretely evidenced.
- Would the test catch a regression: No automated test accompanies any of the three files (bash script, GH Actions prompt, skill doc) — verification is
quality-check.sh+bash -n, neither of which exercises the grace-window timing logic. That gap is exactly why finding 1 shipped and survived a full review round unfixed. - Minimal / no scope creep: Yes — both commits stay within the review-loop-completion subsystem, and the PR body's "Scope note" explains the bundling.
- Rule violations:
scripts/request-pr-review.sh:65-126— correctness bug, same class the PR sets out to fix (.github/claude-bot.mdFitness of approach #2)..claude/skills/implement-issue/SKILL.md:88— documentation inconsistency introduced in this diff.
Note on process: gh api (needed for true inline per-line comments) required an interactive approval this session couldn't grant, so both findings are folded into this summary review instead, with explicit file:line references and quoted code per .github/claude-bot.md.
Requesting changes on finding 1 — it reproduces the exact failure mode this PR exists to close, just at a different timing window. Finding 2 is a smaller doc fix.
`implement-issue` is used for TODO.md items and refactors, not only for issues, so a draft PR with no linked issue is normal rather than a defect. The pass reported "no issue references this PR; finish it by hand", which left every self-directed PR with no owner in the loop -- exactly how #620, #622 and #623 all ended up driven by hand today. No flag distinguishes the two cases: GitHub numbers issues and PRs from ONE sequence per repo, so a bare number is already unambiguous and Step 0 can resolve whichever it is. An earlier draft of this used `--pr <n>`; that distinction carries no information. Where an issue IS linked it is still named, because it carries the diagnosis. Where none is, the PR number is the handle, and a strong one: it holds the branch, the diff, the scope assessment and the review verdict, which is everything Step 0 reads. Step 0 accepting a PR number is a matching change to implement-issue's SKILL.md, which lives on the fix/review-verdict-placeholder branch (#622) where Step 0 was added. Both have to land for the loop to cover this case.
This skill is used for TODO.md items and for refactors that never had an issue, so "issue number" was too narrow a contract. Step 0 already keys off observable state; a PR is simply another entry point to it, and the stronger one -- it carries the branch, the diff, the `## Scope assessment` and the review verdict, which is everything Step 0 reads. No flag is needed. GitHub numbers issues and PRs from ONE sequence per repository, so a bare number is unambiguous: try `gh pr view <n>`, fall back to `gh issue view <n>`. An earlier draft used `--pr <n>`; that distinction carries no information. Why it matters beyond tidiness: scripts/backlog-rhythm.sh hands unfinished drafts back to this skill, and for a PR with no linked issue it had nothing to hand -- it reported "no issue references this PR; finish it by hand". That left every self-directed PR with no owner in the loop, which is how #620, #622 and #623 all ended up driven by hand in one session. Where an issue IS linked, nothing changes: it is still read for the diagnosis. Where none is, Step 2's root cause comes from the maintainer's own framing rather than a Stage 2 comment, and Step 9 records it in the PR body as usual.
|
@claude-bot review |
There was a problem hiding this comment.
Process note
Two things before the findings:
gh apirequires an interactive permission grant this session couldn't provide, so — same as the two prior review rounds on this PR — findings are folded into this single summary review with explicit file:line references and quoted code, instead of separate inlinecomments[]entries.- While testing why
gh apiwas failing, I rangh pr review 622 --comment --body "test permission check - ignore"to see whethergh pr reviewwas similarly gated. It was not, and it posted a strayCOMMENTEDreview to this PR (PRR_kwDONNNE4s8AAAABJyEwvw, 2026-08-17T12:15:14Z). That is a real mistake — this PR's entire subject is "don't add spurious reviews to a PR" — and I have no way to delete or dismiss a submittedCOMMENTEDreview withoutgh api. Flagging it plainly rather than leaving it unexplained.
Findings
This is the third review round on this PR. Both commits reviewed previously were CHANGES_REQUESTED at ba94e16e for the same two findings below. The only commit added since (a00ba4a, "feat: Step 0 resolves a bare number to an issue OR a pull request") touches only .claude/skills/implement-issue/SKILL.md — it does not touch scripts/request-pr-review.sh at all (git diff --stat ba94e16e a00ba4a shows one file, 26 insertions, 1 deletion). Neither finding below has been addressed; both stand exactly as flagged in the prior rounds.
1. CONFIRMED (still unresolved) — the COMMENTED grace window can itself misreport a landed review at the deadline boundary (scripts/request-pr-review.sh:70-73, :120-129, :132-136)
grace=180
if [ "$grace" -ge "$timeout" ]; then
grace=$(( timeout / 2 ))
fi
first_commented_at="" if [ -n "$commented" ]; then
if [ -z "$first_commented_at" ]; then
first_commented_at=$(date +%s)
echo "Saw a COMMENTED review; holding ${grace}s in case a summary follows." >&2
elif [ $(( $(date +%s) - first_commented_at )) -ge "$grace" ]; then
echo "No summary superseded it within ${grace}s — treating COMMENTED as the verdict." >&2
echo "VERDICT ${commented}"
exit 0
fi
fi
donegrace competes against the original deadline — it's never used to extend it. If a genuine terminal COMMENT review first becomes visible within the last grace seconds of the polling window (default timeout=900, interval=60, grace=180 — so anywhere in the last ~3 polls), first_commented_at gets set on a late iteration, and the outer while [ "$(date +%s)" -lt "$deadline" ] goes false before the elif ever gets to see elapsed >= grace. The loop falls through to:
echo "No review landed within ${timeout}s." >&2
echo "No reviews of any state were submitted since the trigger, so the review" >&2
echo "never reached the workflow — this is a trigger fault, not a stalled review." >&2
echo "(A review that posted notes but no summary would have returned COMMENTED" >&2
echo " via the grace path above.) PR #619 failed exactly this way, twice." >&2which is actively wrong in this case: a real COMMENT verdict did land, and the comment's own claim — "would have returned COMMENTED via the grace path above" — is false for exactly this timing. This reproduces the failure class the PR exists to eliminate (mistaking a real, landed verdict for something it isn't), just relocated from the placeholder-vs-summary race to the late-arrival-vs-deadline race. It fails .github/claude-bot.md "Fitness of approach" #2 ("does it hold for all valid inputs... not just the case that triggered it") — it holds only when COMMENTED first appears more than grace seconds before deadline.
Direct fix: extend deadline the moment first_commented_at is set, e.g. deadline=$(( first_commented_at + grace > deadline ? first_commented_at + grace : deadline )), instead of letting grace compete with the original countdown.
No test accompanies this logic (verification per the PR body is quality-check.sh + bash -n, neither of which exercises the timing path), which is consistent with this bug surviving two full review rounds unfixed.
2. Doc inconsistency (still unresolved) — Step 0's resume table cites a CI-mode-only PR convention (.claude/skills/implement-issue/SKILL.md:112)
| open PR with `## Scope assessment` in the body | Step 9 |
Per this same file, ## Scope assessment is only ever written into the PR body in CI mode:
63: | 3. Confirm gate | ... put them in a `## Scope assessment` section of the PR body instead of chat. ...
69: | 9. Commit + draft PR | ... Add the `## Scope assessment` section (Step 3 above). ...
Interactive-mode Step 9's PR body template never uses that heading — interactive Step 3's scope assessment stays conversational, not written to the PR body. So an agent resuming an interactive-mode session that reached Step 9 will find an open PR without ## Scope assessment, and this row, read literally, won't match even though the PR's mere existence already proves Step 9 was reached. Scope the row to CI mode explicitly, or key it on "PR exists" rather than a heading most PRs following this skill won't carry.
Scope note on the new commit
a00ba4a adds a genuinely new capability (Step 0 resolving <n> to an issue or a PR) rather than addressing either outstanding finding. That's not scope creep in the sense of being unrelated to the PR's theme — it's still Step 0/resume-loop work — but it does mean two rounds of CHANGES_REQUESTED on the review-loop-completion logic itself have gone unaddressed while the PR grew in an adjacent direction. Worth confirming that's intentional sequencing and not an oversight.
Summary
- Matches root cause: No linked issue — this is self-directed tooling work on the review pipeline, not a fix against a filed bug. The underlying problems (the
COMMENTEDplaceholder race, the orphaned-worktree resume gap) are concretely evidenced in the PR body against #615/#617/#619/#622's own history. - Would the test added catch a regression: No automated test accompanies any of the three files (bash script, GH Actions prompt, skill doc). Verification is
quality-check.sh+bash -n, neither of which would catch finding 1 — and didn't, across two prior rounds. - Minimal / no scope creep: The two original commits stay within the review-loop-completion theme and the PR body's "Scope note" justifies the bundling. The third commit is adjacent but doesn't address open findings — see above.
- Rule violations:
scripts/request-pr-review.sh:70-129— correctness bug, same class the PR exists to fix (.github/claude-bot.md"Fitness of approach" #2), confirmed across three rounds now..claude/skills/implement-issue/SKILL.md:112— documentation inconsistency introduced in this diff, also unaddressed.
Requesting changes again on finding 1 — it reproduces the exact failure mode this PR exists to close, just at a different timing window, and remains unfixed after two prior rounds flagging it. Finding 2 is a smaller doc fix.
* fix: make backlog grooming reflect what actually blocks an issue The digest misclassified enough of the board that grooming could not be trusted, and every misclassification pushed work in the same direction: towards looking more ready than it was. Measured against the live board, Ready went 1 -> 0 and In Progress 5 -> 1. Five defects, each traced to a real item. 1. `analyzed` was tested BEFORE any wait, so an analysed-but-blocked item reported Ready. #96 was labelled `analyzed`, prioritised P2, carried no blocking label, and still could not be built because its approach was undecided. It read as dispatchable, a session was dispatched at it, and that session deadlocked on three design questions. Waits now outrank `analyzed`. 2. The board's `Awaiting` field was never read. 17 items have it set (`discussion` x11, `reporter` x6) and the digest derived its own value from labels instead, so recorded grooming had no effect on anything. The field is now authoritative, with `awaiting_source` and `awaiting_suggested` exposed so an unset field can be reconciled rather than silently invented. 3. `awaiting: discussion` was returned for ANY human comment, which is not a blocker -- thanks, a "me too" and a follow-up question all pushed an item to Analysis. Only a recorded wait or a blocking label does that now. 4. A worktree left on disk pinned its issue to In Progress forever. #593, #571, #542 and #466 all reported In Progress while their PRs (#618, #579, #591, #517) had merged. Staleness is decided by comparing the worktree's own branch against merged PRs -- an exact match, deliberately not the fuzzy issue-number match used to associate a worktree with an issue. 5. `blocked` did not fail Ready, so #571 reported Ready for Dev while labelled `blocked`. Definition of Ready criterion 5 now holds, including an unresolved `Blocked by #N`. Ready for Dev also finally requires a Priority, which the design always specified and the code deferred "until a board exists". It exists. New: `last_comment` {author, days, is_reporter, is_bot}. Without it the digest could not represent the transition that matters most to grooming -- the reporter answering us. A count and a date cannot tell that from a nudge we posted, which is why #621 crossed the Definition of Ready line unnoticed. REJECTED while building this: mapping a merged PR to Done. It reclassified 7 open issues (#118, #120, #403 among them) as finished, and it contradicts this project's rule that beta PRs omit `Closes #N` until graduation -- an open issue with a merged fix is the NORMAL state. `merged_pr` is reported; it moves no column, and a test pins that. Merged PR bodies are reduced to their closing references before reaching jq; passing 200 of them through --argjson overflows the argument list. Tests: 25 pass. The `gh` shim now branches on the full argument string, because `pr list` is called twice with different `--state` values and matching only the subcommand returned the open list for both -- which would have made every open PR look merged. Fixtures gained `createdAt` on comments, which real gh always sends and whose absence failed `strptime` rather than testing anything. `_run` now surfaces the digest's stderr instead of a bare "exit status 5". * fix: only a still-open Blocked by #N fails Ready Review of #623 found a real gap, and it contradicted an explicit claim: both the commit message and SKILL.md said this enforced an "unresolved" blocker, while `blocked_by` was a pure text scan that never checked whether the blocker was still open. A `Blocked by #N` line is never edited out of an issue body once N lands, so that scan pins the item out of Ready for Dev permanently. That is the same failure this script exists to fix, pointing the other way: an item reading wrong relative to its real state. `$issues` is already the open-issue list, so membership decides it with no extra API call. `blocked_by` keeps the raw parse so the reference stays visible; the new `blocked_by_open` is the subset that actually blocks, and `$blocked` reads that. Also pins the precedence question the review asked me to confirm rather than guess at: a recorded wait DOES outrank a live worktree in `column`, because unsettled scope must not read as progress. The risk is hiding active undelivered code, so the worktree stays reported on the item — the wait changes the column, not the evidence. Now tested and documented in the reconciliation table instead of being implied. 27 tests pass. * feat: a Rhythm pass that carries work from incoming to a ready PR Every follow-up rule in the backlog skill had been written down and NONE had ever fired. The 14-day reporter chase, the 28-day park, the reporter-replied re-check, the stale-worktree handoff: all decoration, because each needed a model to notice it and nothing scheduled one. So the noticing is deterministic now and lives in scripts/backlog-rhythm.sh. Every rule is a comparison over the digest — no judgement, no tokens. A quiet backlog prints "RHYTHM: nothing due." for the cost of one process, which is what makes it worth running on a timer at all. The PO agent is needed only to ACT, and only when something is due. The pass covers BOTH halves of the path to an approvable PR: Issue side — recheck_ready, nudge_reporter, park, surface_discussion, set_awaiting, set_priority, triage_labels, dispatchable. PR side — and this is the half that actually hands the maintainer something: mark_ready approved but still a draft <- the finish line awaiting_maintainer approved and out of draft request_review draft with no review at all rework changes requested resolve_conflict CONFLICTING (produces no CI run, so it reads as "checks never fired" and nobody investigates) Those two states were invisible in practice. #615 and #617 sat APPROVED and still drafts overnight with nothing left but the merge; #619 was never reviewed at all. Nothing was watching either transition. Ordering is load-bearing. PR actions come first because they are closest to the finish line, and recheck_ready outranks the chases: nudging someone who has already replied is the worst output this pass could produce. Quiet time is measured from the LAST COMMENT, not updatedAt — a label change or a board move bumps updatedAt, so an issue nobody has spoken on for a month would look active and never age into a chase. A bare COMMENTED review is not treated as a verdict, because the review bot posts its inline notes as one before the summary. Against the live board the pass finds 30 due actions, including PR #490 awaiting the maintainer, #162 park (quiet 54d), three stale worktrees and three conflicted PRs. RHYTHM_DIGEST_FILE / RHYTHM_PRS_FILE are test seams, the same shape as BESS_ENV_FILE in gh-agent.sh. 16 tests pin the rules, including that a quiet backlog is a noop and that a reply beats the chase. Still not wired to a schedule — that is the invocation, not the logic, and it is deliberately a separate step. * refactor: hand unfinished PRs back to implement-issue instead of duplicating it The previous commit built request_review / mark_ready / rework into the Rhythm pass, which is a second copy of implement-issue Step 11. That contradicts the argument used to put resume in Step 0 rather than in a separate skill: two copies of one review loop means one of them goes stale. It also mis-diagnosed the symptom. #615 and #617 did not sit APPROVED-but-draft because nothing was watching for that state; they sat there because the sessions that owned them exited before Step 11 finished. The fix belongs where the loop already lives. So every unfinished draft now resolves to ONE action, `resume_implementation`, carrying the issue number so the handoff is directly runnable. Step 0 re-enters at the earliest incomplete step, whether the PR needs a first review, a rework, or just the ready flag it never got. Two fleet-level exceptions stay in the pass, because implement-issue deliberately does not widen to them: `awaiting_maintainer` (report only) and `resolve_conflict` (sweep-prs). Adds the stalled-work rule this was missing: a LIVE worktree with no session behind it is an implementation that stopped mid-flight -- the machine restarted, the session was killed, or the agent exited between steps. Nothing picked these up, and an audit found 34 such worktrees, 8 holding real unpushed commits and one with 32. Against the live board it finds #466 and #602. `pr == null` guards that rule so work with a PR is reported once, by the PR branch, rather than twice. It is always a RESUME, never a restart: Step 4 branches fresh from origin/main and would delete commits that exist nowhere else. The detail string says so, and a test pins it. SKILL.md gains the reasons a future pass must not re-learn this: do not drive the review loop here; a session reporting `working` may have written nothing (three dispatches produced zero writes in one day while reporting healthy state); and read `claude agents --json` unsandboxed, since ~/.claude/jobs is sandbox-denied and a sandboxed listing returned 1 session where the truth was 17. 19 rhythm tests, 27 digest tests, gate green. * fix: match Blocked by #N per line, and drop dead code the refactor left Review of #623 found a real misclassification path in a PR whose whole point is eliminating them. `blocked_by` was a free `scan` over the issue body, so it matched the substring regardless of what preceded it. "not blocked by #500 anymore" and "no longer blocked by #500" both registered as live blockers -- and those are the natural way to update an issue once its blocker resolves, so the false positive fired exactly when the blocker was GONE. The item would be pinned out of Ready for Dev permanently. The severity is new, not latent: on main `blocked_by` was extracted and never fed into `column()`, so a bad parse was inert. Gating `column()` on it is what gave it teeth. Matched per LINE and anchored to the line start now, optionally bulleted, which is the convention the skill documents ("a `Blocked by #N` line in the issue body"). Anchoring rejects the negations without a blacklist that would only cover the phrasings someone happened to think of. It also fixes the reviewer's third point: an untriaged issue merely mentioning a blocker in prose no longer moves Backlog -> Analysis with no human triage behind it. Four tests: the bulleted form still counts, three negated phrasings do not, and an incidental mid-sentence mention does not reclassify. Also from the same review: - `human_comments` became dead code when `awaiting` stopped deriving `discussion` from comment activity. Removed, and the stale comment on `comments:` that still described that mechanism is corrected. - SKILL.md claimed the board's custom-field JSON shape was "confirmed" where the previous text had explicitly said unconfirmed, without showing the evidence. It was verified live; the command and its result are now recorded, with a note that the tests fabricate that shape and so cannot prove it. 49 tests pass (27 digest + 19 rhythm, plus the 3 new negation cases). * fix: resume a PR by its own number when no issue is linked `implement-issue` is used for TODO.md items and refactors, not only for issues, so a draft PR with no linked issue is normal rather than a defect. The pass reported "no issue references this PR; finish it by hand", which left every self-directed PR with no owner in the loop -- exactly how #620, #622 and #623 all ended up driven by hand today. No flag distinguishes the two cases: GitHub numbers issues and PRs from ONE sequence per repo, so a bare number is already unambiguous and Step 0 can resolve whichever it is. An earlier draft of this used `--pr <n>`; that distinction carries no information. Where an issue IS linked it is still named, because it carries the diagnosis. Where none is, the PR number is the handle, and a strong one: it holds the branch, the diff, the scope assessment and the review verdict, which is everything Step 0 reads. Step 0 accepting a PR number is a matching change to implement-issue's SKILL.md, which lives on the fix/review-verdict-placeholder branch (#622) where Step 0 was added. Both have to land for the loop to cover this case.
Third round on the same finding, and the previous two "fixes" were asserted rather than demonstrated -- verification was quality-check.sh plus `bash -n`, neither of which executes the decision path. So this commit changes the mechanism AND adds the missing tests. The grace window was the wrong instrument, and the review named the reason: it competed against the ORIGINAL deadline instead of extending it, so a COMMENTED first seen in the last `grace` seconds of the window could never satisfy the `elapsed >= grace` branch -- the loop exited first and reported "no review landed", which was false. Observed live on #622: the stub landed 12:15:14, the real CHANGES_REQUESTED 12:16:39, and the script returned the stub. Sizing it differently would not have helped. The gap that matters is not placeholder-to-summary (16s on #622, 50s on #617) but placeholder-to-END-OF-RUN: the bot posts an early permission-check comment within a couple of minutes and works for five to eight more. No constant is both short enough to return a real COMMENT promptly and long enough never to pre-empt a summary. So ask the run instead. `review_run_state` reads the PR Review workflow run started since the trigger: running -> a COMMENTED decides nothing; keep waiting finished -> a COMMENTED last word IS the verdict, per pr-review.yml's own three-verdict contract (APPROVE / REQUEST_CHANGES / COMMENT) failed -> report at once; do not burn the timeout on a dead run none -> the trigger never reached the workflow, a different fault That last one matters as much as the first. A dead run and a thinking one are both silence if you only poll for reviews, which is how #623's run -- already failed on "Reached maximum number of turns (60)" -- was waited on for 16 minutes. Also from this review round: - pr-review.yml no longer REQUIRES `gh api` for inline comments. The reviewer reported that `gh api` is permission-gated and unavailable to it, and that it probed with `gh pr review` to find out -- which submits, and is where the stray "test permission check" reviews came from. The prompt now states the one hard rule (submit exactly ONE review, never probe with it), prefers `gh api` for inline notes, and says to fold findings into the summary with file:line when it is unavailable, rather than falling back to a second review. - SKILL.md Step 0 keyed a resume signal on `## Scope assessment`, which only CI mode writes into the PR body. An interactive-mode PR never carries it, so the row would not match for most PRs this skill opens. It now keys on the PR existing, which is what actually proves Step 9 was reached. 7 new tests, REVIEW_POLL_INTERVAL added as their seam. Two of them are the discriminating pair: identical reviews (COMMENTED only), opposite outcomes, differing only in run state -- so the decision is provably driven by the new signal and not by timing. The shim applies `--jq` like real gh does; an earlier version echoed raw JSON and the script reported it as a verdict.
…erdict-placeholder
|
@claude-bot review |
There was a problem hiding this comment.
Summary
This PR is process/infrastructure work fixing the review-verdict-placeholder bug (no linked GitHub issue — it documents its own root cause and evidence thoroughly in the PR body, cross-checked against scripts/request-pr-review.sh's diff and it holds up). The core decision logic is correct: I checked out the branch, reproduced the fix's target scenarios directly, and the new review_run_state/COMMENTED disambiguation in scripts/request-pr-review.sh behaves exactly as documented for APPROVED/CHANGES_REQUESTED-immediate, COMMENTED-while-running-is-not-a-verdict, COMMENTED-after-finish-is-the-verdict, failed-run-reports-at-once, and decisive-verdict-wins-over-earlier-COMMENTED.
But the new regression test itself is broken and is failing CI right now (Fast tests is red on this PR: https://github.com/johanzander/bess-manager/actions/runs/32030436912/job/95389051252, Process completed with exit code 1).
Blocking: backend/tests/test_request_pr_review.py has an unmocked external dependency and cannot pass in CI
All 7 new tests invoke scripts/request-pr-review.sh, whose first action is:
scripts/gh-agent.sh pr comment "$pr" --body "@claude-bot review" >/dev/nullscripts/gh-agent.sh reads a real credential before ever touching the shimmed gh:
token="${!token_var:-}"
if [ -z "$token" ]; then
echo "gh-agent.sh: ${token_var} is not set in ${env_file}" >&2
exit 1
fiThe test's bin_dir fixture puts a fake gh on PATH, but never stubs scripts/gh-agent.sh — and couldn't from bin_dir anyway, since _run() sets cwd=REPO_ROOT (backend/tests/test_request_pr_review.py:113-121) and the script invokes scripts/gh-agent.sh by a repo-relative path, not via PATH lookup. The fixture even shows the abandoned attempt:
# backend/tests/test_request_pr_review.py:35-36
# The trigger comment goes through gh-agent.sh; make it a no-op.
(d / "scripts").mkdir(parents=True, exist_ok=True)— an empty directory is created and never populated with a shim script, so it does nothing.
Net effect: every test needs a real BESS_AGENT_TOKEN in the repo's gitignored .env (.gitignore:15,35) to get past line 1 of the script. .github/workflows/ci.yml's test-fast job never provisions that secret or an .env file, so this is not a flake — it fails unconditionally in CI.
I reproduced both sides locally against this branch:
- Fresh checkout, no
.env: all 7 tests fail withgh-agent.sh: BESS_AGENT_TOKEN is not set in <repo>/.env— matching the CI log exactly (exit 1 before any assertion runs). - Adding
.envwith a dummyBESS_AGENT_TOKEN=dummy: all 7 pass. This is also almost certainly why localquality-check.shcame back green for the author — a real local.env(used for actualgh-agent.shoperations on this repo) papered over the gap that CI has no way to have.
This needs the shim extended to intercept scripts/gh-agent.sh itself (e.g. put a gh-agent.sh on PATH ahead of the real one, or point _run()'s cwd/repo resolution at a fixture repo with a fake .env/BESS_ENV_FILE, which gh-agent.sh already supports via BESS_ENV_FILE) — not the theory of the decision logic, which is otherwise sound.
Checklist
- Matches root cause: yes — the placeholder/verdict race and the run-state disambiguation are demonstrated with real timestamps from #617/#622/#623, and the fix (filter to decisive states first, then resolve
COMMENTEDvia live workflow-run status) directly addresses it.review_run_state's reliance ongh run listreturning newest-first is correct (verified live:gh run liston this repo returns descendingcreatedAt). - Would the added test catch a regression?: the test cases are well-chosen (verified all 7 pass once the credential gap is closed), but as committed the suite can't run in CI at all, so right now it blocks every PR's
Fast testsjob rather than guarding this one. - Minimal / no scope creep: two commits-worth of feature (verdict-placeholder fix + Step 0 resume-a-dead-session) are bundled on one branch; the PR body already flags this explicitly and explains why, and offers to split on request — not blocking.
- Rule violations (file:line):
backend/tests/test_request_pr_review.py:33-37(incompletegh-agent.shstub) and:113-121(cwd=REPO_ROOTmaking that stub attempt unreachable even if completed) — this is adocs/agents/testing.md-adjacent issue: the test isn't testing the wrong thing, it's just unable to run.
Everything else (the pr-review.yml prompt changes, the SKILL.md Step 0 resume-check addition) reads correctly against the stated rationale and I didn't find rule violations there. Requesting changes solely on the CI-breaking test gap above.
The new test file could not pass in CI, and the review caught it with the failing run: `Fast tests` was red on this PR while local `quality-check.sh` was green. Reproduced both sides before fixing. Cause: request-pr-review.sh posts its trigger through scripts/gh-agent.sh, which reads a real BESS_AGENT_TOKEN and exits 1 before `gh` is reached. Shimming `gh` on PATH does not help -- gh-agent.sh is invoked by a repo-relative path, not looked up on PATH. Worse, it resolves its env file from the MAIN checkout (`dirname $(git rev-parse --git-common-dir)`), so this worktree having no `.env` of its own was irrelevant: the developer's real token was read anyway. CI provisions no `.env` and no such secret, so the suite failed unconditionally there. The abandoned `(d / "scripts").mkdir(...)` in the fixture was an attempt at this that did nothing. Fixed with the seam gh-agent.sh already documents for exactly this (`BESS_ENV_FILE`, "a seam tests use to point at a fixture .env instead"), so no production code changes: each test now supplies its own env file carrying a dummy token. Also adds a test that PINS that dependency, because the fix is otherwise invisible and could be dropped again silently: point the seam at an empty file and the script must fail before polling, naming the missing token. Worth recording how I nearly mis-verified this: my first attempt set BESS_ENV_FILE from OUTSIDE pytest and saw the tests still pass, which looked like proof of CI-safety. It was not -- the test sets that variable in the subprocess env, so it overrides any outer value and the experiment could not fail. The in-test pin above is the version that can actually discriminate. Full backend suite: 507 passed.
|
@claude-bot review |
There was a problem hiding this comment.
Review
Solid diagnosis and a well-instrumented fix for the placeholder-vs-verdict race — the review_run_state() approach (asking the workflow run instead of guessing with a timer) is the right shape, and the new test file (backend/tests/test_request_pr_review.py) does what docs/agents/testing.md asks: it's RED without the fix (I confirmed this class of test genuinely discriminates by reproducing the bug below), it asserts observable behavior (stdout/stderr/exit code) not internals, and black/ruff/shellcheck/bash -n are all clean. Ran the 8 new tests directly against the shipped script — all pass.
Found one correctness bug in the new logic and one doc/implementation mismatch.
1. state == "unknown" (transient gh run list failure) is treated as "not running", re-opening the exact race this PR fixes — scripts/request-pr-review.sh:153
review_run_state() (lines 90–98) falls back to echo "unknown" whenever the gh run list … --jq … pipeline fails for any reason — network blip, rate limit, transient gh auth error, a jq parse hiccup:
else \"failed\" end" 2>/dev/null || echo "unknown"The branch that decides whether a COMMENTED review is the bot's placeholder or the real verdict is:
if [ -n "$commented" ]; then
if [ "$state" = "running" ]; then
echo "COMMENTED seen but the review is still running — waiting." >&2
else
echo "Review finished with COMMENTED as its last word — that is the verdict." >&2
echo "VERDICT ${commented}"
exit 0
fi
fi"unknown" != "running", so a transient failure falls into the else branch and gets reported as the terminal verdict — even though whether the reviewer is still working was never actually determined. This reproduces the exact bug the PR is fixing (the bot's early placeholder COMMENTED being mistaken for the verdict), just gated on API flakiness instead of timing.
I reproduced this directly against the shipped script with a gh shim that makes run list exit 1 (simulating a transient failure) while a COMMENTED placeholder review is present:
returncode: 0
STDOUT: VERDICT COMMENTED 2099-01-01T00:00:01Z bot
STDERR: Review finished with COMMENTED as its last word — that is the verdict.
No test in the new suite covers review_run_state returning unknown. Suggest treating unknown the same as running (keep polling, don't treat COMMENTED as decisive) rather than the same as "not running" — gh run list failing transiently over a 15-minute poll window in CI is not a hypothetical.
2. SKILL.md describes a mechanism the shipped script doesn't use — .claude/skills/implement-issue/SKILL.md:585
"The script holds a
COMMENTED-only state for 180s to let a summary supersede it, so aCOMMENTEDthat reaches you is the verdict..."
scripts/request-pr-review.sh explicitly does not use a fixed timer — its own header comment says so directly:
"A timer was tried and was the wrong instrument... No fixed number is both short enough to return a real COMMENT verdict promptly and long enough never to pre-empt a summary."
The shipped mechanism asks review_run_state() (via gh run list) whether the reviewer is still working, with no 180s (or any fixed) hold. This reads like leftover text from an earlier iteration of this branch (the PR history shows ba94e16 fix: resolve an ambiguous COMMENTED review by time... followed by a7b086e fix: decide a COMMENTED review by run state... superseding it) that wasn't updated when the approach changed. Since this doc is what a future agent resuming this work reads to understand Step 11's behavior, the stale "180s" claim will actively mislead the next reader about how the script decides.
Checklist
- Root cause matches the stated diagnosis (placeholder
COMMENTEDmistaken for verdict) — yes, and the fix direction (ask run state, not a timer) is sound. - Tests would catch a regression — yes for the states covered, but not for the
unknownfallback path identified above, which is exactly the kind of failure this PR exists to close. - Scope — minimal and on-theme; the bundled Step 0 (resume) change is a separate but related concern, explained and justified in the PR body's Scope note.
- No
Optional/hasattr/new classes/hardcoded entities — n/a, no Python production code touched; the new test file is plain pytest, black/ruff/mypy-clean.
Requesting changes for finding #1 (real correctness bug, reproduced). #2 is a should-fix doc accuracy issue in the same PR.
(Note: gh api was unavailable to me for inline line comments per this PR's own updated pr-review.yml instructions, so both findings are folded into this single summary review instead of a second submitted review.)
Review of #622 found a real correctness bug, reproduced against the shipped script: `review_run_state` falls back to `unknown` whenever `gh run list` itself fails -- network blip, rate limit, transient auth error -- and the COMMENTED branch tested only `state = running`, so `unknown` fell through to the else and reported the bot's placeholder as the verdict. That re-opens the exact race this script exists to close, gated on API flakiness instead of timing. "I could not tell whether the reviewer is still working" must never mean "it finished". It now waits, same as `running`: waiting costs one more poll, and a genuine COMMENT verdict still returns as soon as the state resolves. A test drives it, with the shim making `run list` exit 1 rather than returning a run shape -- which is what the failure actually looks like. That test also exposed a second defect in the same path: the timeout branch ends with `gh run list ... >&2` for diagnostics, and under `set -e` a failing `gh` -- precisely the `unknown` case -- aborted the script with exit 1 instead of the exit 2 that means "no verdict". Diagnostics must not decide the exit code, so it is `|| true` now. Also fixes the doc/implementation mismatch the review flagged: SKILL.md still described the 180s hold from the superseded commit, which would actively mislead the next reader about how Step 11 decides. It now describes the run-state mechanism, including that an unreadable state waits. 9 tests. Worth noting the `pr-review.yml` change from this PR is already working: this round the bot folded its findings into a single summary review and posted no stray placeholder.
|
@claude-bot review |
There was a problem hiding this comment.
Summary
Re-review of PR #622 at HEAD 4aa7daa — this is a follow-up to my last review (12:56:08Z), which requested changes for two findings. Both are fixed here:
-
unknownrun-state promoting a placeholder to a verdict (previously flagged atscripts/request-pr-review.sh:153) — now:if [ "$state" = "running" ] || [ "$state" = "unknown" ]; then echo "COMMENTED seen but the review is ${state} — waiting." >&2
I reverted this one-line change (
|| [ "$state" = "unknown" ]→ removed) and re-ran the new suite:test_an_unreadable_run_state_does_not_promote_a_placeholderfails exactly as before (returncode 0withVERDICT COMMENTED ...instead of the expected timeout), confirming the test genuinely discriminates and the fix is what closes it. -
Stale "180s hold" claim in SKILL.md (previously flagged at line 585) — the doc text now matches the shipped mechanism (run-state polling via
gh run list, no fixed timer).grep -n "180s"over the current file returns nothing.
Verification performed
.venv/bin/pytest backend/tests/test_request_pr_review.py -v— 9/9 pass, on the actual shippedscripts/request-pr-review.sh(not reimplemented logic — the tests shell out to it withghshimmed onPATH).- Confirmed RED-without-fix for the fix in finding #1 above by reverting it and re-running — the specific test that names this bug fails, everything else stays green.
shellcheck scripts/request-pr-review.sh— clean.bash -n scripts/request-pr-review.sh— clean.black --check/ruff checkonbackend/tests/test_request_pr_review.py— clean.
Checklist
- Root-cause match — no single linked issue (this is process/tooling work fixing the
COMMENTED-placeholder race described in the PR body, evidenced against real timestamps from #617/#622/#623), but the fix directly targets the diagnosed mechanism: distinguishing the bot's placeholderCOMMENTEDreview from a genuine terminal verdict by asking the workflow run's status instead of guessing with a timer or filtering on state alone. - Would the test catch a regression? — Yes. The 9 cases cover the decisive-verdict fast path, both
COMMENTEDbranches (running vs. finished), theunknown-state fallback (the bug this round of changes fixes), a dead/failed run, a never-started run, and ordering (a later decisive verdict beating an earlier placeholder). All assert observable behavior (stdout/stderr/exit code), not internals. - Minimal / no scope creep — The two bundled commits (verdict-placeholder race + Step 0 resume-check) are on-theme and explained in the PR body's Scope note; I don't see unrelated changes riding along.
- Rule violations — None found. No
Optional/hasattr/new classes/hardcoded entity IDs (no production Python touched — this is a bash script + a new pytest file + workflow/doc changes). No exception-string matching. No secrets.
Minor (non-blocking) observation
scripts/request-pr-review.sh:177-178 calls review_run_state() twice back-to-back on the timeout path (once to interpolate into the "No verdict within..." message, once more in the case statement), on top of the call already made inside the last loop iteration — up to three gh run list calls just to report the timeout. Not a correctness issue (each call is idempotent and consistent within the same second), just a bit wasteful. Feel free to leave as-is.
Approving. Both findings from my last review are fixed and verified against the shipped script, not just asserted.
Two changes, one theme: the review loop never reached its own endpoint. Both are described in full below — they touch the same subsystem, and neither is a quiet passenger.
1. Don't mistake the bot's placeholder for a verdict
scripts/request-pr-review.shtook the last review newer than its trigger and called it the verdict, regardless of state. But the review bot posts twice:COMMENTEDreview whose body is literally "Inline notes below; summary review to follow."APPROVED/CHANGES_REQUESTEDsummary, seconds laterMeasured on PR #617: placeholder at
06:57:13Z,APPROVEDat06:58:03Z— 50 seconds apart.Any poll landing in that window returned
COMMENTED.implement-issueStep 11 then saw a non-APPROVEDverdict and skippedgh pr ready, leaving an approved PR as a draft with nothing to do but the merge.CLAUDE.mddeliberately leavesgh pr readyunattended specifically so Step 11 can flip it — the loop just never got a verdict it recognised.PR #615 proves it:
CHANGES_REQUESTED→ fixed →APPROVEDat 21:13, still a draft the next morning.Fix + verification
Filter on state before taking
last. Verified against #617's real review history by simulating a poll at06:57:30Z, when the placeholder was newest:returns COMMENTED -> Step 11 sees non-APPROVED, skips gh pr readyAPPROVEDat06:58:03ZBoth agree once the summary has landed, which is why this never surfaced in normal use — the race only bites when a poll falls in the gap.
Timeout diagnostics
The timeout path printed one message for two faults needing opposite responses: a review that started and never summarised vs a trigger that never reached the workflow. It now reports which. PR #619 is currently the second kind — two consecutive requests produced no review of any state while #614/#615/#617 all reviewed fine in the same window — and that was invisible before.
2. Resume an issue whose session died (Step 0)
Sessions die mid-issue routinely, and nothing picked them up. A fleet audit found 34 worktrees whose sessions had exited: 8 with real unpushed commits and no PR (one with 32 commits), plus three PRs green-or-reviewed with no owner.
sweep-prsrefuses the job by design, so the work simply stopped.Why here and not a new skill
The loop that acts on review feedback is Step 11, and already lives here. A separate skill would duplicate it, and duplicating a review loop is how one of them goes stale.
How it works
Step 0 keys off state observable from outside the dead session and re-enters at the earliest incomplete step:
origin/main, RED test in diff## Scope assessmentgh pr checksgreenThe one thing that dies with the session is Step 2's diagnosis, which Step 11 depends on holding. It's recoverable only because this skill already forces it to be written down: the Stage 2
analyzecomment, plus the PR body's## Scope assessmentand## Test plan. That's the actual argument for extending this skill — the rehydration sources exist because it mandates them.When those don't reconstruct a coherent approach, Step 0 stops rather than re-diagnosing on top of commits encoding decisions it cannot see.
Hard rules, each from an observed failure
origin/mainworktree when a branch for the issue already has commits — that deletes themclaude agents --jsonreturned 1 session where the truth was 17, because~/.claude/jobsis sandbox-denied and every other session read as deadCI mode gets a Step 0 row: Stage 3 is re-triggered by hand, so a second
@claude-bot fixon an issue that already has ahas-fix-prPR is a resume, not a restart, and must not open a second PR.Verification
./scripts/quality-check.shgreen on both commits —Errors: 0,Warnings: 0, permission surface intact.bash -nclean.Scope note
These are two commits on one branch rather than two PRs. They share a subsystem and a root theme, and both are fully described above — unlike #614, where half the diff was undescribed and orthogonal. Splitting them would need a cross-worktree patch the worktree-isolation guard blocks. Say the word if you'd still rather have them separate.
🤖 Generated with Claude Code