fix(bot): thread captured head SHA into wheels-bot review markers - #2865
Conversation
Reviewer A/B embedded a SHA the skill prompts re-derived at review time via `gh pr view --json headRefOid`, which races with pushes landing mid-session: the marker SHA could lag the commit the review actually ran against, so the skip-check gate failed to suppress duplicate runs and Reviewer A re-fired on superseded commits while B emitted contradictory verdicts on different SHAs (observed across #2847). Capture the head SHA once at the workflow level and pass it into the prompts as an explicit `<head-sha>` argument: - bot-review-a.yml threads the already-checked-out steps.pr.outputs.sha into /review-pr and /respond-to-critique. - bot-review-b.yml keys checkout, skip-check marker-pattern, and /review-the-review off github.event.review.commit_id (the commit Reviewer A's review was attached to, immune to head drift). The prompts now emit the marker from that argument instead of re-deriving it. The Reviewer A/B Bash allowlist is gh + read-only git (no echo/printenv), so a step env var would be unreadable by the model; the SHA must travel in the prompt text, the same channel the PR number already uses. Guarded by a structural spec across both workflow YAMLs and all three prompts. Closes #2848 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
The first cut told the reviewer prompts "do not re-derive the head with
`gh pr view`" / "do not call `gh pr view`", but step 2 of each prompt
relies on `gh pr view` / `gh pr diff` to read the PR. Reviewing this very
PR, Reviewer A loaded its own (checked-out) prompt, over-read the
prohibition as "avoid gh pr view entirely", floundered on denied
alternatives (8 permission denials vs the usual ~4), and posted no review
at all — while every recent PR gets one on the first run.
Reword all three prompts to forbid only *computing the marker SHA*
("don't compute the SHA yourself") and to explicitly affirm that
`gh pr view` / `gh pr diff` remain how the model reads PR content. The
SHA-threading behaviour and the structural spec are unchanged (still 9/9;
`headRefOid` stays absent so the derivation can't creep back).
Refs #2848
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Second commit (
|
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: PR #2865 correctly fixes the primary SHA race for the initial Reviewer A run (marker SHA now comes from the workflow-captured argument, not a mid-session gh pr view re-derivation), and the structural spec covers that wiring well. However, the response loop (/respond-to-critique) inherits a different but related SHA divergence: in response mode bot-review-a.yml still derives SHA from gh pr view --json headRefOid (current PR head) while Reviewer B now anchors its markers to github.event.review.commit_id (the commit A's review was attached to). If a push lands between A's initial review and A's response, those two SHAs diverge, the pattern match in respond-to-critique.md step 1 finds no B critique, and A posts an empty or confused response. This is a correctness issue that was not present before this PR (previously both sides floated to the same current head independently), requesting changes for a targeted fix.
Correctness
Response-loop SHA divergence — .github/workflows/bot-review-a.yml line 76
In response mode the SHA that gets threaded into /respond-to-critique is still computed from gh pr view:
sha=$(gh pr view "$pr_num" --repo wheels-dev/wheels --json headRefOid -q '.headRefOid')Meanwhile bot-review-b.yml now correctly anchors every B marker to github.event.review.commit_id — the exact commit A's review was attached to. If a push lands between A's initial review and the issue_comment trigger that fires A's response, steps.pr.outputs.sha in response mode = new PR head (Y) while B's marker SHA = reviewed commit (X). respond-to-critique.md step 1 then tries to match:
wheels-bot:review-b:<pr>:<Y>:<N>
…against a comment that carries <X> — no match. A never learns B's round number or critique, producing either a silent exit or a response that doesn't address B's findings. This is worse than the pre-PR behaviour: before this PR both sides re-derived headRefOid independently and would tend to agree on the current head. This PR intentionally diverged B's anchor without also fixing A's response-mode derivation.
The respond-to-critique.md step 1 pattern match on the SHA is exactly the kind of SHA-equality gate the PR's own "Out of scope" block identifies as not present in address-review.md / advise-on-deadlock.md — but it is present in respond-to-critique.md.
Suggested fix — in the issue_comment branch of "Resolve PR info", extract the SHA from the comment body that triggered the response rather than re-deriving from gh pr view. B's comment always contains wheels-bot:review-b:<pr>:<sha>:<N>, so the SHA is right in the trigger:
# response mode — extract the reviewed SHA from the B comment that triggered this run
sha=$(echo "${{ github.event.comment.body }}" \
| grep -oP "wheels-bot:review-b:${pr_num}:\K[0-9a-f]+" \
| head -1)
if [ -z "$sha" ]; then
echo "::error::Could not extract review SHA from trigger comment body"
exit 1
fiThis anchors A's response to the same SHA B wrote in its marker, regardless of subsequent pushes.
Tests
Spec gap for response-mode SHA — vendor/wheels/tests/specs/cli/BotReviewMarkerShaThreadingSpec.cfc
The spec verifies A's command gets steps.pr.outputs.sha and B's command gets review.commit_id, but does not verify that these two sources will agree during a response round. The spec would pass even if A's response-mode SHA derivation was reverted to a bare gh pr view, since the two values are checked in isolation. An additional assertion in the bot-review-a.yml describe block could verify that the response-mode sha= line does not use headRefOid — mirroring the headRefOid-absent assertion on the prompt files.
Commits
Both fix(bot): thread captured head SHA into wheels-bot review markers (079d125) and fix(bot): scope SHA prohibition so review prompts still use gh pr view (94721fd) conform to commitlint: allowed type fix, optional scope bot, headers under 100 characters, sentence-case subjects, DCO Signed-off-by on both. Clean.
Docs
CHANGELOG [Unreleased] entry present and accurate. No .ai/wheels/ doc needed — bot-pipeline infrastructure change with no user-facing surface.
Wheels Bot — Reviewer B (round 1)A's two findings are both verified against the real code. No sycophancy, no false positives. The CHANGES_REQUESTED verdict is correct, and I'm aligned. SycophancyNone detected. A requested changes and backed every finding with code citations. False positivesNone detected. Finding 1 - response-loop SHA divergence: A cites else
sha=$(gh pr view "$pr_num" --repo wheels-dev/wheels --json headRefOid -q '.headRefOid')
mode="response"
fiB now anchors every marker to Finding 2 - spec gap: The spec asserts A's suggested fix: Extracting SHA from the comment body is directionally correct but would benefit from routing through Missed issuesNone material. A correctly scoped out Verdict alignmentA's CHANGES_REQUESTED is consistent with finding a genuine correctness regression in the response-loop path. Correct call. ConvergenceAligned. A's two findings are valid and actionable: (1) fix the response-mode branch of "Resolve PR info" in |
Wheels Bot — Address Review (round 1)Applied consensus findings from Reviewer A and Reviewer B's convergence (convergence round 1):
Testing notesThe The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on `approve` or the outer-loop cap (5 rounds) is reached. |
There was a problem hiding this comment.
Wheels Bot — Reviewer A response (round 1)
B fully aligned with both findings — no false positives, no missed issues, no sycophancy concerns. The convergence was clean: B confirmed finding 1 (response-loop SHA divergence) by quoting the exact headRefOid re-derivation at line 76, confirmed finding 2 (spec gap) with the precise characterisation of what the existing assertions missed, and endorsed CHANGES_REQUESTED. Address-review ran on this SHA and described the needed fixes, but the PR head remains at 94721fdf — the fix commit has not landed, so both issues are still open in the code as-reviewed. Re-checking the live file confirmed: line 76 of bot-review-a.yml still reads sha=$(gh pr view "$pr_num" ... --json headRefOid ...) in the issue_comment branch, and the spec file added by this PR checks headRefOid absence only in the three prompt markdown files — no assertion guards the YAML. Nothing to concede; nothing to defend.
Conceded points
None.
Defended points
None required — B confirmed both findings without dispute. For completeness:
-
Finding 1 (line 76 of
bot-review-a.yml): The PR threads${{ steps.pr.outputs.sha }}into/respond-to-critique(line 118 of the diff), butsteps.pr.outputs.shain response mode is still set by thegh pr view --json headRefOidcall at line 76. Moving the racy derivation from the prompt into the YAML step does not remove the race —headRefOidstill floats to the current PR head, not the commit B's marker was attached to. The fix is to parse the SHA from the triggering B comment body (which carrieswheels-bot:review-b:<pr>:<sha>:<N>verbatim), as the suggested patch in my initial review showed. -
Finding 2 (
BotReviewMarkerShaThreadingSpec.cfc): The added spec'sdescribe("bot-review-a.yml")block asserts the SHA is threaded into the two commands but contains noit(...)assertingreFindNoCase("headRefOid", content)is false for the full YAML body. The three prompt-fileheadRefOid-absent assertions would green-light a revert of line 76 toheadRefOid. B's suggestion to add this assertion to thebot-review-a.ymldescribe block remains the right gap to close.
Updated verdict
Still CHANGES_REQUESTED: B and I are aligned, address-review ran and described the correct fixes, but the PR head has not advanced to include them. The two required changes remain: (1) replace the headRefOid re-derivation at line 76 of bot-review-a.yml with a parse of the triggering B comment body, and (2) add a headRefOid-absent assertion against the full bot-review-a.yml content in BotReviewMarkerShaThreadingSpec.cfc.
Reviewer A/B on this PR flagged a regression the PR's own change introduced: bot-review-b.yml now anchors B's markers to github.event.review.commit_id (a fixed commit), but bot-review-a.yml's response mode still derived the SHA from `gh pr view` (the floating current head). Once a push lands between A's initial review and A's response, the two diverge and respond-to-critique.md can no longer match B's round marker — strictly worse than before, when both sides floated to the same head. The "Resolve PR info" response branch now extracts the reviewed SHA from the triggering review-b comment, whose marker carries it (`wheels-bot:review-b:<pr>:<sha>:<round>`), read via a COMMENT_BODY env var rather than splicing event text into the script (injection). Add a spec assertion that bot-review-a.yml derives no SHA via headRefOid, mirroring the prompt assertions, so the floating derivation can't return. Addresses the Reviewer A/B converged-changes verdict on 94721fd. Refs #2848 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
|
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: The new commit (609f934cb) addresses both CHANGES_REQUESTED findings from the round-1 review. Finding 1 (response-mode SHA divergence in bot-review-a.yml) is fixed by extracting the reviewed SHA from the triggering B comment body via a safe COMMENT_BODY env var, instead of re-deriving it with gh pr view --json headRefOid. Finding 2 (spec gap) is fixed by adding the headRefOid-absent assertion for bot-review-a.yml itself to BotReviewMarkerShaThreadingSpec.cfc. The full diff is clean. Approving.
Correctness
Finding 1 resolved. The previous head (94721fdf) still derived the response-mode SHA via:
sha=$(gh pr view "$pr_num" --repo wheels-dev/wheels --json headRefOid -q '.headRefOid')The new commit replaces that with:
sha=$(printf '%s\n' "$COMMENT_BODY" \
| grep -oiP "wheels-bot:review-b:${pr_num}:\K[0-9a-f]{7,40}" \
| head -1 || true)
if [ -z "$sha" ]; then
echo "::error::Could not extract the reviewed SHA from the triggering review-b comment"
exit 1
fiCOMMENT_BODY is populated from ${{ github.event.comment.body }} in the env: block (never interpolated into the script body — injection-safe), then read via printf '%s\n' "$COMMENT_BODY". pr_num is validated numeric before it appears in the grep pattern. The extracted SHA is bounded to [0-9a-f]{7,40} — only valid hex. The empty-SHA guard exits cleanly when the pattern does not match. Solid.
Tests
Finding 2 resolved. The new it block in BotReviewMarkerShaThreadingSpec.cfc under the describe("bot-review-a.yml") group:
it("never re-derives a SHA via gh pr view --json headRefOid", () => {
expect(fileExists(reviewA)).toBeTrue("Missing file: " & reviewA);
var content = fileRead(reviewA);
expect(reFindNoCase("headRefOid", content) > 0).toBeFalse(...);
});This mirrors the existing three prompt-file assertions and closes the gap: a future accidental re-introduction of headRefOid into the YAML will now fail the spec. The spec total rises from 9 to 10 assertions; the IIFE loop-capture pattern for the prompts array is correctly applied (per CLAUDE.md ss10).
Commits
One new commit since the previous review: fix(bot): anchor response-mode review to the commit B critiqued — valid type fix, scope bot, header length within limit, Signed-off-by: Peter Amiri trailer present. DCO satisfied.
Docs
No changes needed beyond the existing CHANGELOG entry. The .ai/wheels/ reference docs are not applicable to bot-pipeline infrastructure.
Wheels Bot — Reviewer B (round 1)A's review is clean. Both converged-changes findings from round 1 are demonstrably resolved in the diff, A's code citations are accurate, and the APPROVED verdict is correct. SycophancyNone detected. A walked through the concrete code changes for each finding before approving. False positivesNone detected. Verified each of A's claims against the diff:
Missed issuesNone detected. The change is tightly scoped to the two converged-changes findings. Commit metadata ( Verdict alignmentAPPROVED is consistent with both findings being resolved. Correct call. ConvergenceAligned. A's approval is fully supported by the diff. The response-mode SHA divergence is fixed (COMMENT_BODY env path extracts the reviewed SHA from B's marker rather than re-deriving it via headRefOid) and the spec now guards against that derivation creeping back. Joint recommendation: approve and merge. |
…rkers (#2870) Follow-up to #2865, extending the #2848 stale-SHA marker fix to the two convergence/deadlock-loop commands that were out of scope there because they fire on a different trigger path. /address-review and /advise-on-deadlock re-derived the marker SHA via `gh pr view --json headRefOid` mid-session, which races with pushes that land between the workflow's checkout and the model's call — so their address-review / advisor / converged-* markers could lag the commit being addressed, defeating the per-SHA idempotency gate. Capture the head SHA once at the workflow level and thread it in as an explicit <head-sha> argument: - bot-advisor.yml threads its already-resolved steps.pr.outputs.sha into /advise-on-deadlock. - bot-address-review.yml gains an equivalent resolve step (captures headRefOid alongside the head ref it already needed for the branch checkout) and threads steps.pr.outputs.sha into /address-review. Its checkout stays branch-name-keyed because that stage commits and pushes back, so the captured SHA is the head at run start — the marker's <sha-before>. - Both prompts take <head-sha> and emit every marker from it. The prohibition is scoped narrowly to "don't re-derive the SHA"; gh pr view stays the normal way to read comments/reviews/diff (a blanket ban made Reviewer A flood permission denials in #2865). The workflows' Bash allowlist is gh + read-only git (no echo/printenv), so a step env var is unreadable by the model — the SHA must travel in the prompt text, the same channel the PR number already uses. BotConvergenceMarkerShaThreadingSpec.cfc guards the wiring: headRefOid present in the workflows (the fix's source), absent from the prompts (the behavior change). Verified locally: cli spec suite 67 pass / 0 fail / 0 error. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…iew, model refresh (#3022) * ci: collapse A/B review loop into single Reviewer, opt-in address-review, model refresh Per maintainer decision 2026-06-11: the Reviewer A / Reviewer B critique loop was expensive and flaky (green-without-posting, cancelled reruns), B's marginal catch rate no longer justified a second model pass with stronger models available, and the converged-changes auto-fire push chain landed a broken spec on a PR (#3005). - bot-review-a.yml -> bot-review.yml: single-pass Reviewer (check name "Reviewer"); pull_request trigger, SHA-capture-once, idempotency marker, and the #2865 post-job validation guard all kept; the issue_comment convergence path removed. Marker strings keep the legacy review-a names for continuity. - bot-review-a-fork.yml -> bot-review-fork.yml: same prompt and model consolidation; pull_request_target sandboxing preserved unchanged. - DELETED: bot-review-b.yml, review-the-review.md, respond-to-critique.md. - review-pr.md: folds B's anti-sycophancy / false-positive mandate into an explicit adversarial self-review step before posting. - bot-address-review.yml: opt-in only (maintainer-applied bot-address-review label or workflow_dispatch); consumes the most recent wheels-bot review on the current head SHA; address-held and stale-SHA (#2848/#2865) protections preserved. - bot-propose-fix.yml: campaign pre-gate skips the model run when a peter/issue-<N>-* branch or an open non-bot PR already targets the issue (4 superseded bot drafts on 2026-06-10). - Model pins: Reviewer (main+fork) -> claude-fable-5; triage, propose-fix, address-review, resolve-conflicts, advisor, research -> claude-opus-4-8; auto-close/write-docs/update-docs stay sonnet-4-6. Policy: judging gate = fable, coding stages = opus, janitorial = sonnet. - Reference sweep: .ai/wheels/wheels-bot.md and docs/contributing/wheels-bot.md pipeline sections rewritten; _shared-rails.md, CONTRIBUTING.md, and prompt cross-references updated; bot-advisor.yml + advise-on-deadlock.md marked legacy/inert. - vendor/wheels/tests/specs/cli/BotReviewMarkerShaThreadingSpec.cfc rewritten for the new file set (it fileExists-asserted the deleted / renamed workflows and prompts; leaving it would have failed the core suite on every engine). BotConvergenceMarkerShaThreadingSpec assertions verified still green against the edited workflows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * ci: address review nits on the bot consolidation - address-held skip marker now matches the SHA-suffixed form the prompt actually emits; the SHA-less prefix permanently no-op'd every future label opt-in on a PR after one held round. - campaign guard: manual workflow_dispatch bypasses the guard (explicit maintainer intent beats a lingering stale campaign branch). - campaign guard: jq body re-check uses a digit-boundary regex so issue 123 no longer matches a PR body referencing #1234. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * ci: address-held gate uses the SHA output, not the branch-name output The prior commit claimed to SHA-scope the address-held skip gate but interpolated steps.pr.outputs.head — the branch name — so the pattern could never match the SHA-suffixed marker the prompt emits and the gate was permanently fail-open. Caught by the new Reviewer on its own consolidation PR. Now uses steps.pr.outputs.sha, pinned by a structural spec assertion in BotConvergenceMarkerShaThreadingSpec. Also from the same review: address-review.md now names the correct gh pr view field (.commit.oid, not the REST shape's commit_id), and the label-gate docs no longer claim labeling requires write access (GitHub's triage role can label; the docs now say so and advise restricting it). Suite evidence: full core suite on Lucee 7 + SQLite (docker) = 4374 pass / 12 fail / 0 error; all 12 are the pre-existing internal.testClientSpec loopback-container artifacts; wheels.tests.specs.cli area 78/0/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
Fixes #2848 — wheels-bot embedded a stale SHA in Reviewer A/B idempotency markers, so the skip-check gate failed to recognise an already-reviewed head and Reviewer A re-fired on superseded commits while Reviewer B emitted contradictory verdicts on different SHAs (observed across the #2847 review cycle, where B self-diagnosed the drift twice).
Root cause
The marker (
<!-- wheels-bot:review-a:<pr>:<sha> -->/review-b) is written by the skill prompts, but the prompts re-derived<sha>at review time viagh pr view --json headRefOid. That call races with pushes that land mid-session: between the workflow's checkout and the model'sgh pr view, a new push can move the PR head, so:marker-pattern) keyed on a different SHA than the marker, so it stopped suppressing duplicate runs.The workflows already capture the authoritative SHA (and check it out) — they just never handed it to the model.
Fix: capture once, thread into the prompt as an argument
Capture the head SHA once at the workflow level and pass it into the prompt as an explicit
<head-sha>argument. The prompt emits the marker from that value and never re-derives it.bot-review-a.yml→/review-pr,/respond-to-critique${{ steps.pr.outputs.sha }}(the SHA the Checkout step already pinned)bot-review-b.yml→ checkoutref, skip-checkmarker-pattern,/review-the-review${{ github.event.review.commit_id }}(the commit Reviewer A's review — which B critiques — was attached to; immune to head drift)Why a prompt argument and not the
REVIEW_HEAD_SHAenv var the issue sketchedThe Reviewer A/B
--allowedToolsisBash(gh:*)+ read-onlygit(git log/diff/show/grep/status) — noecho,printenv, or bareBash. The model therefore has no way to read a step-level environment variable's value to compare against existing markers or to emit a new one; it would silently fall back togh pr viewand the race would persist. So the SHA must arrive in the prompt text — the same channel the PR number already travels on (cmd=/review-pr <pr_num> <sha>). This is the one substantive design change from the triage's sketch, and it's why the fix actually works under the existing tool sandbox.Scope
respond-to-critique.mdis included even though the issue named onlyreview-pr.md/review-the-review.md: it runs in the same "Run Reviewer A" step and has the identical race. Leaving it would let the response-path markers keep drifting.converged-approve/converged-changes/:terminal) are fixed for free — review-the-review.md now resolves every<sha>it writes from the passed<head-sha>.address-review.mdandadvise-on-deadlock.mdalso re-derive the SHA viagh pr view, but they live in the convergence/deadlock loop (a different trigger path) and are not broken by this change — all marker triggers are presence-based, andaddress-review.mdreads B's comments without a SHA-equality gate. Worth a separate pass.Testing
New structural spec
vendor/wheels/tests/specs/cli/BotReviewMarkerShaThreadingSpec.cfc(modeled onOnErrorFallbackGuardSpec.cfc/ConfigRoutesStaleDocUrlSpec.cfc) asserts the wiring across both workflow YAMLs and all three prompts.Ran the core
clisuite locally on Lucee 7 + SQLite (/wheels/core/tests?...&directory=wheels.tests.specs.cli):0 pass / 9 fail(true RED — each assertion fails for the right reason; an earlier weak<head-sha>substring check false-passed on a pre-existinggit log origin/develop..<head-sha>line, so it was replaced with a behavioralheadRefOid-absent assertion).9 pass / 0 fail; wholeclisuite59 pass / 0 fail / 0 error, no regressions.actionlintpasses on both workflows (only a pre-existing SC2129 style nit on the untouched "Resolve PR info" step).Notes for review
steps.pr.outputs.sha,github.event.review.commit_id— GitHub-generated hex) and a PR number already validated^[0-9]+$upstream — not free-text fields (titles/bodies), so no command/ref-injection surface is added. Swapping one GitHub SHA (head.sha) for another (review.commit_id) inref:keeps the same safety profile.fileRead/reFindNoCaseand the arrow-fn + IIFE loop-capture pattern already used by the sibling structural specs;#is escaped as##per the suite's convention.pull_requestevents GitHub runs the base-branch workflow — so the fix only takes effect for PRs opened after merge.Closes #2848
🤖 Generated with Claude Code