Skip to content

fix(claude-review): keep the reviewer single-pass and stop losing inline findings silently - #165

Merged
topcoder1 merged 1 commit into
mainfrom
claude/infallible-shirley-3b3e4e
Aug 16, 2026
Merged

fix(claude-review): keep the reviewer single-pass and stop losing inline findings silently#165
topcoder1 merged 1 commit into
mainfrom
claude/infallible-shirley-3b3e4e

Conversation

@topcoder1

Copy link
Copy Markdown
Owner

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-review skill, which runs as a background subagent that --max-turns never 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-workflows is 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:

"num_turns": 3, "duration_ms": 3081851, "total_cost_usd": 4.49, "permission_denials_count": 3
Post buffered inline comments: No buffered inline comments

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 every permission_denials[] entry with tool_name + input from the execution_file the action leaves on disk. What the evidence does establish:

  • Tool-name mismatch: refuted. In v1.0.190 the MCP server is github_inline_comment, the tool create_inline_commentmcp__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. Without confirmed=true a call is buffered to /tmp/inline-comments-buffer.jsonl and 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.
  • 51 min ≠ retries on denials. Denials are instant in -p mode. num_turns: 3 at $4.49 is only possible when the work happens inside tool calls: subagents.

The actual mechanism, from two independent sources:

  • Fleet timing. Every consumer's PR Review went from 1–8 min to 20–134 min starting 2026-08-14 ~19:16Z — the 1.0.181→1.0.190 bump (#136) merged at 19:10:44Z. That bump moved Claude Code 2.1.218→2.1.227. Sample: wxa-graph 08-14 03:07Z–08:01Z 1/3/3/1 min; 08-15 onward 41/36/20/56/16/31/34/25 min. ci-workflows 08-14 ≤8 min; 08-14 23:54Z 134 min, 00:03Z 101 min. Every run since carries 3–17 permission denials.
  • Local replay on the same CLI build (2.1.227), same prompt/allowlist/model/turn cap, gh writes 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 seven Bash. First tool call: Skill({"skill":"code-review"}); 246 of 637 stream messages carry a parent_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 nested Agent finders ("Angle A & B: Line-by-line diff scan…", "Angle C & Reuse…", "regression test correctness…"). The denied calls, verbatim: a for d in …; do echo loop, 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 … and gh pr view … | python3 -c …: allowed prefixes, denied because compound/piped, which is exactly how a gh pr comment post can also vanish. The finders' CONFIRMED/PLAUSIBLE verdict labels are what the 03:41Z run's inline comments carry. The skill never ran gh pr diff for its scope (it runs git diff main...HEAD; in a CI checkout there is no local main) 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 --comment fallback, gh api …/pulls/{pr}/comments, which the allowlist denies.

The fix

1. --disallowedTools "Task,Agent,Skill,Workflow" in claude_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 own parseSdkOptions on this exact claude_args string → 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 lists Task; its tool_use blocks say Agent). Effect: the reviewer is single-pass again — reads gh 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()):

step source result
transcript digest steps.claude.outputs.execution_file ::warning:: per denial with tool name + input (300 chars); inline-comment call / error / subagent counts
FACT gh api …/pulls/N/comments --paginate inline review comments by claude[bot] created since a clock anchor step taken just before the action
CLAIM gh api …/issues/N/comments --paginate max N in the bot's summaries: Flagged N issue(s)… + the word "inline"
verdict claim > posted recover from transcript: bodies of denied inline-comment calls (permission_denials[].tool_input), errored ones (422 etc.), buffered-but-never-posted ones (successful tool_use whose 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 -->, phrased flagged N issue(s) so bb-unaddressed-findings.sh admits it)
exit fail when posted = 0 and nothing beyond the summary was recoverable, or when recovered findings could not be posted (a log is not the PR); recovered and posted → warning, green; partial (0 < posted < N) → warning, recovered content posted if any, never fails (the model's count is noisy both ways — the 03:41Z run said 3, posted 7)

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 in test_workflow_guards.py, README entry): static pin of the tool surface + step wiring, then extracts the SHIPPED guard bash and runs it against a stubbed gh through 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, --paginate multi-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/bash 3.2 as well
  • actionlint -shellcheck= clean; shellcheck clean on the selftest at all severities
  • action parser probe + CLI -p probe as above
  • Codex rounds: 6 — r1 four fixes (partial-loss early exit ignored subagent reports; POST failure after recovery exited 0; malformed transcript entries could abort jq mid-way; substantive final message never counted); r2 two declined with rationale in-file (post-buffered step is a regular composite step per the run log; transcript-missing is deliberately not fail-safe); r3 one fix (empty-body calls no longer count as recovery); r4 one fix (buffered-but-unposted calls recovered from tool_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

  • Reviews go back to single-pass Sonnet 4.6 within 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).
  • Expect durations back to minutes and cost back to cents per review.
  • A summary that overclaims (N > 0, nothing inline, nothing recoverable) now fails review / Claude Review — re-run the lane. Recovered findings show up as one github-actions[bot] comment the babysitter sweep counts as unaddressed.

Follow-ups (not in this PR)

  • verifier-on-high-risk.yml and claude-adversarial-review.yml pin the same action version; check their durations since 08-14 and whether they want the same --disallowedTools.
  • A run whose bot posts NO summary at all is warned, not failed (the summary is the "review ran" signal; failing on its absence is a new failure mode for a required check — decide separately).
  • Consider uploading execution_file as a short-retention artifact so future incidents keep the whole transcript, not just the digest.
  • Upstream: classify_inline_comments's buffer reply invites a second confirmed=true call 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

…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>
@cursor

cursor Bot commented Aug 16, 2026

Copy link
Copy Markdown

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.

@github-actions github-actions Bot added the risk:blocked Risk class: blocked label Aug 16, 2026
@github-actions

Copy link
Copy Markdown

Risk class: blocked — manual merge required.

This PR touches one of the blocked path categories from .github/risk-paths.yml (Dockerfiles, docker-compose, .github/workflows/**, **/.env*, **/secrets*, infra/, terraform/, k8s/, or the classifier config itself).

Auto-merge is refused by claude-author-automerge.yml. A maintainer should review the diff and click "Squash and merge" themselves.

(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.")

@github-actions

Copy link
Copy Markdown

Coverage Floor — mode: enforce

metric value
measured 100.0%
floor (current) 99.0%
target 100.0%
last bumped 2026-05-12

@topcoder1

Copy link
Copy Markdown
Owner Author

Live data point from this PR's own review / Claude Review run (31933028054), which ran the current @main reusable (i.e. without this fix):

duration_ms: 3834171 (64 min)   num_turns: 19   total_cost_usd: 5.44   permission_denials_count: 10
Post buffered inline comments: No buffered inline comments

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 (no summary comment by claude[bot] since <anchor>), listed under follow-ups. With --disallowedTools in place the parent posts the summary itself in a single pass, so this shape should become rare — but if you'd rather the guard fail the job when the bot posts no summary at all, it is a two-line change to the step (treat empty SUMMARIES as ::error:: + exit 1). Left as-is here to keep the change to what was asked; say the word and I'll fold it in.

Sweep before merge: no review findings to address (nothing was posted). All checks green; manual click-merge as always for this repo.

@topcoder1
topcoder1 merged commit 83e8491 into main Aug 16, 2026
16 checks passed
@topcoder1
topcoder1 deleted the claude/infallible-shirley-3b3e4e branch August 16, 2026 17:15
topcoder1 added a commit that referenced this pull request Aug 16, 2026
…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>
topcoder1 added a commit that referenced this pull request Aug 20, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:blocked Risk class: blocked

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant