Skip to content

fix(seidroid-review): withdraw the eyes when the review answers - #84

Merged
bdchatham merged 2 commits into
feat/seidroid-reviewfrom
fix/clear-the-eyes-reaction
Sep 6, 2026
Merged

fix(seidroid-review): withdraw the eyes when the review answers#84
bdchatham merged 2 commits into
feat/seidroid-reviewfrom
fix/clear-the-eyes-reaction

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

Implements PLT-1144.

The job adds an eyes reaction to the triggering comment and never removes it.
The stale sets were +1, -1, +1 -1eyes appeared in none. A finished
review therefore reads as still in progress, permanently, and on a conclusion
that earns no thumb there is no completion signal at all.

ai-review.yml removes it on every path.

The change

Three code lines:

failure) reaction="-1"; stale="+1 eyes" ;;
success) reaction="+1"; stale="-1 eyes" ;;
*)       reaction="";   stale="+1 -1 eyes" ;;

The rest is comment accuracy on lines this makes stale. Two of those corrections
are worth naming, because each described a guard as something other than what it
does — the class of defect five review rounds on this step kept finding:

  • The step header claimed the condition names "a verdict to report". It does
    not; it names a comment to answer. The next line already said there is no
    verdict_produced gate, so the block contradicted itself, and a reader
    trusting the first sentence would add the gate back.
  • The absent-check-file comment named only a stale thumb as what an early return
    would strand. The eyes is the larger loss.

Behaviour, measured

The extracted step driven against a gh stub that runs the workflow's own
--jq filter, so the filter is under test. 25 runs: five starting states × five
conclusion inputs.

Start success failure neutral / empty / missing
bot eyes del eyes · post 👍 del eyes · post 👎 del eyes · post none
bot eyes + stale 👎 del both · post 👍 del eyes · post 👎 del both · post none
human 👎 only del none · post 👍 del none · post 👎 del none · post none
nothing del none · post 👍 del none · post 👎 del none · post none
bot eyes + bot 👍 + two humans del eyes · post 👍 del eyes, 👍 · post 👎 del eyes, 👍 · post none

Every run exits 0. No human reaction is deleted in any of the 25.

Degraded paths all exit 0: a failed listing warns and still posts; a failed
delete warns per reaction and still posts; a failed post leaves the withdrawals
standing.

Why the table is not vacuous

The first harness was wrong and passed everything including the base — an
assertion anchored on $ against a log line with a trailing space. Corrected,
then mutation-tested after committing:

Mutant Result
base a32defa eyes survives all 15 — the regression reproduces
head clean
eyes off the success arm caught
eyes off the * arm caught
select(.user.login == $me) removed caught — deletes the humans' reactions
early return on an absent check file caught

The last two are the guards that must not break, and the harness proves it would
notice.

Verification

actionlint    5 findings, rule-for-rule and message-for-message identical to base
              (4×SC2102 at in-script 23:12, 24:12, 175:14, 176:14; 1×SC2129 at 85:3)
shellcheck    clean on both reaction steps
pipelines     none in the step; pipefail has nothing to trip

Out of scope, filed separately

A cancelled run leaves the eyes on. !cancelled() skips this step when the
concurrency group cancels the run, so two @seidroid review comments in quick
succession leave the first wearing eyes with no answer ever coming.
ai-review.yml avoids this by clearing in a separate job under always().
Changing when a step runs on cancellation is past this ticket.

🤖 Generated with Claude Code

@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Workflow-only reaction bookkeeping on issue comments; scoped to github-actions[bot] and review mode, with no auth or review logic changes.

Overview
Fixes the comment-trigger path so a finished @seidroid review no longer leaves the trigger comment stuck with eyes (in-progress) forever.

Acknowledge the trigger now runs only when inputs.mode == 'review' and there is a comment_id, so a close command does not add eyes that nothing clears.

In Answer the request, the bot’s stale-reaction cleanup includes eyes on every conclusion path (success, failure, and neutral/missing), alongside the existing thumb up/down handling. That step removes the acknowledgement eyes before posting the final reaction (or clearing thumbs only), matching the pairing described in the updated comments.

Reviewed by Cursor Bugbot for commit f37d6c6. 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.

