Skip to content

chore(ci): fail the on-demand review when it posts nothing - #185

Merged
thecodedrift merged 1 commit into
mainfrom
fix/review-workflow-silent-success
Aug 26, 2026
Merged

chore(ci): fail the on-demand review when it posts nothing#185
thecodedrift merged 1 commit into
mainfrom
fix/review-workflow-silent-success

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

The on-demand review workflow can report success while posting nothing. This makes that impossible.

What happened

On #182 (run 32933651692) the review job went green having done no work:

{ "type": "result", "subtype": "success", "is_error": false,
  "duration_ms": 147421, "num_turns": 0,
  "total_cost_usd": 1.076006, "permission_denials_count": 4 }

The tracking comment kept its placeholder — "I'll analyze this and get back to you" — the run logged No buffered inline comments, and the PR received zero inline comments. The final ✅ Updated issue comment … with job link step stamped the job URL onto that stub, which is easy to mistake for a review being posted.

A silent no-op is indistinguishable from a clean review. The PR reads as reviewed when nothing read it, and the run is billed either way. That is worse than the job failing outright.

Why is_error cannot gate it

is_error was false in that run. The load-bearing signals are in the action's execution file:

Signal Why it fails the job
num_turns == 0 A review that never took a turn cannot have posted anything.
permission_denials_count > 0 A denial is how a review dies quietly: the tool it needs is not on the allowlist, it has nowhere to say so, and it stops. Failed even with turns — a review that lost a tool could not see everything.
is_error Kept, but as one signal among several rather than the gate.
missing file / no result record Cannot confirm a review ran, so it is not assumed.

The step also writes the counters to the job summary on every run, pass or fail.

Naming the denied tools

When the record carries them, the denied tool names are printed. Tool names are structured data the runner produced, so they are safe here; the tool input is never printed, because it can quote the untrusted diff. This is what makes the next occurrence self-diagnosing instead of a log excavation — the root cause of the #182 run is still unknown precisely because nothing surfaced it.

show_full_output: true would answer the same question and is deliberately not used: this is a public repo, and it dumps text the model produced while ingesting an untrusted diff into a world-readable log. Counters are not attacker-controlled; model prose is.

Verification

Both execution-file shapes the action has written are accepted (a JSON array of messages, and JSONL) rather than pinning a shape it may change. Tested against six fixtures, including the real record from run 32933651692:

Fixture Expected Result
the #182 failure shape (num_turns: 0, 4 denials) fail exit 1
healthy run (14 turns, 0 denials) pass exit 0
denials with turns fail exit 1
denials with named tools fail, names them exit 1, Denied tool(s): TodoWrite, Bash
no result record fail exit 1
missing file / unset variable fail exit 1

if: always() so the step still runs when the action itself fails.

Not addressed here

The root cause of the four denials on that run. It is not visible in the log (full output hidden for security), and shipping a speculative permissions change to a security-sensitive workflow is worse than shipping a guard that turns the next occurrence into a loud, self-diagnosing failure. One plausible mechanism is the action restoring .claude from origin/main and running with settingSources: ["user","project","local"], so this repo's own rules reach the review agent on top of the action's allowedTools allowlist — but that is a hypothesis, not a measurement, and the guard is what will produce the measurement.

CI-only, so no changeset.

Copilot AI lite review requested due to automatic review settings August 26, 2026 05:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thecodedrift
thecodedrift force-pushed the fix/review-workflow-silent-success branch from 20dfed5 to 3363036 Compare August 26, 2026 06:02
MEASURED on PR #182 (run 32933651692): the review action exited `success` with
`is_error: false` having done no work at all — `num_turns: 0`,
`permission_denials_count: 4`, `total_cost_usd: 1.076`. The tracking comment
kept its placeholder ("I'll analyze this and get back to you"), the run logged
`No buffered inline comments`, and the PR received zero inline comments. The job
went green.

That is the worst failure this workflow can have. A silent no-op is
indistinguishable from a clean review, so a pull request reads as reviewed when
nothing read it, and the cost is paid either way.

`is_error` cannot gate this; it was false in that very run. The load-bearing
signal is in the action's execution file: `num_turns` counts the model's
completed turns, and a review that never took a turn cannot have posted
anything. `permission_denials_count` is failed on separately, because a denial
is how a review dies quietly — the tool it needs is not on the allowlist, it
has nowhere to say so, and it stops. A denial with turns is also failed: a
review that lost a tool is a review that could not see everything.

The step names the denied tools when the record carries them. Tool names are
structured data the runner produced, so printing them is safe here; the tool
INPUT is never printed, because it can quote the untrusted diff. That is what
makes the next occurrence self-diagnosing rather than a log excavation.

`show_full_output: true` would answer the same question and is deliberately not
used: this is a public repo and it dumps text the model produced while ingesting
an untrusted diff into a world-readable log. Counters are not attacker
controlled; model prose is.

Both execution-file shapes the action has written (a JSON array of messages, and
JSONL) are accepted, rather than pinning a shape it may change. Verified against
six fixtures including the real record from run 32933651692: the failing shape,
a healthy run, denials-with-turns, a file with no result record, a missing file,
and an unset variable.
@thecodedrift
thecodedrift force-pushed the fix/review-workflow-silent-success branch from 3363036 to 1782fb2 Compare August 26, 2026 06:04
@thecodedrift
thecodedrift merged commit 3c78ffe into main Aug 26, 2026
2 checks passed
@thecodedrift
thecodedrift deleted the fix/review-workflow-silent-success branch August 26, 2026 06:20
thecodedrift added a commit that referenced this pull request Aug 27, 2026
The guard added in #185 caught the zero-turn run on #196, which is what it was
for. It then reported `permission_denials=0` while the streamed job log carried
`permission_denials_count: 6`, so it skipped naming the denied tools, and that
zero was read as evidence that permissions were not the problem. Reporting the
wrong number was worse than reporting nothing.

MEASURED on run 33039882331: the saved execution file's result record omits
`permission_denials_count`, while the streamed stdout carries it. Every other
field matched exactly, including `total_cost_usd` to sixteen digits, so the
guard found the right record and the file simply lacks that one field.

Denials are now the maximum of the count field and the number of denial
records found in the file, and the naming loop reads those records rather than
re-deriving them behind a gate that could never open. The old code already had
the scan; it sat inside `if denials:`, so a missing count made it unreachable.

Also added: a structural census of the execution file, message types and
counts only. Two zero-turn runs have now billed real inference, #182 at $1.08
and #196 at $2.20, and neither left anything to diagnose from afterwards. No
message content, no tool inputs, nothing the model produced while reading an
untrusted diff; those are the reason `show_full_output` stays off on a public
repo, and none of it is needed to say what shape a run had.

Verified against three fixtures: the #196 shape with the count omitted and
denial records present now reports 2 denials and names them, where the old
guard reported 0 and named nothing; the #182 shape with the count present is
unchanged; a healthy run still exits 0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants