fix(claude-review): keep the reviewer single-pass and stop losing inline findings silently - #165
Conversation
…ine findings silently Since the 1.0.181->1.0.190 claude-code-action bump (Claude Code 2.1.218->2.1.227, merged 2026-08-14 19:10Z) the reviewer's first tool call is the CLI's bundled `Skill(code-review)`, which runs as a background subagent and fans out nested finder/verifier agents. `--max-turns` counts only the parent, so `num_turns: 3` reviews took 20-134 min at $4-9 each fleet-wide, and findings sometimes never reached the PR while the required check stayed green: wxa-graph#403 run 31925279662 said "Flagged 6 issues inline", posted zero inline comments, `permission_denials_count: 3`, SUCCESS. The action hides the SDK stream and keeps no transcript, so nothing said which tool was denied or what the findings were. - claude_args: `--disallowedTools "Task,Agent,Skill,Workflow"`. Verified on 2.1.227 `-p` (tools vanish from the init list) and through the action's own parseSdkOptions. Restores the bounded single-pass review the lane documents. - New "Lost-findings guard" step: prints every permission denial WITH tool name and input from the action's execution_file; compares the bot summary's "Flagged N issues inline" claim to the inline comments actually created since a `clock` anchor; recovers denied / errored / buffered-but-unposted inline bodies, subagent reports and a substantive final message into one fallback comment phrased for bb-unaddressed-findings; fails the job only when nothing was recoverable or the fallback could not be posted. Read failures on the GitHub side are warnings + exit 0. - selftest/test_claude_review_lost_findings_guard.sh: extracts the shipped bash and runs 24 scenarios against a stubbed gh; static pin of the tool surface and step wiring. Listed in test_workflow_guards.py; README entry. Local replay on 2.1.227 with the same prompt/allowlist reproduced the mechanism (Skill(code-review) -> background subagent -> 3 nested Agent finders, 27 min, $7.95, 7 Bash denials incl. `cd .. && gh pr diff` and `gh pr view | python3`). Refs: whois-api-llc/wxa-graph#403, run 31925279662. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Risk class: This PR touches one of the blocked path categories from Auto-merge is refused by (This is a policy notice, not a code-quality failure. The classify job itself does not fail — required CI checks remain authoritative for "is the code green.") |
|
Coverage Floor — mode:
|
|
Live data point from this PR's own Outcome on the PR: no summary comment, no inline comments, check SUCCESS. That is the "review said nothing" variant this PR's guard currently only warns on ( Sweep before merge: no review findings to address (nothing was posted). All checks green; manual click-merge as always for this repo. |
…es it (#166) **Manual merge (ci-workflows policy, and this touches `.github/workflows/**` + a fleet-wide REQUIRED check).** `HUMAN_READABLE_SUMMARY:` The Aug-14→16 blowout ($3–7 per review for two days, org over its monthly cap, API paused for CI *and* prod) got past both existing bounds: `--max-turns` counts only parent turns, and `--disallowedTools` matches tool *names*. It also arrived through the **unpinned Claude Code CLI install**, not through the SHA-pinned action. This adds a name-independent dollar cap, pins the CLI (the rollback lever), bounds wall clock, and makes the guard fail a budget-stopped review instead of greening a half-review. **Auto-merge rationale:** manual — `topcoder1/ci-workflows` is always manual; this changes the tool surface and failure modes of a required fleet check. --- ## Why the existing bounds missed it | Bound | What it counts | How Aug-14 got past | | --- | --- | --- | | `--max-turns 25` | parent-session turns | the whole fan-out sat inside **one** parent tool call (`num_turns: 3`, 52 min, $4.49) | | `--disallowedTools "Task,Agent,Skill,Workflow"` (#165) | tool **names** | correct fix, but name-based: the subagent tool has already been both `Task` and `Agent`; a rename reopens it silently | | action SHA pin | the action | the CLI is installed separately from `claude.ai/install.sh` at **latest** — that's where the behavior changed | Three fleet incidents in twelve days came through the CLI channel: 2026-08-04 default model flipped opus-4-8 → sonnet-5 (~$50 → ~$700/day); 2026-08-14 `/code-review` subagent fan-out (#165); 2026-08-16 2.1.233's tool-output offload put a 166 KB `gh pr diff` in a file the allowlist can't read (wxa_vpn#1564 red on max-turns). ## What changes for consumers - New optional input **`max_budget_usd`** (number, default `3`). Fleet baseline per review, n=39 sampled toward the **longest** runs per repo: median **$0.61**, p90 **$1.06**, max **$2.68**. Regressed runs were $3.26–$6.89. - A run stopped at the cap **fails** the check, with a message naming `max_budget_usd` and the pin. Legitimately huge diff → raise the input for that repo (same escape hatch as `max_turns`). - **`CLAUDE_CODE_VERSION: "2.1.233"`** — the version the fleet is already running. The install step passes it to `install.sh`, pins the npm fallback, and **refuses to review** on any other version (≥ 2.1.217 is the floor where `--max-budget-usd` counts subagent spend and stops running subagents). - `timeout-minutes: 30` on the review step, `35` on the job — **step-level**, so a timed-out review still reaches the lost-findings guard and recovers what was posted. - Guard step now prints `total_cost_usd` every run, warns above **$2**, and fails on `subtype: error_max_budget_usd` *before* any GitHub read (so an API 502 can't turn a budget stop into a fail-safe green). - Dependabot: `anthropics/claude-code-action` moves to its **own** group (#136 rode in as a routine minor bump). ## Verification - `selftest/test_claude_review_cost_guardrails.sh` (new): input type + numeric default + flag interpolation, both timeouts and their ordering, exact-semver pin ≥ 2.1.217, install targets the pin and the refusal is followed by `exit 1`, guard env wiring. Negative controls run by hand — `latest` pin, deleted budget flag, `exit 1`→`exit 0`, deleted step timeout, `type: string`, pin 2.1.216, unwired guard env — each fails the test with the intended message. - `test_claude_review_lost_findings_guard.sh` **S19–S21**: budget stop fails and names the cap before any `gh` call; $2.50 warns and stays green; $0.61 is silent. Fixtures use the real result shape captured from a local `claude -p --max-budget-usd 0.0005` run (`subtype: error_max_budget_usd`, `is_error: true`, `result: null`). - `uv run pytest -q`: **76 passed**. `actionlint`: clean. ## Post-merge canary (first 5 runs) `total_cost_usd` ≤ ~$1.50 and duration ≤ ~10 min. **One assumption is unverified locally:** `--max-budget-usd` was exercised on CLI 2.1.222, not on the pinned 2.1.233 — an unknown flag would fail the first run immediately and loudly, so the first review after merge is the real check. `gh run rerun` replays the reusable at the SHA the original run resolved, so this reaches open PRs only on a fresh event. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…at already decided (#169) ## Incident 2026-08-17 18:29–18:36Z, whois-api-llc/wxa-graph: a ~2-minute GitHub comments-API outage (`HTTP 503: No server is currently available to service your request` from `https://api.github.com/graphql`) blocked **all 5 open dependabot PRs (#422–#426)** on REQUIRED checks whose gates had **already passed**: | Lane | Gate decision | What actually failed | Evidence | |---|---|---|---| | `review / Claude Review` | bot-skip computed `total=1 non-bot=0` and printed it | the unguarded courtesy `gh pr comment` under `set -euo pipefail` → step exit 1, **and** `skipped=true` never reached `GITHUB_OUTPUT` | run 32055207194, job 95463657630 (step 3 failure, steps 4–7 skipped) | | `coverage-floor / Measure coverage and enforce floor` | PASS: measured 83.0% vs floor 80.2% | the sticky-comment action step → job failure | run 32055207104 | ## Fix — reporting becomes non-fatal; enforcement stays fatal - **claude-review.yml** (bot-skip): `skipped=true` is written **before** the comment, and the comment is guarded with a `::warning::` downgrade. A comments-API failure can no longer red the check or erase the decision. - **coverage-floor.yml**: `continue-on-error: true` on the sticky-comment step. The enforce step above it remains the gate, unchanged. - **dependabot-auto-merge.yml** (audit hit, same shape): the revoke *explanation* comment runs after `gh pr merge --disable-auto` already succeeded — its failure made a **succeeded revoke look failed**. Now downgraded; the revoke itself stays fatal. - **openapi-types-drift.yml** (audit hit, same shape): the stale-comment cleanup runs on the **passing** (`drift=0`) path; list/delete failures now degrade to warnings (`exit 0`). Also moves `${{ github.repository }}` into `env: REPO`, which is what makes the step executable by the selftest. **Audited, deliberately unchanged** — comment calls whose failure is load-bearing: - claude-review's lost-findings fallback comment (#165): must fail rather than stay green over unpostable findings. - codex-review's verdict comment: it *delivers* findings the automerge findings gate reads; non-fatal there would fail-open lost findings (the exact #165 class). Pinned by `test_codex_verdict_gate_is_wired_and_opt_in`. - pr-classify's sticky: already guarded (retry + warning) since the 2026-06-10 401 incident — the in-repo precedent this PR generalizes. - claude-author-automerge stickies & the drift=1 comment: not required checks / the job reds anyway on the failing path; a red there is honest alerting-delivery failure, not gate inversion. ## Selftests (written first, RED against the old workflows, GREEN after) - **`selftest/test_comment_nonfatal_reporting.sh`** — extracts the *shipped* bash of all three shell steps and runs it against a `gh` stub serving the outage's literal 503. Pins per lane: exit 0, decision output/side-effect intact, `::warning::` emitted; controls prove the healthy path, the decision logic, and the **counter-invariant** (a failed `gh pr merge --disable-auto` still fails the step). Pre-fix it reproduced the incident byte-for-byte: `total=1 non-bot=0` printed, then exit 1 with no `skipped=true`. - **`test_sticky_comment_action_steps_are_nonfatal`** (test_workflow_guards.py) — sweeps every workflow for sticky-comment action steps and requires `continue-on-error: true`; anchored to coverage-floor.yml so it can't pass vacuously. `uv run pytest -q`: **78 passed** (full suite incl. the pre-existing bot-skip authorship test over the reordered step). actionlint on the 4 workflows: no new findings (the SC2016 at coverage-floor.yml:650 pre-exists on main and CI's actionlint runs without shellcheck). shellcheck clean on the new test. ## Relation to task_f6871c59 (PR #165) Same failure *shape* — the lane decides correctly, then the post-decision reporting path defects — but different mechanisms: #165 was findings silently **lost** behind a green check (fail-open, from subagent tool denials); this one is reporting failure **redding** a pass and erasing the decision output (fail-closed noise + lost state, from unguarded error propagation). No shared code path, so no shared one-line fix — the shared principle is now institutionalized instead: *reporting non-fatal, enforcement fatal*, enforced by the new behavioral selftest plus the repo-wide sticky sweep. Auto-merge rationale: manual click-merge — PRs to ci-workflows are always manual, and this touches `.github/workflows/**` (prod infra). Codex rounds: 2 (both NO FINDINGS). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Manual merge (ci-workflows policy). This changes the tool surface of the review lane every fleet PR runs, and adds a step that can fail a REQUIRED check — read the "What changes for consumers" section before merging.
HUMAN_READABLE_SUMMARY:Since the 1.0.190 action bump the reviewer's first move is the CLI's bundled multi-agent/code-reviewskill, which runs as a background subagent that--max-turnsnever sees — reviews went from 1–8 min to 20–134 min fleet-wide, and findings sometimes never reached the PR while the check stayed green. This removes the fan-out tools from the reviewer (--disallowedTools Task,Agent,Skill,Workflow) so the lane is single-pass and bounded again, and adds a guard that compares the summary's "Flagged N issues inline" claim against the inline comments actually posted, prints every permission denial WITH its tool name, recovers lost findings from the transcript into a fallback comment, and fails the job only when nothing was recoverable.Auto-merge rationale: manual —
topcoder1/ci-workflowsis always manual; this touches.github/workflows/**and a fleet-wide required check.The incident (whois-api-llc/wxa-graph#403, run 31925279662)
review / Claude Review— required fleet-wide — ran 03:52→04:45Z (52 min), posted "Flagged 6 issues inline — most critical: …", created zero inline review comments, and concluded SUCCESS. The log held only:Four of the six findings were lost; the babysitting session recovered the two the summary happened to spell out (its comment).
What the investigation found (answers to the three questions asked)
1. What was denied? Not recoverable for that run — the action hides the SDK stream (
show_full_output: false), prints only a count, and keeps no transcript artifact. This PR closes that gap: the guard prints everypermission_denials[]entry withtool_name+ input from theexecution_filethe action leaves on disk. What the evidence does establish:github_inline_comment, the toolcreate_inline_comment→mcp__github_inline_comment__create_inline_comment, byte-identical to the allowlist (install-mcp-server.ts, github-inline-comment-server.ts).classify_inline_comments: true: not the cause. Buffering has shipped since v1.0.151. Withoutconfirmed=truea call is buffered to/tmp/inline-comments-buffer.jsonland posted by the action's own post-step; with it the comment posts live and the buffer copy is dropped. The 03:41Z run on the same PR (permission_denials_count: 13, also "No buffered inline comments") posted 7 inline comments that way — for a summary that said "Flagged 3". "No buffered inline comments" in the bad run therefore means the tool was never successfully invoked, not that a buffer was dropped.-pmode.num_turns: 3at $4.49 is only possible when the work happens inside tool calls: subagents.The actual mechanism, from two independent sources:
ghwrites shimmed, action's MCP server with a dummy token — full transcript captured. Result block:num_turns: 11, 27 min,total_cost_usd: 7.95,permission_denials: 7— all sevenBash. First tool call:Skill({"skill":"code-review"}); 246 of 637 stream messages carry aparent_tool_use_id(the CLI's bundled review runs as a background subagent — Claude Code 2.1.218 changelog: "Changed /code-review to run as a background subagent") and it spawned three nestedAgentfinders ("Angle A & B: Line-by-line diff scan…", "Angle C & Reuse…", "regression test correctness…"). The denied calls, verbatim: afor d in …; do echoloop,cat <<'EOF' > /tmp/regex_check.go(a finder writing a Go program to test a regex),find /Users/… -name CLAUDE.md,git -C … diff,tail -c 5000 …, and — the instructive two —cd … && gh pr diff 403 …andgh pr view … | python3 -c …: allowed prefixes, denied because compound/piped, which is exactly how agh pr commentpost can also vanish. The finders'CONFIRMED/PLAUSIBLEverdict labels are what the 03:41Z run's inline comments carry. The skill never rangh pr difffor its scope (it runsgit diff main...HEAD; in a CI checkout there is no localmain) and its own report drifted onto files outside the PR — the parent's final message: "The skill's subagents drifted into files not touched by this PR … I discarded those findings and reviewed the actual six-file diff directly." Findings return to the parent as text; whether they reach the PR depends on the parent re-posting them through the MCP tool — or the skill's--commentfallback,gh api …/pulls/{pr}/comments, which the allowlist denies.The fix
1.
--disallowedTools "Task,Agent,Skill,Workflow"inclaude_args. Verified on 2.1.227-p: the four tools vanish from the init tool list; the model reports it has no Skill/Agent/Task tool. Verified through the action's ownparseSdkOptionson this exactclaude_argsstring →disallowedTools: ["Task","Agent","Skill","Workflow"], allowlist intact. Both subagent spellings are named because the tool has carried each across CLI versions (2.1.227's init listsTask; its tool_use blocks sayAgent). Effect: the reviewer is single-pass again — readsgh pr diff, posts through the inline-comment MCP tool, and the turn cap is real. This is the design the lane's own comment describes ("the turn cap bounds worst-case tokens regardless of future model or binary behavior changes") — a statement that has been false since 08-14.2. Lost-findings guard (new step, after the action,
!cancelled()):steps.claude.outputs.execution_file::warning::per denial with tool name + input (300 chars); inline-comment call / error / subagent countsgh api …/pulls/N/comments --paginateclaude[bot]created since aclockanchor step taken just before the actiongh api …/issues/N/comments --paginateFlagged N issue(s)…+ the word "inline"permission_denials[].tool_input), errored ones (422 etc.), buffered-but-never-posted ones (successfultool_usewhose body no posted comment carries), any subagent/skill report, and a final message that is clearly more than the summary echoed back → ONE fallback comment (marker<!-- claude-review:lost-findings -->, phrasedflagged N issue(s)sobb-unaddressed-findings.shadmits it)Fail-safe on the reads that decide the verdict: API errors listing either comment set, or phrasing outside the mandated shape → warning + exit 0. The transcript is evidence for recovery, not for the verdict — claim > posted is established from GitHub alone, so a missing/malformed transcript makes a real loss unrecoverable (red), never spurious; malformed entries are skipped, never fatal. Overlapping runs on one PR cannot happen (every installed caller carries
concurrency: claude-review-<pr>-<draft|live>, cancel-in-progress: true; a cancelled run's guard does not execute).Dry-run of the shipped bash against the real PR #403 data (writes shimmed): window of the bad run → "summary claims 6 inline finding(s); 0 inline review comment(s) … nothing recoverable → exit 1"; window of the good run → "claims 6 … 8 posted → exit 0".
Selftest
selftest/test_claude_review_lost_findings_guard.sh(listed intest_workflow_guards.py, README entry): static pin of the tool surface + step wiring, then extracts the SHIPPED guard bash and runs it against a stubbedghthrough 24 scenarios — the incident shape with denied inline bodies (recovered, exit 0), the incident as it actually looked (unrecoverable, exit 1, denials named), the 03:41Z undercount (silent), clean review, Skill-report recovery, errored-call recovery, buffered-then-dropped recovery with dedup against posted bodies, both API-failure fail-safes, the accepted phrasing miss (asserted), partial loss with/without recoverable content, no transcript, malformed transcript entries, empty-body calls, substantive vs bare final message, anchor/author filtering,--paginatemulti-page flattening, POST failure after recovery (fails).Verification
uv run pytest -q selftest/→ 75 passed (includes the new test + completeness guard)bash selftest/test_claude_review_lost_findings_guard.sh→ 24/24 PASSED, under/bin/bash3.2 as wellactionlint -shellcheck=clean;shellcheckclean on the selftest at all severities-pprobe as abovetool_use.input) + one declined (partial mismatch stays a warning); r5 one declined (overlap prevented by the callers' concurrency group); r6 clean.What changes for consumers
max_turns— no CONFIRMED/PLAUSIBLE verify pass. That is the pre-08-14 baseline the fleet ran on; if a repo wants the deep review it should be an explicit opt-in, not a side effect of a dependency bump (not added here).review / Claude Review— re-run the lane. Recovered findings show up as onegithub-actions[bot]comment the babysitter sweep counts as unaddressed.Follow-ups (not in this PR)
verifier-on-high-risk.ymlandclaude-adversarial-review.ymlpin the same action version; check their durations since 08-14 and whether they want the same--disallowedTools.execution_fileas a short-retention artifact so future incidents keep the whole transcript, not just the digest.classify_inline_comments's buffer reply invites a secondconfirmed=truecall per finding (the good run posted 2 duplicates); worth an issue on claude-code-action.Refs: wxa-graph#403 comments 5305797473, 5305810583; run 31925279662.
🤖 Generated with Claude Code