The core change is correct: all three case arms now include eyes in stale, the acknowledgement and the withdrawal both react as github-actions[bot] via github.token, the select(.user.login == $me) scope still spares human reactions, and the case \" $stale \" in *\" $content \"* word match is unambiguous across GitHub's reaction contents. The gap is that the acknowledgement step carries no mode gate while the withdrawal step does, so a comment-triggered close still strands the eyes — contradicting the new comment that says otherwise.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • The !cancelled() gate on Answer the request leaves one more path where the eyes survives: when a newer @seidroid review cancels an in-flight run (cancel-in-progress: true), the cancelled run skips the step, and the superseding run replays the new comment id — so the older trigger comment keeps its 👀 permanently, reading as still-running. If that path is meant to be out of scope, the new comment at .github/workflows/seidroid-review.yml:624 ("on every path it takes") is the place to say so.
  • Answer the request runs before Post the verdict (line 1346), so the eyes is withdrawn and the thumb applied while the verdict comment does not yet exist. If Post the verdict then fails (it is continue-on-error: true), the requester is left with a thumb, no eyes, and no verdict. Placing this step after the posting step would make the withdrawal coincide with the answer actually landing.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.
  • 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] The guard's parse step writes a command=close/command=review output (.github/workflows/seidroid-review.yml:384-386) but the guard never exposes it as a job output, and nothing outside the guard reads it beyond the admit step's skip-label bypass. The routing decision it encodes is duplicated in the caller's mode input, so the two can disagree with nothing to catch it.

# the same comment returns the existing reaction rather than adding a
# second one, so a retry needs no cleanup.
#
# `Answer the request` withdraws this reaction, on every path it takes. A

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] This claim does not hold for a comment-triggered close. Acknowledge the trigger is gated only on needs.guard.outputs.comment_id != '' (line 612), while Answer the request additionally requires inputs.mode == 'review' (line 1253). The guard explicitly supports issue_comment + mode: closeparse sets command=close for @seidroid review close (line 384) and still emits comment_id (line 422), and the review job's condition admits github.event_name == 'issue_comment' at any mode (line 542), with lines 526-528 stating that a close asked for in a comment goes through the guard on purpose.

So on a teardown request the acknowledgement posts 👀 and nothing ever withdraws it — the exact permanent "still in progress" state this PR exists to remove, just moved to the close path. Either gate the acknowledgement on inputs.mode == 'review' so a close never earns eyes it cannot lose, or drop the mode gate from Answer the request so the close path clears them too. As written the comment reads as a guarantee that is not in the file.

bdchatham and others added 2 commits September 6, 2026 12:02
A finished review read as a review still in progress. `Acknowledge the
trigger` reacts eyes on the triggering comment, and no step took it off,
so the acknowledgement stayed on the request for good.

`Answer the request` now names eyes in every stale set, beside the thumb
each conclusion replaces: `+1 eyes` on failure, `-1 eyes` on success, and
`+1 -1 eyes` where the conclusion earns no reaction. The listing already
returns the content beside the id for this bot's own reactions. The
clearing loop therefore deletes the eyes with no extra call, and it widens
no scope.

The step holds every property it had. It stays off `verdict_produced`, so
a run that reaches no verdict still clears what an earlier attempt left.
An absent check file still falls to the clearing arm rather than returning.
The delete stays scoped to `github-actions[bot]`, so a human's reaction
survives. The step still runs no pipeline, and it still skips the POST when
the conclusion earns none.

Measured against a gh stub over 25 runs: five starting states by five
conclusion inputs. The eyes goes in every run where this bot left one. A
human's `-1` and a human's eyes survive all 25.

Refs PLT-1144
`Acknowledge the trigger` was gated only on a comment id, while
`Answer the request` also requires a review turn. So `@seidroid review close`
posted the eyes and nothing withdrew them: the requester reads a teardown that
is still running, forever.

The two steps now name the same two facts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham
bdchatham force-pushed the fix/clear-the-eyes-reaction branch from 7b275f6 to f37d6c6 Compare September 6, 2026 19:02
@bdchatham

Copy link
Copy Markdown
Contributor Author

Rebased onto feat/seidroid-review after #83 merged, and pushed one more fix in f37d6c6.

The gap: Acknowledge the trigger was gated only on comment_id != '', while Answer the request also requires inputs.mode == 'review'. So @seidroid review close posted the eyes and nothing withdrew them — the requester reads a teardown that never finishes.

The two steps now name the same two facts. Every eyes this workflow posts is a review turn, and every review turn reaches the step that withdraws it.

@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.

The fix is correct for the paths it covers: eyes is added to all three stale sets, the withdrawal stays scoped to github-actions[bot], and gating the acknowledgement on mode == 'review' keeps it symmetric with the step that clears it. Three non-blocking gaps remain, around cancellation, the close path, and delete/post ordering.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • The PR description's behaviour table covers review mode only; the close path's new silence (no reaction at all) isn't characterised there, and it is the one user-visible behaviour change beyond the eyes withdrawal.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

# request to tear a session down earns no answer.
# No verdict_produced gate: a re-run replays the trigger comment id, so a run
# reaching no verdict still has to clear a thumb an earlier attempt left there.
# The condition names three facts: a review turn, not cancelled, and 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.

