Skip to content

feat(seidroid-review): fail soft when the verdict cannot post, and state the finding counts - #71

Merged
bdchatham merged 2 commits into
feat/seidroid-reviewfrom
feat/seidroid-findings-line
Sep 4, 2026
Merged

feat(seidroid-review): fail soft when the verdict cannot post, and state the finding counts#71
bdchatham merged 2 commits into
feat/seidroid-reviewfrom
feat/seidroid-findings-line

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

Stacks on #70. Merge that one first. The base of this pull request is
fix/seidroid-review-publish-failsoft, not main. Both change the Post
verdict step, and this one depends on #70's byte-budget block being there.
If #70 is rebased or squashed, rebase this onto the result before merging.

What this does

The verdict comment carries prose and nothing countable. A reader has to open
the check run to learn how many findings the review made, and scroll the diff
to learn how many reached a line. ai-review prints one line that answers
both. Both reviewers run during the transition, so a reader moves between them
and should read one format.

This adds that line under the review's prose:

**Findings:** 2 blocking | 3 non-blocking | 5 posted inline | 1 pre-existing

MARKER is unchanged, so the sticky upsert still finds its prior comment.
Permissions are unchanged. The diff is 106 added lines and no deleted ones.

The counts are corrected, not copied

ai-review's counts have two defects. This does not reproduce either.

Its blocking total adds pre-existing blockers. seidroid's CheckConclusion
excludes pre-existing from the gate on purpose: the code is already on the base
branch, so a gate on it fails every pull request that touches the file, and the
author who must clear the check is the one person who did not cause it. A total
that folds them in prints a number the check run contradicts — "3 blocking"
beside a neutral check. So pre-existing is counted apart and named. Folded into
non-blocking instead, it would tell an author their change has problems it
does not have.

Its non-blocking total counts only anchored comments. It computes
inlineOther = reviewComments.length - inlineBlockers, so an orphaned
suggestion is dropped from the total while still being shown. Ours reads the
driver's own count over every reported finding, anchored or not.

Term Source Covers
N blocking check.json .blocking every reported blocking finding, placed or not, plus the blockers bucket. Excludes pre-existing, which is what makes it agree with CheckConclusion.
M non-blocking check.json .non_blocking non_blockers plus every reported non-blocking finding, anchored or not, after the nit filter.
P posted inline on_line + on_file from the place step both placements land in the pull request's file view, which is what "inline" means to a reader.
K pre-existing check.json .pre_existing its own term, omitted at zero. Counted in neither N nor M.

unplaced is exported but not rendered: the note section below already names
each one under its own heading, which says more than a number. It is required
to be present as proof the output set is whole.

N and M count findings; P counts comments. They measure different things and
may legitimately differ — dedupe, the 50-comment placement cap, the nit filter.
The labels say which is which.

Where the numbers come from, and why not from here

The counts come from the driver rather than from a second pass over the
findings. The driver derived the check run's conclusion from those same
findings, and a second derivation is a second thing that can disagree.

Where the two do disagree the step emits a ::warning:: and still prints what
it was given. Correcting a number here would publish a third answer and hide
the defect that produced the first two.

Two supporting changes

The place step exports its counters. on_line, on_file and unplaced go
to $GITHUB_OUTPUT at exactly two points: the early exit for "nothing to
place", as literal zeros, and the end of the run block. A run that dies between
them leaves them unwritten, and unwritten is correct there — placement neither
finished nor was skipped, so no number it could publish would be true. All
three go in one brace-group append, so a consumer never reads a half-written
set. I rejected a trap ... EXIT that emits partial counts: it would report
"3 posted inline" for a step that crashed after 3 of 10, fires on the early-exit
path too, and clobbers $?.

The line is appended before the note block, and that ordering is
load-bearing twice. It puts the line under the prose, where the format wants
it. And #70's note budget computes room from wc -c of $body, so a line
added after that measurement is a line nothing accounted for — and what it
pushes past GitHub's 65,536-byte cap is the whole review. Inserting before the
block means #70's arithmetic absorbs the change with no edit to it.

Nothing here can fail the publish

Every value is validated by is_count() before use, and every term is dropped
rather than guessed. The measured matrix:

Case Rendered
New driver, placement ran 2 blocking | 3 non-blocking | 5 posted inline | 1 pre-existing
New driver, placement skipped or died 2 blocking | 3 non-blocking | inline placement did not run | 1 pre-existing + ::notice::
Old driver, placement ran 5 posted inline + ::notice:: — shorter, still true, still published
Old driver and placement skipped no Findings line at all, ::notice:: only
Clean review, placement ran 0 blocking | 0 non-blocking | 0 posted inline — every zero earned
check.json missing, empty or malformed degrades as the old-driver case
Partial place write (on_file empty) whole placement term degrades; a half-written set is trusted for nothing

The jq sentinel is // "?" rather than // 0, because a numeric sentinel is
indistinguishable from a real count. "?" fails is_count by construction.
jq's // treats only null and false as falsy, so a legitimate 0 passes
through untouched.

The driver contract this waits on

check.json today carries {conclusion, title, summary} only — verified
against internal/review/check.go. The three count fields do not exist yet. So
on merge this ships in the old-driver row of the table above: the comment gains
**Findings:** N posted inline and a ::notice::. It is correct and it is
published; it simply says less until the driver catches up.

The driver needs to add three JSON integers named blocking, non_blocking
and pre_existing, in the existing bare-noun style.

One thing for the driver author to resolve. CheckConclusion returns
failure when Decision() == "request_changes", independently of any count. A
reply that requests changes with an empty blockers array and no
blocker-severity finding yields conclusion: failure with a natural count of
0. That posts REQUEST_CHANGES beside **Findings:** 0 blocking, and trips
the disagreement warning on a legitimate review. The workflow deliberately does
not paper over it — see above — so the driver has to decide whether the
escalation contributes to the count or the count carries a separate term.

Known limitations, stated rather than fixed

  • The line sits after the driver's provenance footer. RenderComment
    returns prose + footer, and the workflow appends after that. The parity
    target puts the line directly under the prose. Moving it requires
    RenderComment to own the line, which needs the placement counts the driver
    never sees.
  • The ### Blockers / ### Non-blocking / ### Pre-existing issues sections
    are not in scope here.
    They exist today only in checkSummary, on the check
    run. The workflow has integers, not buckets, and cannot render them. Adding
    them to the comment also imports checkSummary's threat model into
    publish.go — model text under this tool's headings needs defuseMarkup,
    and check_test.go:286 already fixtures a non-blocker that forges a
    ### Blocking section. That belongs in a driver change with that guard.
  • Model prose can suppress the line. RenderComment posts v.Text
    verbatim and does not defuse it. A verdict ending inside an unterminated code
    fence renders what follows as code; a trailing <!-- hides it. The residual
    is bounded: the counts are driver integers and shell counters, so the worst
    case is a suppressed line, never a forged one. A model cannot write itself
    a clean Findings line.
  • maxPlaceableFindings = 50 is invisible here. Findings past the cap never
    reach findings.json, so they reach neither the placement nor the note. N+M
    can exceed what is shown. The workflow cannot detect this: N+M legitimately
    exceeds the placed count anyway, because the blockers and non_blockers
    buckets are tied to no line. Surfacing it needs the driver.

Verification

  • python3 -c "import yaml; yaml.safe_load(...)" parses.
  • bash -n passes on all 11 extracted run: blocks.
  • shellcheck is clean on both changed blocks. actionlint reports the same
    five pre-existing findings as the base — this change adds none.
  • The place step was executed on all five of its paths against a stubbed gh:
    empty findings, line placement, file fallback, full fallthrough to the note,
    and a mid-run death. The mid-run death writes no keys, as designed.
  • The verdict step was executed on the seven-case matrix plus both disagreement
    warnings and the null / negative / non-numeric skew inputs. All exit 0. No
    input renders null blocking.
  • The byte budget was exercised at caps of 900, 1200, 2000 and 65,536 with a
    40-entry note: the marker stays first, the Findings line survives, the note
    absorbs the cut, and the body stays under the cap.

None of this exercises a live GitHub API. The gh calls are stubbed.