[suggestion] !cancelled() still strands the eyes, so "withdraws them ... on every path it takes" isn't quite true. Cancellation is a routine path here, not an exotic one: this job sets cancel-in-progress: true keyed on (mode, repo, PR), so a second @seidroid review on the same pull request cancels the first run. The cancelled run skips this step, and because the newer run replays a different trigger comment id, nothing ever clears the eyes off the first comment — it reads as permanently in progress, which is the exact defect this PR fixes elsewhere.

ai-review.yml, cited in the description as the workflow that removes it on every path, uses always() on its complete_review_reaction job for this reason; always() steps do run during a job's cancellation grace period. Switching to always() here needs no other change: a cancelled run has no readable check file, so it falls into the *) arm, clears +1 -1 eyes and posts nothing, which is the right answer for a superseded run.

Related: the new comment on Acknowledge the trigger says this step "runs on the same two facts" — it runs on three, and the third is the one that opens this gap.

# A review turn, and a comment to react to. `Answer the request` withdraws this
# reaction and it runs on the same two facts, so a mode it skips must not react
# here: a close would leave eyes that nothing clears.
if: ${{ inputs.mode == 'review' && needs.guard.outputs.comment_id != '' }}

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] Adding inputs.mode == 'review' is the right call for symmetry, but it leaves a comment-triggered close with no signal at all. @seidroid review close parses to command=close with comment_id set (guard, lines 390-400), the review job runs, and every step that touches the pull request — this one, Answer the request, Post the verdict, State the review's position — is now gated on mode == 'review'. So a person asking for teardown gets nothing back: no eyes, no thumb, no comment, while the drive step reclaims the sandbox for however long that takes. Previously they at least got a receipt.

If the close path is meant to stay silent, that's a defensible choice, but it's worth stating in the comment here so a later reader doesn't read the omission as an oversight. Otherwise, a close-mode step that reacts and then clears on completion (or simply posts a terminal reaction once teardown finishes) would restore the acknowledgement without reintroducing the uncleared-eyes problem.


# Withdraw this bot's stale thumbs first. A reaction is not a toggle and a
# re-run replays the same comment id, so a comment would otherwise wear a
# Withdraw this bot's stale reactions first: the eyes that acknowledged the

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] Withdrawing before posting means a failed POST — which the step deliberately treats as non-fatal — leaves the trigger comment wearing nothing: the eyes are gone and the thumb never arrived, so a finished review reads as one that was never seen. Before this PR the same failure left the eyes standing, which at least read as in-progress.

Posting first and withdrawing second avoids that: the POST is idempotent per (identity, content), so a re-run is safe, and the worst case becomes a comment briefly wearing both eyes and a thumb (or, if a DELETE then fails, permanently — but with the answer visible, which is the part that matters). The reaction="" arm is unaffected since it has nothing to post.