bdchatham and others added 2 commits September 4, 2026 13:44
The sticky upsert was the only publish step that could fail the job. The five
steps around it all carry continue-on-error: read-threads, record-commit,
place-findings, publish-check-run and state-position. They carry it because a
publishing failure must not bury a review that ran. This step carried none, and
it is the last step in the job. A failure there threw the whole review away:
the model spend, the sandbox run, and the verdict the reader waits for.

continue-on-error alone makes that failure silent, which is worse than a red
job. Two things now carry the signal, because neither one is enough alone:

- An ::error:: annotation, written from inside the step. It survives
  continue-on-error, because that flag changes the step's conclusion and not a
  workflow command. It needs nothing but the runner. It also prints the
  unposted body to the log, so the run still holds the verdict.
- A check run named `review`, with conclusion failure. Nobody opens a green
  run, so the annotation alone never reaches the reader. The name matches the
  check the step above publishes. That later check supersedes the green one on
  the same commit, and a run that does post clears it again.

The same rewrite bounds $NOTE against what the marker and the verdict already
spend. The driver clips the verdict it writes (review.MaxBodyBytes). Nothing
clipped $NOTE, which carries raw model prose with no length limit on it.

Up to 50 findings can reach it. The usual route into it is by design: a finding
on a file the pull request does not touch has nowhere to go. Unbounded, that tail
pushes the body past GitHub's comment limit. It then loses a whole review over
its least important part. The note is now cut on whole lines, and the comment
names how many observations it shows.

MARKER is unchanged, so every open pull request keeps its sticky comment. This
change leaves permissions alone, declares no new job output, and adds no step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The verdict comment carried prose and nothing countable. A reader had to
open the check run to learn how many findings the review made, and had to
scroll the diff to learn how many reached a line. ai-review prints one
line that answers both. Both reviewers run during the transition, so a
reader moves between them and should read one format.

This adds that line under the review's prose:

    **Findings:** 2 blocking | 3 non-blocking | 5 posted inline | 1 pre-existing

The counts are corrected, not copied. ai-review has two defects here and
this does not reproduce them.

First, its blocking total adds pre-existing blockers. seidroid's
CheckConclusion excludes pre-existing from the gate on purpose: the code
is already on the base branch, so a gate on it fails every pull request
that touches the file, and the author who must clear the check is the one
person who did not cause it. A total that folds them in prints a number
the check run contradicts. So pre-existing is counted apart and named.
Folded into "non-blocking" instead, it would tell an author their change
has problems it does not have.

Second, its non-blocking total counts only anchored comments, so an
orphaned suggestion is dropped from the total but still shown. Ours reads
the driver's own count over every reported finding, anchored or not.

The counts come from the driver rather than from a second pass over the
findings. The driver derived the check run's conclusion from those same
findings, and a second derivation is a second thing that can disagree.
Where the two do disagree the step says so in the log and still prints
what it was given: correcting a number here would publish a third answer
and hide the defect that produced the first two.

Two supporting changes carry it:

The place step now writes on_line, on_file and unplaced to $GITHUB_OUTPUT.
It writes them at exactly two points -- the early exit for "nothing to
place", as literal zeros, and the end of the run block. A run that dies
between them leaves them unwritten, and unwritten is correct there:
placement neither finished nor was skipped, so no number it could publish
would be true. All three go in one append, so a reader never sees a
half-written set.

The line is appended before the unplaced-observations note, and that
ordering is load-bearing twice. It puts the line under the prose, where
the format wants it. And the note's byte budget measures $body to decide
what room is left, so a line added after that measurement is a line
nothing accounted for -- and what it pushes past GitHub's 65,536-byte cap
is the whole review.

Nothing here can fail the publish. Every count is validated before use and
every term is dropped rather than guessed. A driver that predates the
check.json count fields makes jq answer null, the sentinel fails
validation, and the comment ships with a shorter line that is still true.
A skipped placement says "inline placement did not run" rather than
claiming zero. When neither is readable the line is not written at all.

MARKER is unchanged, so the sticky upsert still finds its prior comment.
The line goes after it. Permissions are unchanged.

Verified: the workflow parses; bash -n passes on all 11 run blocks;
shellcheck is clean on both changed blocks and actionlint reports the same
five pre-existing findings as the base. The place step was executed on all
five of its paths, and the verdict step on the seven-case skew matrix,
against a stubbed gh. All exit 0. The byte budget was exercised at caps of
900, 1200, 2000 and 65,536: the line survives, the note absorbs the cut,
and the body stays under the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Sep 4, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Workflow-only publishing logic with fail-soft validation; behavior degrades cleanly until the driver adds count fields to check.json.

Overview
The sticky verdict comment now includes an Findings: line (matching ai-review’s shape) built in the Post verdict step, using driver totals from check.json and placement totals from the Place findings step.

Place findings writes on_line, on_file, and unplaced to $GITHUB_OUTPUT only on the empty-findings early exit (literal zeros) and at successful completion—skipped or crashed placement stays empty so the summary does not claim “0 posted inline” when placement never ran.

The line is inserted before the unplaced-note byte budget: blocking/non-blocking from check.json (with "?" sentinels for older drivers), on_line + on_file as “posted inline” when all three placement counts are numeric, optional pre-existing when > 0, and ::warning:: when check conclusion and blocking count disagree. Missing or invalid counts drop terms or omit the whole line rather than failing publish.

Reviewed by Cursor Bugbot for commit d551628. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Adds a **Findings:** … line to the sticky verdict comment, sourced from the driver's check.json counts plus the place step's new on_line/on_file/unplaced outputs. The logic is defensive and correct — every value is validated before use, missing values drop their term rather than being guessed, and the line is appended before the note's byte-budget calculation so the budget accounts for it; only minor wording and cross-tool consistency notes.

Findings: 0 blocking | 3 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • N non-blocking comes from the driver's own total (which the PR description says is taken after a nit filter) while P posted inline counts every comment the place step posted, including nits. If findings.json still carries nits, posted inline can exceed blocking + non-blocking and read to a reader as an arithmetic error. Worth confirming the driver applies the same filter to both, or the line could say N blocking | M non-blocking (+K nits).
  • Merge-order hazard the author already flagged: the findings line must stay above the room=$(( MAX_BODY_BYTES - ... )) computation from #70. If #70 is squashed or rebased and this is replayed onto the result, verify that ordering survived — a findings line appended after the measurement is unaccounted bytes that can push the whole comment past GitHub's 65,536-byte cap.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

add_term "$(( ON_LINE + ON_FILE )) posted inline"
counted=true
else
add_term "inline placement did not run"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] "did not run" overstates what the empty outputs prove. The place step is continue-on-error: true and writes all three outputs only at its two terminal points, so a run that dies partway — say the gh api .../pulls/$PR --jq .head.sha read fails, or a base64 --decode trips set -e mid-loop — can have posted several inline comments and still leave ON_LINE/ON_FILE/UNPLACED empty. The reader then sees seidroid comments in the file view beside a summary saying placement never happened, which is the same class of false statement the surrounding comments are careful to avoid elsewhere. "inline placement did not complete" (or "…count unavailable") covers both the skip and the partial-death case without asserting the stronger claim.

@bdchatham
bdchatham changed the base branch from fix/seidroid-review-publish-failsoft to feat/seidroid-review September 4, 2026 21:33
@bdchatham bdchatham changed the title feat(seidroid-review): state the finding counts on the verdict comment feat(seidroid-review): fail soft when the verdict cannot post, and state the finding counts Sep 4, 2026
@bdchatham

Copy link
Copy Markdown
Contributor Author

Retargeted to feat/seidroid-review. This is now the single PR for the whole change — #70 was closed and its commit ff34d3ff is carried here rather than rewritten, so nothing from it is lost:

ff34d3ff  fail soft when the step cannot post the verdict   (was #70)
d551628f  state the finding counts on the verdict comment

Both are the same concern — how a review reaches the pull request — so they review better as one unit than as a stack against an unmerged branch.

The prompt schema change is deliberately NOT here. An adversarial pass returned UNSAFE on backward compatibility, so the non_blockers severity work was withheld rather than shipped on a claim that did not survive checking. **[suggestion]** prefixes therefore stay absent for now; the counts do not depend on them.

@bdchatham
bdchatham merged commit 2f5fcfc into feat/seidroid-review Sep 4, 2026
6 checks passed
@bdchatham
bdchatham deleted the feat/seidroid-findings-line branch September 4, 2026 21:50
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.

1 participant