@bdchatham
bdchatham merged commit 543f11f into feat/seidroid-review Sep 6, 2026
6 checks passed
@bdchatham
bdchatham deleted the fix/clear-the-eyes-reaction branch September 6, 2026 19:09
bdchatham added a commit that referenced this pull request Sep 6, 2026
…the dismissal (#88)

The step that records the review's position required the App identity,
so a repository with no `SEIDROID_APP_ID` got a verdict comment and
never appeared in the reviewers list. It now falls back to
`github.token`, which is what every sibling publisher in this file
already does.

## What changed

- `State the review's position on the pull request` drops
`steps.identity.outputs.token != ''` from its condition and reads
`steps.identity.outputs.token || github.token`. The App still authors
the position wherever a caller configured one.
- The position post gains a `COMMENT` rung, mirroring
`.github/workflows/ai-review.yml:983-986`. When the API refuses
`APPROVE` or `REQUEST_CHANGES`, the step retries the same body as
`COMMENT` and warns.
- Two comments now state the present: the position step names what the
fallback identity costs, and the `Answer the request` step names why it
stands apart from the position step.

The withdrawal, its interlocks and its loud failure on a stuck block are
untouched.

## What a GITHUB_TOKEN review can and cannot do

**Measured in this repository.** `github-actions[bot]` — the login
`GITHUB_TOKEN` carries — has created `APPROVE` reviews here through
`ai-review.yml`'s identical fallback: PRs #84, #83, #75, #74, #73, #72,
#71, #70 and #61 all carry a `github-actions[bot]` `APPROVED` review
whose body opens with `<!-- ai-combined-review -->`. It has also created
`REQUEST_CHANGES` reviews and dismissed them: the timeline of #75
records two `review_dismissed` events with `actor=github-actions[bot]`,
`state=changes_requested`, message `Superseded: latest AI review found
no blocking issues.` So the create path and the dismissal path both work
under this repository's settings.

That the approvals land at all means sei-protocol allows Actions to
approve a pull request. GitHub refuses the call outright when that
setting is off, and none of these calls was refused.

**Cannot.** A `COMMENT` review casts no vote. Where the rung fires, an
`APPROVE` clears no approval requirement and a `REQUEST_CHANGES` blocks
no merge. The check run remains the gate in both cases.

## The branch-protection question

**Neither identity clears the review requirement on `sei-chain` today.**
sei-chain PRs #4094 and #4100 each carry exactly one review —
`seidroid[bot]` `APPROVED`, from the App — and GraphQL `reviewDecision`
on both reads `REVIEW_REQUIRED`. sei-chain's `main` is protected and
carries `.github/CODEOWNERS`. So a bot approval, App-authored or not,
does not satisfy that gate now. The fallback costs nothing there that
the App identity was buying.

`uci` itself has no gate to satisfy. `main` reports `protected: false`,
and the one ruleset (id 7955617, `~DEFAULT_BRANCH`) is `enforcement:
disabled`. Were it enabled it would ask for
`required_approving_review_count: 1`, `required_reviewers: []`,
`require_code_owner_review: false`, `dismissal_restriction.enabled:
false` — a rule that names no reviewer and restricts no dismissal.

## What I could not verify

- **Whether a `github-actions[bot]` approval satisfies a
required-approval rule.** `GET
/repos/sei-protocol/uci/actions/permissions/workflow` and the org
equivalent both returned 403 for my token, so I could not read
`can_approve_pull_request_reviews` directly — the successful approvals
above are the inference. No repository in reach has a merge gate that an
Actions approval has ever been put to.
- **sei-chain's protection detail.** `GET
/repos/sei-protocol/sei-chain/branches/main/protection` returns 404 for
my token. CODEOWNERS is the probable reason `reviewDecision` stays
`REVIEW_REQUIRED`, not a measured one.
- **A live run.** Nothing here ran on a runner. The evidence is the
incumbent's history, the API state above and the local battery below.
- **The `COMMENT` rung under a real refusal.** I never saw the API
refuse a position, so the rung is exercised against a stub, not against
GitHub.

## Two consequences worth naming

1. A repository with no App now runs the withdrawal where it previously
skipped the whole step. A dismissal the API refuses fails the job, by
the design this step already states. That is the intended loud failure,
and it is new exposure for those repositories.
2. If the first post lands server-side but the client reports a failure,
the `COMMENT` retry writes a second review. `ai-review.yml:968-991`
carries the same hazard; the cost is one extra review, never a changed
gate.

## Verification

`actionlint` 1.7.12, against base `543f11f`:

```
base: 4 findings, exit 1  -> 4 SC2102:info
new:  4 findings, exit 1  -> 4 SC2102:info
rule set diff: identical
```

Both findings pre-date this change and sit in steps it does not touch.

```
$ python3 -c "import yaml; yaml.safe_load(open('.github/workflows/seidroid-review.yml'))"
yaml ok
$ shellcheck -s bash <the position step's script>
(clean)
```

**Behaviour battery.** I extracted the step's script from the YAML at
both revisions, put a stubbed `gh` on `PATH`, and ran twelve paths
against each: event accepted, event refused, every event refused,
approve off, blocking, blocking with the event refused, dismissals
refused, review list unreadable, neutral with a blocker, neutral with
nothing written down, neutral with no counts, and no check file. The
base and the new step produce byte-identical output on every path but
the two where the new `COMMENT` rung fires:

```
--- clean, approve on, event refused
-::warning::could not record APPROVE ...; the verdict comment stands
+::warning::... would not take APPROVE, so this review is recorded as a comment;
+  the check run carries the success conclusion
--- blocking, request changes refused
-::warning::could not record REQUEST_CHANGES ...; the verdict comment stands
+::warning::... would not take REQUEST_CHANGES, so this review is recorded as a
+  comment; the check run carries the failure conclusion
```

Both loud failures still exit 1: the stuck dismissal and the unreadable
review list. All four withdrawal interlocks are unchanged — a blocker
beside a soft conclusion stops the withdrawal, a soft conclusion with
zero counts stops it, absent counts let it proceed with a warning, and a
`failure` conclusion exits before it.

## One thing the ticket got wrong

The ticket's phrasing — "drop the identity requirement from the step's
condition" — was written against a base that still carried the
cross-repository target grammar. `bf507f3` removed that grammar, so the
step now only ever posts to `github.repository` and dropping the
requirement outright is safe. Against the older base it was not:
`github.token` reaches no other repository, the withdrawal's review
listing would have 404'd, and the step would have failed the job
reporting a block it could not see.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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